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) } } 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()