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