Compare commits

..

24 Commits

Author SHA1 Message Date
androidkotlindev
f7d5a52fd9
Merge 729bdd43b9 into aca3aeb592 2026-10-28 20:27:16 +00:00
Kingkor Roy Tirtho
aca3aeb592 fix(theme): follow the OS dark mode on desktop
Compose's isSystemInDarkTheme never updates under the Tao window, so Theme.SYSTEM
stayed light. Use Nucleus' darkmode-detector on JVM and keep Compose's check on mobile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:53:47 +06:00
Kingkor Roy Tirtho
5b36edd259 fix(windows): make VLC playback work through the stream proxy
The proxy copied every upstream header, duplicating Content-Length, which VLC
rejects. Forward only the range/validator headers. Bump vlcj-bundler to 0.2.1,
which bundles the Matroska demuxer needed for WebM. Make StreamingUrlRepository's
platform injectable and add VLC log switch via DebugConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
ea01dec6ea fix(settings): palette swatches follow the active light/dark appearance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
79266fd483 fix(windows): window not spawning due to early COM initialization 2026-10-27 16:35:27 +06:00
Kingkor Roy Tirtho
31a3233c79 ci: remove nightly tag movement from release workflow 2026-10-26 18:11:35 +06:00
Kingkor Roy Tirtho
91867f571d ci: remove nightly tag movement from release workflow 2026-10-26 17:39:24 +06:00
Kingkor Roy Tirtho
cde359a9de cd: add iOS build process and package IPA for release 2026-10-26 16:52:56 +06:00
Kingkor Roy Tirtho
1b4fbe8d25 chore: update changelog for 6.0.0-beta.0 and optimize Gradle build settings 2026-10-26 16:40:46 +06:00
Kingkor Roy Tirtho
67e452493a feat(release): add beta release type
A beta is the stable build released as <version>-beta.N, with N set via
betaNumber in composeApp/build.gradle.kts. Betas and stable releases are
drafts; only nightly publishes immediately.
2026-10-26 16:34:02 +06:00
Kingkor Roy Tirtho
d7c7e3108d docs: add new features and enhancements for version 6.0.0 2026-10-26 16:09:24 +06:00
Kingkor Roy Tirtho
1ea5f1d1ee fix: update website URLs from spotube.org to spotube.cc 2026-10-26 15:31:17 +06:00
Kingkor Roy Tirtho
9bb80ebb47 Merge branch 'master' into dev 2026-10-26 15:23:11 +06:00
Kingkor Roy Tirtho
a7e47d0e00
Merge pull request #3165 from team-spotube/ios-crash-fix
Ios crash fix
2026-10-26 15:16:29 +06:00
Kingkor Roy Tirtho
bdf3655a23 fix(ios): make the login WebView identify as Mobile Safari
accounts.spotify.com rejected iOS logins ("Oops, something went wrong") because
the WebView sent WKWebView's default User-Agent, which lacks Safari's
"Version/x ... Safari/604.1" and reads as an embedded browser. The random
User-Agent header never reached any request: the screen replaces the content
before it loads.

Have WebKit build Safari's exact UA (with its frozen OS version) via a probe
WKWebView using Safari's applicationNameForUserAgent, prime it at startup and
apply it through webSettings.customUserAgentString so every request uses it.
2026-10-26 15:04:26 +06:00
Kingkor Roy Tirtho
cc21568457 fix(build): restore version catalog lost in dev merge
The merge of dev resolved the libs.versions.toml conflict by taking the wrong
catalog: it reverted the Kotlin 2.4.21 / Compose 1.12.2 / Zipline 1.28.0
upgrade and dropped dev's jmustache entries, so buildSrc failed with
"Unresolved reference 'jmustache'". Keep both sides: mokkery 3.5.1 plus jmustache.
2026-10-26 15:04:26 +06:00
Kingkor Roy Tirtho
7fa3b029f6 Merge branch 'dev' into ios-crash-fix
# Conflicts:
#	gradle/libs.versions.toml
2026-10-26 13:45:32 +06:00
Kingkor Roy Tirtho
cb8316d141 fix(ios): stream formats AVPlayer can play
AVPlayer can't open WebM/Ogg or DASH, so the default opus/webm stream failed
silently on iOS.

- Rank streams the platform's player can play above format/quality prefs
- Default iOS streaming to aac/mp4, migrate saved WebM/Ogg prefs on iOS and
  hide unplayable formats in the picker (desktop/Android keep opus/webm)
- Normalize codec/container names on iOS like androidJvm (mp4a -> aac, m4a -> mp4)
- Wait up to 30s for the audio plugin to load when resolving, so the queue
  restored at startup isn't failed permanently before plugins are ready
2026-10-26 13:40:00 +06:00
Kingkor Roy Tirtho
99ba08501b fix(server): don't crash when the proxy port is still in use
CIO reports a failed bind from its accept coroutine; unhandled, that
exception terminates the process on Kotlin/Native before the port fallback
can run, so relaunching iOS shortly after playback crashed (EADDRINUSE).
Contain it with a parent CoroutineExceptionHandler and enable reuseAddress
so the preferred port can be rebound while old connections are in TIME_WAIT.
2026-10-26 13:40:00 +06:00
Kingkor Roy Tirtho
0acc457890 feat(plugin): implement automatic plugin updates and enhance JSON parsing 2026-10-26 12:44:04 +06:00
Kingkor Roy Tirtho
7eb56c8593 fix(ios): run Zipline on an 8 MiB stack thread
newSingleThreadContext threads get only 512 KiB of stack on iOS, while
QuickJS may recurse ~6 MiB deep when compiling a plugin, so the app crashed
with SIGBUS at startup once a plugin was installed. Host the dispatcher on an
NSThread with an 8 MiB stack, matching JVM/Android.
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
ff7a1714ff refactor(network): central HttpClientFactory; drop Ktor CIO client
On iOS the CIO client engine competed with Darwin as the default engine and
has no TLS on Kotlin/Native, so HTTPS requests (e.g. plugin discovery) failed.

Remove ktor-client-cio and build every client through a Koin-provided
HttpClientFactory that owns one client on the platform default engine
(Darwin on iOS, OkHttp on Android/desktop). Consumers use the shared
default/json clients or derive their own via create { }, sharing the engine.
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
09df4b494c build: upgrade to Kotlin 2.4.21 and Compose Multiplatform 1.12.2
Several dependencies (coil 3.5.0, millennium, mqtt/ditchoom, composeunstyled,
markdown-renderer, composewebview, kmp-zip, filekit) now ship klibs built with
Kotlin 2.4, which Kotlin/Native 2.3 refuses to read, breaking the iOS build.

