Compare commits

...

26 Commits

Author SHA1 Message Date
dependabot[bot]
5a573ba65e
Merge d48b0a2f65 into aca3aeb592 2026-10-27 16:27:09 -04: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
2edbfefe93 fix(gradle): restore VFS watch setting and clean up properties 2026-10-23 10:12:10 +06:00
dependabot[bot]
d48b0a2f65
chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:25 +00: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 1152 additions and 239 deletions

View File

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

View File

@ -10,6 +10,7 @@ on:
default: 'stable' default: 'stable'
options: options:
- stable - stable
- beta
- nightly - nightly
dry_run: dry_run:
description: 'Dry run (build & upload artifacts only, skip creating release)' description: 'Dry run (build & upload artifacts only, skip creating release)'
@ -26,7 +27,7 @@ on:
permissions: permissions:
contents: read 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: concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}
cancel-in-progress: false cancel-in-progress: false
@ -215,7 +216,7 @@ jobs:
fi fi
# Desktop entry + metainfo, with the release history from git-cliff # Desktop entry + metainfo, with the release history from git-cliff
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} ./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 - name: Upload tarball
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -335,7 +336,7 @@ jobs:
fi fi
# Desktop entry + metainfo, with the release history from git-cliff # Desktop entry + metainfo, with the release history from git-cliff
./gradlew :composeApp:renderPackagingLinux -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} ./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 - name: Upload tarball
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -485,7 +486,16 @@ jobs:
env: env:
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit # Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 - name: Rename macOS artifact
run: | run: |
@ -504,6 +514,75 @@ jobs:
with: with:
limit-access-to-actor: true 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: create-release:
name: Create GitHub Release name: Create GitHub Release
if: ${{ !inputs.dry_run }} if: ${{ !inputs.dry_run }}
@ -513,6 +592,7 @@ jobs:
- build-linux-arm64 - build-linux-arm64
- build-windows - build-windows
- build-macos - build-macos
- build-ios
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@ -538,8 +618,9 @@ jobs:
- name: Set release variables - name: Set release variables
id: vars id: vars
run: | run: |
if [ "${{ inputs.release_type }}" = "stable" ]; then if [ "${{ inputs.release_type }}" != "nightly" ]; then
chmod +x gradlew chmod +x gradlew
# printVersion includes the -beta.N suffix when betaNumber is set in build.gradle.kts
VERSION=$(./gradlew -q :composeApp:printVersion) VERSION=$(./gradlew -q :composeApp:printVersion)
TAG="v${VERSION}" TAG="v${VERSION}"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
@ -561,7 +642,7 @@ jobs:
run: | run: |
echo "## Downloads" > release-notes.md echo "## Downloads" > release-notes.md
echo "" >> 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 if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md echo "### $dir" >> release-notes.md
for f in "$dir"/*; do for f in "$dir"/*; do
@ -571,13 +652,6 @@ jobs:
fi fi
done 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 - name: Create Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
@ -601,3 +675,4 @@ jobs:
linux-tarball-arm64/*.tar.xz linux-tarball-arm64/*.tar.xz
windows-exe/*.exe windows-exe/*.exe
macos-dmg/*.dmg macos-dmg/*.dmg
ios-ipa/*.ipa

37
.vscode/settings.json vendored
View File

@ -1,4 +1,37 @@
{ {
"gradle.autoDetect": "off", "gradle.autoDetect": "off",
"java.gradle.buildServer.enabled": "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 ## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - `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). - **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) ## 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`. - 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. - 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 # 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) ## [5.1.2](https://github.com/team-spotube/spotube/compare/v5.1.1...v5.1.2) (2026-06-05)
### Bug Fixes ### Bug Fixes
@ -23,6 +174,8 @@
- **playback**: Add dab music source - **playback**: Add dab music source
- **playback**: Add uncompressed flac playback support - **playback**: Add uncompressed flac playback support
- Add plugin audio source models and api service - 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 - **plugins**: Filter plugins by abilities in plugins page and show abilities as
badge badge
- Add setting default audio source support - Add setting default audio source support
@ -54,6 +207,8 @@
- **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints - **metadata-plugin**: Add pagination support, feed and playlist CRUD endpoints
- **metadata-plugin**: Add local storage api - **metadata-plugin**: Add local storage api
- Add webview, totp and setInterval apis for plugins - 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 - Enhance local storage and webview APIs with improved error handling and
resource management resource management
- **metadata_plugin**: Add logout method - **metadata_plugin**: Add logout method
@ -92,6 +247,8 @@
- Local track not working and images of local not showing up - Local track not working and images of local not showing up
- Local playback not working for tracks with special # (hashtag) characters - Local playback not working for tracks with special # (hashtag) characters
- Inaccessible streaming url causing rapid skips - 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 - **yt**: Fallback to different search result if all streaming url is
inaccessible inaccessible
- **playback**: Skip network requests if cached file already exists - **playback**: Skip network requests if cached file already exists
@ -124,6 +281,10 @@
- **android**: navigation overlaying in app navigation - **android**: navigation overlaying in app navigation
- add to playlist not working in smaller screen devices - add to playlist not working in smaller screen devices
- language picker search broken - 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 - **generate_playlist**: create playlist not adding tracks nor navigating to
playlist page playlist page
- **desktop**: double titlebar in local library folders and massive space in - **desktop**: double titlebar in local library folders and massive space in
@ -154,6 +315,8 @@
- youtube source taking too long to buffer - youtube source taking too long to buffer
- youtube tracks keeps skipping despite being matched correctly - youtube tracks keeps skipping despite being matched correctly
- follow artist not working #2057 - 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 - youtube_explode_dart failing for many videos due to youtube ios client visitor
data change data change
- piped api not working - piped api not working
@ -180,6 +343,10 @@
#2039 #2039
- **mobile**: ensure audio session is activated when playback is resumed after - **mobile**: ensure audio session is activated when playback is resumed after
interruption #2092 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 ### Features
@ -215,11 +382,15 @@
- **translations**: correct some basque incorrect translations (#1815) - **translations**: correct some basque incorrect translations (#1815)
- **lyrics**: LRCLIB lyrics should be usable without logging in #1803 - **lyrics**: LRCLIB lyrics should be usable without logging in #1803
- playlist displaying descriptions unescaped html #1784 - 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 - **android**: pressing back while the player is open doesn't take to previous
page page
- handle dublicated items in playback queue correctly #1852 - handle dublicated items in playback queue correctly #1852
- **desktop**: scrollbar overlapping with more options of tracks and playlists - **desktop**: scrollbar overlapping with more options of tracks and playlists
- **discord**: stop discord rpc from try update presence when not connected - **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 - **stats**: minutes page shows plays and streams page shows minutes which
should be the opposite #1880 should be the opposite #1880
- **android**: clears queue upon swiping away notification - **android**: clears queue upon swiping away notification
@ -1655,9 +1826,13 @@
### New ### 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 & - Playback Cache Support. So unfinished playlist and tracks remains cached &
starts automatically when application is launched again starts automatically when application is launched again
- Login Screen guided tutorial about how to obtain Client ID & Client Secret - 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 - Signed Android Application so now longer need to uninstall the old version for
installing the new one installing the new one
- OS Media controls for Linux. Keyboard media keys now work in Linux - OS Media controls for Linux. Keyboard media keys now work in Linux
@ -1677,6 +1852,10 @@
### Improved ### Improved
- Loading screens & animations. Now uses Skeleton Loading - 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 - Playlist & Album Pages now show Album Art & extra metadata as Header with
vibrant gradient background in a Sliver vibrant gradient background in a Sliver
- Playback is now more consistent & the API is simpler. Also its the single - Playback is now more consistent & the API is simpler. Also its the single
@ -1686,6 +1865,10 @@
in Desktop edition in Desktop edition
- Track match Cache support for previously played tracks. This dramatically - Track match Cache support for previously played tracks. This dramatically
reduces track change latency & load on the YouTube search engine too 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 ### Bug Fixes
@ -1790,6 +1973,8 @@
- Search - now scrolls & paginates for Playlists & Albums - Search - now scrolls & paginates for Playlists & Albums
- Authentication - allows guest accounts making authentication optional - 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 - Lyrics - can be fetched without requiring GeniusAccessToken. This makes
geniusAccessToken optional geniusAccessToken optional
- UI snappiness & faster load times - UI snappiness & faster load times
@ -1806,6 +1991,8 @@
### New ### New
- Global custom reconfigurable _hotkey_ support for playback controls
(play-pause/next/previous)
- Global custom reconfigurable _hotkey_ support for playback controls - Global custom reconfigurable _hotkey_ support for playback controls
(play-pause/next/previous) (play-pause/next/previous)
- Credit section in the Settings page with important links - Credit section in the Settings page with important links
@ -1853,6 +2040,10 @@
### Bug fixes ### 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 - Unsafe access to album art/artist/user Images with `.first` or `.last` causing
accessing empty List error accessing empty List error
- `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain - `url_launcher`'s unstable `canLaunch` method blocks OAuth login in certain
@ -1876,6 +2067,8 @@
- Complete re-write in Flutter/Dart (799e13c) - Complete re-write in Flutter/Dart (799e13c)
- mpv & youtube-dl runtime dependencies dropped (07b1891) - 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 - just_audio (libwinmedia + libwebkit2gtk-4.0-dev) + youtube_explode based
playback & streaming playback & streaming
- lyrics are provided by genius.com (requires access_token) (d647d5e) - lyrics are provided by genius.com (requires access_token) (d647d5e)
@ -1893,6 +2086,8 @@
### New ### New
- Automated installer for Windows (now doesn't require manual mpv-player
install)
- Automated installer for Windows (now doesn't require manual mpv-player - Automated installer for Windows (now doesn't require manual mpv-player
install) install)
- Playback caching - Playback caching
@ -1902,11 +2097,15 @@
### Improved ### 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 - Inapp Shortcuts.Now it doesn't interfere while typing in a input box in Search
page page
### Bug fixes ### 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 - Cached image didn't get deleted after exiting certain cache limit fix. Cache
gets recreated after exiting the limit gets recreated after exiting the limit
@ -1925,12 +2124,16 @@
### Bug Fixes ### 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 - `access_token not found` Error after OAuth Login with Spotify credentials
(used to need a restart of the app to load the access_token) (used to need a restart of the app to load the access_token)
- Volume level wasn't cached even after changing volume - Volume level wasn't cached even after changing volume
# v0.0.1 # 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 Spotube v0.0.1 - initial release of the open source software for playing Spotify
music using Youtube public API music using Youtube public API
@ -1939,6 +2142,8 @@ music using Youtube public API
- Local playback handling - Local playback handling
- Playback Queue - Playback Queue
- Save to Liked Tracks/Playlists - 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 - Bypass API rate limitation on basic usage using personal developer Apps for
spotify API spotify API
- Youtube search & get handled using scrape-yt - Youtube search & get handled using scrape-yt

View File

@ -4,7 +4,7 @@
A cross-platform extensible open-source music streaming platform.<br> 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! 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://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> <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
Download Spotube from the Official Website: https://spotube.org Download Spotube from the Official Website: https://spotube.cc
### 🔄 Nightly Builds ### 🔄 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 ## 🕳️ Building from source
@ -166,7 +166,7 @@ Android picks the Rust profile from its build type (`debug` or `release`) on its
### Releases ### 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 ## 👥 The Spotube team

View File

@ -48,6 +48,10 @@ plugins {
val bundleId = "org.spotube.spotube" val bundleId = "org.spotube.spotube"
val version = "6.0.0" 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 val buildNumber = 46
// Desktop icon channel; Android picks its icons via the stable/nightly product flavor. Use -Pnightly=true. // 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) 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 targetName = "Spotube"
val homePageUrl = "https://spotube.org" val homePageUrl = "https://spotube.cc"
val repositoryUrl = "https://github.com/team-spotube/spotube" 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 appBuildNumber = if (isNightly) nightlyBuildNumber else buildNumber
val appRepositoryUrl = repositoryUrl val appRepositoryUrl = repositoryUrl
// CI reads the release version from here instead of scraping this file. // 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.logging)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)
@ -363,6 +366,7 @@ kotlin {
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control) implementation(libs.nucleus.media.control)
implementation(libs.nucleus.darkmode.detector)
} }
} }
} }
@ -376,8 +380,8 @@ android {
applicationId = bundleId applicationId = bundleId
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = buildNumber versionCode = appBuildNumber
versionName = version versionName = appVersionName
} }
// stable = production channel, nightly = beta channel (own icon, name and installable side by side) // stable = production channel, nightly = beta channel (own icon, name and installable side by side)
flavorDimensions += "channel" 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". Without this a real AAC stream never matched the preference, and the hard-coded
* aac/mp4 DASH/HLS manifests always won instead. * 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) { private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
DeliveryMethod.DASH -> StreamProtocol.DASH DeliveryMethod.DASH -> StreamProtocol.DASH
DeliveryMethod.HLS -> StreamProtocol.HLS DeliveryMethod.HLS -> StreamProtocol.HLS

View File

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

View File

@ -39,4 +39,6 @@ object DebugConfig {
* Keep it `false` when committing. * Keep it `false` when committing.
*/ */
const val FORCE_SUPPORT_REMINDER: Boolean = false 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 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 co.touchlab.kermit.Logger
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository
@ -108,6 +110,7 @@ expect val platformModules: Module
val sharedModules = module { val sharedModules = module {
includes(navigationModule) includes(navigationModule)
singleOf(::Database) singleOf(::Database)
single { HttpClientFactory() } withOptions { onClose { it?.close() } }
singleOf(::WebViewController) singleOf(::WebViewController)
@ -164,7 +167,7 @@ val sharedModules = module {
viewModelOf(::WelcomeViewModel) viewModelOf(::WelcomeViewModel)
// Updates // Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() } single { AppUpdateService(get(), get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads // Downloads
@ -273,7 +276,7 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() } single { RemoteControlClient(get()) }
singleOf(::DeviceDiscoveryService) singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions { single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart() createdAtStart()

View File

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

@ -50,3 +50,13 @@ data class MediaStream(
val bitrate: Int, val bitrate: Int,
val isAudio: Boolean, 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 package org.spotube.spotube.core.remote
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.url 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. * WebSocket client for controlling a remote Spotube instance.
* Connects to the remote device's `/control` endpoint and sends commands. * 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 logger = Logger.withTag("RemoteControlClient")
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -59,7 +59,7 @@ class RemoteControlClient {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
install(WebSockets) install(WebSockets)
install(HttpTimeout) { install(HttpTimeout) {
connectTimeoutMillis = 10_000 connectTimeoutMillis = 10_000

View File

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

View File

@ -21,7 +21,10 @@ package org.spotube.spotube.core.server
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.application.serverConfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeout 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). * 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]. * 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( internal suspend fun startServerWithPortFallback(
host: String, host: String,
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
val candidates = listOf(preferredPort, 0).distinct() val candidates = listOf(preferredPort, 0).distinct()
var lastFailure: Throwable? = null 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) { 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 { try {
engine.start(wait = false) engine.start(wait = false)
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }

View File

@ -425,7 +425,7 @@ internal class StreamProxy(
if (requestMethod == HttpMethod.Head) { if (requestMethod == HttpMethod.Head) {
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
}) })
return return
@ -446,7 +446,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
override val contentType: ContentType? = override val contentType: ContentType? =
downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse)
@ -594,4 +594,28 @@ internal class StreamProxy(
} }
}.build() }.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.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.plugin.AudioPluginSource
import org.spotube.spotube.modules.settings.UserSettingsSource 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
@ -75,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, 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 logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -111,7 +117,14 @@ class StreamingUrlRepository(
// read it, nor replace it with a stream playback didn't ask for. // read it, nor replace it with a stream playback didn't ask for.
val useUrlCache = request == null 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 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) { if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" } logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null return null
@ -133,7 +146,7 @@ class StreamingUrlRepository(
} else { } else {
stream.streams stream.streams
} }
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
} }
@ -243,10 +256,12 @@ class StreamingUrlRepository(
logger.i { logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + "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 -> streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " " 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}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
@ -254,6 +269,10 @@ class StreamingUrlRepository(
} }
when { when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" } 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 { formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})" "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 * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
* resolved against the YouTube origin; progressive URLs are returned unchanged. * 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 { internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url if (url.startsWith("http")) return url
return when (protocol) { 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( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( preferredQuality: AudioQuality,
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream -> compareBy<AudioStream> { stream ->
if (stream.isPlayableOn(platform)) 0 else 1
}.thenBy { stream ->
formatMismatchCount(stream, preferredFormat) formatMismatchCount(stream, preferredFormat)
}.thenBy { stream -> }.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1 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 = private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, 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 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 co.touchlab.kermit.Logger
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.getPlatform 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.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess 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 * [random] is intentionally synchronous and never blocks on the network: it serves
* whatever is cached (even stale) and refreshes in the background when needed. * 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 = private const val SOURCE_URL =
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
private const val CACHE_FILE_NAME = "user_agents_cache.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 fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
private suspend fun fetchRemote(): List<String> { private suspend fun fetchRemote(): List<String> {
val client = HttpClient(CIO) val res = httpClientFactory.default.get(SOURCE_URL)
try { if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
val res = client.get(SOURCE_URL) return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
} finally {
client.close()
}
} }
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()

View File

@ -17,7 +17,6 @@
package org.spotube.spotube.core.ui.theming package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -205,7 +204,7 @@ fun SpotubeTheme(
val isDarkTheme = when (settings.theme) { val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false Theme.LIGHT -> false
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemDarkTheme()
} }
val palette: MillenniumPalette = settings.palette 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 * size is ~1 MiB, which causes native stack overflow when compiling large
* JS modules like kotlin-stdlib (~491 KB). * 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 expect fun createZiplineDispatcher(): ZiplineDispatcher

View File

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

View File

@ -17,11 +17,14 @@
package org.spotube.spotube.core.zipline.plugin_apis.lrclib 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.LyricsAPI
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine 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.lyrics.LyricsResponse
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.defaultRequest
@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable @Serializable
data class LRCLibResponse( data class LRCLibResponse(
@ -47,19 +49,22 @@ data class LRCLibResponse(
val plainLyrics: String?, val plainLyrics: String?,
val syncedLyrics: String?) val syncedLyrics: String?)
class RealLRCLibLyricsAPI : LyricsAPI { class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent {
private val httpClient = HttpClient { private val httpClientFactory: HttpClientFactory by inject()
install(Logging) { private val httpClient by lazy {
level = LogLevel.ALL httpClientFactory.create {
logger = Logger.SIMPLE install(Logging) {
} level = LogLevel.ALL
install(ContentNegotiation) { logger = Logger.SIMPLE
json(Json { ignoreUnknownKeys = true }) }
} install(ContentNegotiation) {
defaultRequest { json(HttpJson)
url("https://lrclib.net/api/get") }
// TODO: Add the actual version of Spotube instead of hardcoding it defaultRequest {
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") 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 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.tools.user_agents.UserAgents import org.spotube.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent {
.lowercase() .lowercase()
} }
val client = HttpClient() { private val httpClientFactory: HttpClientFactory by inject()
install(ContentNegotiation) { private val client get() = httpClientFactory.json
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
private fun MetadataTrack.describe() = private fun MetadataTrack.describe() =
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +

View File

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

View File

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

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin 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.Database
import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.db.DatabaseKeys
import org.spotube.spotube.core.di.injectLogger 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.BuiltInPluginService
import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.ZiplinePluginService import org.spotube.spotube.core.zipline.ZiplinePluginService
import io.ktor.client.HttpClient
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
@ -113,15 +113,19 @@ interface ScrobblePluginSource {
class PluginManager( class PluginManager(
val database: Database, 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>() 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 -> private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
} }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
val pluginsDirPath: Path get() = pluginsDir val pluginsDirPath: Path get() = pluginsDir
private val httpClient = HttpClient() private val httpClient get() = httpClientFactory.default
enum class InstallPromptKind { enum class InstallPromptKind {
INSTALL, 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. */ /** Parses the zip, reads plugin.json, then surfaces a PendingPlugin for the UI to confirm. */
suspend fun preparePlugin(bytes: ByteArray) { suspend fun preparePlugin(bytes: ByteArray) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -362,7 +375,7 @@ class PluginManager(
val pluginJson = val pluginJson =
FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() } FileSystem.SYSTEM.source(pluginJsonPath).buffer().use { it.readUtf8() }
val pluginEntry = try { val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson) pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty()))
} }
@ -484,7 +497,7 @@ class PluginManager(
} }
val pluginEntry = try { val pluginEntry = try {
Json.decodeFromString<PluginEntry>(pluginJson) pluginJsonParser.decodeFromString<PluginEntry>(pluginJson)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException(getString(Res.string.plugin_error_invalid_plugin_json, e.message.orEmpty())) 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 package org.spotube.spotube.modules.plugin
import com.goncalossilva.murmurhash.MurmurHash3 import com.goncalossilva.murmurhash.MurmurHash3
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 { enum class PluginCapability {
@SerialName("persistent_storage") @SerialName("persistent_storage")
PERSISTENT_STORAGE, PERSISTENT_STORAGE,
@ -43,6 +52,38 @@ enum class PluginAbility {
SCROBBLE, 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 @Serializable
data class PluginEntry( data class PluginEntry(
val name: String, val name: String,
@ -50,7 +91,9 @@ data class PluginEntry(
val apiVersion: String, val apiVersion: String,
val description: String, val description: String,
val author: String, val author: String,
@Serializable(with = LenientPluginCapabilityListSerializer::class)
val capabilities: List<PluginCapability>, val capabilities: List<PluginCapability>,
@Serializable(with = LenientPluginAbilityListSerializer::class)
val abilities: List<PluginAbility>, val abilities: List<PluginAbility>,
val contact: String, val contact: String,
val repository: String, val repository: String,

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
@ -141,10 +142,11 @@ private data class PluginScreenState(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class PluginViewModel( class PluginViewModel(
private val pluginManager: PluginManager, 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 logger by injectLogger<PluginViewModel>()
private val gitHubRepo = GitHubPluginRepository() private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json)
private val _screenState = MutableStateFlow(PluginScreenState()) private val _screenState = MutableStateFlow(PluginScreenState())
private val _allRepos = mutableListOf<GitHubRepo>() 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 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.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -42,15 +45,7 @@ data class UserSettings(
val palette: MillenniumPalette = MillenniumPalette.Blue, val palette: MillenniumPalette = MillenniumPalette.Blue,
// Playback // Playback
val streamingMusicFormat: AudioFormat = AudioFormat( val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(),
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 streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000),
val enableMusicCaching: Boolean = true, val enableMusicCaching: Boolean = true,
val cacheFolder: String? = null, val cacheFolder: String? = null,
@ -111,3 +106,24 @@ data class JamBroker(
val clientIdPrefix: String = "spotube", val clientIdPrefix: String = "spotube",
val keepAliveSeconds: Int = 30, val keepAliveSeconds: Int = 30,
val connectionTimeoutSeconds: Int = 10) 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 -> override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY] val json = prefs[SETTINGS_KEY]
if (json != null) { if (json != null) {
settingsJson.decodeFromString<UserSettings>(json as String) settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
suspend fun getCurrentSettings(): UserSettings { suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first() val preferences = database.settingsDataStore.data.first()
val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.spotube.spotube.core.ui.theming.AppTheme 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.SettingsViewModel
import org.spotube.spotube.modules.settings.Theme import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings import org.spotube.spotube.modules.settings.UserSettings
@ -206,7 +206,8 @@ private fun PaletteSwatch(
label: String, label: String,
selected: Boolean, selected: Boolean,
onClick: () -> Unit) { 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( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
@ -222,7 +223,8 @@ private fun PaletteSwatch(
@Composable @Composable
private fun PaletteTrailingSwatch(palette: MillenniumPalette) { 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( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
@ -241,7 +243,7 @@ private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
@Composable @Composable
private fun PalettePreview(palette: MillenniumPalette) { private fun PalettePreview(palette: MillenniumPalette) {
val dark = AppTheme.colors.background.luminance() < 0.5f val dark = !isLightAppearance()
MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) {
Column( Column(
modifier = Modifier modifier = Modifier

View File

@ -18,6 +18,8 @@
package org.spotube.spotube.modules.settings.sections 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.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
@ -56,6 +58,7 @@ private val standardLossyQualities = listOf(
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_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( internal val streamingFormatPresets = listOf(
AudioFormat( AudioFormat(
codec = "opus", codec = "opus",
@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf(
AudioFormat( AudioFormat(
codec = "vorbis", codec = "vorbis",
container = "ogg", container = "ogg",
qualities = standardLossyQualities)) qualities = standardLossyQualities)
).filter { isContainerPlayableOn(it.container, currentPlatform) }
internal val downloadFormatPresets = listOf( internal val downloadFormatPresets = listOf(
AudioFormat( AudioFormat(

View File

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

View File

@ -20,7 +20,10 @@ package org.spotube.spotube.modules.update
import org.spotube.millennium.composables.MillenniumAlertDialog import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding 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 androidx.compose.foundation.text.selection.SelectionContainer
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text 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 * 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 * persistently silences this specific plugin version so the check moves on to the
* next newer one (or another plugin). * next newer one (or another plugin).
*/ */
@ -53,6 +56,7 @@ fun PluginUpdateDialog(
MillenniumAlertDialog( MillenniumAlertDialog(
visible = true, visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
title = { title = {
Text( Text(
stringResource( 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() { fun dismissUpdate() {
_availableUpdate.value = null _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.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
@ -97,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest {
audioPlayerQueue = queue, audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository, matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings, 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 { fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI) val service = FakeAudioPluginService(pluginId, audioAPI)
@ -130,6 +135,22 @@ class StreamingUrlRepositoryBehaviorTest {
assertNull(info) 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 @Test
fun `returns null when the plugin yields no sources`() = runTest { fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness() 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) val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url) assertEquals("https://example.com/opus.webm", first?.url)

View File

@ -1,5 +1,6 @@
package org.spotube.spotube.core.server 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.AudioFormat
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality 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.AudioStream
@ -13,6 +14,7 @@ import kotlin.test.assertSame
* Tests for the stream preference selection used by [StreamingUrlRepository]. * Tests for the stream preference selection used by [StreamingUrlRepository].
* *
* Ordering contract (best first): * 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. * 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless). * 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate. * 3. Closest bitrate / sample rate.
@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest {
private fun select( private fun select(
streams: List<AudioStream>, streams: List<AudioStream>,
format: AudioFormat = opusWebm(), 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 ---------- // ---------- empty / single ----------
@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest {
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) 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.extractor.NewPipe
import com.yushosei.newpipe.util.DefaultDownloaderImpl import com.yushosei.newpipe.util.DefaultDownloaderImpl
import org.spotube.spotube.core.di.initKoin import org.spotube.spotube.core.di.initKoin
import org.spotube.spotube.core.webview.SafariUserAgent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -35,6 +36,7 @@ import org.koin.core.component.inject
fun MainViewController() = ComposeUIViewController(configure = { fun MainViewController() = ComposeUIViewController(configure = {
initKoin() initKoin()
SafariUserAgent.prime()
}) { InitNewPipe { App() } } }) { InitNewPipe { App() } }
@Composable @Composable

View File

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

View File

@ -27,6 +27,8 @@ import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { 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) @OptIn(ExperimentalForeignApi::class)

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 package org.spotube.spotube.core.zipline
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.newSingleThreadContext 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 { actual fun createZiplineDispatcher(): ZiplineDispatcher {
// On iOS/native, thread stack sizes are typically 8 MiB by default, val dispatcher = CompletableDeferred<CoroutineDispatcher>()
// so newSingleThreadContext is sufficient. val stopped = CompletableDeferred<Unit>()
val ctx = newSingleThreadContext("Zipline") // NSThread is the only way to choose a thread's stack size here; it hosts an event
return ZiplineDispatcher(ctx) { // loop (runBlocking) whose dispatcher accepts work from any thread.
ctx.close() 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.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent 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.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.medialist.MediaList 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) private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow() 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 playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow() actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.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 loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow() actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow() actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
init { init {
VLCBundleLoaderGenerated.getVerifiedPath() VLCBundleLoaderGenerated.getVerifiedPath()
val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray()
mediaPlayerFactory = MediaPlayerFactory( mediaPlayerFactory = MediaPlayerFactory(
null as NativeDiscovery?, null as NativeDiscovery?,
"--no-video", "--no-video",
@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
"--aout=any", "--aout=any",
"--no-osd", "--no-osd",
"--no-snapshot-preview", "--no-snapshot-preview",
// "--verbose=2" *vlcLogArgs
// "--quiet"
) )
audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) {
override fun opening(mediaPlayer: MediaPlayer) { override fun opening(mediaPlayer: MediaPlayer) {
@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
) { ) {
val durationMs = mediaPlayer.status().length() val durationMs = mediaPlayer.status().length()
_bufferingPosition.tryEmit( _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) _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( override fun nextItem(
mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer,
item: uk.co.caprica.vlcj.media.MediaRef) { item: uk.co.caprica.vlcj.media.MediaRef
) {
lock.withLock { lock.withLock {
val targetIndex = pendingNextIndex ?: run { val targetIndex = pendingNextIndex ?: run {
val currentMrl = runCatching { 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 { lock.withLock {
if (disposed) return 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) 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) @OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
fun main() { fun main() {
// Sets compose.application.resources.dir in the native image. initKoin() creates the audio // Sets compose.application.resources.dir in the native image. initKoin() creates the audio
@ -140,8 +149,19 @@ fun main() {
GraalVmInitializer.initialize() GraalVmInitializer.initialize()
AppImageEnvironment.apply() AppImageEnvironment.apply()
FileKit.init(appId = "org.spotube.spotube") FileKit.init(appId = "org.spotube.spotube")
initKoin() val initApp = {
NewPipeDownloader.init(KoinPathsProvider.paths) 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) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
nucleusApplication(backend = NucleusBackend.Tao) { 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.daemon.jvmargs=-Xmx3072M
kotlin.mpp.stability.nowarn=true kotlin.mpp.stability.nowarn=true
kotlin.incremental=true kotlin.incremental=true
kotlin.native.jvmArgs=-Xmx6g
#Gradle #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.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
@ -35,6 +37,3 @@ android.newDsl=false
android.sourceset.disallowProvider=false android.sourceset.disallowProvider=false
org.gradle.vfs.watch=true org.gradle.vfs.watch=true
nightly=true
buildNumber=999

View File

@ -28,7 +28,7 @@ appdirs = "1.5.0"
cache4k = "0.14.0" cache4k = "0.14.0"
carApp = "1.7.0" carApp = "1.7.0"
composeHotReload = "1.2.0" composeHotReload = "1.2.0"
composeMultiplatform = "1.11.1" composeMultiplatform = "1.12.2"
coil = "3.5.0" coil = "3.5.0"
composeShimmer = "1.5.0" composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5" desugar_jdk_libs = "2.1.5"
@ -37,7 +37,7 @@ jna = "5.18.1"
junit = "4.13.2" junit = "4.13.2"
kermit = "2.1.0" kermit = "2.1.0"
kmpZip = "1.0.0" kmpZip = "1.0.0"
kotlin = "2.3.21" kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1" kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
@ -52,7 +52,7 @@ nucleusNucleusApplication = "2.5.18"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
ziplineVersion = "1.27.0" ziplineVersion = "1.28.0"
ktor = "3.5.1" ktor = "3.5.1"
dnssdkt = "1.1.0" dnssdkt = "1.1.0"
runner = "1.7.0" runner = "1.7.0"
@ -68,15 +68,15 @@ feather-icons = "1.1.1"
kotlinx-datetime = "0.8.0" kotlinx-datetime = "0.8.0"
media3 = "1.10.1" media3 = "1.10.1"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
ksp = "2.3.5" ksp = "2.3.12"
spotube-gradle = "0.1.2" spotube-gradle = "0.1.2"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.2.0" vlcjBundler = "0.2.1"
vanniktechMavenPublish = "0.37.0" vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7" uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0" kotlinx-atomicfu = "0.33.0"
mokkery = "3.3.0" mokkery = "3.5.1"
jmustache = "1.16" jmustache = "1.16"
[libraries] [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-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-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-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-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import SwiftUI import SwiftUI
import ComposeApp
@main @main
struct iOSApp: App { struct iOSApp: App {

View File

@ -4,7 +4,7 @@ pkgver={{version}}
pkgrel={{pkgrel}} pkgrel={{pkgrel}}
pkgdesc="Open source, plugin powered music streaming client" pkgdesc="Open source, plugin powered music streaming client"
arch=(x86_64 aarch64) arch=(x86_64 aarch64)
url="https://spotube.org" url="https://spotube.cc"
license=('AGPL-3.0-or-later') license=('AGPL-3.0-or-later')
# libvlc + plugins for playback (FFmpeg demux/decode, GnuTLS for HTTPS streams, PulseAudio/PipeWire output), # 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 # 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 == --> <!-- == SOFTWARE SPECIFIC SECTION == -->
<title>spotube (Install)</title> <title>spotube (Install)</title>
<authors>Team Spotube</authors> <authors>Team Spotube</authors>
<projectUrl>https://spotube.org</projectUrl> <projectUrl>https://spotube.cc</projectUrl>
<iconUrl> <iconUrl>
https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png</iconUrl> https://rawcdn.githack.com/team-spotube/spotube/69a310c78f5ceaf4eab7dfee98f187d38211c9ba/assets/branding/spotube-logo.png</iconUrl>
<copyright>2026 Kingkor Roy Tirtho and Spotube Contributors</copyright> <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> <licenseUrl>https://github.com/team-spotube/spotube/blob/master/LICENSE</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance> <requireLicenseAcceptance>true</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/team-spotube/spotube</projectSourceUrl> <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> <bugTrackerUrl>https://github.com/team-spotube/spotube/issues/new</bugTrackerUrl>
<tags>spotube music audio music-player streaming lyrics plugins foss</tags> <tags>spotube music audio music-player streaming lyrics plugins foss</tags>
<summary>Open source, plugin powered music streaming client</summary> <summary>Open source, plugin powered music streaming client</summary>

View File

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

View File

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