mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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.
This commit is contained in:
parent
0a30aa7137
commit
538ba636d4
@ -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<StreamingUrlRepository>()
|
||||
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<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
if (stream.isPlayableOn(platform)) 0 else 1
|
||||
}.thenBy { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.thenBy { stream ->
|
||||
if (stream.matchesQualityType(preferredQuality)) 0 else 1
|
||||
@ -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 =
|
||||
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
|
||||
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
|
||||
|
||||
@ -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",
|
||||
/** 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)))
|
||||
}
|
||||
|
||||
/** 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 ->
|
||||
val json = prefs[SETTINGS_KEY]
|
||||
if (json != null) {
|
||||
settingsJson.decodeFromString<UserSettings>(json as String).withPlayableStreamingFormat()
|
||||
settingsJson.decodeFromString<UserSettings>(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<UserSettings>(storedJson).withPlayableStreamingFormat()
|
||||
return settingsJson.decodeFromString<UserSettings>(storedJson)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<AudioStream>,
|
||||
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)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user