- Bump zipline (1.28.0), mokkery (3.5.1) and ksp (2.3.12) for the new compiler
- Type TOP_LEVEL_ROUTES as Set<NavKey> (Kotlin 2.4 inferred Set<Any>)
- Import ComposeApp in iOSApp.swift, which uses ExternalUriHandler
2026-10-23 11:54:29 +06:00
Kingkor Roy Tirtho
69a310c78f
Release 5.1.2+45 (#3049)
* chore: fix white screen issue

* fix(newpipe): fallback to muxed streams if no audio stream is available

* fix: dismiss search dropdown and keyboard on submission

Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com>

* fix: custom image helper null exception

* chore: generate changelog and bump version for release

---------

Co-authored-by: tomasalias <pasek.domi@post.cz>
Co-authored-by: Akshat-104 <akkusuryan@gmail.com>
Co-authored-by: Tobse <1190109+TobseF@users.noreply.github.com>
2026-06-05 10:49:09 +06:00
58 changed files with 1151 additions and 235 deletions

View File

@ -76,7 +76,7 @@ body:
description: Where did you install Spotube from?
multiple: true
options:
- "Website (spotube.org)"
- "Website (spotube.cc)"
- "GitHub Releases (Binary)"
- "GitHub Actions (Nightly Binary)"
- "Play Store (Android)"

View File

@ -10,6 +10,7 @@ on:
default: 'stable'
options:
- stable
- beta
- nightly
dry_run:
description: 'Dry run (build & upload artifacts only, skip creating release)'
@ -26,7 +27,7 @@ on:
permissions:
contents: read
# Never cancel a running release when another is dispatched, and let stable and nightly run side by side
# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}
cancel-in-progress: false
@ -215,7 +216,7 @@ jobs:
fi
# Desktop entry + metainfo, with the release history from git-cliff
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" .
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" .
- name: Upload tarball
uses: actions/upload-artifact@v7
@ -335,7 +336,7 @@ jobs:
fi
# Desktop entry + metainfo, with the release history from git-cliff
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type }}" .
packaging/package-linux-tarball.sh "$VERSION" "${{ inputs.release_type == 'nightly' && 'nightly' || 'stable' }}" .
- name: Upload tarball
uses: actions/upload-artifact@v7
@ -485,7 +486,16 @@ jobs:
env:
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmDmg -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
# The free macOS runner has 3 cores / 7 GB. With the default 4g Gradle + 3g Kotlin daemons idling,
# native-image only gets ~3 GB and spends ~40% of its time in GC (47 min build vs ~15 on Linux).
# A single-use daemon with a smaller heap and in-process Kotlin compilation leaves the RAM to it.
run: >
./gradlew :composeApp:packageGraalvmDmg
--no-daemon
-Dorg.gradle.jvmargs="-Xmx3g -XX:MaxMetaspaceSize=1g"
-Pkotlin.compiler.execution.strategy=in-process
-Pnightly=${{ inputs.release_type == 'nightly' }}
-PbuildNumber=${{ github.run_number }}
- name: Rename macOS artifact
run: |
@ -504,6 +514,75 @@ jobs:
with:
limit-access-to-actor: true
build-ios:
name: iOS
needs: prepare-deps
timeout-minutes: 120
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Download maven local
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x gradlew
# Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading
# (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate.
# The Gradle build phase inside Xcode reads these as project properties.
- name: Build unsigned app
env:
ORG_GRADLE_PROJECT_nightly: ${{ inputs.release_type == 'nightly' }}
ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }}
run: >
xcodebuild
-project iosApp/iosApp.xcodeproj
-scheme iosApp
-configuration Release
-sdk iphoneos
-destination 'generic/platform=iOS'
-derivedDataPath build/ios
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_REQUIRED=NO
CODE_SIGN_IDENTITY=""
DEVELOPMENT_TEAM=""
build
- name: Package IPA
run: |
APP=$(find build/ios/Build/Products/Release-iphoneos -maxdepth 1 -name "*.app" -type d | head -1)
mkdir Payload
cp -R "$APP" Payload/
zip -qry Spotube-ios.ipa Payload
- name: Upload IPA
uses: actions/upload-artifact@v7
with:
name: ios-ipa
path: Spotube-ios.ipa
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
create-release:
name: Create GitHub Release
if: ${{ !inputs.dry_run }}
@ -513,6 +592,7 @@ jobs:
- build-linux-arm64
- build-windows
- build-macos
- build-ios
runs-on: ubuntu-latest
permissions:
contents: write
@ -538,8 +618,9 @@ jobs:
- name: Set release variables
id: vars
run: |
if [ "${{ inputs.release_type }}" = "stable" ]; then
if [ "${{ inputs.release_type }}" != "nightly" ]; then
chmod +x gradlew
# printVersion includes the -beta.N suffix when betaNumber is set in build.gradle.kts
VERSION=$(./gradlew -q :composeApp:printVersion)
TAG="v${VERSION}"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
@ -561,7 +642,7 @@ jobs:
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg; do
for dir in android-apk linux-deb linux-rpm linux-appimage linux-tarball linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 linux-tarball-arm64 windows-exe macos-dmg ios-ipa; do
if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md
for f in "$dir"/*; do
@ -571,13 +652,6 @@ jobs:
fi
done
# The rolling nightly tag already exists, and the release action never moves an existing tag
- name: Move nightly tag to this commit
if: ${{ inputs.release_type == 'nightly' }}
run: |
git tag -f nightly "$GITHUB_SHA"
git push -f origin refs/tags/nightly
- name: Create Release
uses: softprops/action-gh-release@v3
with:
@ -601,3 +675,4 @@ jobs:
linux-tarball-arm64/*.tar.xz
windows-exe/*.exe
macos-dmg/*.dmg
ios-ipa/*.ipa

37
.vscode/settings.json vendored
View File

@ -1,4 +1,37 @@
{
"gradle.autoDetect": "off",
"java.gradle.buildServer.enabled": "off"
"gradle.autoDetect": "off",
"java.gradle.buildServer.enabled": "off",
"cmake.configureOnOpen": false,
"cSpell.words": [
"acousticness",
"ambiguate",
"Amoled",
"Buildless",
"configurators",
"danceability",
"fuzzywuzzy",
"gapless",
"instrumentalness",
"isrc",
"Mpris",
"RGBO",
"riverpod",
"Scrobblenaut",
"shadcn",
"skeletonizer",
"songlink",
"speechiness",
"Spotube",
"titlebar",
"winget"
],
"editor.formatOnSave": true,
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml",
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart"
},
"dart.flutterSdkPath": ".fvm/versions/3.35.2",
"makefile.configureOnOpen": false
}

View File

@ -19,9 +19,13 @@
## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations.
- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`).
- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority).
## Networking
- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop).
- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
## GraalVM native image (desktop)
- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`.
- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM.

View File

@ -1,5 +1,156 @@
# Changelog
## [6.0.0-beta.0](https://github.com/team-spotube/spotube/compare/v5.1.2...v6.0.0-beta.0) (2026-10-26)
### Features
- Migration to Compose Multiplatform
- Add ChipTab component and refactor SearchScreen to use it
- Add AutocompleteTextField component and integrate it into SearchScreen
- Enhance AutocompleteTextField with focus management and dropdown behavior
- Enhance AppSidebar with improved button interactions and visual feedback
- Implement endless playback recommendations in DeviceAudioPlayerQueue
- Add GhostIconButton component and enhance button interactions in TrackList
- Replace IconButton with GhostIconButton in AppExpandedPlayer and AppLargePlayer for improved visual consistency
- Replace IconButton with GhostIconButton in PlayerQueueContent and PlayableCard for visual consistency
- Enhance UI components with LocalBaseUITheme for consistent styling and improved interactions
- Enhance button styles and interactions in Buttons and Theme components for improved UI consistency
- Simplify highlight implementation in UI components for improved readability and maintainability
- Add genre selection and tabs to HomeScreen for improved content navigation
- Implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists
- Add genre and featured browsing capabilities to MediaBrowseHelper
- Implement playlist creation and editing functionality with UI components
- Add 'Add to Playlist' functionality with UI components and dialogs
- Add Toggle component and update UI to use it in place of Switch
- Implement settings section cards for updates, desktop, language, caching, and plugins
- Update icons in settings sections to use Iconsax library for improved UI consistency
- Replace FeatherIcons with Iconsax in PluginCard and PluginPermissionsDialog for improved UI consistency
- Refactor PluginPermissionsDialog to use custom UI components for improved consistency and readability
- Enhance UI with shared element transitions and improve state management in album and playlist screens
- Integrate Uniffi for audio metadata reading and writing with Lofty library
- Replace FeatherIcons with Iconsax in window controls and update sidebar with better design
- Implement haze effect and UI enhancements in AppLargePlayer and AppShell
- Enhance haze effect integration in AppShell and AppSidebar for improved UI experience
- Add adaptive dialog bottom sheet component and enhance plugin installation UI
- Enhance AdaptiveDialogBottomSheet with dismiss icon and improve plugin screen layout
- Implement CollectionView component and refactor Album, Playlist, SavedTracks screens for improved UI consistency
- Add current time management in AudioPlayer for improved playback control
- Simplify playback control logic in AudioPlayer by removing redundant state checks
- Introduce AudioPlayerInterface and refactor AudioPlayer implementations for better abstraction
- Update skipToNext and skipToPrevious methods in AudioPlayer for improved media item management
- Refactor LyricsViewModel to use AudioPlayerInterface for improved abstraction
- Add Discord Rich Presence integration for enhanced media playback visibility
- Implement System Tray integration for window management and media controls
- Add hover effect to menu items in System Tray for improved user experience
- Add SSH debug session setup on failure for improved troubleshooting
- Implement blacklist functionality for tracks and artists with UI updates
- **systemtray**: Self-manage tray lifecycle via Koin DI
- **plugin_interfaces**: Update Maven publishing configuration and add GitHub Actions workflow
- **plugin_discovery**: Implement GitHub plugin discovery and add logo support in UI
- **plugin_discovery**: Implement GitHub plugin discovery and add logo support and information dialog in UI
- **plugin_install**: Add plugin installation dialog and release fetching functionality
- Update dependencies and configure compose hot reload settings
- Improve NewPipe video rank matching
- Integrate Nucleus framework for enhanced window management and webview support
- Implement system tray functionality with Compose Native Tray integration
- **mediacontrol**: Implement SystemMediaControlService for OS media integration
- **devices**: Add device discovery and remote control features
- **deeplinks**: Implement deep link handling for jam sessions
- **remote-control**: Implement remote playback functionality and UI components
- **remote-control**: Enhance queue management and player state synchronization
- **remote-control**: Enhance remote playback functionality with new commands and UI integration
- **webrtc**: Update ICE server configuration and enhance logging for peer connection events
- **jam-session**: Add connection state management and update UI feedback for session status
- **jam-session**: Enhance jam session functionality with improved playback synchronization and error handling
- **remote-control**: Refactor service registration and cleanup logic for improved reliability
- **jam-session**: Integrate MQTT broker configuration and enhance jam session settings
- **jam-session**: Implement track addition to jam session and enhance UI feedback for jam state
- **jam-session**: Enhance guest functionality with track addition and participant moderation options
- **release**: Add support for Linux ARM64 builds and artifact uploads
- **player-options**: Add PlayerOptionsViewModel and enhance player UI with options menu
- **player-options**: Enhance options menu with localized strings and icons
- **jam-settings**: Implement JamSettings screen and view model for broker configuration
- Refactor track source management to support plugin-specific caching and retrieval
- Implement shuffle play functionality for albums, playlists, and saved tracks
- **navigation**: Refactor sidebar and bottombar navigation structure with new tab and group models
- **search**: Enhance LazyRow with drag scrolling functionality
- **autocomplete**: Simplify focus management and remove unused key event handling
- **update**: Implement update checker with dialog for available updates
- **update**: Refactor update checking mechanism and implement new dialog for app and plugin updates
- **scrobble**: Implement ScrobbleManager to handle track scrobbling and integrate with audio player
- **welcome**: Implement onboarding flow with multiple pages and donation options
- **previews**: Add comprehensive previews for UI components including sliders, toggles, checkboxes, and text fields
- **settings**: Add 'About' section and improve header visibility in settings sections
- **surface**: Implement Frutiger-Aero themed surfaces with glass and acrylic styles
- **ui**: Replace Scaffold with BaseScaffold for consistent surface theming across screens
- **ui**: New custom surface integration on all components
- **onboarding**: Enhance welcome screen with structured guide steps and improved layout
- **player**: Add content descriptions for play, pause, and add to queue actions
- **support**: Implement support reminder dialog and donation options
- **player**: Refactor artist display and improve search field labels
- **search**: Enhance search functionality with recent search suggestions and improved UI components
- **sidebar**: Adjust spacing in sidebar and setting cards for improved layout
- **jam**: Implement group jam functionality with UI components and state management
- **library**: Enhance library UI with new tab titles, filtering options, and improved state management
- **library**: Add Library screen list mode beside grid mode, Local Track, Download screen, Local Library folders dialog visual improvement
- **ui**: Integrate LocalTextStyle for dynamic tinting in player controls and UI components
- **navigation**: Add custom navigation transition specifications for push and pop actions
- **i18n**: Replace hardcoded strings with localized resources across multiple components
- **plugin**: Implement MetadataPluginGuard to manage plugin dependencies and user prompts
- **ui**: Replace IndeterminateProgress with new loading components and enhance dialog structures
- **ui**: Update MillenniumModalBottomSheet with compact padding and spacing adjustments
- **plugin**: Add login prompt for metadata plugin access and implement authentication view model
- **user-agents**: Implement dynamic user agent fetching and caching mechanism
- **lyrics**: Implement SyncedLyricsList component and enhance LyricsViewModel for better state management
- **server**: Implement port fallback mechanism for playback proxy server
- **window**: Implement window state persistence and restoration
- Enhance Android build configuration with stable/nightly flavors and update launcher icons
- **graalvm**: Enable GraalVM native image support and update packaging configurations
- **local-tracks**: Implement LocalFolder screen and view model for folder navigation
- **navigation**: Implement fade-through and stack transition animations for navigation
- **remote**: Redesign remote control and devices screens, fix connection issues
- Download diagnostics, live connect status, remote settings on devices screen
- Enhance loading states for various UI components and update dependencies
- **packaging**: Flathub, AUR, WinGet and Chocolatey publishing
- **plugin**: Implement automatic plugin updates and enhance JSON parsing
### Bug Fixes
- **newpipe**: Fallback to muxed streams if no audio stream is available
- Dismiss search dropdown and keyboard on submission
- Custom image helper null exception
- **plugin**: Reload Zipline service on same-version replace
- Convert buildCurrentMediaItemList to a suspend function for coroutine support
- Enhance Discord RPC connection management with cooldown and error handling
- Cached track audio not being invalidated causing alternative track source to not being played
- Play next not working
- Update pendingNextIndex when playing a track from the playlist
- **webview**: Linux webview crashing the app
- Add mavenLocal() to plugin and dependency resolution management
- **plugin**: Handle service closure exception during authentication check
- **sidebar**: Add spacer to AppSidebar for improved layout
- **remote-control**: Update connection handling and improve logging for remote control events
- Add window insets to top bar and bottom navigation bar, queue sheet
- **stream-proxy**: Stream upstream audio instead of buffering the whole track
- **newpipe**: Fall back through multiple searches when ISRC search misses
- **audio-api**: Enhance fallback mechanism for text search results in YouTube
- **audio-player**: Android shuffle wasnt working
- **stream-proxy**: Improve logging for cache timeout and handle disconnection scenarios
- **macos**: Add GraalVM runtime dependency and initialize GraalVm in main function
- **android**: Create intermediate directories for Rust targets to prevent runtime errors
- **android**: Update sharing functionality to use chooser intent with new task flag
- **android**: Simplify service start logic for PlaybackService to avoid crashes on background playback
- **android**: Update playback state management to ensure guests follow host's play state
- **android**: App stuck on splash screen from main-thread string loading
- **downloads**: Respect download format/quality and never save manifests or video
- **ios**: Run Zipline on an 8 MiB stack thread
- **server**: Don't crash when the proxy port is still in use
- **ios**: Stream formats AVPlayer can play
- **gradle**: Restore VFS watch setting and clean up properties
- **build**: Restore version catalog lost in dev merge
- **ios**: Make the login WebView identify as Mobile Safari
- Update website URLs from spotube.org to spotube.cc
## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05)
### Bug Fixes
@ -23,6 +174,8 @@
- **playback**: Add dab music source
- **playback**: Add uncompressed flac playback support
- Add plugin audio source models and api service
- **plugins**: Filter plugins by abilities in plugins page and show abilities as
badge
- **plugins**: Filter plugins by abilities in plugins page and show abilities as
badge
- Add setting default audio source support
@ -54,6 +207,8 @@
- **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints
- **metadata-plugin**: Add local storage api
- Add webview, totp and setInterval apis for plugins
- Enhance local storage and webview APIs with improved error handling and
resource management
- Enhance local storage and webview APIs with improved error handling and
resource management
- **metadata_plugin**: Add logout method
@ -92,6 +247,8 @@
- Local track not working and images of local not showing up
- Local playback not working for tracks with special # (hashtag) characters
- Inaccessible streaming url causing rapid skips
- **yt**: Fallback to different search result if all streaming url is
inaccessible
- **yt**: Fallback to different search result if all streaming url is
inaccessible
- **playback**: Skip network requests if cached file already exists
@ -124,6 +281,10 @@
- **android**: navigation overlaying in app navigation
- add to playlist not working in smaller screen devices
- language picker search broken
- **generate_playlist**: create playlist not adding tracks nor navigating to
playlist page
- **desktop**: double titlebar in local library folders and massive space in
overlay player
- **generate_playlist**: create playlist not adding tracks nor navigating to
playlist page
- **desktop**: double titlebar in local library folders and massive space in
@ -154,6 +315,8 @@
- youtube source taking too long to buffer
- youtube tracks keeps skipping despite being matched correctly
- follow artist not working #2057
- youtube_explode_dart failing for many videos due to youtube ios client visitor
data change
- youtube_explode_dart failing for many videos due to youtube ios client visitor
data change
- piped api not working
@ -180,6 +343,10 @@
#2039
- **mobile**: ensure audio session is activated when playback is resumed after
interruption #2092
- selecting an Alternative Track Source removes the current song from the queue
#2039
- **mobile**: ensure audio session is activated when playback is resumed after
interruption #2092
### Features
@ -215,11 +382,15 @@
- **translations**: correct some basque incorrect translations (#1815)
- **lyrics**: LRCLIB lyrics should be usable without logging in #1803
- playlist displaying descriptions unescaped html #1784
- **android**: pressing back while the player is open doesn't take to previous
page
- **android**: pressing back while the player is open doesn't take to previous
page
- handle dublicated items in playback queue correctly #1852
- **desktop**: scrollbar overlapping with more options of tracks and playlists
- **discord**: stop discord rpc from try update presence when not connected
- **stats**: minutes page shows plays and streams page shows minutes which
should be the opposite #1880
- **stats**: minutes page shows plays and streams page shows minutes which
should be the opposite #1880
- **android**: clears queue upon swiping away notification
@ -1655,9 +1826,13 @@
### New
- Playback Cache Support. So unfinished playlist and tracks remains cached &
starts automatically when application is launched again
- Playback Cache Support. So unfinished playlist and tracks remains cached &
starts automatically when application is launched again
- Login Screen guided tutorial about how to obtain Client ID & Client Secret
- Signed Android Application so now longer need to uninstall the old version for
installing the new one
- Signed Android Application so now longer need to uninstall the old version for
installing the new one
- OS Media controls for Linux. Keyboard media keys now work in Linux
@ -1677,6 +1852,10 @@
### Improved
- Loading screens & animations. Now uses Skeleton Loading
- Playlist & Album Pages now show Album Art & extra metadata as Header with
vibrant gradient background in a Sliver
- Playback is now more consistent & the API is simpler. Also its the single
source of truth for AudioPlayback instead of the AudioServiceHandler
- Playlist & Album Pages now show Album Art & extra metadata as Header with
vibrant gradient background in a Sliver
- Playback is now more consistent & the API is simpler. Also its the single
@ -1686,6 +1865,10 @@
in Desktop edition
- Track match Cache support for previously played tracks. This dramatically
reduces track change latency & load on the YouTube search engine too
- Home Genre playlists can be scrolled horizontally by dragging with mouse even
in Desktop edition
- Track match Cache support for previously played tracks. This dramatically
reduces track change latency & load on the YouTube search engine too
### Bug Fixes
@ -1790,6 +1973,8 @@
- Search - now scrolls & paginates for Playlists & Albums
- Authentication - allows guest accounts making authentication optional
- Lyrics - can be fetched without requiring GeniusAccessToken. This makes
geniusAccessToken optional
- Lyrics - can be fetched without requiring GeniusAccessToken. This makes
geniusAccessToken optional
- UI snappiness & faster load times
@ -1806,6 +1991,8 @@
### New
- Global custom reconfigurable _hotkey_ support for playback controls
(play-pause/next/previous)
- Global custom reconfigurable _hotkey_ support for playback controls
(play-pause/next/previous)
- Credit section in the Settings page with important links
@ -1853,6 +2040,10 @@
### Bug fixes
- Unsafe access to album art/artist/user Images with `.first` or `.last` causing
accessing empty List error
- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain
\*nix OSs
- Unsafe access to album art/artist/user Images with `.first` or `.last` causing
accessing empty List error
- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain
@ -1876,6 +2067,8 @@
- Complete re-write in Flutter/Dart (799e13c)
- mpv & youtube-dl runtime dependencies dropped (07b1891)
- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based
playback & streaming
- just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based
playback & streaming
- lyrics are provided by genius.com (requires access_token) (d647d5e)
@ -1893,6 +2086,8 @@
### New
- Automated installer for Windows (now doesn't require manual mpv-player
install)
- Automated installer for Windows (now doesn't require manual mpv-player
install)
- Playback caching
@ -1902,11 +2097,15 @@
### Improved
- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search
page
- Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search
page
### Bug fixes
- Cached image didn't get deleted after exiting certain cache limit fix. Cache
gets recreated after exiting the limit
- Cached image didn't get deleted after exiting certain cache limit fix. Cache
gets recreated after exiting the limit
@ -1925,12 +2124,16 @@
### Bug Fixes
- `access_token not found` Error after OAuth Login with Spotify credentials
(used to need a restart of the app to load the access_token)
- `access_token not found` Error after OAuth Login with Spotify credentials
(used to need a restart of the app to load the access_token)
- Volume level wasn't cached even after changing volume
# v0.0.1
Spotube v0.0.1 - initial release of the open source software for playing Spotify
music using Youtube public API
Spotube v0.0.1 - initial release of the open source software for playing Spotify
music using Youtube public API
@ -1939,6 +2142,8 @@ music using Youtube public API
- Local playback handling
- Playback Queue
- Save to Liked Tracks/Playlists
- Bypass API rate limitation on basic usage using personal developer Apps for
spotify API
- Bypass API rate limitation on basic usage using personal developer Apps for
spotify API
- Youtube search & get handled using scrape-yt

View File

@ -4,7 +4,7 @@
A cross-platform extensible open-source music streaming platform.<br>
Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era!
<a href="https://spotube.org"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
<a href="https://patreon.com/krtirtho"><img alt="Support me on Patron" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/patreon-singular_vector.svg"></a>
@ -37,11 +37,11 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm
### ⬇️ Download
Download Spotube from the Official Website: https://spotube.org
Download Spotube from the Official Website: https://spotube.cc
### 🔄 Nightly Builds
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.org/downloads/nightly).
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly).
## 🕳️ Building from source
@ -166,7 +166,7 @@ Android picks the Rust profile from its build type (`debug` or `release`) on its
### Releases
Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable` or `nightly`. Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK.
Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable`, `beta` or `nightly`. A beta is the stable build released like a stable release (draft, not a pre-release), tagged `v<version>-beta.N` (set `betaNumber` in `composeApp/build.gradle.kts` to N, and back to `null` for stable). Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK.
## 👥 The Spotube team

View File

@ -48,6 +48,10 @@ plugins {
val bundleId = "org.spotube.spotube"
val version = "6.0.0"
// Set to N to release <version>-beta.N (same app id, icons and name as stable); null for a plain stable release.
// Installer metadata (packageVersion) must stay numeric, so it keeps using plain `version`.
val betaNumber: Int? = 0
// Betas share the stable app id, so bump this for every beta: Android only updates to a higher versionCode.
val buildNumber = 46
// Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true.
val isNightly = providers.gradleProperty("nightly").map { it.toBoolean() }.getOrElse(false)
@ -68,9 +72,9 @@ val isProductionBuild = providers.gradleProperty("production").map { it.toBoolea
}
)
val targetName = "Spotube"
val homePageUrl = "https://spotube.org"
val homePageUrl = "https://spotube.cc"
val repositoryUrl = "https://github.com/team-spotube/spotube"
val appVersionName = version
val appVersionName = if (betaNumber != null) "$version-beta.$betaNumber" else version
val appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber
val appRepositoryUrl = repositoryUrl
// CI reads the release version from here instead of scraping this file.
@ -232,7 +236,6 @@ kotlin {
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
@ -363,6 +366,7 @@ kotlin {
implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control)
implementation(libs.nucleus.darkmode.detector)
}
}
}
@ -376,8 +380,8 @@ android {
applicationId = bundleId
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = buildNumber
versionName = version
versionCode = appBuildNumber
versionName = appVersionName
}
// stable = production channel, nightly = beta channel (own icon, name and installable side by side)
flavorDimensions += "channel"

View File

@ -162,14 +162,6 @@ actual class NewPipeService {
* "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded
* aac/mp4 DASH/HLS manifests always won instead.
*/
private fun normalizeCodec(codec: String?): String {
val value = codec.orEmpty()
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
}
private fun normalizeContainer(suffix: String): String =
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
DeliveryMethod.DASH -> StreamProtocol.DASH
DeliveryMethod.HLS -> StreamProtocol.HLS

