From 0a30aa713740047cbf24ce7af1347ca8a7f7aa27 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 3 Nov 2026 16:53:36 +0600 Subject: [PATCH 1/2] feat(ios): play audio with VLCKit instead of AVPlayer AVPlayer misreads the duration of some fragmented MP4 streams (it reports double the real length, leaving the second half silent and never ending the track). Playback on iOS now goes through VLCKit 4, added as a Swift package pinned to videolan/vlckit@6b87735. - NativePlayerEngine (iosMain): single-media engine contract that Swift implements and registers at launch; VLCKit ships as an SPM binary target, so it can't be bound with cinterop from Gradle. - AudioPlayer.ios.kt: rewritten on the engine. Playlist, loop modes, shuffle and auto-advance live in Kotlin, mirroring the JVM player (the old iOS player never advanced to the next track). - VlcPlayerEngine.swift: VLCKit-backed engine. VLCKit 4 has no "ended" state, so natural ends are told apart from requested stops; after a pause/resume it stops reporting time, so a no-op seek restores position updates. --- .../core/audioplayer/AudioPlayer.ios.kt | 479 ++++++++++-------- .../core/audioplayer/NativePlayerEngine.kt | 81 +++ iosApp/iosApp.xcodeproj/project.pbxproj | 28 + .../xcshareddata/swiftpm/Package.resolved | 14 + iosApp/iosApp/VlcPlayerEngine.swift | 256 ++++++++++ iosApp/iosApp/iOSApp.swift | 4 + 6 files changed, 664 insertions(+), 198 deletions(-) create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iosApp/iosApp/VlcPlayerEngine.swift diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt index 3c99747b..d5df44b4 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -15,16 +15,12 @@ * along with this program. If not, see . */ -@file:OptIn(ExperimentalForeignApi::class) - package org.spotube.spotube.core.audioplayer -import kotlinx.cinterop.CValue -import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,41 +28,38 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import platform.AVFoundation.AVPlayer -import platform.AVFoundation.AVPlayerItem -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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.milliseconds +/** + * iOS player. Playback is done by VLCKit 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") actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface { 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 lock = Mutex() private var disposed = false + private val originalPlaylist = mutableListOf() private val currentPlaylist = mutableListOf() - private val urlIndexMap = mutableMapOf() + 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 _currentMediaItem = MutableStateFlow(null) @@ -94,97 +87,159 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override val completionFlow: Flow = _completion.asSharedFlow() actual override val errorFlow: Flow = _error.asSharedFlow() - private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null - - init { - avPlayer.addPeriodicTimeObserverForInterval( - interval = CMTimeMake(1, 4), - queue = null - ) { time -> - if (!disposed) { - val seconds = CMTimeGetSeconds(time) - _position.tryEmit(seconds.seconds) - - avPlayer.currentItem?.let { item -> - val durationSeconds = CMTimeGetSeconds(item.duration) - if (durationSeconds > 0) { - _duration.tryEmit(durationSeconds.seconds) - } - val loadedRanges = item.loadedTimeRanges -// if (loadedRanges.count() > 0u) { -// val range = loadedRanges.objectAtIndex(0u) -// } - } + private val engineListener = object : NativePlayerListener { + override fun onState(state: NativePlayerState) { + if (disposed) return + when (state) { + NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE) + NativePlayerState.OPENING, + NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING) + NativePlayerState.PLAYING -> _playerState.tryEmit(PlayerState.PLAYING) + NativePlayerState.PAUSED -> _playerState.tryEmit(PlayerState.PAUSED) + NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } } } } - scope.launch { - while (!disposed) { - syncTimeControlStatus() - delay(500) + override fun onTime(positionMs: Long) { + if (disposed || positionMs < 0) return + _position.tryEmit(positionMs.milliseconds) + } + + override fun onDuration(durationMs: Long) { + if (disposed || durationMs <= 0) return + _duration.tryEmit(durationMs.milliseconds) + } + + override fun onBuffered(percent: Float) { + if (disposed) return + val durationMs = _duration.value.inWholeMilliseconds + _bufferingPosition.tryEmit( + if (durationMs > 0) (durationMs * (percent.coerceIn(0f, 100f) / 100f)).toLong().milliseconds + else Duration.ZERO + ) + } + + override fun onError(message: String) { + if (disposed) return + _playerState.tryEmit(PlayerState.IDLE) + _error.tryEmit(IllegalStateException("VLC playback error: $message")) + } + } + + init { + engine.setListener(engineListener) + } + + /** 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() { - val status = avPlayer.timeControlStatus - if (status == lastTimeControlStatus) return - lastTimeControlStatus = status - - val playerState = when (status) { - AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING - AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED - AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING - else -> PlayerState.IDLE + actual override suspend fun play() { + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + if (currentIndex !in currentPlaylist.indices) { + currentIndex = 0 + _currentMediaItem.tryEmit(currentPlaylist[0]) + } + if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true) } - _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() + engineHasMedia = false + pendingStartMs = 0L + _position.tryEmit(Duration.ZERO) _playerState.tryEmit(PlayerState.IDLE) } } - private fun resolveState(): PlayerState { - return when (avPlayer.timeControlStatus) { - AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING - AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED - AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING - else -> PlayerState.IDLE + actual override suspend fun seekTo(position: Duration) { + lock.withLock { + if (disposed) return + val maxMs = _duration.value.inWholeMilliseconds + val targetMs = if (maxMs > 0) { + 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) { - _loopState.tryEmit(state) + lock.withLock { + if (disposed) return + _loopState.tryEmit(state) + } } 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( @@ -192,143 +247,179 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface autoPlay: Boolean, startPosition: Int ) { - currentPlaylist.clear() - currentPlaylist.addAll(playlist) - urlIndexMap.clear() - playlist.forEachIndexed { index, item -> - urlIndexMap[item.url] = index - } - _playlist.tryEmit(currentPlaylist.toList()) + lock.withLock { + if (disposed) return - if (playlist.isEmpty()) { - _playerState.tryEmit(PlayerState.IDLE) - _currentMediaItem.tryEmit(null) + originalPlaylist.clear() + originalPlaylist.addAll(playlist) + currentPlaylist.clear() + currentPlaylist.addAll(playlist) + shuffleEnabled = false + _shuffleMode.tryEmit(false) + _playlist.tryEmit(currentPlaylist.toList()) + + engine.stop() + engineHasMedia = false + pendingStartMs = 0L _position.tryEmit(Duration.ZERO) - _duration.tryEmit(Duration.ZERO) - return - } + _bufferingPosition.tryEmit(Duration.ZERO) - val safeIndex = startPosition.coerceIn(0, playlist.lastIndex) - val item = playlist[safeIndex] - val avItem = buildAVPlayerItem(item.url) + if (currentPlaylist.isEmpty()) { + currentIndex = -1 + _currentMediaItem.tryEmit(null) + _playerState.tryEmit(PlayerState.IDLE) + _duration.tryEmit(Duration.ZERO) + return + } - if (avItem == null) { - _error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}")) - _playerState.tryEmit(PlayerState.IDLE) - return - } + currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex) + _currentMediaItem.tryEmit(currentPlaylist[currentIndex]) + _duration.tryEmit(currentPlaylist[currentIndex].duration) - avPlayer.replaceCurrentItemWithPlayerItem(avItem) - _currentMediaItem.tryEmit(item) - - if (autoPlay) { - avPlayer.play() + if (autoPlay) { + loadCurrentIntoEngineLocked(autoPlay = true) + } else { + _playerState.tryEmit(PlayerState.PAUSED) + } } } actual override suspend fun addMediaItem(mediaItem: MediaItem) { - currentPlaylist.add(mediaItem) - urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex - _playlist.tryEmit(currentPlaylist.toList()) + lock.withLock { + if (disposed) return + 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) { - val currentIndex = currentPlaylist.indexOfFirst { - it.url == _currentMediaItem.value?.url + lock.withLock { + 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) { - val index = urlIndexMap[mediaItem.url] ?: return - currentPlaylist.removeAt(index) - urlIndexMap.clear() - currentPlaylist.forEachIndexed { i, item -> - urlIndexMap[item.url] = i + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + + originalPlaylist.removeAll { it.url == mediaItem.url } + 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) { - if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return - val item = currentPlaylist.removeAt(fromIndex) - currentPlaylist.add(toIndex, item) - urlIndexMap.clear() - currentPlaylist.forEachIndexed { i, it -> - urlIndexMap[it.url] = i + lock.withLock { + if (disposed) return + if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return + + val item = currentPlaylist.removeAt(fromIndex) + 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() { - val currentIndex = currentPlaylist.indexOfFirst { - it.url == _currentMediaItem.value?.url - } - 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() - } + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex)) } } actual override suspend fun skipToPrevious() { - val currentIndex = currentPlaylist.indexOfFirst { - it.url == _currentMediaItem.value?.url - } - 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() - } + lock.withLock { + if (disposed || currentPlaylist.isEmpty()) return + playIndexLocked((currentIndex - 1).coerceAtLeast(0)) } } actual override suspend fun jumpTo(index: Int) { - if (index in currentPlaylist.indices) { - val item = currentPlaylist[index] - val avItem = buildAVPlayerItem(item.url) - if (avItem != null) { - avPlayer.replaceCurrentItemWithPlayerItem(avItem) - _currentMediaItem.tryEmit(item) - } + lock.withLock { + if (disposed || index !in currentPlaylist.indices) return + playIndexLocked(index) } } actual override suspend fun setVolume(volume: Float) { - val clamped = volume.coerceIn(0f, 1f) - avPlayer.volume = clamped - _volume.tryEmit(clamped) + lock.withLock { + if (disposed) return + val clamped = volume.coerceIn(0f, 1f) + engine.setVolume(clamped) + _volume.tryEmit(clamped) + } } actual override suspend fun setPlaybackSpeed(speed: Float) { - val clamped = speed.coerceIn(0.25f, 4f) - avPlayer.rate = clamped - _playbackSpeed.tryEmit(clamped) + lock.withLock { + if (disposed) return + val clamped = speed.coerceIn(0.25f, 4f) + engine.setRate(clamped) + _playbackSpeed.tryEmit(clamped) + } } actual override fun isDisposed(): Boolean = disposed actual override fun dispose() { + if (disposed) return disposed = true - avPlayer.pause() - avPlayer.replaceCurrentItemWithPlayerItem(null) + engine.setListener(null) + engine.destroy() + scope.cancel() + + originalPlaylist.clear() currentPlaylist.clear() - urlIndexMap.clear() + currentIndex = -1 + engineHasMedia = false _playerState.tryEmit(PlayerState.IDLE) _currentMediaItem.tryEmit(null) @@ -338,11 +429,3 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface _bufferingPosition.tryEmit(Duration.ZERO) } } - -private fun CMTimeMake(value: Long, timescale: Int): CValue { - return platform.CoreMedia.CMTimeMake(value, timescale) -} - -private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue { - return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale) -} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt new file mode 100644 index 00000000..b8ce4d1a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/audioplayer/NativePlayerEngine.kt @@ -0,0 +1,81 @@ +/* + * 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 . + */ + +package org.spotube.spotube.core.audioplayer + +/** + * Single-media playback engine implemented in Swift on top of VLCKit (`iosApp/iosApp/VlcPlayerEngine.swift`). + * + * VLCKit ships as a SwiftPM binary target, so it can't be bound with cinterop from Gradle. 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 VLCKit 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) + + /** Network cache fill of the current media, 0..100. */ + fun onBuffered(percent: Float) + 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 +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 0eb68270..ed20c29c 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -41,6 +45,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,6 +90,7 @@ ); name = iosApp; packageProductDependencies = ( + 9A1B2C3D4E5F60718293A4B3 /* VLCKit */, ); productName = iosApp; productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */; @@ -114,6 +120,9 @@ ); mainGroup = EFA169D322B90F32841D602F; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 835615636B5FE86AA850B7DB /* Products */; projectDirPath = ""; @@ -368,6 +377,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/videolan/vlckit"; + requirement = { + kind = revision; + revision = 6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9A1B2C3D4E5F60718293A4B3 /* VLCKit */ = { + isa = XCSwiftPackageProductDependency; + package = 9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */; + productName = VLCKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = B056E79C138AE849968965F1 /* Project object */; } \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..c30addc3 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b", + "pins" : [ + { + "identity" : "vlckit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/videolan/vlckit", + "state" : { + "revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/VlcPlayerEngine.swift b/iosApp/iosApp/VlcPlayerEngine.swift new file mode 100644 index 00000000..2653c968 --- /dev/null +++ b/iosApp/iosApp/VlcPlayerEngine.swift @@ -0,0 +1,256 @@ +// 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 . + +import AVFoundation +import ComposeApp +import Foundation +import VLCKit + +/// Registers the VLCKit 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 VLCKit. The Kotlin `AudioPlayer` owns the playlist; see +/// `NativePlayerEngine.kt` for the contract. +/// +/// Every VLCKit 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? + + // VLCKit 4 has no "ended" state: reaching the end of a media goes Stopping -> Stopped, the + // same as stop(). `stopRequested` marks the stops this class asked for, so any other stop of + // a media that had started playing is a natural end. + private var stopRequested = false + private var hasStarted = false + private var finished = false + private var released = false + + private var rate: Float = 1 + private var volumePercent: Int32 = 100 + + override init() { + super.init() + player.delegate = self + player.timeChangeUpdateInterval = 0.25 + player.minimalTimePeriod = 250_000 + 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), let media = VLCMedia(url: mediaUrl) else { + self.listener?.onError(message: "Invalid URL: \(url)") + return + } + + self.stopCurrentMedia() + self.hasStarted = false + self.finished = false + if startPositionMs > 0 { + media.addOption(":start-time=\(Double(startPositionMs) / 1000.0)") + } + self.player.media = media + self.listener?.onState(state: .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.stopCurrentMedia() + } + } + + 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(_ newState: VLCMediaPlayerState) { + onMain { + guard !self.released else { return } + switch newState { + case .opening: + self.listener?.onState(state: .opening) + case .playing: + self.hasStarted = true + self.applyPlaybackSettings() + self.reportDuration() + self.listener?.onState(state: .playing) + case .paused: + self.listener?.onState(state: .paused) + case .stopping, .stopped: + self.handleStop(isFinalStop: newState == .stopped) + case .error: + self.finished = true + self.listener?.onError(message: "VLC could not play this media") + self.listener?.onState(state: .idle) + default: + break + } + } + } + + func mediaPlayerTimeChanged(_ aNotification: Notification) { + onMain { + guard !self.released, let ms = self.player.time.value?.int64Value, ms >= 0 else { return } + self.listener?.onTime(positionMs: ms) + } + } + + func mediaPlayerLengthChanged(_ length: Int64) { + onMain { + guard !self.released else { return } + self.listener?.onDuration(durationMs: length) + } + } + + func mediaPlayerBufferingChanged(_ progress: Float) { + onMain { + guard !self.released else { return } + self.listener?.onBuffered(percent: progress * 100) + // VLC always reports 0 and 1 before the first frame; only mid-playback stalls matter. + guard self.hasStarted else { return } + if progress < 1 { + self.listener?.onState(state: .buffering) + } else if self.player.isPlaying { + self.listener?.onState(state: .playing) + } + } + } + + // MARK: Internals + + private func startPlayback() { + try? AVAudioSession.sharedInstance().setActive(true) + let wasPaused = player.state == .paused + player.play() + if wasPaused { kickTimeReporting() } + } + + /// After a pause, VLCKit 4 withholds time updates until libvlc reports a fresh time point, and + /// on iOS that report doesn't come after a resume: audio plays but the position stays frozen. + /// A seek goes through a different callback that does reset VLCKit's time tracking, so seek + /// to where we already are. + private func kickTimeReporting() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self, !self.released, self.player.isPlaying, self.player.isSeekable else { return } + self.player.time = self.player.time + } + } + + /// Stops the current media and marks the resulting stop events as ours. + private func stopCurrentMedia() { + switch player.state { + case .opening, .playing, .paused, .stopping: + stopRequested = true + player.stop() + default: + break + } + } + + private func handleStop(isFinalStop: Bool) { + if stopRequested { + if isFinalStop { + stopRequested = false + listener?.onState(state: .idle) + } + return + } + if hasStarted && !finished { + finished = true + listener?.onState(state: .ended) + } else if isFinalStop && !finished { + listener?.onState(state: .idle) + } + } + + private func applyPlaybackSettings() { + player.audio?.volume = volumePercent + if player.rate != rate { player.rate = rate } + } + + private func reportDuration() { + if let ms = player.media?.length.value?.int64Value, ms > 0 { + listener?.onDuration(durationMs: ms) + } + } + + private func onMain(_ work: @escaping () -> Void) { + DispatchQueue.main.async(execute: work) + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 349b4ba1..bfa61537 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -18,6 +18,10 @@ import ComposeApp @main struct iOSApp: App { + init() { + registerNativePlayerEngine() + } + var body: some Scene { WindowGroup { ContentView() From 538ba636d4eda9132bad9b4ee2be88a480a03b66 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 3 Nov 2026 16:53:36 +0600 Subject: [PATCH 2/2] 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) } }