mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
18 Commits
7560647647
...
20cd18bd3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20cd18bd3b | ||
|
|
6223c438cf | ||
|
|
d2768c6a60 | ||
|
|
68fdd68cb9 | ||
|
|
fa2183ed18 | ||
|
|
3a856d6d03 | ||
|
|
4639b50a3d | ||
|
|
e02c1b73b4 | ||
|
|
f9c040015e | ||
|
|
538ba636d4 | ||
|
|
0a30aa7137 | ||
|
|
784d9260d1 | ||
|
|
9a47a1af5f | ||
|
|
431fd2353f | ||
|
|
f23c66c0c5 | ||
|
|
8537718225 | ||
|
|
b0327fd224 | ||
|
|
ed3743bbea |
12
.github/workflows/spotube-release-binary.yml
vendored
12
.github/workflows/spotube-release-binary.yml
vendored
@ -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
1
.gitignore
vendored
@ -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/
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal file
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal 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>
|
||||||
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal file
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal 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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 ->
|
||||||
|
|||||||
@ -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]) }
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) })
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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()))
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
9
iosApp/Podfile
Normal 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
16
iosApp/Podfile.lock
Normal 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
|
||||||
@ -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,7 +429,16 @@
|
|||||||
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 */;
|
||||||
}
|
}
|
||||||
|
|||||||
10
iosApp/iosApp.xcworkspace/contents.xcworkspacedata
generated
Normal file
10
iosApp/iosApp.xcworkspace/contents.xcworkspacedata
generated
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
205
iosApp/iosApp/VlcPlayerEngine.swift
Normal file
205
iosApp/iosApp/VlcPlayerEngine.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user