View File

@ -253,7 +253,7 @@ fun App(
onDismiss = appUpdateService::dismissUpdate,
onIgnore = appUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser("https://spotube.org/downloads/") }
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate()
})
}
@ -262,10 +262,7 @@ fun App(
update = update,
onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
})
onUpdate = pluginUpdateService::installUpdate)
}
SupportDialog(
visible = showSupportDialog,

View File

@ -39,4 +39,6 @@ object DebugConfig {
* Keep it `false` when committing.
*/
const val FORCE_SUPPORT_REMINDER: Boolean = false
const val FORCE_VLC_LOGS: Boolean = false
}

View File

@ -17,6 +17,8 @@
package org.spotube.spotube.core.di
import org.koin.core.module.dsl.onClose
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository
@ -108,6 +110,7 @@ expect val platformModules: Module
val sharedModules = module {
includes(navigationModule)
singleOf(::Database)
single { HttpClientFactory() } withOptions { onClose { it?.close() } }
singleOf(::WebViewController)
@ -164,7 +167,7 @@ val sharedModules = module {
viewModelOf(::WelcomeViewModel)
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { AppUpdateService(get(), get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads
@ -273,7 +276,7 @@ val sharedModules = module {
createdAtStart()
}
single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() }
single { RemoteControlClient(get()) }
singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart()

View File

@ -40,7 +40,7 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.koin.core.component.KoinComponent
val TOP_LEVEL_ROUTES = setOf(
val TOP_LEVEL_ROUTES: Set<NavKey> = setOf(
Routes.Home,
Routes.Search,
Routes.Library,

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* The single place the app builds Ktor [HttpClient]s.
*
* It owns one client on the platform's default engine (Darwin on iOS, OkHttp on
* Android/desktop), so every client shares that engine's connection pool and
* no code path can accidentally pick an engine that doesn't work on a platform.
*
* - [default] and [json] are shared: use them directly and never close them.
* - [create] derives a client with extra configuration (timeouts, WebSockets,
* default request, …). It reuses the shared engine; the caller owns the
* returned client and may close it without affecting anyone else.
*/
class HttpClientFactory : AutoCloseable {
private val root = HttpClient()
/** Plain client with no plugins installed. */
val default: HttpClient get() = root
/** Client that (de)serializes JSON bodies via [HttpJson]. */
val json: HttpClient by lazy {
create { install(ContentNegotiation) { json(HttpJson) } }
}
fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block)
override fun close() {
root.close()
}
}
/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */
val HttpJson = Json {
ignoreUnknownKeys = true
isLenient = true
}

View File

@ -49,4 +49,14 @@ data class MediaStream(
val container: String,
val bitrate: Int,
val isAudio: Boolean,
val isVideo: Boolean)
val isVideo: Boolean)
/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */
internal fun normalizeCodec(codec: String?): String {
val value = codec.orEmpty()
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
}
/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */
internal fun normalizeContainer(suffix: String): String =
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix

View File

@ -17,8 +17,8 @@
package org.spotube.spotube.core.remote
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header
import io.ktor.client.request.url
@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed
* WebSocket client for controlling a remote Spotube instance.
* Connects to the remote device's `/control` endpoint and sends commands.
*/
class RemoteControlClient {
class RemoteControlClient(httpClientFactory: HttpClientFactory) {
private val logger = Logger.withTag("RemoteControlClient")
private val json = Json {
ignoreUnknownKeys = true
@ -59,7 +59,7 @@ class RemoteControlClient {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val httpClient = HttpClient {
private val httpClient = httpClientFactory.create {
install(WebSockets)
install(HttpTimeout) {
connectTimeoutMillis = 10_000

View File

@ -17,16 +17,15 @@
package org.spotube.spotube.core.server
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.remote.RemoteControlHandler
import org.spotube.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
@ -60,11 +59,12 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
private val remoteControlHandler: RemoteControlHandler,
httpClientFactory: HttpClientFactory) : KoinComponent {
val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient {
// A stalled upstream connection must not wedge the CIO dispatcher thread
private val httpClient = httpClientFactory.create {
// A stalled upstream connection must not wedge an engine thread
// forever. Only the connect phase is bounded — the proxy streams long
// audio bodies, so request/socket timeouts would cut playback short.
install(HttpTimeout) {

View File

@ -21,7 +21,10 @@ package org.spotube.spotube.core.server
import io.ktor.server.application.Application
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer
import io.ktor.server.application.serverConfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeout
@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val
* preferred port can't be bound, retry on an OS-assigned free port (port 0).
*
* The caller must read [BoundServer.port] instead of assuming [preferredPort].
*
* `reuseAddress` lets a restarted app rebind its old port while connections from the previous
* run (e.g. the player's) are still in TIME_WAIT.
*/
internal suspend fun startServerWithPortFallback(
host: String,
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
val candidates = listOf(preferredPort, 0).distinct()
var lastFailure: Throwable? = null
// CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the
// process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still
// reaches us through resolvedConnectors(), so it only needs to be contained here.
val swallowBindFailure = CoroutineExceptionHandler { _, _ -> }
for (candidate in candidates) {
val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module)
val engine = embeddedServer(
factory = CIO,
rootConfig = serverConfig {
parentCoroutineContext = swallowBindFailure
module { module() }
},
configure = {
connector {
this.host = host
this.port = candidate
}
reuseAddress = true
})
try {
engine.start(wait = false)
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }

View File

@ -425,7 +425,7 @@ internal class StreamProxy(
if (requestMethod == HttpMethod.Head) {
call.respond(object : OutgoingContent.NoContent() {
override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders
override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength
})
return
@ -446,7 +446,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders
override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength
override val contentType: ContentType? =
downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse)
@ -594,4 +594,28 @@ internal class StreamProxy(
}
}.build()
}
}
private val PLAYER_PASSTHROUGH_HEADERS = listOf(
HttpHeaders.ContentRange,
HttpHeaders.AcceptRanges,
HttpHeaders.ETag,
HttpHeaders.LastModified,
)
/**
* The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate
* Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop
* headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after
* the response headers. Content-Range and Accept-Ranges must stay: without them the player
* treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed.
*/
internal fun Headers.passthroughForPlayer(): Headers {
val upstream = this
return HeadersBuilder().apply {
PLAYER_PASSTHROUGH_HEADERS.forEach { name ->
upstream.getAll(name)?.forEach { value -> append(name, value) }
}
}.build()
}

View File

@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.plugin.AudioPluginSource
import org.spotube.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent
import kotlin.math.abs
import kotlin.time.Clock
@ -75,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
@ -111,7 +117,14 @@ class StreamingUrlRepository(
// read it, nor replace it with a stream playback didn't ask for.
val useUrlCache = request == null
// A built-in audio plugin is always selected, so null means plugins are still loading.
// The player requests the restored queue's track right at startup; failing that request
// leaves the item permanently failed (AVPlayer), so wait for the plugin instead.
val audioPlugin = pluginManager.selectedAudioPlugin.value
?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) {
logger.i { "[Resolve $trackId] waiting for the audio plugin to load" }
pluginManager.selectedAudioPlugin.filterNotNull().first()
}
if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null
@ -133,7 +146,7 @@ class StreamingUrlRepository(
} else {
stream.streams
}
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
}
@ -243,10 +256,12 @@ class StreamingUrlRepository(
logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" +
"(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"lowest wins):\n" +
streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
@ -254,6 +269,10 @@ class StreamingUrlRepository(
}
when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(platform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject"
}
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})"
@ -371,6 +390,8 @@ class StreamingUrlRepository(
* Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
* resolved against the YouTube origin; progressive URLs are returned unchanged.
*/
private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds
internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url
return when (protocol) {
@ -379,12 +400,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
}
}
/** Selects the available stream closest to the user's preferred format and quality. */
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
/**
* Selects the available stream closest to the user's preferred format and quality.
* Streams the [platform]'s player can decode always win over ones it can't, whatever the
* preferences say: e.g. opus/webm is unplayable on iOS.
*/
internal fun selectPreferredAudioStream(
streams: List<AudioStream>,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
preferredQuality: AudioQuality,
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream ->
if (stream.isPlayableOn(platform)) 0 else 1
}.thenBy { stream ->
formatMismatchCount(stream, preferredFormat)
}.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1
@ -395,6 +425,25 @@ internal fun selectPreferredAudioStream(
}
)
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
// AVPlayer has no WebM/Ogg/Matroska demuxer.
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
/**
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) &&
!(platform == PlatformType.IOS && protocol == StreamProtocol.DASH)
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)

View File

@ -17,12 +17,13 @@
package org.spotube.spotube.core.tools.user_agents
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.getPlatform
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
@ -53,7 +54,8 @@ import kotlin.time.Clock
* [random] is intentionally synchronous and never blocks on the network: it serves
* whatever is cached (even stale) and refreshes in the background when needed.
*/
object UserAgents {
object UserAgents : KoinComponent {
private val httpClientFactory: HttpClientFactory by inject()
private const val SOURCE_URL =
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
private const val CACHE_FILE_NAME = "user_agents_cache.json"
@ -111,14 +113,9 @@ object UserAgents {
private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
private suspend fun fetchRemote(): List<String> {
val client = HttpClient(CIO)
try {
val res = client.get(SOURCE_URL)
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
} finally {
client.close()
}
val res = httpClientFactory.default.get(SOURCE_URL)
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
}
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()

View File

@ -17,7 +17,6 @@
package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -205,7 +204,7 @@ fun SpotubeTheme(
val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false
Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme()
Theme.SYSTEM -> isSystemDarkTheme()
}
val palette: MillenniumPalette = settings.palette

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.runtime.Composable
/** Whether the OS is in dark mode, observed live; desktop can't rely on Compose's own check under Tao. */
@Composable
expect fun isSystemDarkTheme(): Boolean

View File

@ -40,7 +40,8 @@ class ZiplineDispatcher(
* size is ~1 MiB, which causes native stack overflow when compiling large
* JS modules like kotlin-stdlib (~491 KB).
*
* This expect/actual ensures the backing thread has a sufficiently large stack.
* iOS secondary threads get only 512 KiB, so every platform must create its own
* large-stack thread. This expect/actual ensures the backing thread has one.
*/
expect fun createZiplineDispatcher(): ZiplineDispatcher

View File

@ -17,11 +17,12 @@
package org.spotube.spotube.core.zipline.host_apis
import org.koin.core.component.inject
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.host_apis.HttpClientAPI
import org.spotube.plugin_interfaces.host_apis.HttpMethod
import org.spotube.plugin_interfaces.host_apis.HttpResponse
import org.spotube.spotube.core.di.injectLogger
import io.ktor.client.HttpClient
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
val logger by injectLogger<RealHttpClientAPI>()
val httpClient = HttpClient()
private val httpClientFactory: HttpClientFactory by inject()
private val httpClient get() = httpClientFactory.default
override suspend fun request(
method: HttpMethod,

View File

@ -17,11 +17,14 @@
package org.spotube.spotube.core.zipline.plugin_apis.lrclib
import org.koin.core.component.inject
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.network.HttpJson
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class LRCLibResponse(
@ -47,19 +49,22 @@ data class LRCLibResponse(
val plainLyrics: String?,
val syncedLyrics: String?)
class RealLRCLibLyricsAPI : LyricsAPI {
private val httpClient = HttpClient {
install(Logging) {
level = LogLevel.ALL
logger = Logger.SIMPLE
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
defaultRequest {
url("https://lrclib.net/api/get")
// TODO: Add the actual version of Spotube instead of hardcoding it
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin")
class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent {
private val httpClientFactory: HttpClientFactory by inject()
private val httpClient by lazy {
httpClientFactory.create {
install(Logging) {
level = LogLevel.ALL
logger = Logger.SIMPLE
}
install(ContentNegotiation) {
json(HttpJson)
}
defaultRequest {
url("https://lrclib.net/api/get")
// TODO: Add the actual version of Spotube instead of hardcoding it
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin")
}
}
}

View File

@ -17,15 +17,15 @@
package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt
import org.koin.core.component.inject
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import org.spotube.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent {
.lowercase()
}
val client = HttpClient() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
private val httpClientFactory: HttpClientFactory by inject()
private val client get() = httpClientFactory.json
private fun MetadataTrack.describe() =
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.downloads
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.paths.Paths
@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.core.server.urlSummary
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.head
@ -88,7 +88,8 @@ sealed interface DownloadStatus {
class DownloadManager(
private val settingsRepository: SettingsRepository,
private val paths: Paths,
private val streamingUrlRepository: StreamingUrlRepository
private val streamingUrlRepository: StreamingUrlRepository,
private val httpClientFactory: HttpClientFactory
) {
companion object {
const val MAX_CONCURRENT_DOWNLOADS = 4
@ -101,7 +102,7 @@ class DownloadManager(
private val fileSystem = FileSystem.SYSTEM
private val queueMutex = Mutex()
private val httpClient = HttpClient {
private val httpClient = httpClientFactory.create {
install(HttpTimeout) {
requestTimeoutMillis = 300_000
connectTimeoutMillis = 30_000

View File

@ -17,9 +17,9 @@
package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
@ -27,7 +27,6 @@ import io.ktor.http.append
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class GitHubRepoSearchResponse(
@ -64,12 +63,8 @@ data class GitHubAsset(
val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String)
class GitHubPluginRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */
class GitHubPluginRepository(private val httpClient: HttpClient) {
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
return httpClient.get("https://api.github.com/search/repositories") {
@ -113,8 +108,4 @@ class GitHubPluginRepository {
emptyList()
}
}
fun close() {
httpClient.close()
}
}

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.spotube.core.db.Database
import org.spotube.spotube.core.db.DatabaseKeys
import org.spotube.spotube.core.di.injectLogger
@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.core.zipline.BuiltInPluginService
import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.ZiplinePluginService
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.readRemaining
@ -113,15 +113,19 @@ interface ScrobblePluginSource {
class PluginManager(
val database: Database,
val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
val paths: Paths,
private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource {
private val logger by injectLogger<PluginManager>()
// plugin.json may carry fields newer than this app knows about (e.g. `entryPoint`).
private val pluginJsonParser = Json { ignoreUnknownKeys = true }
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
val pluginsDirPath: Path get() = pluginsDir
private val httpClient = HttpClient()
private val httpClient get() = httpClientFactory.default
enum class InstallPromptKind {
INSTALL,
@ -324,6 +328,15 @@ class PluginManager(
}
}
/** Downloads and installs a newer plugin build over the installed one, without prompting. */
suspend fun updatePluginFromURL(url: String) {
withContext(Dispatchers.IO) {
val response = httpClient.get(url)
val bytes = response.bodyAsChannel().readRemaining().readByteArray()
addPluginFromByteArray(bytes = bytes, allowReplacingInstalled = true)
}
}
/** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */
suspend fun preparePlugin(bytes: ByteArray) {
withContext(Dispatchers.IO) {
@ -362,7 +375,7 @@ class PluginManager(
val pluginJson =
FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() }
val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson)
pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty()))
}
@ -484,7 +497,7 @@ class PluginManager(
}
val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson)
pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty()))
}

View File

@ -18,9 +18,18 @@
package org.spotube.spotube.modules.plugin
import com.goncalossilva.murmurhash.MurmurHash3
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
@Serializable
enum class PluginCapability {
@SerialName("persistent_storage")
PERSISTENT_STORAGE,
@ -43,6 +52,38 @@ enum class PluginAbility {
SCROBBLE,
}
/**
* Decodes a list of enums while dropping values this app version doesn't know about, so a
* plugin.json written for another plugin API revision still parses.
*/
abstract class LenientEnumListSerializer<T : Enum<T>>(
private val elementSerializer: KSerializer<T>) : KSerializer<List<T>> {
private val listSerializer = ListSerializer(elementSerializer)
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun serialize(encoder: Encoder, value: List<T>) =
listSerializer.serialize(encoder, value)
override fun deserialize(decoder: Decoder): List<T> {
val jsonDecoder = decoder as? JsonDecoder ?: return listSerializer.deserialize(decoder)
val array = jsonDecoder.decodeJsonElement() as? JsonArray
?: throw SerializationException("Expected a JSON array")
return array.mapNotNull { element ->
try {
jsonDecoder.json.decodeFromJsonElement(elementSerializer, element)
} catch (_: SerializationException) {
null
}
}
}
}
object LenientPluginCapabilityListSerializer :
LenientEnumListSerializer<PluginCapability>(PluginCapability.serializer())
object LenientPluginAbilityListSerializer :
LenientEnumListSerializer<PluginAbility>(PluginAbility.serializer())
@Serializable
data class PluginEntry(
val name: String,
@ -50,7 +91,9 @@ data class PluginEntry(
val apiVersion: String,
val description: String,
val author: String,
@Serializable(with = LenientPluginCapabilityListSerializer::class)
val capabilities: List<PluginCapability>,
@Serializable(with = LenientPluginAbilityListSerializer::class)
val abilities: List<PluginAbility>,
val contact: String,
val repository: String,

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.di.injectLogger
@ -141,10 +142,11 @@ private data class PluginScreenState(
@OptIn(ExperimentalCoroutinesApi::class)
class PluginViewModel(
private val pluginManager: PluginManager,
private val webViewController: WebViewController) : ViewModel(), KoinComponent {
private val webViewController: WebViewController,
httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginViewModel>()
private val gitHubRepo = GitHubPluginRepository()
private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json)
private val _screenState = MutableStateFlow(PluginScreenState())
private val _allRepos = mutableListOf<GitHubRepo>()
@ -658,8 +660,4 @@ class PluginViewModel(
}
}
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
}

View File

@ -17,6 +17,9 @@
package org.spotube.spotube.modules.settings
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import kotlinx.serialization.SerialName
@ -42,15 +45,7 @@ data class UserSettings(
val palette: MillenniumPalette = MillenniumPalette.Blue,
// Playback
val streamingMusicFormat: AudioFormat = AudioFormat(
codec = "opus",
container = "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000))
),
val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(),
val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000),
val enableMusicCaching: Boolean = true,
val cacheFolder: String? = null,
@ -111,3 +106,24 @@ data class JamBroker(
val clientIdPrefix: String = "spotube",
val keepAliveSeconds: Int = 30,
val connectionTimeoutSeconds: Int = 10)
/**
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
*/
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
val aac = !isContainerPlayableOn("webm", platform)
return AudioFormat(
codec = if (aac) "aac" else "opus",
container = if (aac) "mp4" else "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000)))
}
/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */
internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings =
if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this
else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform))

View File

@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY]
if (json != null) {
settingsJson.decodeFromString<UserSettings>(json as String)
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
} else {
UserSettings() // Default value
}
@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first()
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return settingsJson.decodeFromString<UserSettings>(storedJson)
return settingsJson.decodeFromString<UserSettings>(storedJson).withPlayableStreamingFormat()
}
}

View File

@ -44,11 +44,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.core.ui.theming.isLightAppearance
import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings
@ -206,7 +206,8 @@ private fun PaletteSwatch(
label: String,
selected: Boolean,
onClick: () -> Unit) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) }
val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Box(
modifier = Modifier
.size(40.dp)
@ -222,7 +223,8 @@ private fun PaletteSwatch(
@Composable
private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) }
val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically) {
@ -241,7 +243,7 @@ private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
@Composable
private fun PalettePreview(palette: MillenniumPalette) {
val dark = AppTheme.colors.background.luminance() < 0.5f
val dark = !isLightAppearance()
MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) {
Column(
modifier = Modifier

View File

@ -18,6 +18,8 @@
package org.spotube.spotube.modules.settings.sections
import org.spotube.spotube.core.server.isContainerPlayableOn
import org.spotube.spotube.core.server.currentPlatform
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.theme.MillenniumTheme
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000))
// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg).
internal val streamingFormatPresets = listOf(
AudioFormat(
codec = "opus",
@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf(
AudioFormat(
codec = "vorbis",
container = "ogg",
qualities = standardLossyQualities))
qualities = standardLossyQualities)
).filter { isContainerPlayableOn(it.container, currentPlatform) }
internal val downloadFormatPresets = listOf(
AudioFormat(

View File

@ -17,13 +17,11 @@
package org.spotube.spotube.modules.update
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger
import org.spotube.spotube.AppBuildInfo
import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
@ -52,14 +49,11 @@ data class AvailableAppUpdate(
* process, independent of any screen's lifecycle.
*/
class AppUpdateService(
private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable {
private val settingsRepository: SettingsRepository,
httpClientFactory: HttpClientFactory) : KoinComponent {
private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val httpClient = httpClientFactory.json
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
@ -117,10 +111,6 @@ class AppUpdateService(
private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version)
}.getOrNull()
override fun close() {
httpClient.close()
}
}
@Serializable

View File

@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update
import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.selection.SelectionContainer
import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
@ -40,7 +43,7 @@ import spotube.composeapp.generated.resources.update_action_update
/**
* Prompts the user that a newer version of one of their installed plugins is
* available. [onUpdate] should open the plugin's direct download URL; [onIgnore]
* available. [onUpdate] should download and install the update; [onIgnore]
* persistently silences this specific plugin version so the check moves on to the
* next newer one (or another plugin).
*/
@ -53,6 +56,7 @@ fun PluginUpdateDialog(
MillenniumAlertDialog(
visible = true,
onDismissRequest = onDismiss,
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
title = {
Text(
stringResource(

View File

@ -124,6 +124,18 @@ class PluginUpdateService(
}
}
fun installUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
scope.launch {
runCatching {
pluginManager.updatePluginFromURL(update.info.directDownloadUrl)
}.onFailure { e ->
logger.e(e) { "Failed to install update for plugin ${update.pluginName}" }
}
}
}
fun dismissUpdate() {
_availableUpdate.value = null
}

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.server
import io.ktor.http.HeadersBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class StreamProxyHeadersTest {
// Lowercase names and values as googlevideo sends them
private val upstream = HeadersBuilder().apply {
append("accept-ranges", "bytes")
append("alt-svc", "h3=\":443\"")
append("cache-control", "private, max-age=21298")
append("connection", "keep-alive")
append("content-length", "3523553")
append("content-range", "bytes 0-3523552/3523553")
append("content-type", "audio/webm")
append("last-modified", "Tue, 18 Feb 2025 22:42:11 GMT")
}.build()
@Test
fun `keeps the headers a player needs to seek`() {
val forwarded = upstream.passthroughForPlayer()
assertEquals("bytes 0-3523552/3523553", forwarded["Content-Range"])
assertEquals("bytes", forwarded["Accept-Ranges"])
assertEquals("Tue, 18 Feb 2025 22:42:11 GMT", forwarded["Last-Modified"])
}
@Test
fun `drops headers that are duplicated or hop-by-hop`() {
val forwarded = upstream.passthroughForPlayer()
// Length and type are set on the OutgoingContent; forwarding them too emits them twice
assertNull(forwarded["Content-Length"])
assertNull(forwarded["Content-Type"])
assertNull(forwarded["Connection"])
assertNull(forwarded["Alt-Svc"])
assertNull(forwarded["Cache-Control"])
}
}

View File

@ -5,9 +5,12 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
@ -97,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest {
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs)
streamUrlCacheTtlMs = cacheTtlMs,
// Not the host's platform: Windows and iOS don't prefer opus/webm
platform = PlatformType.Linux)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)
@ -130,6 +135,22 @@ class StreamingUrlRepositoryBehaviorTest {
assertNull(info)
}
@Test
fun `waits for an audio plugin that is still loading`() = runTest {
val h = Harness()
val loader = launch {
delay(5_000)
h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
}
val info = h.repository.resolveStreamInfo(track("t1"))
loader.join()
assertEquals("https://example.com/a.opus", info?.url)
}
@Test
fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness()
@ -218,6 +239,11 @@ class StreamingUrlRepositoryBehaviorTest {
)
}
// The default format depends on the host platform (aac/mp4 on Windows and iOS)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "opus", container = "webm"))
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url)

View File

@ -1,5 +1,6 @@
package org.spotube.spotube.core.server
import org.spotube.spotube.PlatformType
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
@ -13,6 +14,7 @@ import kotlin.test.assertSame
* Tests for the stream preference selection used by [StreamingUrlRepository].
*
* Ordering contract (best first):
* 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH).
* 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate.
@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest {
private fun select(
streams: List<AudioStream>,
format: AudioFormat = opusWebm(),
quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
quality: AudioQuality = AudioQuality.Lossy(256_000),
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
// ---------- empty / single ----------
@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest {
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
}
// ---------- device playability ----------
@Test
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS))
}
@Test
fun `on iOS dash manifests are skipped in favour of progressive streams`() {
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH)
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS))
}
@Test
fun `on iOS an unplayable stream is still returned when nothing else exists`() {
val opus = lossy("opus-160", 160_000)
assertSame(opus, select(listOf(opus), platform = PlatformType.IOS))
}
@Test
fun `off iOS the preferred opus webm stream still wins`() {
val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android))
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.settings
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat
import org.spotube.spotube.PlatformType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class StreamingFormatDefaultsTest {
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
@Test
fun `ios defaults to aac in mp4`() {
val format = defaultStreamingMusicFormat(PlatformType.IOS)
assertEquals("aac" to "mp4", format.codec to format.container)
}
@Test
fun `other platforms keep opus in webm`() {
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
val format = defaultStreamingMusicFormat(platform)
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
}
}
@Test
fun `saved opus webm is moved to aac on ios`() {
val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS)
assertEquals("mp4", migrated.streamingMusicFormat.container)
}
@Test
fun `saved opus webm is kept off ios`() {
val settings = UserSettings(streamingMusicFormat = opusWebm)
assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux))
}
}

View File

@ -27,6 +27,7 @@ import androidx.compose.ui.window.ComposeUIViewController
import com.yushosei.newpipe.extractor.NewPipe
import com.yushosei.newpipe.util.DefaultDownloaderImpl
import org.spotube.spotube.core.di.initKoin
import org.spotube.spotube.core.webview.SafariUserAgent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -35,6 +36,7 @@ import org.koin.core.component.inject
fun MainViewController() = ComposeUIViewController(configure = {
initKoin()
SafariUserAgent.prime()
}) { InitNewPipe { App() } }
@Composable

View File

@ -77,9 +77,9 @@ actual class NewPipeService {
.map { stream ->
org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy(
url = stream.content,
codec = stream.codec ?: "unknown",
codec = normalizeCodec(stream.codec ?: "unknown"),
bitrate = stream.bitrate,
container = stream.format?.suffix ?: "unknown")
container = normalizeContainer(stream.format?.suffix ?: "unknown"))
},
videoStreams = listOf(), // Not yet supported
id = id

View File

@ -1,41 +1,43 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.webview
import dev.nucleusframework.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage
import platform.Foundation.distantPast
import platform.WebKit.WKWebsiteDataStore
import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun platformClearWebviewData(pluginId: String?) {
if (pluginId == null) return
val dataStore = WKWebsiteDataStore.defaultDataStore()
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.webview
import dev.nucleusframework.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage
import platform.Foundation.distantPast
import platform.WebKit.WKWebsiteDataStore
import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
// Applied to the WKWebView itself, so it covers redirects and XHR too, not just the first request.
SafariUserAgent.value?.let { webView.webSettings.customUserAgentString = it }
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun platformClearWebviewData(pluginId: String?) {
if (pluginId == null) return
val dataStore = WKWebsiteDataStore.defaultDataStore()
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
}

View File

@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.webview
import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readValue
import platform.CoreGraphics.CGRectZero
import platform.UIKit.UIDevice
import platform.UIKit.UIUserInterfaceIdiomPad
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
/**
* Mobile Safari's exact User-Agent on this device.
*
* WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`,
* which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went
* wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26
* reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application
* name and reports `navigator.userAgent`, which is cached for every web view.
*/
internal object SafariUserAgent {
var value: String? = null
private set
// Kept alive until its async evaluation calls back.
private var probe: WKWebView? = null
/** Must run on the main thread (WebKit); call once at startup. */
@OptIn(ExperimentalForeignApi::class)
fun prime() {
if (value != null || probe != null) return
val config = WKWebViewConfiguration().apply { applicationNameForUserAgent = safariApplicationName() }
probe = WKWebView(frame = CGRectZero.readValue(), configuration = config).also { webView ->
webView.evaluateJavaScript("navigator.userAgent") { result, _ ->
value = result as? String
probe = null
Logger.withTag("SafariUserAgent").i { "WebView User-Agent: $value" }
}
}
}
// What Safari appends after WebKit's base UA; iPad Safari sends the desktop-class form.
private fun safariApplicationName(): String {
val version = UIDevice.currentDevice.systemVersion.split('.').take(2).joinToString(".")
return if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
"Version/$version Safari/605.1.15"
} else {
"Version/$version Mobile/15E148 Safari/604.1"
}
}
}

View File

@ -17,17 +17,32 @@
package org.spotube.spotube.core.zipline
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import platform.Foundation.NSThread
import kotlin.coroutines.ContinuationInterceptor
// Same as JVM/Android. Zipline lets QuickJS recurse up to ~6 MiB of native stack, but
// iOS gives secondary threads (incl. newSingleThreadContext's) only 512 KiB, so loading
// a plugin overflowed into the guard page and crashed the app with SIGBUS.
private const val ZIPLINE_THREAD_STACK_SIZE: ULong = 8_388_608u // 8 MiB
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
actual fun createZiplineDispatcher(): ZiplineDispatcher {
// On iOS/native, thread stack sizes are typically 8 MiB by default,
// so newSingleThreadContext is sufficient.
val ctx = newSingleThreadContext("Zipline")
return ZiplineDispatcher(ctx) {
ctx.close()
val dispatcher = CompletableDeferred<CoroutineDispatcher>()
val stopped = CompletableDeferred<Unit>()
// NSThread is the only way to choose a thread's stack size here; it hosts an event
// loop (runBlocking) whose dispatcher accepts work from any thread.
val thread = NSThread {
runBlocking {
dispatcher.complete(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
stopped.await()
}
}
thread.name = "Zipline"
thread.stackSize = ZIPLINE_THREAD_STACK_SIZE
thread.start()
return ZiplineDispatcher(runBlocking { dispatcher.await() }) {
stopped.complete(Unit)
}
}

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.DebugConfig
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.medialist.MediaList
@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> =
_currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> =
_bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
init {
VLCBundleLoaderGenerated.getVerifiedPath()
val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray()
mediaPlayerFactory = MediaPlayerFactory(
null as NativeDiscovery?,
"--no-video",
@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
"--aout=any",
"--no-osd",
"--no-snapshot-preview",
// "--verbose=2"
// "--quiet"
*vlcLogArgs
)
audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) {
override fun opening(mediaPlayer: MediaPlayer) {
@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
) {
val durationMs = mediaPlayer.status().length()
_bufferingPosition.tryEmit(
if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO)
if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO
)
_playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY)
}
@ -184,7 +188,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
override fun nextItem(
mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer,
item: uk.co.caprica.vlcj.media.MediaRef) {
item: uk.co.caprica.vlcj.media.MediaRef
) {
lock.withLock {
val targetIndex = pendingNextIndex ?: run {
val currentMrl = runCatching {
@ -336,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
}
}
actual override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
actual override suspend fun load(
playlist: List<MediaItem>,
autoPlay: Boolean,
startPosition: Int
) {
lock.withLock {
if (disposed) return

View File

@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.runtime.Composable
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
// The Tao window never feeds Compose's LocalSystemTheme, so ask the OS directly
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkMode()

View File

@ -132,6 +132,15 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta
return PersistedWindowState.of(size, saved.placement)
}
/** Runs [block] on a fresh thread and waits for it, rethrowing whatever it threw. */
private fun runOnWorkerThread(name: String, block: () -> Unit) {
var failure: Throwable? = null
val thread = Thread({ runCatching(block).onFailure { failure = it } }, name)
thread.start()
thread.join()
failure?.let { throw it }
}
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
fun main() {
// Sets compose.application.resources.dir in the native image. initKoin() creates the audio
@ -140,8 +149,19 @@ fun main() {
GraalVmInitializer.initialize()
AppImageEnvironment.apply()
FileKit.init(appId = "org.spotube.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
val initApp = {
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
}
// libvlc and the system media controls initialize COM as multithreaded on the calling thread. In the
// native image the main thread also creates the Tao window, whose OleInitialize needs a single-threaded
// apartment and panics with RPC_E_CHANGED_MODE, so on Windows Koin's eager singletons are created
// elsewhere. COM doesn't exist on macOS/Linux, where this stays on the main thread as before.
if (getPlatform().type == PlatformType.Windows) {
runOnWorkerThread("spotube-init", initApp)
} else {
initApp()
}
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
nucleusApplication(backend = NucleusBackend.Tao) {

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme()

View File

@ -20,9 +20,11 @@ kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
kotlin.mpp.stability.nowarn=true
kotlin.incremental=true
kotlin.native.jvmArgs=-Xmx6g
#Gradle
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
# The Kotlin/Native release framework link (iOS) runs in the Gradle daemon and OOMs below ~6g
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g
org.gradle.configuration-cache=true
org.gradle.caching=true

View File

@ -28,7 +28,7 @@ appdirs = "1.5.0"
cache4k = "0.14.0"
carApp = "1.7.0"
composeHotReload = "1.2.0"
composeMultiplatform = "1.11.1"
composeMultiplatform = "1.12.2"
coil = "3.5.0"
composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5"
@ -37,7 +37,7 @@ jna = "5.18.1"
junit = "4.13.2"
kermit = "2.1.0"
kmpZip = "1.0.0"
kotlin = "2.3.21"
kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0"
@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18"
semver = "2.1.0"
vlcj = "4.12.1"
vlcjNative = "4.12.0"
ziplineVersion = "1.27.0"
ziplineVersion = "1.28.0"
ktor = "3.5.1"
dnssdkt = "1.1.0"
runner = "1.7.0"
@ -68,15 +68,15 @@ feather-icons = "1.1.1"
kotlinx-datetime = "0.8.0"
media3 = "1.10.1"
androidx-datastore = "1.2.1"
ksp = "2.3.5"
ksp = "2.3.12"
spotube-gradle = "0.1.2"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.2.0"
vlcjBundler = "0.2.1"
vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.0"
mokkery = "3.5.1"
jmustache = "1.16"
[libraries]
@ -134,6 +134,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" }
nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" }
nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleusNucleusApplication" }
nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }

View File

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {

View File

@ -4,7 +4,7 @@ pkgver={{version}}
pkgrel={{pkgrel}}
pkgdesc="Open source, plugin powered music streaming client"
arch=(x86_64 aarch64)
url="https://spotube.org"
url="https://spotube.cc"
license=('AGPL-3.0-or-later')
# libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output),
# WebKitGTK 4.1 for the webview, and the X11/GL/ALSA libraries the AWT/Skiko natives link

View File

@ -13,7 +13,7 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t
<!-- == SOFTWARE SPECIFIC SECTION == -->
<title>spotube (Install)</title>
<authors>Team Spotube</authors>
<projectUrl>https://spotube.org</projectUrl>
<projectUrl>https://spotube.cc</projectUrl>
<iconUrl>
https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png</iconUrl>
<copyright>2026 Kingkor Roy Tirtho and Spotube Contributors</copyright>
@ -21,7 +21,7 @@ enclosed in quotation marks, you should use an editor that supports UTF-8, not t
<licenseUrl>https://github.com/team-spotube/spotube/blob/master/LICENSE</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/team-spotube/spotube</projectSourceUrl>
<docsUrl>https://spotube.org</docsUrl>
<docsUrl>https://spotube.cc</docsUrl>
<bugTrackerUrl>https://github.com/team-spotube/spotube/issues/new</bugTrackerUrl>
<tags>spotube music audio music-player streaming lyrics plugins foss</tags>
<summary>Open source, plugin powered music streaming client</summary>

View File

@ -47,7 +47,7 @@
<keyword>lyrics</keyword>
</keywords>
<url type="homepage">https://spotube.org</url>
<url type="homepage">https://spotube.cc</url>
<url type="bugtracker">https://github.com/team-spotube/spotube/issues</url>
<url type="vcs-browser">https://github.com/team-spotube/spotube</url>
<url type="donation">https://opencollective.com/spotube</url>

View File

@ -6,10 +6,10 @@ PackageLocale: en-US
Publisher: Team Spotube
PublisherUrl: https://github.com/team-spotube
PublisherSupportUrl: https://github.com/team-spotube/spotube/issues
PrivacyUrl: https://spotube.org/p/privacy
PrivacyUrl: https://spotube.cc/p/privacy
Author: Team Spotube
PackageName: Spotube
PackageUrl: https://spotube.org
PackageUrl: https://spotube.cc
License: AGPL-3.0-or-later
LicenseUrl: https://github.com/team-spotube/spotube/blob/master/LICENSE
Copyright: Copyright (c) 2022-2026 Kingkor Roy Tirtho and Spotube Contributors