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