Compare commits

...

18 Commits

Author SHA1 Message Date
Jonatan Nyberg
20cd18bd3b
Merge b0327fd224 into 6223c438cf 2026-11-03 19:45:51 +00:00
Kingkor Roy Tirtho
6223c438cf ci(ios): install CocoaPods and build the workspace
The iOS job still built iosApp.xcodeproj without running pod install, so
Xcode couldn't find the Pods xcconfig and file lists (iosApp/Pods/ is
gitignored) and the build failed. Install the pods from Podfile.lock
(cached on it) and build iosApp.xcworkspace.
2026-11-03 19:29:50 +06:00
Kingkor Roy Tirtho
d2768c6a60 fix: update default Jam server URL to new endpoint 2026-11-03 19:17:43 +06:00
Kingkor Roy Tirtho
68fdd68cb9
Merge pull request #3208 from team-spotube/fix/ios-startup-crash
fix: crash at startup from a Koin cycle in DeviceAudioPlayerQueue
2026-11-03 19:08:23 +06:00
Kingkor Roy Tirtho
fa2183ed18 fix: crash at startup from a Koin cycle in DeviceAudioPlayerQueue
#3170 made DeviceAudioPlayerQueue resolve StreamingUrlRepository eagerly in
a property initializer, but StreamingUrlRepository is constructed with the
AudioPlayerQueue (this queue). Koin recursed between the two definitions
until the stack overflowed, crashing the app at launch (SIGSEGV in
Koin's SingleInstanceFactory). The queue is common code, so every
platform was affected.

The dependency only existed for an iOS-only path that resolved every
queue entry to a direct stream URL, bypassing the local stream proxy.
That was an AVPlayer workaround; iOS now plays through VLC, which plays
the proxied streams like the other platforms, so the path is removed and
the queue is back to how it was before #3170. The iOS media session from
#3170 is kept.
2026-11-03 18:52:06 +06:00
Kingkor Roy Tirtho
3a856d6d03
Merge pull request #3207 from team-spotube/ios-mobilevlckit-cocoapods
fix(ios): use MobileVLCKit from CocoaPods instead of a git Swift package
2026-11-03 18:21:57 +06:00
Samuele Giacomin
4639b50a3d
feat: Add iOS background playback support (#3170)
* Add iOS background playback support

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Remove redundant iOS validation workflow

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix Kotlin Native iOS media session APIs

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix iOS stream repository injection

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: sibylla canton <sibyllacanton@Air-di-sibylla.home>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 18:04:04 +06:00
Kingkor Roy Tirtho
e02c1b73b4 fix(ios): use MobileVLCKit from CocoaPods instead of a git Swift package
Android Studio couldn't build the iOS app: resolving the videolan/vlckit
Swift package fails inside it (Xcode's source control service reports
"Communication with the service was interrupted"), ending in
"Missing package product 'VLCKit'". VLCKit's README only documents
CocoaPods and Carthage anyway.

- Podfile with MobileVLCKit 3.7.5 (iOS-only build, 233 MB instead of the
  922 MB cross-platform VLCKit 4 binary); build iosApp.xcworkspace.
- VlcPlayerEngine ported to the MobileVLCKit 3 API: it has a real
  "ended" state, so the stop bookkeeping and the post-resume seek
  workaround needed for VLCKit 4 are gone. Buffering/ES-added
  notifications keep arriving while playing, so "playing" is reported
  whenever VLC is playing.
- NativePlayerListener.onBuffered removed: MobileVLCKit has no buffering
  progress callback.
- Pods/ is ignored; AGENTS.md notes the pod install + workspace setup.
2026-11-03 17:57:55 +06:00
Kingkor Roy Tirtho
f9c040015e
Merge pull request #3206 from team-spotube/ios-vlckit-player
feat(ios): play audio with VLCKit and drop AVPlayer format restrictions
2026-11-03 16:55:12 +06:00
Kingkor Roy Tirtho
538ba636d4 feat(ios): drop AVPlayer-era streaming format restrictions
VLC on iOS plays every container, codec and protocol plugins return, so iOS
no longer needs special casing:

- Stream selection no longer ranks WebM/MKV/Ogg or DASH streams as
  unplayable on iOS; the platform playability mechanism is removed.
- Opus in WebM is the default streaming format on every platform.
- Saved Opus/WebM preferences are no longer migrated to AAC on iOS.
- The streaming format picker offers all formats on iOS.
2026-11-03 16:53:36 +06:00
Kingkor Roy Tirtho
0a30aa7137 feat(ios): play audio with VLCKit instead of AVPlayer
AVPlayer misreads the duration of some fragmented MP4 streams (it reports
double the real length, leaving the second half silent and never ending the
track). Playback on iOS now goes through VLCKit 4, added as a Swift package
pinned to videolan/vlckit@6b87735.

- NativePlayerEngine (iosMain): single-media engine contract that Swift
  implements and registers at launch; VLCKit ships as an SPM binary target,
  so it can't be bound with cinterop from Gradle.
- AudioPlayer.ios.kt: rewritten on the engine. Playlist, loop modes, shuffle
  and auto-advance live in Kotlin, mirroring the JVM player (the old iOS
  player never advanced to the next track).
- VlcPlayerEngine.swift: VLCKit-backed engine. VLCKit 4 has no "ended" state,
  so natural ends are told apart from requested stops; after a pause/resume
  it stops reporting time, so a no-op seek restores position updates.
2026-11-03 16:53:36 +06:00
Kingkor Roy Tirtho
784d9260d1
Merge pull request #3132 from wuqqers/fix/desugar-jdk-libs-nio
fix(android): use desugar_jdk_libs_nio so NewPipeExtractor works on minSdk 28
2026-11-03 13:43:44 +06:00
Kingkor Roy Tirtho
9a47a1af5f
Merge branch 'dev' into fix/desugar-jdk-libs-nio 2026-11-03 13:42:50 +06:00
Kingkor Roy Tirtho
431fd2353f
Merge pull request #3204 from team-spotube/fix/large-list-memory
fix: memory blow-ups and crashes with large lists and local media
2026-11-03 13:15:19 +06:00
Kingkor Roy Tirtho
f23c66c0c5 build: raise the desktop heap ceiling to 1 GB
384 MB left little headroom for large libraries and long sessions. It is
a ceiling, not a reservation: the serial GC only grows the heap to what
is live.
2026-11-02 14:20:10 +06:00
Kingkor Roy Tirtho
8537718225 fix: stop memory blow-ups and crashes with large lists and local media
TrackList rendered every loaded track inside one LazyColumn item, so all
rows stayed composed and recomposed together; scrolling ~1350 saved
tracks exhausted the desktop heap (#3200). Rows are now separate lazy
items and the card is drawn once behind them. Only the current row
depends on the playing state, the saved tracks screen no longer reloads
from page 1 whenever a page arrives, cached tracks are streamed in 64 KB
chunks instead of read whole, and queue loading/restoring maps entries
in one pass instead of quadratically.

Desktop kept full-size embedded covers for local files and serialized
them as JSON number arrays into the local media index and the queue,
which is persisted on every change (#3193). Covers are now shrunk to
160 px like on Android, the queue no longer persists them (restore fills
them in from the index), and store files already bloated past 8 MB are
dropped instead of loaded.

Lists keyed on plugin ids crashed on blank or repeated ids (#3196); they
now go through uniqueLazyKeys. Queue persistence failures are logged
instead of crashing the app, a likely cause of #3198.

Fixes #3200
Fixes #3193
Fixes #3196
2026-11-02 13:37:05 +06:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
Omer Kaya Ekici
ed3743bbea fix(android): use desugar_jdk_libs_nio so URLEncoder.encode(String, Charset) is backported on API < 33
The default desugar_jdk_libs artifact does not include the java.nio/charset backing APIs, so on API < 33 (minSdk 28) NewPipeExtractor's URLEncoder.encode(String, Charset) call throws NoSuchMethodError and the app crashes on launch. The _nio variant backports those APIs (2.1.5 is available for it and matches the AGP 8.13.2 requirement).

Refs #3130
2026-09-20 10:43:52 +03:00
43 changed files with 1970 additions and 613 deletions

View File

@ -659,6 +659,16 @@ jobs:
- name: Make gradlew executable - name: Make gradlew executable
run: chmod +x gradlew run: chmod +x gradlew
# MobileVLCKit comes from CocoaPods; iosApp/Pods/ is gitignored and the workspace needs it.
- name: Cache CocoaPods
uses: actions/cache@v6
with:
path: iosApp/Pods
key: pods-${{ runner.os }}-${{ hashFiles('iosApp/Podfile.lock') }}
- name: Install CocoaPods dependencies
run: pod install --deployment --project-directory=iosApp
# Unsigned on purpose: there is no Apple developer account in CI. The IPA is meant for sideloading # 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. # (AltStore, Sideloadly, ...), which re-signs it with the user's own certificate.
# The Gradle build phase inside Xcode reads these as project properties. # The Gradle build phase inside Xcode reads these as project properties.
@ -668,7 +678,7 @@ jobs:
ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }} ORG_GRADLE_PROJECT_buildNumber: ${{ github.run_number }}
run: > run: >
xcodebuild xcodebuild
-project iosApp/iosApp.xcodeproj -workspace iosApp/iosApp.xcworkspace
-scheme iosApp -scheme iosApp
-configuration Release -configuration Release
-sdk iphoneos -sdk iphoneos

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ captures
!*.xcodeproj/project.xcworkspace/ !*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
iosApp/Pods/
node_modules/ node_modules/
composeApp/release/ composeApp/release/

View File

@ -8,6 +8,7 @@
- Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`). - Desktop/JVM: `composeApp/src/jvmMain/kotlin/org/spotube/spotube/main.kt` (`mainClass = org.spotube.spotube.MainKt`).
- Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`. - Android: `composeApp/src/androidMain/kotlin/org/spotube/spotube/MainActivity.kt`.
- iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`. - iOS bridge: `composeApp/src/iosMain/kotlin/org/spotube/spotube/MainViewController.kt` and `iosApp/iosApp/ContentView.swift`.
- iOS audio: MobileVLCKit from CocoaPods (`iosApp/Podfile`), driven by `iosApp/iosApp/VlcPlayerEngine.swift` through `NativePlayerEngine` (iosMain). Run `pod install` in `iosApp/` first, then build `iosApp/iosApp.xcworkspace` (not the `.xcodeproj`, which can't build the Pods). Android Studio's iOS run config must point at the workspace too.
## High-value commands ## High-value commands
- Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only. - Use Gradle wrapper (`./gradlew` or `./gradlew.bat`) only.

View File

@ -449,7 +449,7 @@ nucleus.application {
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM --- // --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) "-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
) )
@ -556,7 +556,7 @@ nucleus.application {
// ProGuard is not used for native images; -Os is the size equivalent // ProGuard is not used for native images; -Os is the size equivalent
optimization = NativeImageOptimization.SIZE optimization = NativeImageOptimization.SIZE
// Same ceiling as the JVM distribution's -Xmx // Same ceiling as the JVM distribution's -Xmx
maxHeapSize = "384m" maxHeapSize = "1g"
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
// a second ~30 MB copy as a resource // a second ~30 MB copy as a resource
buildArgs.add("-H:+UnlockExperimentalVMOptions") buildArgs.add("-H:+UnlockExperimentalVMOptions")

View File

@ -0,0 +1,22 @@
<!--
~ 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/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

View File

@ -0,0 +1,169 @@
<!--
~ 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/>.
-->
<resources>
<string name="settings_screen_title">Inställningar</string>
<string name="settings_section_plugins">Tillägg</string>
<string name="settings_section_language_region">Språk och region</string>
<string name="settings_section_appearance">Utseende</string>
<string name="settings_section_playback">Uppspelning</string>
<string name="settings_section_downloads">Nedladdningar</string>
<string name="settings_section_caching">Cachelagring</string>
<string name="settings_section_desktop">Dator</string>
<string name="settings_section_updates">Uppdateringar</string>
<string name="settings_action_close">Stäng</string>
<string name="settings_action_save">Spara</string>
<string name="settings_action_cancel">Avbryt</string>
<string name="settings_language_title">Språk</string>
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
<string name="settings_country_title">Land</string>
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
<string name="settings_theme_title">Tema</string>
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
<string name="settings_theme_light">Ljust</string>
<string name="settings_theme_dark">Mörkt</string>
<string name="settings_theme_system">System</string>
<string name="settings_accent_title">Accentfärg</string>
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
<string name="settings_accent_dialog_title">Accentfärg</string>
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
<string name="settings_preview_light">Ljust</string>
<string name="settings_preview_dark">Mörkt</string>
<string name="settings_preview_label">Förhandsgranskning</string>
<string name="settings_preview_button">Knapp</string>
<string name="settings_accent_green_goblin">Gröna trollet</string>
<string name="settings_accent_electric_violet">Elektriskt violett</string>
<string name="settings_accent_oceanic_cyan">Havscyan</string>
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
<string name="settings_accent_rose_garden">Rosenträdgård</string>
<string name="settings_accent_midnight_blue">Midnattsblått</string>
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
<string name="settings_local_media_folders_manage">Hantera</string>
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
<string name="settings_download_format_title">Musikformat för nedladdning</string>
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
<string name="settings_streaming_format_title">Musikformat för strömning</string>
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
<string name="settings_cache_folder_title">Cachemapp</string>
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
<string name="settings_cache_size_limit_placeholder">500</string>
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
<string name="settings_enable_connect_title">Aktivera Connect</string>
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
<string name="settings_blacklist_title">Spärrlista</string>
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
<string name="settings_plugins_manage_title">Hantera tillägg</string>
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
<string name="settings_plugins_ability_metadata">Metadata</string>
<string name="settings_plugins_ability_audio">Ljud</string>
<string name="settings_plugins_ability_lyrics">Låttexter</string>
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
<string name="settings_plugins_action_change">Ändra</string>
<string name="settings_plugins_action_select">Välj</string>
<string name="settings_plugins_selected">Valt</string>
<string name="settings_plugins_clear">Rensa val</string>
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
<string name="settings_plugins_clear_content_description">Rensa</string>
<string name="plugin_configure_title">Konfigurera</string>
<string name="plugin_screen_title">Tilläggshanterare</string>
<string name="plugin_error_enter_url">Ange en URL</string>
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
<string name="plugin_installed_singular">tillägg</string>
<string name="plugin_installed_plural">tillägg</string>
<string name="plugin_install_section_title">Installera ett tillägg</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Ladda ner</string>
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
<string name="plugin_state_active">Aktivt</string>
<string name="plugin_version_label">v%1$s</string>
<string name="plugin_state_builtin">Inbyggt</string>
<string name="plugin_action_remove">Ta bort tillägg</string>
<string name="plugin_action_login">Logga in</string>
<string name="plugin_action_logout">Logga ut</string>
<string name="plugin_section_url_title">Ladda ner från URL</string>
<string name="plugin_section_file_title">Installera från fil</string>
<string name="plugin_section_install">Installera</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
<string name="plugin_permissions_installed_label">Installerat</string>
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
</resources>

View File

@ -21,6 +21,7 @@ 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 kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlin.time.Duration import kotlin.time.Duration
data class MediaItem( data class MediaItem(
@ -84,7 +85,9 @@ sealed interface QueueEntry {
val artists: List<String>, val artists: List<String>,
val duration: Long, val duration: Long,
val album: String?, val album: String?,
val coverBytes: ByteArray?, // Not persisted: the queue is saved on every change and covers made it hundreds of MB
// of JSON (#3193). Restore fills them back in from the local media index.
@Transient val coverBytes: ByteArray? = null,
override val url: String, override val url: String,
override val addedBy: String = "") : QueueEntry { override val addedBy: String = "") : QueueEntry {
override val artworkUrl: String? get() = null override val artworkUrl: String? get() = null

View File

@ -20,8 +20,10 @@ package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@ -37,6 +39,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -130,14 +133,15 @@ class DeviceAudioPlayerQueue(
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?) { collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val resolved = resolveMediaItems(entries)
val mediaItems = resolved.map { it.first }
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
logger.w { "load: no valid media items after filtering blanks" } logger.w { "load: no valid media items after filtering blanks" }
} }
val normalizedStartPosition = val normalizedStartPosition =
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } entryByMediaUrl.value = entriesByUrl(resolved)
updateCollectionContext(collectionEntry) updateCollectionContext(collectionEntry)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady() persistQueueStateIfReady()
@ -401,19 +405,66 @@ class DeviceAudioPlayerQueue(
return return
} }
val mediaItems = state.entries.mapNotNull { it.toMediaItem() } val restored = resolveMediaItems(withLocalCovers(state.entries))
if (mediaItems.isEmpty()) { if (restored.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" } logger.w { "restorePersistedState: all entries had blank urls" }
return return
} }
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } val mediaItems = restored.map { it.first }
entryByMediaUrl.value = entriesByUrl(restored)
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
logger.i { "Persisted state restored successfully" } logger.i { "Persisted state restored successfully" }
} }
/**
* Converts each entry once. Looking every item's entry up again by converting the whole list
* per item was quadratic: ~900k suspending conversions to start a 1353-track playlist.
*/
private suspend fun resolveMediaItems(entries: List<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
/** First entry wins for a repeated url, as before. */
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
}
/** Local covers aren't persisted with the queue; look them up in the local media index. */
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
if (entries.none { it is QueueEntry.LocalTrack }) return entries
val covers = runCatching {
get<LocalMediaCacheRepository>().cacheFlow.first().folders
.flatMap { it.tracks }
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
.toMap()
}.getOrElse {
logger.w(it) { "Couldn't read local media index for queue covers" }
emptyMap()
}
if (covers.isEmpty()) return entries
return entries.map { entry ->
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
} else entry
}
}
/**
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
*/
private suspend fun persistQueueStateIfReady() { private suspend fun persistQueueStateIfReady() {
try {
persistQueueState()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e(e) { "Failed to persist queue state" }
}
}
private suspend fun persistQueueState() {
if (isRestoringPersistedState) { if (isRestoringPersistedState) {
logger.v { "Skipping persist: still restoring persisted state" } logger.v { "Skipping persist: still restoring persisted state" }
return return

View File

@ -61,13 +61,13 @@ class Database(val paths: Paths) {
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy { val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) }
) )
} }
val localMediaDataStore: DataStore<Preferences> by lazy { val localMediaDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) }
) )
} }
@ -83,7 +83,23 @@ class Database(val paths: Paths) {
) )
} }
/**
* DataStore reads a whole preferences file into the heap. Earlier builds stored full-size
* embedded covers as JSON number arrays in the queue and local media stores, growing them to
* hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so
* drop one that is far past any sane size instead of loading it.
*/
private fun dropIfOversized(path: okio.Path): okio.Path {
runCatching {
val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path
if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path)
}
return path
}
private companion object { private companion object {
const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024
/** Preference files written by previous launches; their presence means an upgrade. */ /** Preference files written by previous launches; their presence means an upgrade. */
val EXISTING_INSTALL_PROBE_FILES = listOf( val EXISTING_INSTALL_PROBE_FILES = listOf(
"spotube.preferences_pb", "spotube.preferences_pb",

View File

@ -196,22 +196,18 @@ class CacheManager(
val (start, end) = range val (start, end) = range
val length = end - start + 1 val length = end - start + 1
val contentRange = "bytes $start-$end/$fileSize" val contentRange = "bytes $start-$end/$fileSize"
fileSystem.source(filePath).buffer().use { source -> call.respond(object : OutgoingContent.WriteChannelContent() {
source.skip(start) override val status: HttpStatusCode = HttpStatusCode.PartialContent
val bytes = source.readByteArray(length) override val contentLength: Long = length
call.respond(object : OutgoingContent.WriteChannelContent() { override val contentType: ContentType = entry.toContentType()
override val status: HttpStatusCode = HttpStatusCode.PartialContent override val headers: Headers = HeadersBuilder().apply {
override val contentLength: Long = bytes.size.toLong() append(HttpHeaders.ContentRange, contentRange)
override val contentType: ContentType = entry.toContentType() append(HttpHeaders.AcceptRanges, "bytes")
override val headers: Headers = HeadersBuilder().apply { }.build()
append(HttpHeaders.ContentRange, contentRange) override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
append(HttpHeaders.AcceptRanges, "bytes") streamFileRange(fileSystem, filePath, start, length, channel)
}.build() }
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { })
channel.writeFully(bytes)
}
})
}
return return
} }
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
@ -223,19 +219,42 @@ class CacheManager(
return return
} }
fileSystem.source(filePath).buffer().use { source -> call.respond(object : OutgoingContent.WriteChannelContent() {
val bytes = source.readByteArray() override val status: HttpStatusCode = HttpStatusCode.OK
call.respond(object : OutgoingContent.WriteChannelContent() { override val contentLength: Long = fileSize
override val status: HttpStatusCode = HttpStatusCode.OK override val contentType: ContentType = entry.toContentType()
override val contentLength: Long = bytes.size.toLong() override val headers: Headers = HeadersBuilder().apply {
override val contentType: ContentType = entry.toContentType() append(HttpHeaders.AcceptRanges, "bytes")
override val headers: Headers = HeadersBuilder().apply { }.build()
append(HttpHeaders.AcceptRanges, "bytes") override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
}.build() streamFileRange(fileSystem, filePath, 0, fileSize, channel)
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { }
channel.writeFully(bytes) })
} }
})
}
private const val STREAM_CHUNK_BYTES = 64 * 1024
/**
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
* player re-requests them on every seek, so they must never be read into the heap whole.
*/
internal suspend fun streamFileRange(
fileSystem: FileSystem,
filePath: Path,
start: Long,
length: Long,
channel: io.ktor.utils.io.ByteWriteChannel) {
fileSystem.source(filePath).buffer().use { source ->
source.skip(start)
val chunk = ByteArray(STREAM_CHUNK_BYTES)
var remaining = length
while (remaining > 0) {
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
if (read == -1) break
channel.writeFully(chunk, 0, read)
remaining -= read
} }
} }
} }

View File

@ -23,11 +23,9 @@ 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.filterNotNull
@ -80,8 +78,7 @@ 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, private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
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()
@ -146,7 +143,7 @@ class StreamingUrlRepository(
} else { } else {
stream.streams stream.streams
} }
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform) return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
} }
@ -256,12 +253,11 @@ 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 = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " +
"lowest wins):\n" + "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=(${if (stream.isPlayableOn(platform)) 0 else 1}, " + "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
"${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)}"
@ -269,10 +265,6 @@ 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})"
@ -400,21 +392,16 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
} }
} }
internal val currentPlatform: PlatformType by lazy { getPlatform().type }
/** /**
* Selects the available stream closest to the user's preferred format and quality. * Selects the available stream closest to the user's preferred format and quality. Every
* Streams the [platform]'s player can decode always win over ones it can't, whatever the * platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container,
* preferences say: e.g. opus/webm is unplayable on iOS. * codec and protocol plugins return, so nothing is ruled out up front.
*/ */
internal fun selectPreferredAudioStream( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality, preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
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
@ -425,25 +412,6 @@ 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

@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,6 +81,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@ -96,6 +98,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
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.ui.misc.SkeletonTree import org.spotube.spotube.core.ui.misc.SkeletonTree
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
import org.spotube.spotube.core.ui.misc.TextWithShimmer import org.spotube.spotube.core.ui.misc.TextWithShimmer
import org.spotube.spotube.core.ui.misc.shimmerApply import org.spotube.spotube.core.ui.misc.shimmerApply
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
@ -119,6 +122,42 @@ private val CompactTrackListBreakpoint = 600.dp
private val LargeTrackListBreakpoint = 840.dp private val LargeTrackListBreakpoint = 840.dp
private const val ShimmerRowCount = 6 private const val ShimmerRowCount = 6
private val TrackCardOuterPadding = 8.dp
private val TrackCardInnerPadding = 12.dp
private const val TrackCardTopKey = "track-card-top"
private const val TrackCardRowKeyPrefix = "track-card-row:"
private const val TrackCardFooterKey = "track-card-footer"
private const val TrackCardBottomKey = "track-card-bottom"
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
/**
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
*/
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
layout { measurable, constraints ->
val info = listState.layoutInfo
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
// Item offsets are relative to the viewport start, which is negative by the top padding.
val toLocal = -info.viewportStartOffset
val viewport = info.viewportEndOffset - info.viewportStartOffset
val overscan = viewport * 3
val first = cardItems.first()
val last = cardItems.last()
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
val bottom = if (last.key == TrackCardBottomKey) {
last.offset + last.size + toLocal - edgeInsetPx
} else viewport + overscan
val height = (bottom - top).coerceAtLeast(0)
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
}
enum class TrackSortOption(val label: StringResource) { enum class TrackSortOption(val label: StringResource) {
None(Res.string.track_list_sort_original), None(Res.string.track_list_sort_original),
Title(Res.string.track_list_sort_title), Title(Res.string.track_list_sort_title),
@ -198,7 +237,7 @@ fun TrackList(
val shouldLoadMore = remember(density) { val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
val loadMoreThreshold = with(density) { 200.dp.toPx() } val loadMoreThreshold = with(density) { 200.dp.toPx() }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
// Trigger when the bottom of the track card is close to/inside the viewport, // Trigger when the bottom of the track card is close to/inside the viewport,
// which means the user has scrolled through (or is about to scroll through) // which means the user has scrolled through (or is about to scroll through)
// all loaded tracks. // all loaded tracks.
@ -219,115 +258,124 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
// Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
}
// Shimmer, error, empty and next-page rows at the end of the card.
val trackCardFooter: @Composable () -> Unit = {
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
TrackListFeedbackRow(
message = error,
isError = true)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
val trackCardContent: @Composable () -> Unit = { val trackCardContent: @Composable () -> Unit = {
MillenniumCard( MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = TrackCardOuterPadding),
contentPadding = PaddingValues(0.dp)) { contentPadding = PaddingValues(0.dp)) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp) .padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
) { ) {
visibleTracks.forEachIndexed { displayedIndex, track -> visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) { if (displayedIndex > 0) {
MillenniumSeparator() MillenniumSeparator()
} }
TrackListRow( trackRow(displayedIndex, track)
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
TrackListFeedbackRow(
message = error,
isError = true)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp))
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions)
} }
trackCardFooter()
} }
} }
} }
@ -454,29 +502,57 @@ fun TrackList(
if (scrollable) { if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
// Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
// they sit on is drawn once behind the list, spanning the card's laid-out items.
MillenniumCard(
modifier = Modifier
.matchParentSize()
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
contentPadding = PaddingValues(0.dp)) {}
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
// gaps around the card are applied to the items before and after it instead.
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier contentPadding = contentPadding) {
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp)) {
if (headerContent != null) { if (headerContent != null) {
item { item {
headerContent() Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
} }
} }
if (!simplified) { if (!simplified) {
item { filterSortRow() } item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
}
item(key = TrackCardTopKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
}
// Plugin ids can repeat (the same track twice in a playlist).
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
Column(Modifier.fillMaxWidth()) {
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, visibleTracks[displayedIndex])
}
}
item(key = TrackCardFooterKey) {
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
}
item(key = TrackCardBottomKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
} }
item(key = "track-card") { trackCardContent() }
if (footerContent != null) { if (footerContent != null) {
item { item {
footerContent() Box(Modifier.padding(top = 2.dp)) { footerContent() }
} }
} }
} }
}
if (!useDropdownForOptions) { if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track -> selectedTrackForOptions?.let { track ->

View File

@ -0,0 +1,67 @@
/*
* 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.misc
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
/*
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
*/
private const val DUPLICATE_SEPARATOR = '\u0000'
/**
* One key per item: an item keeps its id the first time it appears, so scroll position and
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
* a real id.
*/
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
val occurrences = HashMap<String, Int>(items.size)
return items.map { item ->
val base = id(item)
val occurrence = occurrences[base] ?: 0
occurrences[base] = occurrence + 1
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
}
}
fun <T> LazyListScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}
fun <T> LazyGridScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyGridItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.artist package org.spotube.spotube.modules.artist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -638,7 +639,7 @@ private fun AlbumsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(albums, key = { it.id }) { album -> itemsWithUniqueKeys(albums, id = { it.id }) { album ->
AlbumCard(album = album) AlbumCard(album = album)
} }
} }
@ -676,7 +677,7 @@ private fun RelatedArtistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(artists, key = { it.id }) { artist -> itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
ArtistCard(artist = artist) ArtistCard(artist = artist)
} }
} }
@ -714,7 +715,7 @@ private fun FeaturedPlaylistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(playlists, key = { it.id }) { playlist -> itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
PlaylistCard(playlist = playlist) PlaylistCard(playlist = playlist)
} }
} }

View File

@ -3,6 +3,7 @@
*/ */
package org.spotube.spotube.modules.library package org.spotube.spotube.modules.library
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -58,7 +59,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } } if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
else { else {
if (header != null) item(key = "library_header") { header(true) } if (header != null) item(key = "library_header") { header(true) }
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) } itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) }
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
} }
} }
@ -71,7 +72,7 @@ internal fun <T> LibraryMediaCollection(
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } } if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
else { else {
if (header != null) item(key = "library_header") { header(false) } if (header != null) item(key = "library_header") { header(false) }
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) } itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) }
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } } if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
} }
} }

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.library.playlist package org.spotube.spotube.modules.library.playlist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
@ -195,7 +196,7 @@ private fun PlaylistPickerContent(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(playlists, key = { it.id }) { playlist -> itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
PlaylistPickerRow( PlaylistPickerRow(
playlist = playlist, playlist = playlist,
onClick = { onPlaylistSelected(playlist.id) }) onClick = { onPlaylistSelected(playlist.id) })

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.playlist package org.spotube.spotube.modules.playlist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
@ -138,7 +139,7 @@ fun AddTracksToPlaylistDialog(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(state.results, key = { it.track.id }) { result -> itemsWithUniqueKeys(state.results, id = { it.track.id }) { result ->
TrackSearchResultRow( TrackSearchResultRow(
track = result.track, track = result.track,
isAdding = result.isAdding, isAdding = result.isAdding,

View File

@ -50,6 +50,14 @@ class SavedTracksRepository(
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow() val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
/**
* Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow]
* also grows while pages and saved-states are fetched, so reloading the list on it restarted
* paging on every page (#3200).
*/
private val _libraryChanges = MutableStateFlow(0)
val libraryChanges: StateFlow<Int> = _libraryChanges.asStateFlow()
init { init {
scope.launch { scope.launch {
pluginManager.selectedMetadataPlugin pluginManager.selectedMetadataPlugin
@ -71,6 +79,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value = emptySet() savedTrackIds.value = emptySet()
_libraryChanges.value++
} }
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
@ -109,6 +118,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value += ids.toSet() savedTrackIds.value += ids.toSet()
_libraryChanges.value++
} }
} }
@ -122,6 +132,7 @@ class SavedTracksRepository(
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
savedTrackIds.value -= ids.toSet() savedTrackIds.value -= ids.toSet()
_libraryChanges.value++
} }
} }

View File

@ -152,7 +152,7 @@ class SavedTracksViewModel(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged(), .distinctUntilChanged(),
repository.savedTracksIdsFlow repository.libraryChanges
) { _, _ -> }.collect { ) { _, _ -> }.collect {
loadInitialData() loadInitialData()
loadCurrentUser() loadCurrentUser()

View File

@ -17,9 +17,6 @@
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
@ -95,29 +92,18 @@ data class UserSettings(
val lastSupportReminderEpochMs: Long = 0L) val lastSupportReminderEpochMs: Long = 0L)
/** The public Group Jam room server. */ /** The public Group Jam room server. */
const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev" const val DEFAULT_JAM_SERVER_URL = "https://jam.spotube.cc"
/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */ /** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
internal fun isJamServerUrl(url: String): Boolean = internal fun isJamServerUrl(url: String): Boolean =
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim()) Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
/** /** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC. internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat(
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). codec = "opus",
*/ container = "webm",
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { qualities = listOf(
val aac = !isContainerPlayableOn("webm", platform) AudioQuality.Lossy(bitrate = 44_000),
return AudioFormat( AudioQuality.Lossy(bitrate = 96_000),
codec = if (aac) "aac" else "opus", AudioQuality.Lossy(bitrate = 128_000),
container = if (aac) "mp4" else "webm", AudioQuality.Lossy(bitrate = 256_000)))
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

@ -59,7 +59,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
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).withPlayableStreamingFormat() settingsJson.decodeFromString<UserSettings>(json as String)
} else { } else {
UserSettings() // Default value UserSettings() // Default value
} }
@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
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).withPlayableStreamingFormat() return settingsJson.decodeFromString<UserSettings>(storedJson)
} }
} }

View File

@ -18,8 +18,6 @@
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
@ -58,7 +56,6 @@ 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",
@ -72,7 +69,7 @@ internal val streamingFormatPresets = listOf(
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

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.shell.alternative_track package org.spotube.spotube.modules.shell.alternative_track
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
import org.spotube.millennium.composables.MillenniumSpokeSpinner import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
@ -119,9 +120,9 @@ fun AlternativeTrackContent(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(6.dp)) {
items( itemsWithUniqueKeys(
items = uiState.alternatives, items = uiState.alternatives,
key = { source -> source.id }) { source -> id = { source -> source.id }) { source ->
AlternativeSourceRow( AlternativeSourceRow(
source = source, source = source,
isActive = source.id == uiState.activeSourceId, isActive = source.id == uiState.activeSourceId,

View File

@ -8,8 +8,10 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.koin.dsl.module import org.koin.dsl.module
@ -423,7 +425,10 @@ class DeviceAudioPlayerQueueTest {
) )
val queue = createQueue() val queue = createQueue()
withContext(Dispatchers.Default) { } // yield to let init coroutine run // Restore runs on Dispatchers.IO; wait for it on real time rather than assuming one yield is enough.
withContext(Dispatchers.Default) {
withTimeout(5_000) { while (fakePlayer.loadCallCount == 0) delay(10) }
}
assertEquals(1, fakePlayer.loadCallCount) assertEquals(1, fakePlayer.loadCallCount)
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry()) assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())

View File

@ -5,7 +5,6 @@ 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
@ -100,9 +99,7 @@ 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)

View File

@ -1,6 +1,5 @@
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
@ -14,7 +13,6 @@ 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.
@ -56,8 +54,7 @@ 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), quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality)
platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform)
// ---------- empty / single ---------- // ---------- empty / single ----------
@ -407,33 +404,20 @@ class StreamingUrlRepositoryTest {
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
} }
// ---------- device playability ---------- // ---------- no platform restrictions ----------
@Test @Test
fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { fun `the preferred opus webm stream wins over aac`() {
val opus = lossy("opus-160", 160_000) val opus = lossy("opus-160", 160_000)
val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) assertSame(opus, select(listOf(aac, opus)))
} }
@Test @Test
fun `on iOS dash manifests are skipped in favour of progressive streams`() { fun `a dash manifest closest to the preferences is not skipped`() {
val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) val dash = lossy("dash", 256_000, protocol = StreamProtocol.DASH)
val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a")
assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) assertSame(dash, select(listOf(progressive, dash)))
}
@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

@ -17,39 +17,19 @@
package org.spotube.spotube.modules.settings 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertSame
class StreamingFormatDefaultsTest { class StreamingFormatDefaultsTest {
private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
@Test @Test
fun `ios defaults to aac in mp4`() { fun `streaming defaults to opus in webm`() {
val format = defaultStreamingMusicFormat(PlatformType.IOS) val format = defaultStreamingMusicFormat()
assertEquals("aac" to "mp4", format.codec to format.container) assertEquals("opus" to "webm", format.codec to format.container)
} }
@Test @Test
fun `other platforms keep opus in webm`() { fun `user settings default to the streaming default`() {
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform -> assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat)
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

@ -15,16 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalForeignApi::class)
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -32,41 +28,39 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import platform.AVFoundation.AVPlayer import kotlinx.coroutines.sync.Mutex
import platform.AVFoundation.AVPlayerItem import kotlinx.coroutines.sync.withLock
import platform.AVFoundation.AVPlayerTimeControlStatus
import platform.AVFoundation.AVPlayerTimeControlStatusPaused
import platform.AVFoundation.AVPlayerTimeControlStatusPlaying
import platform.AVFoundation.AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate
import platform.AVFoundation.addPeriodicTimeObserverForInterval
import platform.AVFoundation.currentItem
import platform.AVFoundation.duration
import platform.AVFoundation.loadedTimeRanges
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.rate
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.AVFoundation.seekToTime
import platform.AVFoundation.timeControlStatus
import platform.AVFoundation.volume
import platform.CoreMedia.CMTime
import platform.CoreMedia.CMTimeGetSeconds
import platform.Foundation.NSURL
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.milliseconds
/**
* iOS player. Playback is done by MobileVLCKit through [NativePlayerEngine]; AVPlayer misreports the
* duration of some fragmented MP4 streams (doubling it, which leaves the second half silent).
* The engine plays a single URL, so the playlist, loop, shuffle and auto-advance live here.
*/
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
actual val context: Any = context actual val context: Any = context
private val avPlayer: AVPlayer = AVPlayer() private val engine: NativePlayerEngine = checkNotNull(NativePlayerRegistry.factory) {
"NativePlayerRegistry.factory must be set from Swift before the first AudioPlayer is created"
}.create()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val lock = Mutex()
private val mediaSession = IosMediaSession()
private var disposed = false private var disposed = false
private val originalPlaylist = mutableListOf<MediaItem>()
private val currentPlaylist = mutableListOf<MediaItem>() private val currentPlaylist = mutableListOf<MediaItem>()
private val urlIndexMap = mutableMapOf<String, Int>() private var currentIndex = -1
private var shuffleEnabled = false
// The engine holds media only after the first play(); until then a requested start position
// is kept here and handed over with the media.
private var engineHasMedia = false
private var pendingStartMs = 0L
private val _playerState = MutableStateFlow(PlayerState.IDLE) private val _playerState = MutableStateFlow(PlayerState.IDLE)
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null) private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
@ -94,97 +88,182 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow() actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow() actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null private val engineListener = object : NativePlayerListener {
override fun onState(state: NativePlayerState) {
init { if (disposed) return
avPlayer.addPeriodicTimeObserverForInterval( when (state) {
interval = CMTimeMake(1, 4), NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
queue = null NativePlayerState.OPENING,
) { time -> NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
if (!disposed) { NativePlayerState.PLAYING -> {
val seconds = CMTimeGetSeconds(time) _playerState.tryEmit(PlayerState.PLAYING)
_position.tryEmit(seconds.seconds) mediaSession.update(
_currentMediaItem.value,
avPlayer.currentItem?.let { item -> _position.value.inWholeMilliseconds / 1000.0,
val durationSeconds = CMTimeGetSeconds(item.duration) _duration.value.inWholeMilliseconds / 1000.0,
if (durationSeconds > 0) { true)
_duration.tryEmit(durationSeconds.seconds)
}
val loadedRanges = item.loadedTimeRanges
// if (loadedRanges.count() > 0u) {
// val range = loadedRanges.objectAtIndex(0u)
// }
} }
NativePlayerState.PAUSED -> {
_playerState.tryEmit(PlayerState.PAUSED)
mediaSession.update(
_currentMediaItem.value,
_position.value.inWholeMilliseconds / 1000.0,
_duration.value.inWholeMilliseconds / 1000.0,
false)
}
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
} }
} }
scope.launch { override fun onTime(positionMs: Long) {
while (!disposed) { if (disposed || positionMs < 0) return
syncTimeControlStatus() _position.tryEmit(positionMs.milliseconds)
delay(500) mediaSession.update(
_currentMediaItem.value,
positionMs / 1000.0,
_duration.value.inWholeMilliseconds / 1000.0,
_playerState.value == PlayerState.PLAYING)
}
override fun onDuration(durationMs: Long) {
if (disposed || durationMs <= 0) return
_duration.tryEmit(durationMs.milliseconds)
}
override fun onError(message: String) {
if (disposed) return
_playerState.tryEmit(PlayerState.IDLE)
_error.tryEmit(IllegalStateException("VLC playback error: $message"))
}
}
init {
engine.setListener(engineListener)
mediaSession.configure(
onPlay = { scope.launch { play() } },
onPause = { scope.launch { pause() } },
onSeek = { seconds -> scope.launch { seekTo(seconds.milliseconds) } },
onNext = { scope.launch { skipToNext() } },
onPrevious = { scope.launch { skipToPrevious() } })
}
/** Loads [currentPlaylist] at [currentIndex] into the engine. Caller holds [lock]. */
private fun loadCurrentIntoEngineLocked(autoPlay: Boolean) {
val item = currentPlaylist.getOrNull(currentIndex) ?: return
val startMs = pendingStartMs
pendingStartMs = 0L
engineHasMedia = true
_position.tryEmit(startMs.milliseconds)
_bufferingPosition.tryEmit(Duration.ZERO)
_duration.tryEmit(item.duration)
engine.load(item.url, autoPlay, startMs)
}
/** Switches to [index] and starts it. Caller holds [lock]. */
private fun playIndexLocked(index: Int) {
currentIndex = index
_currentMediaItem.tryEmit(currentPlaylist[index])
pendingStartMs = 0L
loadCurrentIntoEngineLocked(autoPlay = true)
}
private fun onMediaEndedLocked() {
if (disposed || currentPlaylist.isEmpty()) return
_completion.tryEmit(Unit)
when {
_loopState.value == LoopState.ONE -> playIndexLocked(currentIndex.coerceAtLeast(0))
currentIndex < currentPlaylist.lastIndex -> playIndexLocked(currentIndex + 1)
_loopState.value == LoopState.ALL -> playIndexLocked(0)
else -> {
_playerState.tryEmit(PlayerState.COMPLETED)
engineHasMedia = false
} }
} }
} }
private fun syncTimeControlStatus() { actual override suspend fun play() {
val status = avPlayer.timeControlStatus lock.withLock {
if (status == lastTimeControlStatus) return if (disposed || currentPlaylist.isEmpty()) return
lastTimeControlStatus = status mediaSession.activate()
if (currentIndex !in currentPlaylist.indices) {
val playerState = when (status) { currentIndex = 0
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING _currentMediaItem.tryEmit(currentPlaylist[0])
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED }
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
else -> PlayerState.IDLE
} }
_playerState.tryEmit(playerState) }
if (avPlayer.currentItem == null) { actual override suspend fun pause() {
lock.withLock {
if (disposed) return
engine.pause()
}
}
actual override suspend fun stop() {
lock.withLock {
if (disposed) return
engine.stop()
mediaSession.deactivate()
mediaSession.update(
_currentMediaItem.value,
0.0,
_duration.value.inWholeMilliseconds / 1000.0,
false)
engineHasMedia = false
pendingStartMs = 0L
_position.tryEmit(Duration.ZERO)
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
} }
} }
private fun resolveState(): PlayerState { actual override suspend fun seekTo(position: Duration) {
return when (avPlayer.timeControlStatus) { lock.withLock {
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING if (disposed) return
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED val maxMs = _duration.value.inWholeMilliseconds
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING val targetMs = if (maxMs > 0) {
else -> PlayerState.IDLE position.inWholeMilliseconds.coerceIn(0, maxMs)
} else {
position.inWholeMilliseconds.coerceAtLeast(0)
}
if (engineHasMedia) engine.seekTo(targetMs) else pendingStartMs = targetMs
_position.tryEmit(targetMs.milliseconds)
} }
} }
private fun buildAVPlayerItem(url: String): AVPlayerItem? {
val nsUrl = NSURL.URLWithString(url) ?: return null
return AVPlayerItem(nsUrl)
}
actual override suspend fun play() {
avPlayer.play()
}
actual override suspend fun pause() {
avPlayer.pause()
}
actual override suspend fun stop() {
avPlayer.pause()
avPlayer.seekToTime(CMTimeMake(0, 1))
_playerState.tryEmit(PlayerState.IDLE)
}
actual override suspend fun seekTo(position: Duration) {
val seconds = position.inWholeMilliseconds / 1000.0
val cmTime = CMTimeMakeWithSeconds(seconds, 1000)
avPlayer.seekToTime(cmTime)
_position.tryEmit(position)
}
actual override suspend fun loop(state: LoopState) { actual override suspend fun loop(state: LoopState) {
_loopState.tryEmit(state) lock.withLock {
if (disposed) return
_loopState.tryEmit(state)
}
} }
actual override suspend fun shuffle(enabled: Boolean) { actual override suspend fun shuffle(enabled: Boolean) {
_shuffleMode.tryEmit(enabled) lock.withLock {
if (disposed || currentPlaylist.isEmpty() || shuffleEnabled == enabled) return
val currentItem = _currentMediaItem.value
val rebuilt = if (enabled) {
if (currentItem != null) {
listOf(currentItem) + originalPlaylist.filterNot { it.url == currentItem.url }.shuffled()
} else {
originalPlaylist.shuffled()
}
} else {
originalPlaylist.toList()
}
currentPlaylist.clear()
currentPlaylist.addAll(rebuilt)
shuffleEnabled = enabled
_shuffleMode.tryEmit(enabled)
_playlist.tryEmit(currentPlaylist.toList())
currentIndex = if (currentItem == null) 0 else {
currentPlaylist.indexOfFirst { it.url == currentItem.url }.takeIf { it >= 0 } ?: 0
}
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
}
} }
actual override suspend fun load( actual override suspend fun load(
@ -192,143 +271,186 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int startPosition: Int
) { ) {
currentPlaylist.clear() lock.withLock {
currentPlaylist.addAll(playlist) if (disposed) return
urlIndexMap.clear()
playlist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
_playlist.tryEmit(currentPlaylist.toList())
if (playlist.isEmpty()) { originalPlaylist.clear()
_playerState.tryEmit(PlayerState.IDLE) originalPlaylist.addAll(playlist)
_currentMediaItem.tryEmit(null) currentPlaylist.clear()
currentPlaylist.addAll(playlist)
shuffleEnabled = false
_shuffleMode.tryEmit(false)
_playlist.tryEmit(currentPlaylist.toList())
engine.stop()
engineHasMedia = false
pendingStartMs = 0L
_position.tryEmit(Duration.ZERO) _position.tryEmit(Duration.ZERO)
_duration.tryEmit(Duration.ZERO) _bufferingPosition.tryEmit(Duration.ZERO)
return
}
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex) if (currentPlaylist.isEmpty()) {
val item = playlist[safeIndex] currentIndex = -1
val avItem = buildAVPlayerItem(item.url) _currentMediaItem.tryEmit(null)
_playerState.tryEmit(PlayerState.IDLE)
_duration.tryEmit(Duration.ZERO)
return
}
if (avItem == null) { currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}")) _currentMediaItem.tryEmit(currentPlaylist[currentIndex])
_playerState.tryEmit(PlayerState.IDLE) _duration.tryEmit(currentPlaylist[currentIndex].duration)
return mediaSession.update(
} _currentMediaItem.value,
0.0,
_duration.value.inWholeMilliseconds / 1000.0,
false)
avPlayer.replaceCurrentItemWithPlayerItem(avItem) if (autoPlay) {
_currentMediaItem.tryEmit(item) loadCurrentIntoEngineLocked(autoPlay = true)
} else {
if (autoPlay) { _playerState.tryEmit(PlayerState.PAUSED)
avPlayer.play() }
} }
} }
actual override suspend fun addMediaItem(mediaItem: MediaItem) { actual override suspend fun addMediaItem(mediaItem: MediaItem) {
currentPlaylist.add(mediaItem) lock.withLock {
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex if (disposed) return
_playlist.tryEmit(currentPlaylist.toList()) originalPlaylist.add(mediaItem)
currentPlaylist.add(mediaItem)
_playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
}
} }
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) { actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
val currentIndex = currentPlaylist.indexOfFirst { lock.withLock {
it.url == _currentMediaItem.value?.url if (disposed) return
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else 0
originalPlaylist.add(insertIndex.coerceAtMost(originalPlaylist.size), mediaItem)
currentPlaylist.add(insertIndex, mediaItem)
_playlist.tryEmit(currentPlaylist.toList())
if (currentIndex == -1) {
currentIndex = 0
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
}
} }
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
currentPlaylist.add(insertIndex, mediaItem)
urlIndexMap.clear()
currentPlaylist.forEachIndexed { index, item ->
urlIndexMap[item.url] = index
}
_playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun removeMediaItem(mediaItem: MediaItem) { actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
val index = urlIndexMap[mediaItem.url] ?: return lock.withLock {
currentPlaylist.removeAt(index) if (disposed || currentPlaylist.isEmpty()) return
urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, item -> originalPlaylist.removeAll { it.url == mediaItem.url }
urlIndexMap[item.url] = i val currentUrl = _currentMediaItem.value?.url
val removedCurrent = currentUrl == mediaItem.url
val removalIndices = currentPlaylist.withIndex()
.filter { it.value.url == mediaItem.url }
.map { it.index }
if (removalIndices.isEmpty()) return
// Items before the playing one shift it down, so keep the index on the same item.
val removedBefore = removalIndices.count { it < currentIndex }
removalIndices.asReversed().forEach { currentPlaylist.removeAt(it) }
if (currentPlaylist.isEmpty()) {
currentIndex = -1
_currentMediaItem.tryEmit(null)
engine.stop()
engineHasMedia = false
_playerState.tryEmit(PlayerState.IDLE)
} else if (removedCurrent) {
currentIndex = currentIndex.coerceIn(0, currentPlaylist.lastIndex)
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
engine.stop()
engineHasMedia = false
} else {
currentIndex = (currentIndex - removedBefore).coerceIn(0, currentPlaylist.lastIndex)
}
_playlist.tryEmit(currentPlaylist.toList())
} }
_playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) { actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return lock.withLock {
val item = currentPlaylist.removeAt(fromIndex) if (disposed) return
currentPlaylist.add(toIndex, item) if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
urlIndexMap.clear()
currentPlaylist.forEachIndexed { i, it -> val item = currentPlaylist.removeAt(fromIndex)
urlIndexMap[it.url] = i currentPlaylist.add(toIndex, item)
if (!shuffleEnabled && fromIndex in originalPlaylist.indices && toIndex in originalPlaylist.indices) {
originalPlaylist.add(toIndex, originalPlaylist.removeAt(fromIndex))
}
val currentUrl = _currentMediaItem.value?.url
currentIndex = currentUrl?.let { url ->
currentPlaylist.indexOfFirst { it.url == url }.takeIf { it >= 0 }
} ?: currentIndex.coerceIn(0, currentPlaylist.lastIndex)
_playlist.tryEmit(currentPlaylist.toList())
_currentMediaItem.tryEmit(currentPlaylist.getOrNull(currentIndex))
} }
_playlist.tryEmit(currentPlaylist.toList())
} }
actual override suspend fun skipToNext() { actual override suspend fun skipToNext() {
val currentIndex = currentPlaylist.indexOfFirst { lock.withLock {
it.url == _currentMediaItem.value?.url if (disposed || currentPlaylist.isEmpty()) return
} playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex))
val nextIndex = currentIndex + 1
if (nextIndex < currentPlaylist.size) {
val nextItem = currentPlaylist[nextIndex]
val avItem = buildAVPlayerItem(nextItem.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(nextItem)
avPlayer.play()
}
} }
} }
actual override suspend fun skipToPrevious() { actual override suspend fun skipToPrevious() {
val currentIndex = currentPlaylist.indexOfFirst { lock.withLock {
it.url == _currentMediaItem.value?.url if (disposed || currentPlaylist.isEmpty()) return
} playIndexLocked((currentIndex - 1).coerceAtLeast(0))
val prevIndex = currentIndex - 1
if (prevIndex >= 0) {
val prevItem = currentPlaylist[prevIndex]
val avItem = buildAVPlayerItem(prevItem.url)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(prevItem)
avPlayer.play()
}
} }
} }
actual override suspend fun jumpTo(index: Int) { actual override suspend fun jumpTo(index: Int) {
if (index in currentPlaylist.indices) { lock.withLock {
val item = currentPlaylist[index] if (disposed || index !in currentPlaylist.indices) return
val avItem = buildAVPlayerItem(item.url) playIndexLocked(index)
if (avItem != null) {
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
_currentMediaItem.tryEmit(item)
}
} }
} }
actual override suspend fun setVolume(volume: Float) { actual override suspend fun setVolume(volume: Float) {
val clamped = volume.coerceIn(0f, 1f) lock.withLock {
avPlayer.volume = clamped if (disposed) return
_volume.tryEmit(clamped) val clamped = volume.coerceIn(0f, 1f)
engine.setVolume(clamped)
_volume.tryEmit(clamped)
}
} }
actual override suspend fun setPlaybackSpeed(speed: Float) { actual override suspend fun setPlaybackSpeed(speed: Float) {
val clamped = speed.coerceIn(0.25f, 4f) lock.withLock {
avPlayer.rate = clamped if (disposed) return
_playbackSpeed.tryEmit(clamped) val clamped = speed.coerceIn(0.25f, 4f)
engine.setRate(clamped)
_playbackSpeed.tryEmit(clamped)
}
} }
actual override fun isDisposed(): Boolean = disposed actual override fun isDisposed(): Boolean = disposed
actual override fun dispose() { actual override fun dispose() {
if (disposed) return
disposed = true disposed = true
avPlayer.pause() engine.setListener(null)
avPlayer.replaceCurrentItemWithPlayerItem(null) engine.destroy()
mediaSession.deactivate()
mediaSession.clear()
scope.cancel()
originalPlaylist.clear()
currentPlaylist.clear() currentPlaylist.clear()
urlIndexMap.clear() currentIndex = -1
engineHasMedia = false
_playerState.tryEmit(PlayerState.IDLE) _playerState.tryEmit(PlayerState.IDLE)
_currentMediaItem.tryEmit(null) _currentMediaItem.tryEmit(null)
@ -338,11 +460,3 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
_bufferingPosition.tryEmit(Duration.ZERO) _bufferingPosition.tryEmit(Duration.ZERO)
} }
} }
private fun CMTimeMake(value: Long, timescale: Int): CValue<CMTime> {
return platform.CoreMedia.CMTimeMake(value, timescale)
}
private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue<CMTime> {
return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale)
}

View File

@ -0,0 +1,187 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalForeignApi::class)
package org.spotube.spotube.core.audioplayer
import kotlinx.cinterop.ExperimentalForeignApi
import platform.AVFAudio.AVAudioSession
import platform.AVFAudio.AVAudioSessionCategoryPlayback
import platform.AVFAudio.AVAudioSessionModeDefault
import platform.AVFAudio.setActive
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSNumber
import platform.Foundation.NSOperationQueue
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
import platform.MediaPlayer.MPMediaItemPropertyArtist
import platform.MediaPlayer.MPMediaItemPropertyPlaybackDuration
import platform.MediaPlayer.MPMediaItemPropertyTitle
import platform.MediaPlayer.MPNowPlayingInfoCenter
import platform.MediaPlayer.MPNowPlayingInfoPropertyElapsedPlaybackTime
import platform.MediaPlayer.MPNowPlayingInfoPropertyPlaybackRate
import platform.MediaPlayer.MPRemoteCommand
import platform.MediaPlayer.MPRemoteCommandCenter
import platform.MediaPlayer.MPRemoteCommandHandlerStatusCommandFailed
import platform.MediaPlayer.MPRemoteCommandHandlerStatusSuccess
internal class IosMediaSession {
private val audioSession = AVAudioSession.sharedInstance()
private val nowPlayingInfoCenter = MPNowPlayingInfoCenter.defaultCenter()
private val remoteCommandCenter = MPRemoteCommandCenter.sharedCommandCenter()
private var configured = false
private val notificationCenter = NSNotificationCenter.defaultCenter()
private val notificationTokens = mutableListOf<Any>()
private val remoteCommandTokens = mutableListOf<Pair<MPRemoteCommand, Any>>()
private var onPlay: (() -> Unit)? = null
private var onPause: (() -> Unit)? = null
private var onSeek: ((Double) -> Unit)? = null
private var onNext: (() -> Unit)? = null
private var onPrevious: (() -> Unit)? = null
fun configure(
onPlay: () -> Unit,
onPause: () -> Unit,
onSeek: (Double) -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit
) {
this.onPlay = onPlay
this.onPause = onPause
this.onSeek = onSeek
this.onNext = onNext
this.onPrevious = onPrevious
if (configured) return
configured = true
audioSession.setCategory(
category = AVAudioSessionCategoryPlayback,
mode = AVAudioSessionModeDefault,
options = 0u,
error = null)
registerAudioSessionNotifications()
registerRemoteCommands()
}
fun activate() {
audioSession.setActive(true, error = null)
}
fun deactivate() {
audioSession.setActive(false, error = null)
}
fun update(
item: MediaItem?,
positionSeconds: Double,
durationSeconds: Double,
isPlaying: Boolean
) {
if (item == null) {
nowPlayingInfoCenter.nowPlayingInfo = null
return
}
val info = buildMap<Any?, Any?> {
put(MPMediaItemPropertyTitle, item.title)
put(MPMediaItemPropertyArtist, item.artist)
item.album.takeIf { it.isNotBlank() }?.let {
put(MPMediaItemPropertyAlbumTitle, it)
}
if (durationSeconds.isFinite() && durationSeconds > 0) {
put(MPMediaItemPropertyPlaybackDuration, durationSeconds)
}
if (positionSeconds.isFinite() && positionSeconds >= 0) {
put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds)
}
put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0)
}
nowPlayingInfoCenter.nowPlayingInfo = info
}
fun clear() {
nowPlayingInfoCenter.nowPlayingInfo = null
notificationTokens.forEach { notificationCenter.removeObserver(it) }
notificationTokens.clear()
remoteCommandTokens.forEach { (command, token) -> command.removeTarget(token) }
remoteCommandTokens.clear()
configured = false
onPlay = null
onPause = null
onSeek = null
onNext = null
onPrevious = null
}
private fun registerAudioSessionNotifications() {
notificationCenter.addObserverForName(
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
`object` = audioSession,
queue = NSOperationQueue.mainQueue()) { notification ->
val type = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber
if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
onPause?.invoke()
} else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
activate()
val options = notification?.userInfo?.get(
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber
val shouldResume = options?.unsignedLongValue
?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume)
?.let { it != 0UL } == true
if (shouldResume) onPlay?.invoke()
}
}?.let { notificationTokens += it }
}
private fun registerRemoteCommands() {
fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) {
command.addTargetWithHandler(handler)?.let {
remoteCommandTokens += command to it
}
}
register(remoteCommandCenter.playCommand) {
onPlay?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
register(remoteCommandCenter.pauseCommand) {
onPause?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
register(remoteCommandCenter.changePlaybackPositionCommand) { event ->
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
if (position != null && position.isFinite() && position >= 0) {
onSeek?.invoke(position)
MPRemoteCommandHandlerStatusSuccess
} else {
MPRemoteCommandHandlerStatusCommandFailed
}
}
register(remoteCommandCenter.nextTrackCommand) {
onNext?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
register(remoteCommandCenter.previousTrackCommand) {
onPrevious?.invoke()
MPRemoteCommandHandlerStatusSuccess
}
}
}

View File

@ -0,0 +1,79 @@
/*
* 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.audioplayer
/**
* Single-media playback engine implemented in Swift on top of MobileVLCKit
* (`iosApp/iosApp/VlcPlayerEngine.swift`).
*
* MobileVLCKit comes from CocoaPods into the Xcode project, so Gradle can't bind it with cinterop.
* Swift implements this contract instead and registers it in [NativePlayerRegistry] before the
* Compose view controller is created. The playlist, loop and shuffle logic stays in Kotlin
* ([AudioPlayer]); the engine only ever plays one URL at a time.
*
* Implementations may be called from any thread and must hop to the thread VLC needs
* themselves. [NativePlayerListener] callbacks may arrive on any thread.
*/
interface NativePlayerEngine {
fun setListener(listener: NativePlayerListener?)
/** Replaces the current media. Starts playing right away when [autoPlay] is set. */
fun load(url: String, autoPlay: Boolean, startPositionMs: Long)
fun play()
fun pause()
fun stop()
fun seekTo(positionMs: Long)
/** [volume] is 0..1. */
fun setVolume(volume: Float)
fun setRate(rate: Float)
/**
* Releases the native player. The engine must not be used afterwards. Not named `release`:
* that would export to Objective-C as `-release` and clash with NSObject.
*/
fun destroy()
}
interface NativePlayerListener {
fun onState(state: NativePlayerState)
fun onTime(positionMs: Long)
fun onDuration(durationMs: Long)
fun onError(message: String)
}
enum class NativePlayerState {
/** Nothing loaded, or playback was stopped. */
IDLE,
OPENING,
BUFFERING,
PLAYING,
PAUSED,
/** The media played to its end. Sent once per loaded media. */
ENDED
}
interface NativePlayerEngineFactory {
fun create(): NativePlayerEngine
}
/** Set from Swift (`iOSApp.init`) before the first [AudioPlayer] is created. */
object NativePlayerRegistry {
var factory: NativePlayerEngineFactory? = null
}

View File

@ -19,6 +19,13 @@ package org.spotube.spotube.modules.library.local_tracks.media
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.FilterMipmap
import org.jetbrains.skia.FilterMode
import org.jetbrains.skia.Image
import org.jetbrains.skia.MipmapMode
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import java.nio.file.FileSystems import java.nio.file.FileSystems
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@ -70,7 +77,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
durationMs = metadata?.durationMs ?: 0L, durationMs = metadata?.durationMs ?: 0L,
album = metadata?.album, album = metadata?.album,
coverBytes = metadata?.coverBytes) coverBytes = metadata?.coverBytes?.let(::coverThumbnail))
folders.getOrPut(parent) { mutableListOf() }.add(track) folders.getOrPut(parent) { mutableListOf() }.add(track)
} }
} }
@ -187,3 +194,30 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
"wma") "wma")
} }
} }
/**
* Embedded covers can be several MB each and the index is serialised to JSON, so keep a small
* JPEG like Android does (#3193). Returns null for unreadable art.
*/
internal fun coverThumbnail(bytes: ByteArray, size: Int = COVER_THUMBNAIL_SIZE): ByteArray? = runCatching {
Image.makeFromEncoded(bytes).use { source ->
val scale = size.toFloat() / minOf(source.width, source.height)
if (scale >= 1f && bytes.size <= MAX_SMALL_COVER_BYTES) return@runCatching bytes
val width = (source.width * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
val height = (source.height * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
Surface.makeRasterN32Premul(width, height).use { surface ->
surface.canvas.drawImageRect(
source,
Rect.makeWH(source.width.toFloat(), source.height.toFloat()),
Rect.makeWH(width.toFloat(), height.toFloat()),
FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE),
null,
true)
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.JPEG, 85)?.bytes }
}
}
}.getOrNull()
private const val COVER_THUMBNAIL_SIZE = 160
private const val MAX_SMALL_COVER_BYTES = 64 * 1024

View File

@ -0,0 +1,59 @@
/*
* 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.utils.io.ByteChannel
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.io.readByteArray
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import java.nio.file.Files
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertContentEquals
class StreamFileRangeTest {
// Several chunks plus a partial one.
private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234)
private fun copy(start: Long, length: Long): ByteArray = runBlocking {
val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() }
Files.write(file, data)
val channel = ByteChannel()
launch {
streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel)
channel.flushAndClose()
}
channel.readRemaining().readByteArray()
}
@Test
fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong()))
@Test
fun rangeAcrossChunks() {
val start = 70_000L
val length = 120_000L
assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length))
}
@Test
fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong()))
}

View File

@ -0,0 +1,101 @@
/*
* 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.component
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.use
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.ui.theming.SpotubeTheme
import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** #3200: big track lists must only compose the rows on screen. */
@OptIn(ExperimentalComposeUiApi::class)
class TrackListVirtualizationTest {
private fun track(index: Int) = MetadataTrack(
// Every 100th id repeats: playlists can hold the same track twice.
id = if (index % 100 == 0) "dup" else "t$index",
title = "Song $index",
durationMs = 200_000L,
trackNumber = 1,
discNumber = 1,
artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)),
album = null,
thumbnails = emptyList(),
explicit = false,
popularity = 0,
isrcCode = null,
externalUri = null)
private fun allNodes(scene: ImageComposeScene): List<SemanticsNode> {
fun flatten(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap(::flatten)
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
}
private fun songTitles(scene: ImageComposeScene): Set<String> = allNodes(scene)
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
.map { it.text }
.filter { it.startsWith("Song ") }
.toSet()
@Test
fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() {
val tracks = (0 until 2000).map(::track)
val listState = LazyListState()
var scope: CoroutineScope? = null
var nextPageRequests = 0
ImageComposeScene(1000, 800) {
scope = rememberCoroutineScope()
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
TrackList(
tracks = tracks,
hasMore = true,
state = listState,
onLoadNextPage = { nextPageRequests++ })
}
}.use { scene ->
repeat(3) { scene.render().close() }
val visible = songTitles(scene)
assertTrue("Song 1" in visible, "first rows render: $visible")
assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}")
assertEquals(0, nextPageRequests)
scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) }
repeat(3) { scene.render().close() }
val atEnd = songTitles(scene)
assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}")
assertTrue("Song 1" !in atEnd)
assertEquals(1, nextPageRequests)
}
}
}

View File

@ -0,0 +1,40 @@
/*
* 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.misc
import kotlin.test.Test
import kotlin.test.assertEquals
class UniqueLazyKeysTest {
@Test
fun blankAndRepeatedIdsGetDistinctKeys() {
val ids = listOf("a", "", "b", "a", "", "a")
val keys = uniqueLazyKeys(ids) { it }
assertEquals(ids.size, keys.toSet().size)
// First occurrences keep their id so scroll position stays stable.
assertEquals("a", keys[0])
assertEquals("b", keys[2])
}
@Test
fun suffixesCannotCollideWithRealIds() {
val ids = listOf("a", "a", "a#1", "a1", "a_1")
assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size)
}
}

View File

@ -0,0 +1,56 @@
/*
* 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.library.local_tracks.media
import org.jetbrains.skia.EncodedImageFormat
import org.jetbrains.skia.Image
import org.jetbrains.skia.Paint
import org.jetbrains.skia.Rect
import org.jetbrains.skia.Surface
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class CoverThumbnailTest {
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
// Noise-free but non-uniform so the PNG isn't trivially small.
for (x in 0 until width step 7) {
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
}
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
}
@Test
fun largeCoverIsShrunkToASmallJpeg() {
val original = png(1600, 1200)
val thumbnail = assertNotNull(coverThumbnail(original))
Image.makeFromEncoded(thumbnail).use { image ->
assertEquals(160, minOf(image.width, image.height))
assertEquals(213, image.width)
}
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
}
@Test
fun unreadableCoverIsDropped() {
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
}
}

View File

@ -73,7 +73,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }

9
iosApp/Podfile Normal file
View File

@ -0,0 +1,9 @@
# MobileVLCKit plays audio on iOS (iosApp/iosApp/VlcPlayerEngine.swift).
# After `pod install`, open and build iosApp.xcworkspace, not iosApp.xcodeproj.
platform :ios, '18.2'
target 'iosApp' do
use_frameworks!
pod 'MobileVLCKit', '3.7.5'
end

16
iosApp/Podfile.lock Normal file
View File

@ -0,0 +1,16 @@
PODS:
- MobileVLCKit (3.7.5)
DEPENDENCIES:
- MobileVLCKit (= 3.7.5)
SPEC REPOS:
trunk:
- MobileVLCKit
SPEC CHECKSUMS:
MobileVLCKit: 9fb4475cd820b2d271ac394d5d7ba18c525332ab
PODFILE CHECKSUM: 6dfb07dbc8a64d0c6fe7231a81687c56f08e8250
COCOAPODS: 1.16.2

View File

@ -6,8 +6,15 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = "<group>"; };
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -31,6 +38,8 @@
}; };
E265D0F6D10F52F5C1374EEB /* Configuration */ = { E265D0F6D10F52F5C1374EEB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Configuration; path = Configuration;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -41,21 +50,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EC1521163DBA0344F6C5A520 /* Pods_iosApp.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
EFA169D322B90F32841D602F = {
isa = PBXGroup;
children = (
E265D0F6D10F52F5C1374EEB /* Configuration */,
5BB730BF90365122B74925FF /* iosApp */,
835615636B5FE86AA850B7DB /* Products */,
);
sourceTree = "<group>";
};
835615636B5FE86AA850B7DB /* Products */ = { 835615636B5FE86AA850B7DB /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -64,6 +65,35 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
85BADDD666F59900213B22FE /* Frameworks */ = {
isa = PBXGroup;
children = (
3F2995B965BD790B4CF9EE4E /* Pods_iosApp.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
92A495F3BC0A87A606BB8FD4 /* Pods */ = {
isa = PBXGroup;
children = (
34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */,
BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
EFA169D322B90F32841D602F = {
isa = PBXGroup;
children = (
E265D0F6D10F52F5C1374EEB /* Configuration */,
5BB730BF90365122B74925FF /* iosApp */,
835615636B5FE86AA850B7DB /* Products */,
92A495F3BC0A87A606BB8FD4 /* Pods */,
85BADDD666F59900213B22FE /* Frameworks */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -71,10 +101,12 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */; buildConfigurationList = 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */;
buildPhases = ( buildPhases = (
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */,
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */, 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */,
314349EF3C4149F4A4A72378 /* Sources */, 314349EF3C4149F4A4A72378 /* Sources */,
EFCF243BC7D8246E008EF23D /* Frameworks */, EFCF243BC7D8246E008EF23D /* Frameworks */,
ED9557BFD7438D37A5E9513D /* Resources */, ED9557BFD7438D37A5E9513D /* Resources */,
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -84,8 +116,6 @@
5BB730BF90365122B74925FF /* iosApp */, 5BB730BF90365122B74925FF /* iosApp */,
); );
name = iosApp; name = iosApp;
packageProductDependencies = (
);
productName = iosApp; productName = iosApp;
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -135,6 +165,23 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
19DB94093A1327B00D11FC9C /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = { 6413B0F29E004BC8B0C4D22A /* Compile Kotlin Framework */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@ -154,6 +201,28 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
}; };
FD8DD8DB9F90C61C8EB92C92 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-iosApp-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -167,6 +236,122 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
1C42200356BD14712FAE1CFA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
4FB276594B17F1A0D71D2462 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BA9738EB07A613714DD0F50B /* Pods-iosApp.release.xcconfig */;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AC996E9458BD3DAE9610EA49 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 34377C7B259B7315C03BE940 /* Pods-iosApp.debug.xcconfig */;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AF5828948B37FEEF5EF7F345 /* Debug */ = { AF5828948B37FEEF5EF7F345 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */; baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
@ -232,132 +417,9 @@
}; };
name = Debug; name = Debug;
}; };
1C42200356BD14712FAE1CFA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = E265D0F6D10F52F5C1374EEB /* Configuration */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AC996E9458BD3DAE9610EA49 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
4FB276594B17F1A0D71D2462 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AF5828948B37FEEF5EF7F345 /* Debug */,
1C42200356BD14712FAE1CFA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = { 6F8AC65FA015674062B91E20 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@ -367,6 +429,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
79A44D8DEA428263AC7DC2E1 /* Build configuration list for PBXProject "iosApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AF5828948B37FEEF5EF7F345 /* Debug */,
1C42200356BD14712FAE1CFA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = B056E79C138AE849968965F1 /* Project object */; rootObject = B056E79C138AE849968965F1 /* Project object */;

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:iosApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -4,6 +4,10 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>

View File

@ -0,0 +1,205 @@
// 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/>.
import AVFoundation
import ComposeApp
import Foundation
import MobileVLCKit
/// Registers the MobileVLCKit engine with the shared Kotlin `AudioPlayer`. Call before the first
/// `ComposeView` is created.
func registerNativePlayerEngine() {
NativePlayerRegistry.shared.factory = VlcPlayerEngineFactory()
}
final class VlcPlayerEngineFactory: NSObject, NativePlayerEngineFactory {
func create() -> NativePlayerEngine {
VlcPlayerEngine()
}
}
/// Plays one URL at a time with MobileVLCKit. The Kotlin `AudioPlayer` owns the playlist; see
/// `NativePlayerEngine.kt` for the contract.
///
/// Every VLC call is made on the main queue, in the order the Kotlin side issued it.
final class VlcPlayerEngine: NSObject, NativePlayerEngine, VLCMediaPlayerDelegate {
private let player = VLCMediaPlayer()
private var listener: NativePlayerListener?
private var released = false
private var rate: Float = 1
private var volumePercent: Int32 = 100
private var lastReportedDurationMs: Int64 = 0
private var lastReportedState: NativePlayerState?
override init() {
super.init()
player.delegate = self
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
}
// MARK: NativePlayerEngine
func setListener(listener: NativePlayerListener?) {
onMain { self.listener = listener }
}
func load(url: String, autoPlay: Bool, startPositionMs: Int64) {
onMain {
guard !self.released else { return }
guard let mediaUrl = URL(string: url) else {
self.listener?.onError(message: "Invalid URL: \(url)")
return
}
let media = VLCMedia(url: mediaUrl)
if startPositionMs > 0 {
media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)")
}
self.lastReportedDurationMs = 0
self.player.media = media
self.report(.opening)
if autoPlay { self.startPlayback() }
}
}
func play() {
onMain {
guard !self.released else { return }
self.startPlayback()
}
}
func pause() {
onMain {
guard !self.released, self.player.isPlaying else { return }
self.player.pause()
}
}
func stop() {
onMain {
guard !self.released else { return }
self.player.stop()
}
}
func seekTo(positionMs: Int64) {
onMain {
guard !self.released, self.player.isSeekable else { return }
self.player.time = VLCTime(number: NSNumber(value: positionMs))
}
}
func setVolume(volume: Float) {
onMain {
self.volumePercent = Int32((min(max(volume, 0), 1) * 100).rounded())
self.player.audio?.volume = self.volumePercent
}
}
func setRate(rate: Float) {
onMain {
self.rate = rate
self.player.rate = rate
}
}
func destroy() {
onMain {
guard !self.released else { return }
self.released = true
self.listener = nil
self.player.delegate = nil
self.player.stop()
self.player.media = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
// MARK: VLCMediaPlayerDelegate
func mediaPlayerStateChanged(_ aNotification: Notification) {
onMain {
guard !self.released else { return }
switch self.player.state {
case .opening:
self.report(.opening)
case .buffering, .esAdded:
// MobileVLCKit keeps sending these while it plays; only a stall counts as buffering.
self.report(self.player.isPlaying ? .playing : .buffering)
case .playing:
self.report(.playing)
case .paused:
self.report(.paused)
case .ended:
self.report(.ended)
case .stopped:
self.report(.idle)
case .error:
self.listener?.onError(message: "VLC could not play this media")
self.report(.idle)
default:
break
}
}
}
func mediaPlayerTimeChanged(_ aNotification: Notification) {
onMain {
guard !self.released else { return }
// Time only advances while playing, whatever the last state notification said.
if self.player.isPlaying { self.report(.playing) }
self.reportDuration()
if let ms = self.player.time.value?.int64Value, ms >= 0 {
self.listener?.onTime(positionMs: ms)
}
}
}
// MARK: Internals
private func startPlayback() {
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
}
/// Sends [state] to Kotlin unless it was the last one sent.
private func report(_ state: NativePlayerState) {
guard state != lastReportedState else { return }
lastReportedState = state
if state == .playing {
applyPlaybackSettings()
reportDuration()
}
listener?.onState(state: state)
}
private func applyPlaybackSettings() {
player.audio?.volume = volumePercent
if player.rate != rate { player.rate = rate }
}
/// MobileVLCKit has no length callback, so the length is read from the media as it plays.
private func reportDuration() {
guard let ms = player.media?.length.value?.int64Value, ms > 0, ms != lastReportedDurationMs else { return }
lastReportedDurationMs = ms
listener?.onDuration(durationMs: ms)
}
private func onMain(_ work: @escaping () -> Void) {
DispatchQueue.main.async(execute: work)
}
}

View File

@ -18,6 +18,10 @@ import ComposeApp
@main @main
struct iOSApp: App { struct iOSApp: App {
init() {
registerNativePlayerEngine()
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()