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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalForeignApi::class)
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.audioplayer
|
package org.spotube.spotube.core.audioplayer
|
||||||
|
|
||||||
import kotlinx.cinterop.CValue
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -32,41 +28,38 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import platform.AVFoundation.AVPlayer
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import platform.AVFoundation.AVPlayerItem
|
import kotlinx.coroutines.sync.withLock
|
||||||
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 kotlin.time.Duration
|
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")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface {
|
||||||
|
|
||||||
actual val context: Any = context
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val lock = Mutex()
|
||||||
|
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
|
private val originalPlaylist = mutableListOf<MediaItem>()
|
||||||
private val currentPlaylist = 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 _playerState = MutableStateFlow(PlayerState.IDLE)
|
||||||
private val _currentMediaItem = MutableStateFlow<MediaItem?>(null)
|
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 completionFlow: Flow<Unit> = _completion.asSharedFlow()
|
||||||
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
actual override val errorFlow: Flow<Throwable> = _error.asSharedFlow()
|
||||||
|
|
||||||
private var lastTimeControlStatus: AVPlayerTimeControlStatus? = null
|
private val engineListener = object : NativePlayerListener {
|
||||||
|
override fun onState(state: NativePlayerState) {
|
||||||
init {
|
if (disposed) return
|
||||||
avPlayer.addPeriodicTimeObserverForInterval(
|
when (state) {
|
||||||
interval = CMTimeMake(1, 4),
|
NativePlayerState.IDLE -> _playerState.tryEmit(PlayerState.IDLE)
|
||||||
queue = null
|
NativePlayerState.OPENING,
|
||||||
) { time ->
|
NativePlayerState.BUFFERING -> _playerState.tryEmit(PlayerState.BUFFERING)
|
||||||
if (!disposed) {
|
NativePlayerState.PLAYING -> _playerState.tryEmit(PlayerState.PLAYING)
|
||||||
val seconds = CMTimeGetSeconds(time)
|
NativePlayerState.PAUSED -> _playerState.tryEmit(PlayerState.PAUSED)
|
||||||
_position.tryEmit(seconds.seconds)
|
NativePlayerState.ENDED -> scope.launch { lock.withLock { onMediaEndedLocked() } }
|
||||||
|
|
||||||
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)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
override fun onTime(positionMs: Long) {
|
||||||
while (!disposed) {
|
if (disposed || positionMs < 0) return
|
||||||
syncTimeControlStatus()
|
_position.tryEmit(positionMs.milliseconds)
|
||||||
delay(500)
|
}
|
||||||
|
|
||||||
|
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() {
|
actual override suspend fun play() {
|
||||||
val status = avPlayer.timeControlStatus
|
lock.withLock {
|
||||||
if (status == lastTimeControlStatus) return
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
lastTimeControlStatus = status
|
if (currentIndex !in currentPlaylist.indices) {
|
||||||
|
currentIndex = 0
|
||||||
val playerState = when (status) {
|
_currentMediaItem.tryEmit(currentPlaylist[0])
|
||||||
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
}
|
||||||
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
if (engineHasMedia) engine.play() else loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
|
||||||
else -> PlayerState.IDLE
|
|
||||||
}
|
}
|
||||||
_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)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveState(): PlayerState {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
return when (avPlayer.timeControlStatus) {
|
lock.withLock {
|
||||||
AVPlayerTimeControlStatusPlaying -> PlayerState.PLAYING
|
if (disposed) return
|
||||||
AVPlayerTimeControlStatusPaused -> PlayerState.PAUSED
|
val maxMs = _duration.value.inWholeMilliseconds
|
||||||
AVPlayerTimeControlStatusWaitingToPlayAtSpecifiedRate -> PlayerState.BUFFERING
|
val targetMs = if (maxMs > 0) {
|
||||||
else -> PlayerState.IDLE
|
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) {
|
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) {
|
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(
|
actual override suspend fun load(
|
||||||
@ -192,143 +247,179 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int
|
startPosition: Int
|
||||||
) {
|
) {
|
||||||
currentPlaylist.clear()
|
lock.withLock {
|
||||||
currentPlaylist.addAll(playlist)
|
if (disposed) return
|
||||||
urlIndexMap.clear()
|
|
||||||
playlist.forEachIndexed { index, item ->
|
|
||||||
urlIndexMap[item.url] = index
|
|
||||||
}
|
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
|
||||||
|
|
||||||
if (playlist.isEmpty()) {
|
originalPlaylist.clear()
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
originalPlaylist.addAll(playlist)
|
||||||
_currentMediaItem.tryEmit(null)
|
currentPlaylist.clear()
|
||||||
|
currentPlaylist.addAll(playlist)
|
||||||
|
shuffleEnabled = false
|
||||||
|
_shuffleMode.tryEmit(false)
|
||||||
|
_playlist.tryEmit(currentPlaylist.toList())
|
||||||
|
|
||||||
|
engine.stop()
|
||||||
|
engineHasMedia = false
|
||||||
|
pendingStartMs = 0L
|
||||||
_position.tryEmit(Duration.ZERO)
|
_position.tryEmit(Duration.ZERO)
|
||||||
_duration.tryEmit(Duration.ZERO)
|
_bufferingPosition.tryEmit(Duration.ZERO)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val safeIndex = startPosition.coerceIn(0, playlist.lastIndex)
|
if (currentPlaylist.isEmpty()) {
|
||||||
val item = playlist[safeIndex]
|
currentIndex = -1
|
||||||
val avItem = buildAVPlayerItem(item.url)
|
_currentMediaItem.tryEmit(null)
|
||||||
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
|
_duration.tryEmit(Duration.ZERO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (avItem == null) {
|
currentIndex = startPosition.coerceIn(0, currentPlaylist.lastIndex)
|
||||||
_error.tryEmit(IllegalArgumentException("Invalid URL: ${item.url}"))
|
_currentMediaItem.tryEmit(currentPlaylist[currentIndex])
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_duration.tryEmit(currentPlaylist[currentIndex].duration)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
if (autoPlay) {
|
||||||
_currentMediaItem.tryEmit(item)
|
loadCurrentIntoEngineLocked(autoPlay = true)
|
||||||
|
} else {
|
||||||
if (autoPlay) {
|
_playerState.tryEmit(PlayerState.PAUSED)
|
||||||
avPlayer.play()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
actual override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
currentPlaylist.add(mediaItem)
|
lock.withLock {
|
||||||
urlIndexMap[mediaItem.url] = currentPlaylist.lastIndex
|
if (disposed) return
|
||||||
_playlist.tryEmit(currentPlaylist.toList())
|
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) {
|
actual override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
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) {
|
actual override suspend fun removeMediaItem(mediaItem: MediaItem) {
|
||||||
val index = urlIndexMap[mediaItem.url] ?: return
|
lock.withLock {
|
||||||
currentPlaylist.removeAt(index)
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
urlIndexMap.clear()
|
|
||||||
currentPlaylist.forEachIndexed { i, item ->
|
originalPlaylist.removeAll { it.url == mediaItem.url }
|
||||||
urlIndexMap[item.url] = i
|
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) {
|
actual override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) {
|
||||||
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
lock.withLock {
|
||||||
val item = currentPlaylist.removeAt(fromIndex)
|
if (disposed) return
|
||||||
currentPlaylist.add(toIndex, item)
|
if (fromIndex !in currentPlaylist.indices || toIndex !in currentPlaylist.indices || fromIndex == toIndex) return
|
||||||
urlIndexMap.clear()
|
|
||||||
currentPlaylist.forEachIndexed { i, it ->
|
val item = currentPlaylist.removeAt(fromIndex)
|
||||||
urlIndexMap[it.url] = i
|
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() {
|
actual override suspend fun skipToNext() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
}
|
playIndexLocked((currentIndex + 1).coerceAtMost(currentPlaylist.lastIndex))
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun skipToPrevious() {
|
actual override suspend fun skipToPrevious() {
|
||||||
val currentIndex = currentPlaylist.indexOfFirst {
|
lock.withLock {
|
||||||
it.url == _currentMediaItem.value?.url
|
if (disposed || currentPlaylist.isEmpty()) return
|
||||||
}
|
playIndexLocked((currentIndex - 1).coerceAtLeast(0))
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun jumpTo(index: Int) {
|
actual override suspend fun jumpTo(index: Int) {
|
||||||
if (index in currentPlaylist.indices) {
|
lock.withLock {
|
||||||
val item = currentPlaylist[index]
|
if (disposed || index !in currentPlaylist.indices) return
|
||||||
val avItem = buildAVPlayerItem(item.url)
|
playIndexLocked(index)
|
||||||
if (avItem != null) {
|
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(avItem)
|
|
||||||
_currentMediaItem.tryEmit(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun setVolume(volume: Float) {
|
actual override suspend fun setVolume(volume: Float) {
|
||||||
val clamped = volume.coerceIn(0f, 1f)
|
lock.withLock {
|
||||||
avPlayer.volume = clamped
|
if (disposed) return
|
||||||
_volume.tryEmit(clamped)
|
val clamped = volume.coerceIn(0f, 1f)
|
||||||
|
engine.setVolume(clamped)
|
||||||
|
_volume.tryEmit(clamped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
actual override suspend fun setPlaybackSpeed(speed: Float) {
|
||||||
val clamped = speed.coerceIn(0.25f, 4f)
|
lock.withLock {
|
||||||
avPlayer.rate = clamped
|
if (disposed) return
|
||||||
_playbackSpeed.tryEmit(clamped)
|
val clamped = speed.coerceIn(0.25f, 4f)
|
||||||
|
engine.setRate(clamped)
|
||||||
|
_playbackSpeed.tryEmit(clamped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual override fun isDisposed(): Boolean = disposed
|
actual override fun isDisposed(): Boolean = disposed
|
||||||
|
|
||||||
actual override fun dispose() {
|
actual override fun dispose() {
|
||||||
|
if (disposed) return
|
||||||
disposed = true
|
disposed = true
|
||||||
avPlayer.pause()
|
engine.setListener(null)
|
||||||
avPlayer.replaceCurrentItemWithPlayerItem(null)
|
engine.destroy()
|
||||||
|
scope.cancel()
|
||||||
|
|
||||||
|
originalPlaylist.clear()
|
||||||
currentPlaylist.clear()
|
currentPlaylist.clear()
|
||||||
urlIndexMap.clear()
|
currentIndex = -1
|
||||||
|
engineHasMedia = false
|
||||||
|
|
||||||
_playerState.tryEmit(PlayerState.IDLE)
|
_playerState.tryEmit(PlayerState.IDLE)
|
||||||
_currentMediaItem.tryEmit(null)
|
_currentMediaItem.tryEmit(null)
|
||||||
@ -338,11 +429,3 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
_bufferingPosition.tryEmit(Duration.ZERO)
|
_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;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1B2C3D4E5F60718293A4B3 /* VLCKit */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
11DF02E4B44E5BCF61736473 /* Spotube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spotube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -41,6 +45,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
9A1B2C3D4E5F60718293A4B1 /* VLCKit in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -85,6 +90,7 @@
|
|||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
9A1B2C3D4E5F60718293A4B3 /* VLCKit */,
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
productReference = 11DF02E4B44E5BCF61736473 /* Spotube.app */;
|
||||||
@ -114,6 +120,9 @@
|
|||||||
);
|
);
|
||||||
mainGroup = EFA169D322B90F32841D602F;
|
mainGroup = EFA169D322B90F32841D602F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
9A1B2C3D4E5F60718293A4B2 /* XCRemoteSwiftPackageReference "vlckit" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
productRefGroup = 835615636B5FE86AA850B7DB /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -368,6 +377,25 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
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
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
init() {
|
||||||
|
registerNativePlayerEngine()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user