From 538ba636d4eda9132bad9b4ee2be88a480a03b66 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 3 Nov 2026 16:53:36 +0600 Subject: [PATCH] feat(ios): drop AVPlayer-era streaming format restrictions VLC on iOS plays every container, codec and protocol plugins return, so iOS no longer needs special casing: - Stream selection no longer ranks WebM/MKV/Ogg or DASH streams as unplayable on iOS; the platform playability mechanism is removed. - Opus in WebM is the default streaming format on every platform. - Saved Opus/WebM preferences are no longer migrated to AAC on iOS. - The streaming format picker offers all formats on iOS. --- .../core/server/StreamingUrlRepository.kt | 48 ++++--------------- .../modules/settings/SettingsModels.kt | 32 ++++--------- .../modules/settings/SettingsRepository.kt | 4 +- .../modules/settings/sections/utils.kt | 5 +- .../StreamingUrlRepositoryBehaviorTest.kt | 5 +- .../core/server/StreamingUrlRepositoryTest.kt | 30 +++--------- .../settings/StreamingFormatDefaultsTest.kt | 30 ++---------- 7 files changed, 33 insertions(+), 121 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 11a8ae60..503beb92 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -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.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import org.spotube.spotube.PlatformType import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger -import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.settings.UserSettingsSource import kotlinx.coroutines.flow.filterNotNull @@ -80,8 +78,7 @@ class StreamingUrlRepository( private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: TrackSourceRepository, private val settingsRepository: UserSettingsSource, - private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, - private val platform: PlatformType = currentPlatform) : KoinComponent { + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val logger by injectLogger() private val streamUrlCacheMutex = Mutex() @@ -146,7 +143,7 @@ class StreamingUrlRepository( } else { stream.streams } - return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform) + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } } @@ -256,12 +253,11 @@ class StreamingUrlRepository( logger.i { "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + - "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + "lowest wins):\n" + streams.joinToString("\n") { stream -> val marker = if (stream === selected) " -> " else " " - "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " + - "${formatMismatchCount(stream, preferredFormat)}, " + + "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" @@ -269,10 +265,6 @@ class StreamingUrlRepository( } when { selected == null -> logger.w { "[Resolve $trackId] no stream selected" } - !selected.isPlayableOn(platform) -> logger.w { - "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " + - "${selected.describeForLog()}, which the player will likely reject" - } formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "got ${selected.codec}/${selected.container} (${selected.protocol})" @@ -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. - * Streams the [platform]'s player can decode always win over ones it can't, whatever the - * preferences say: e.g. opus/webm is unplayable on iOS. + * Selects the available stream closest to the user's preferred format and quality. Every + * platform's player (ExoPlayer on Android, VLC elsewhere including iOS) can play any container, + * codec and protocol plugins return, so nothing is ruled out up front. */ internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality, - platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( compareBy { stream -> - if (stream.isPlayableOn(platform)) 0 else 1 - }.thenBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> 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> = mapOf( - // AVPlayer has no WebM/Ogg/Matroska demuxer. - PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga")) - -/** - * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle - * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. - */ -internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = - isContainerPlayableOn(container, platform) && - !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) - -/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ -internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = - container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty() - private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index fe66b4d9..5bdadbe4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -17,9 +17,6 @@ package org.spotube.spotube.modules.settings -import org.spotube.spotube.core.server.isContainerPlayableOn -import org.spotube.spotube.core.server.currentPlatform -import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import kotlinx.serialization.SerialName @@ -101,23 +98,12 @@ const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev" internal fun isJamServerUrl(url: String): Boolean = Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim()) -/** - * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC. - * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). - */ -internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { - val aac = !isContainerPlayableOn("webm", platform) - return AudioFormat( - codec = if (aac) "aac" else "opus", - container = if (aac) "mp4" else "webm", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000))) -} - -/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ -internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = - if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this - else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) +/** opus/webm on every platform: all of their players (ExoPlayer, VLC) can play it. */ +internal fun defaultStreamingMusicFormat(): AudioFormat = AudioFormat( + codec = "opus", + container = "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index 0c4f62c1..d2d02302 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -59,7 +59,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() + settingsJson.decodeFromString(json as String) } else { UserSettings() // Default value } @@ -78,6 +78,6 @@ class SettingsRepository(private val database: Database) : UserSettingsStore { suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() + return settingsJson.decodeFromString(storedJson) } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index 34849447..fdc5f736 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -18,8 +18,6 @@ package org.spotube.spotube.modules.settings.sections -import org.spotube.spotube.core.server.isContainerPlayableOn -import org.spotube.spotube.core.server.currentPlatform import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.theme.MillenniumTheme @@ -58,7 +56,6 @@ private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 256_000)) -// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", @@ -72,7 +69,7 @@ internal val streamingFormatPresets = listOf( codec = "vorbis", container = "ogg", qualities = standardLossyQualities) -).filter { isContainerPlayableOn(it.container, currentPlatform) } +) internal val downloadFormatPresets = listOf( AudioFormat( diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index 28feb80c..1b14fb54 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -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.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import org.spotube.spotube.PlatformType import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger @@ -100,9 +99,7 @@ class StreamingUrlRepositoryBehaviorTest { audioPlayerQueue = queue, matchedTracksRepository = matchedTracksRepository, settingsRepository = settings, - streamUrlCacheTtlMs = cacheTtlMs, - // Not the host's platform: Windows and iOS don't prefer opus/webm - platform = PlatformType.Linux) + streamUrlCacheTtlMs = cacheTtlMs) fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { val service = FakeAudioPluginService(pluginId, audioAPI) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt index b5f68eb9..bafa424a 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -1,6 +1,5 @@ package org.spotube.spotube.core.server -import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream @@ -14,7 +13,6 @@ import kotlin.test.assertSame * Tests for the stream preference selection used by [StreamingUrlRepository]. * * Ordering contract (best first): - * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). * 1. Fewest codec/container mismatches against the preferred format. * 2. Matching quality family (lossy vs lossless). * 3. Closest bitrate / sample rate. @@ -56,8 +54,7 @@ class StreamingUrlRepositoryTest { private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000), - platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) + quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) // ---------- empty / single ---------- @@ -407,33 +404,20 @@ class StreamingUrlRepositoryTest { assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) } - // ---------- device playability ---------- + // ---------- no platform restrictions ---------- @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 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 - fun `on iOS dash manifests are skipped in favour of progressive streams`() { - val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + fun `a dash manifest closest to the preferences is not skipped`() { + val dash = lossy("dash", 256_000, protocol = StreamProtocol.DASH) val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") - assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) - } - - @Test - fun `on iOS an unplayable stream is still returned when nothing else exists`() { - val opus = lossy("opus-160", 160_000) - assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) - } - - @Test - fun `off iOS the preferred opus webm stream still wins`() { - val opus = lossy("opus-160", 160_000) - val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") - assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + assertSame(dash, select(listOf(progressive, dash))) } } diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt index c25d82a6..3b12f62f 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -17,39 +17,19 @@ package org.spotube.spotube.modules.settings -import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat -import org.spotube.spotube.PlatformType import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertSame class StreamingFormatDefaultsTest { - private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) - @Test - fun `ios defaults to aac in mp4`() { - val format = defaultStreamingMusicFormat(PlatformType.IOS) - assertEquals("aac" to "mp4", format.codec to format.container) + fun `streaming defaults to opus in webm`() { + val format = defaultStreamingMusicFormat() + assertEquals("opus" to "webm", format.codec to format.container) } @Test - fun `other platforms keep opus in webm`() { - listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform -> - val format = defaultStreamingMusicFormat(platform) - assertEquals("opus" to "webm", format.codec to format.container, platform.name) - } - } - - @Test - fun `saved opus webm is moved to aac on ios`() { - val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) - assertEquals("mp4", migrated.streamingMusicFormat.container) - } - - @Test - fun `saved opus webm is kept off ios`() { - val settings = UserSettings(streamingMusicFormat = opusWebm) - assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + fun `user settings default to the streaming default`() { + assertEquals(defaultStreamingMusicFormat(), UserSettings().streamingMusicFormat) } }