mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(ios): play audio with VLCKit instead of AVPlayer
AVPlayer misreads the duration of some fragmented MP4 streams (it reports double the real length, leaving the second half silent and never ending the track). Playback on iOS now goes through VLCKit 4, added as a Swift package pinned to videolan/vlckit@6b87735. - NativePlayerEngine (iosMain): single-media engine contract that Swift implements and registers at launch; VLCKit ships as an SPM binary target, so it can't be bound with cinterop from Gradle. - AudioPlayer.ios.kt: rewritten on the engine. Playlist, loop modes, shuffle and auto-advance live in Kotlin, mirroring the JVM player (the old iOS player never advanced to the next track). - VlcPlayerEngine.swift: VLCKit-backed engine. VLCKit 4 has no "ended" state, so natural ends are told apart from requested stops; after a pause/resume it stops reporting time, so a no-op seek restores position updates.
This commit is contained in:
parent
784d9260d1
commit
0a30aa7137
@ -15,16 +15,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@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<MediaItem>()
|
||||
private val currentPlaylist = mutableListOf<MediaItem>()
|
||||
private val urlIndexMap = mutableMapOf<String, Int>()
|
||||
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<MediaItem?>(null)
|
||||
@ -94,97 +87,159 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
actual override val completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||
|
||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
engine.setListener(engineListener)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
while (!disposed) {
|
||||
syncTimeControlStatus()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
/** 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_playerState.tryEmit(playerState)
|
||||
|
||||
if (avPlayer.currentItem == null) {
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
}
|
||||
/** 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 resolveState(): PlayerState {
|
||||
return when (avPlayer.timeControlStatus) {
|
||||
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
||||
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
||||
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
||||
else -> PlayerState.IDLE
|
||||
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 buildAVPlayerItem(url: String): AVPlayerItem? {
|
||||
val nsUrl = NSURL.URLWithString(url) ?: return null
|
||||
return AVPlayerItem(nsUrl)
|
||||
}
|
||||
|
||||
actual override suspend fun play() {
|
||||
avPlayer.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)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun pause() {
|
||||
avPlayer.pause()
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
engine.pause()
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun stop() {
|
||||
avPlayer.pause()
|
||||
avPlayer.seekToTime(CMTimeMake(0, 1))
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
engine.stop()
|
||||
engineHasMedia = false
|
||||
pendingStartMs = 0L
|
||||
_position.tryEmit(Duration.ZERO)
|
||||
_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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun loop(state: LoopState) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
_loopState.tryEmit(state)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun shuffle(enabled: Boolean) {
|
||||
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
|
||||
) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
|
||||
originalPlaylist.clear()
|
||||
originalPlaylist.addAll(playlist)
|
||||
currentPlaylist.clear()
|
||||
currentPlaylist.addAll(playlist)
|
||||
urlIndexMap.clear()
|
||||
playlist.forEachIndexed { index, item ->
|
||||
urlIndexMap[item.url] = index
|
||||
}
|
||||
shuffleEnabled = false
|
||||
_shuffleMode.tryEmit(false)
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
|
||||
if (playlist.isEmpty()) {
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
_currentMediaItem.tryEmit(null)
|
||||
engine.stop()
|
||||
engineHasMedia = false
|
||||
pendingStartMs = 0L
|
||||
_position.tryEmit(Duration.ZERO)
|
||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||
|
||||
if (currentPlaylist.isEmpty()) {
|
||||
currentIndex = -1
|
||||
_currentMediaItem.tryEmit(null)
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
_duration.tryEmit(Duration.ZERO)
|
||||
return
|
||||
}
|
||||
|
||||
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
|
||||
val item = playlist[safeIndex]
|
||||
val avItem = buildAVPlayerItem(item.url)
|
||||
|
||||
if (avItem == null) {
|
||||
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
||||
_playerState.tryEmit(PlayerState.IDLE)
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
||||
_currentMediaItem.tryEmit(item)
|
||||
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
||||
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
||||
|
||||
if (autoPlay) {
|
||||
avPlayer.play()
|
||||
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||
} else {
|
||||
_playerState.tryEmit(PlayerState.PAUSED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
originalPlaylist.add(mediaItem)
|
||||
currentPlaylist.add(mediaItem)
|
||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
||||
_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
|
||||
}
|
||||
val insertIndex = if (currentIndex >= 0) currentIndex + 1 else currentPlaylist.size
|
||||
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)
|
||||
urlIndexMap.clear()
|
||||
currentPlaylist.forEachIndexed { index, item ->
|
||||
urlIndexMap[item.url] = index
|
||||
}
|
||||
_playlist.tryEmit(currentPlaylist.toList())
|
||||
if (currentIndex == -1) {
|
||||
currentIndex = 0
|
||||
_currentMediaItem.tryEmit(currentPlaylist.firstOrNull())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
||||
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)
|
||||
urlIndexMap.clear()
|
||||
currentPlaylist.forEachIndexed { i, it ->
|
||||
urlIndexMap[it.url] = i
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
avPlayer.volume = clamped
|
||||
engine.setVolume(clamped)
|
||||
_volume.tryEmit(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||
lock.withLock {
|
||||
if (disposed) return
|
||||
val clamped = speed.coerceIn(0.25f, 4f)
|
||||
avPlayer.rate = clamped
|
||||
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<CMTime> {
|
||||
return platform.CoreMedia.CMTimeMake(value, timescale)
|
||||
}
|
||||
|
||||
private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue<CMTime> {
|
||||
return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale)
|
||||
}
|
||||
|
||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
@ -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 */;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"originHash" : "1299735cf5953086f977e465ed8fbd8efae67129992b979462f68d933745ea6b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "vlckit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/videolan/vlckit",
|
||||
"state" : {
|
||||
"revision" : "6b87735a95fd3aa96f0fdb6d1b465a2d92badd8f"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
256
iosApp/iosApp/VlcPlayerEngine.swift
Normal file
256
iosApp/iosApp/VlcPlayerEngine.swift
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,10 @@ import ComposeApp
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
init() {
|
||||
registerNativePlayerEngine()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user