package dev.stave.playback import dev.stave.domain.model.DidlItem import dev.stave.domain.renderer.PlaybackState import dev.stave.domain.renderer.Renderer import dev.stave.domain.renderer.RepeatMode import dev.stave.ui.events.UiEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.media.MediaRef import uk.co.caprica.vlcj.medialist.MediaList import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.list.MediaListPlayer import uk.co.caprica.vlcj.player.list.MediaListPlayerEventAdapter import uk.co.caprica.vlcj.player.list.PlaybackMode import java.util.concurrent.atomic.AtomicInteger import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** * Desktop Renderer backed by libvlc via VLCJ. * * Design notes: * - `MediaListPlayer` plays the queue and advances to the next item on its own. * - We keep our own [MediaList] reference because in VLCJ 4.x, `MediaListPlayer.list()` * is read-only — mutation (add/remove) happens on the MediaList we own and then we * push a fresh `MediaListRef` into the player. * - **Our `queueIndex` is the source of truth, not libvlc's.** libvlc's list player * remembers the playing item as a plain index ("path") that only changes when it * plays something; list edits (`remove`/`insert`/`set_media_list`) don't move it * (libvlc 3.0 `media_list_player.c`: `mlist_item_deleted` is a no-op and * `set_media_list` keeps `current_playing_item_path`). After a reorder that moves * the current track, libvlc's own auto-advance can pick the wrong next track, or * stop early. So: every explicit jump goes through [playAtLocked] (`play(index)`), * Next/Previous are computed from our index, and [onLibvlcMediaChanged] / * [scheduleAdvanceCheck] put libvlc back on our track when its auto-advance * disagrees. That lets reorders (drag, shuffle on/off) leave the current track * playing instead of restarting it. * - Index tracking uses `MediaPlayer.mediaChanged` and `MediaListPlayer.nextItem` * (both fire for one change; [onLibvlcMediaChanged] de-duplicates them). * - PlaybackState semantics mirror the Android Media3 `LocalRenderer`: `isStopped` * vs paused-mid-track, enqueue-into-empty-queue does NOT auto-play, playNext into * empty queue DOES auto-play. * - **Pause is `setPause(true)`, never `pause()`.** vlcj `controls().pause()` is * libvlc's *toggle*: pausing an already-paused player resumed it (MPRIS/UPnP * `Pause` twice, the coordinator's double pause when switching to cast, Stop after * Pause). And libvlc's `set_pause(1)` *stops* the input when it can't pause yet * (still opening, or a live stream): `if (can_pause) PAUSE else input_Stop()`. * So a "prepared but not started" track (session restore, `load(autoplay=false)`, * Stop) is kept as [pendingStart] — libvlc isn't started at all until Play, and * the saved position is applied on the first `playing` event. * - Unplayable tracks (Android PlaybackService rule, Android parity, 2026-10-29): libvlc's * list player only advances on EndReached, so an error leaves the queue stuck. * We skip to the next track with a message, and stop after * [MAX_CONSECUTIVE_ERRORS] failures in a row (reset once something plays). */ class VlcjLocalRenderer( private val settings: dev.stave.data.SettingsStore, private val uiEvents: UiEvents, ) : Renderer { private val factory: MediaPlayerFactory = MediaPlayerFactory( "--no-video", "--quiet", "--intf=dummy", ) private val listPlayer: MediaListPlayer = factory.mediaPlayers().newMediaListPlayer() private val mediaPlayer: MediaPlayer = factory.mediaPlayers().newMediaPlayer().also { listPlayer.mediaPlayer().setMediaPlayer(it) } // We own the MediaList so we can mutate it directly (add/remove). After mutation we // re-push a fresh MediaListRef to the player via attachMediaList(). private var currentMediaList: MediaList? = null private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val _state = MutableStateFlow(PlaybackState()) override val state: StateFlow = _state.asStateFlow() /** Serialises every public operation (UI, MPRIS, the LAN renderer service, the * coordinator and our own error/advance corrections all call in from different * threads). Bodies never suspend while holding it. */ private val opLock = Mutex() @Volatile private var released = false @Volatile private var queueTracks: List = emptyList() /** Saved copy of queueTracks at the moment shuffle was enabled, so we can restore * the original order when shuffle is turned off. Null when shuffle is off. */ private var preShuffleSnapshot: List? = null private var tickerJob: Job? = null private var loggedMediaChanged: Boolean = false /** A track that is selected but deliberately NOT started in libvlc (see class doc). */ private data class PendingStart(val index: Int, val positionMs: Long) @Volatile private var pendingStart: PendingStart? = null /** Position to jump to once libvlc reports `playing` for the track we just started. */ @Volatile private var seekOnPlayingMs: Long = 0L /** Bookkeeping for [onLibvlcMediaChanged]: what we last asked libvlc to play, and * the last media change libvlc reported. */ @Volatile private var lastRequestedIndex: Int = -1 @Volatile private var lastRequestAtMs: Long = 0L private val changeLock = Any() private var lastChangeMrl: String? = null @Volatile private var lastChangeAtMs: Long = 0L private val consecutiveErrors = AtomicInteger(0) /** The libvlc volume last applied (user slider × ReplayGain), re-applied on each * `playing` event because some audio outputs (PulseAudio) reset the stream volume * when a new stream starts. */ @Volatile private var appliedVolume: Int = 100 init { mediaPlayer.events().addMediaPlayerEventListener(object : MediaPlayerEventAdapter() { override fun opening(mp: MediaPlayer) { _state.update { it.copy(isBuffering = true) } } override fun playing(mp: MediaPlayer) { consecutiveErrors.set(0) _state.update { it.copy(isPlaying = true, isBuffering = false, isStopped = false, error = null) } val seek = seekOnPlayingMs val vol = appliedVolume // Never call back into libvlc from its own event thread; vlcj's submit() // runs these on the player's executor. mp.submit { runCatching { mp.audio().setVolume(vol) } if (seek > 0L) { runCatching { mp.controls().setTime(seek) } seekOnPlayingMs = 0L } } } override fun paused(mp: MediaPlayer) { _state.update { it.copy(isPlaying = false, isBuffering = false) } } override fun stopped(mp: MediaPlayer) { _state.update { it.copy(isPlaying = false, isBuffering = false) } } override fun buffering(mp: MediaPlayer, newCache: Float) { _state.update { it.copy(isBuffering = newCache < 100f) } } override fun finished(mp: MediaPlayer) { // The last item playing out IS stopped (Android C.2 rule). val s = _state.value val atEnd = autoNextIndex(s.queueIndex, s.repeatMode) == null _state.update { it.copy(isPlaying = false, isBuffering = false, isStopped = atEnd || it.isStopped) } if (!atEnd) scheduleAdvanceCheck(s.queueIndex) } override fun lengthChanged(mp: MediaPlayer, newLength: Long) { _state.update { it.copy(duration = newLength.milliseconds) } } override fun error(mp: MediaPlayer) { val s = _state.value val title = s.current?.title?.takeIf { it.isNotBlank() } _state.update { it.copy( isPlaying = false, isBuffering = false, error = if (title != null) "Couldn't play ‘$title’" else "Couldn't play this track", ) } val failedIndex = s.queueIndex val errorAt = System.currentTimeMillis() scope.launch { onPlaybackError(failedIndex, title, errorAt) } } /** * Fires when the MediaListPlayer swaps in a new track. The player's own * `media().info().mrl()` is the most portable way to get the new URL across * VLCJ 4.x minor versions. We canonicalize both sides before comparing — * libvlc may re-encode percent escapes on the URL we hand in, so raw `==` * on the strings was returning -1 and leaving state frozen while VLC kept * advancing through tracks. */ override fun mediaChanged(mp: MediaPlayer, media: MediaRef) { if (!loggedMediaChanged) { System.err.println("[Earhorn] mediaChanged event fired at least once.") loggedMediaChanged = true } val mrl = runCatching { mp.media().info().mrl() }.getOrNull() ?: return onLibvlcMediaChanged(mrl) } }) // MediaListPlayer events — fire alongside mediaChanged when the list player // swaps items (version-dependent in VLCJ 4.x). Listen to both so at least one // path works; onLibvlcMediaChanged de-duplicates. listPlayer.events().addMediaListPlayerEventListener(object : MediaListPlayerEventAdapter() { override fun nextItem(listPlayer: MediaListPlayer, item: MediaRef) { val mrl = runCatching { item.newMedia()?.let { m -> try { m.info().mrl() } finally { m.release() } } }.getOrNull() ?: runCatching { mediaPlayer.media().info().mrl() }.getOrNull() if (mrl == null) { System.err.println("[Earhorn] nextItem fired but MRL was null") return } onLibvlcMediaChanged(mrl) } }) startTicker() startVolumeCollector() } // ---- libvlc → our index --------------------------------------------------------------- /** * libvlc reports it is now on [mrl]. Work out which queue row that is and keep our * index authoritative: * 1. Right after our own `play(index)`: accept it if it is the row we asked for; * otherwise it's a stale event from a superseded request — ignore it. * 2. Otherwise it's libvlc's own auto-advance. We expect [autoNextIndex]. If the * MRL is that row (duplicates allowed: we pick the expected one, not the first * copy — P2-6), move there. If libvlc picked something else (its index went * stale after a reorder), put it on our track. If we expected the end of the * queue, stop. */ private fun onLibvlcMediaChanged(mrl: String) { val now = System.currentTimeMillis() val target = canonicalMrl(mrl) synchronized(changeLock) { // mediaChanged and nextItem both fire for one change. if (target == lastChangeMrl && now - lastChangeAtMs < DUPLICATE_EVENT_WINDOW_MS) return lastChangeMrl = target lastChangeAtMs = now val tracks = queueTracks var matches = tracks.indices.filter { canonicalMrl(tracks[it].resourceUrl) == target } if (matches.isEmpty()) { val tail = target.substringAfterLast('/') matches = tracks.indices.filter { canonicalMrl(tracks[it].resourceUrl).substringAfterLast('/') == tail } } if (matches.isEmpty()) { System.err.println("[Earhorn] no queue match for MRL=$mrl (target=$target)") return } val s = _state.value val cur = s.queueIndex if (now - lastRequestAtMs < EXPLICIT_REQUEST_WINDOW_MS) { val req = lastRequestedIndex if (req in matches && req != cur) moveStateTo(req, 0L) return } val expected = autoNextIndex(cur, s.repeatMode) when { expected != null && expected in matches -> moveStateTo(expected, 0L) expected != null -> { System.err.println("[Earhorn] libvlc advanced to the wrong row; correcting to $expected") moveStateTo(expected, 0L) scope.launch { locked { if (_state.value.queueIndex == expected && lastRequestAtMs < now) { playAtLocked(expected) } } } } else -> { // We think the queue has ended, libvlc thinks otherwise. scope.launch { locked { if (lastRequestAtMs < now && _state.value.queueIndex == cur) { runCatching { listPlayer.controls().stop() } _state.update { it.copy(isPlaying = false, isBuffering = false, isStopped = true) } } } } } } } } /** After a track finished: if libvlc didn't move on by itself (its index went past * the end of a list we reordered), play our next track. */ private fun scheduleAdvanceCheck(finishedIndex: Int) { val finishedAt = System.currentTimeMillis() scope.launch { delay(ADVANCE_CHECK_DELAY_MS) locked { val s = _state.value if (s.queueIndex != finishedIndex || pendingStart != null) return@locked if (lastChangeAtMs >= finishedAt || lastRequestAtMs >= finishedAt) return@locked val next = autoNextIndex(finishedIndex, s.repeatMode) ?: return@locked playAtLocked(next) } } } /** Where playback goes when the current track ends (repeat-aware). */ private fun autoNextIndex(cur: Int, mode: RepeatMode): Int? { val last = queueTracks.lastIndex if (last < 0 || cur < 0) return null return when { mode == RepeatMode.ONE -> cur cur + 1 <= last -> cur + 1 mode == RepeatMode.ALL -> 0 else -> null } } /** Where Next/skip goes. Repeat ONE doesn't trap Next (Media3 navigation rule). */ private fun manualNextIndex(cur: Int, mode: RepeatMode): Int? { val last = queueTracks.lastIndex if (last < 0) return null return when { cur < 0 -> 0 cur + 1 <= last -> cur + 1 mode == RepeatMode.ALL -> 0 else -> null } } private fun moveStateTo(index: Int, positionMs: Long) { val t = queueTracks.getOrNull(index) _state.update { it.copy( queueIndex = index, current = t, position = positionMs.milliseconds, duration = t?.duration ?: Duration.ZERO, ) } } private suspend inline fun locked(block: () -> Unit) { opLock.withLock { if (!released) block() } } // ---- Unplayable tracks (Team 2 #25) --------------------------------------------------- private suspend fun onPlaybackError(failedIndex: Int, title: String?, errorAtMs: Long) { // libvlc may still be tearing the failed input down; also lets an auto-advance // or a user action that raced the error win. delay(ERROR_SKIP_GRACE_MS) locked { val name = title?.let { "‘$it’" } ?: "a track" val n = consecutiveErrors.incrementAndGet() if (n >= MAX_CONSECUTIVE_ERRORS) { consecutiveErrors.set(0) stopLocked() uiEvents.post("Playback stopped — $MAX_CONSECUTIVE_ERRORS tracks in a row couldn't be played.") return@locked } val s = _state.value val movedOn = s.queueIndex != failedIndex || pendingStart != null || lastChangeAtMs > errorAtMs || lastRequestAtMs > errorAtMs val next = if (failedIndex in queueTracks.indices) manualNextIndex(failedIndex, s.repeatMode) else null if (movedOn || next == null) { if (next == null) consecutiveErrors.set(0) uiEvents.post("Couldn't play $name.") return@locked } uiEvents.post("Couldn't play $name — skipped.") playAtLocked(next) } } // ---- Ticker / volume -------------------------------------------------------------------- private fun startTicker() { tickerJob?.cancel() tickerJob = scope.launch { while (true) { // Sleep while nothing plays (the maintainer's I.39 rule): the old // loop read VLC twenty times a second for the life of the app. if (!_state.value.isPlaying) { delay(1_000); continue } // While a start position is still being applied, libvlc reports ~0; // keep showing the target instead of flashing 0:00. if (seekOnPlayingMs <= 0L) { val ms = runCatching { mediaPlayer.status().time() }.getOrDefault(-1L) if (ms >= 0L) { _state.update { it.copy(position = ms.milliseconds) } } } // 50ms ticker for tight lyric-highlight sync. libvlc's status().time() // call is cheap and Compose doesn't recompose faster than the display's // refresh rate anyway. Going lower than this yields diminishing returns // (Compose coalesces recompositions) while going higher introduces // perceptible lag on short lyric phrases. delay(50) } } } /** * Applies the libvlc volume = the user's slider × the ReplayGain factor. * * The slider ([PlaybackState.volumePercent], 0..100) is the ceiling. ReplayGain only * attenuates relative to it: the RG factor (gain + pre-amp, in dB → linear) is * clamped to ≤ 1, exactly as Android clamps Media3's player volume to [0, 1]. The * old code set libvlc to `linear*100` and ignored the slider, so at 20 % with * +3 dB pre-amp and a −2 dB track the player went to 112 % — several times louder * than the slider — on every track change, and the next setVolume dropped RG * again (Team 3 P1-2). With RG OFF the factor is 1, so libvlc follows the slider. * * Recomputed whenever the track, the slider, the RG mode or the pre-amp changes. */ private fun startVolumeCollector() { scope.launch { combine( _state.map { it.current }.distinctUntilChanged { a, b -> a?.id == b?.id }, _state.map { it.volumePercent }.distinctUntilChanged(), settings.replayGainMode, settings.replayGainPreAmpDb, ) { track, userVolume, mode, preAmp -> val factor = replayGainFactor(track, mode, preAmp) (userVolume * factor).roundToInt().coerceIn(0, 100) }.distinctUntilChanged().collect { v -> appliedVolume = v if (!released) runCatching { mediaPlayer.audio().setVolume(v) } } } } /** * ReplayGain multiplier in [0, 1] for [track]. 1 (no change) when RG is OFF or * there is no track. Pre-amp still applies to untagged tracks (a user preference * independent of tags), matching Android. * * Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN from the track's extras map * (case-insensitively — tag keys vary across containers). ALBUM mode falls back to * TRACK when the album tag is absent, so users who chose ALBUM don't hit unity * volume on half their library just because some files only have per-track tags. */ private fun replayGainFactor( track: DidlItem.Track?, mode: dev.stave.data.ReplayGainMode, preAmpDb: Float, ): Double { if (mode == dev.stave.data.ReplayGainMode.OFF || track == null) return 1.0 val key = when (mode) { dev.stave.data.ReplayGainMode.TRACK -> "REPLAYGAIN_TRACK_GAIN" dev.stave.data.ReplayGainMode.ALBUM -> "REPLAYGAIN_ALBUM_GAIN" dev.stave.data.ReplayGainMode.OFF -> return 1.0 } val rgGainRaw = track.extras.entries .firstOrNull { it.key.equals(key, ignoreCase = true) } ?.value ?: track.extras.entries .firstOrNull { it.key.equals("REPLAYGAIN_TRACK_GAIN", ignoreCase = true) } ?.value val rgGainDb = rgGainRaw?.let { parseRgGain(it) } ?: 0f val totalDb = rgGainDb + preAmpDb val linear = Math.pow(10.0, totalDb / 20.0) return when { linear.isNaN() || linear.isInfinite() -> 1.0 else -> linear.coerceIn(0.0, 1.0) } } /** Parse an RG tag like "-7.22 dB", "-7.22dB", "-7.22", "+3.5 dB". Returns 0f if * unparseable — tag authors get creative, we stay defensive. */ private fun parseRgGain(raw: String): Float { val match = Regex("""[+-]?\d+(?:\.\d+)?""").find(raw.trim()) return match?.value?.toFloatOrNull() ?: 0f } // ---- libvlc list plumbing --------------------------------------------------------------- /** Push the currently-owned MediaList into the MediaListPlayer via a ref. */ private fun attachMediaList(list: MediaList) { val ref = list.newMediaListRef() try { listPlayer.list().setMediaList(ref) } finally { ref.release() } } /** Replace libvlc's list. Does NOT stop what is playing: `set_media_list` leaves * the media player alone (its index goes stale, which [onLibvlcMediaChanged] * handles). */ private fun rebuildMediaList(tracks: List) { currentMediaList?.release() val list = factory.media().newMediaList() tracks.forEach { list.media().add(it.resourceUrl) } currentMediaList = list attachMediaList(list) // NB: do NOT reset the playback mode here — repeat settings (LOOP/REPEAT) must // survive queue mutations so an enqueue or reorder doesn't silently turn off // repeat-all that the user enabled earlier. } /** Start [index] in libvlc, optionally at [startMs]. The only place that calls * `listPlayer.play(index)`. State is updated first (P2-6). */ private fun playAtLocked(index: Int, startMs: Long = 0L) { if (index !in queueTracks.indices) return pendingStart = null seekOnPlayingMs = startMs.coerceAtLeast(0L) moveStateTo(index, startMs.coerceAtLeast(0L)) _state.update { it.copy(isStopped = false, error = null) } lastRequestedIndex = index lastRequestAtMs = System.currentTimeMillis() listPlayer.controls().play(index) // play() can block while libvlc tears the previous input down; the media // change event it queued must still count as ours. lastRequestAtMs = System.currentTimeMillis() } /** Stop libvlc and keep [index] selected, to start at [positionMs] on Play. */ private fun parkLocked(index: Int, positionMs: Long) { runCatching { listPlayer.controls().stop() } seekOnPlayingMs = 0L pendingStart = if (index in queueTracks.indices) PendingStart(index, positionMs.coerceAtLeast(0L)) else null _state.update { it.copy(isPlaying = false, isBuffering = false, position = positionMs.coerceAtLeast(0L).milliseconds) } } /** Same semantics as Android LocalRenderer.stop(): the next Play restarts the * current track from 0. isStopped flips so the NowPlayingBar hides. A real libvlc * stop (releases the stream), not a pause toggle (Team 3 P1-1). */ private fun stopLocked() { parkLocked(_state.value.queueIndex, 0L) _state.update { it.copy(isStopped = true, position = Duration.ZERO) } } private fun libvlcIsActive(): Boolean = when (runCatching { mediaPlayer.status().state() }.getOrNull()) { State.OPENING, State.BUFFERING, State.PLAYING -> true else -> false } // ---- Queue load / mutation ------------------------------------------------------------- override suspend fun load(tracks: List, startIndex: Int, autoplay: Boolean) = locked { consecutiveErrors.set(0) if (!autoplay || tracks.isEmpty()) runCatching { listPlayer.controls().stop() } queueTracks = tracks preShuffleSnapshot = if (_state.value.shuffleEnabled) tracks else null rebuildMediaList(tracks) pendingStart = null seekOnPlayingMs = 0L val clamped = startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0)) _state.update { it.copy( queue = tracks, queueIndex = if (tracks.isNotEmpty()) clamped else -1, current = tracks.getOrNull(clamped), position = Duration.ZERO, duration = tracks.getOrNull(clamped)?.duration ?: Duration.ZERO, isStopped = tracks.isEmpty(), isPlaying = false, // goes true on the playing event (if autoplay) isBuffering = false, error = null, ) } if (tracks.isNotEmpty()) { if (autoplay) { playAtLocked(clamped) } else { // Session restore / migrate-to-local without autoplay: select the // track but don't start libvlc. play()+setPause(true) raced the // demuxer: before it reports can_pause, libvlc's set_pause *stops* the // input, which lost the restored position (Team 3 P2-8). seekTo() // while pending just moves the start position. pendingStart = PendingStart(clamped, 0L) } } } override suspend fun enqueue(tracks: List) = locked { if (tracks.isEmpty()) return@locked if (queueTracks.isEmpty()) { // Seed the queue but do NOT auto-play — matches Android LocalRenderer.enqueue. runCatching { listPlayer.controls().stop() } queueTracks = tracks rebuildMediaList(tracks) pendingStart = PendingStart(0, 0L) _state.update { it.copy( queue = queueTracks, queueIndex = 0, current = queueTracks.firstOrNull(), position = Duration.ZERO, duration = queueTracks.firstOrNull()?.duration ?: Duration.ZERO, isStopped = true, ) } } else { val newQueue = queueTracks + tracks queueTracks = newQueue val list = currentMediaList if (list != null) { tracks.forEach { t -> list.media().add(t.resourceUrl) } } _state.update { it.copy(queue = newQueue) } } } override suspend fun playNext(tracks: List) = locked { if (tracks.isEmpty()) return@locked if (queueTracks.isEmpty()) { // Empty queue + "play next" ≡ "play now". Auto-start. queueTracks = tracks rebuildMediaList(tracks) _state.update { it.copy(queue = tracks) } playAtLocked(0) } else { val current = _state.value.queueIndex.coerceAtLeast(0) val insertAt = (current + 1).coerceAtMost(queueTracks.size) val newQueue = queueTracks.toMutableList().apply { addAll(insertAt, tracks) } queueTracks = newQueue val list = currentMediaList if (list != null) { tracks.forEachIndexed { i, t -> list.media().insert(insertAt + i, t.resourceUrl) } } _state.update { it.copy(queue = newQueue) } } } override suspend fun skipToQueueItem(index: Int) = locked { if (index !in queueTracks.indices) return@locked playAtLocked(index) } override suspend fun removeFromQueue(index: Int) = locked { if (index !in queueTracks.indices) return@locked val cur = _state.value.queueIndex val wasCurrent = index == cur val wasActive = wasCurrent && pendingStart == null && libvlcIsActive() val newQueue = queueTracks.toMutableList().apply { removeAt(index) } queueTracks = newQueue runCatching { currentMediaList?.media()?.remove(index) } if (newQueue.isEmpty()) { runCatching { listPlayer.controls().stop() } pendingStart = null _state.update { it.copy( queue = emptyList(), queueIndex = -1, current = null, isStopped = true, isPlaying = false, isBuffering = false, position = Duration.ZERO, ) } } else if (wasCurrent) { val newIndex = index.coerceAtMost(newQueue.lastIndex) _state.update { it.copy(queue = newQueue) } if (wasActive) { playAtLocked(newIndex) } else { // Removing the paused/stopped track must not start the next one // (Team 3 P3; CastRenderer uses autoplay = isPlaying the same way). val wasStopped = _state.value.isStopped parkLocked(newIndex, 0L) moveStateTo(newIndex, 0L) _state.update { it.copy(isStopped = wasStopped) } } } else { val newCurrentIndex = if (cur >= 0 && index < cur) cur - 1 else cur pendingStart?.let { p -> if (index < p.index) pendingStart = p.copy(index = p.index - 1) } _state.update { it.copy(queue = newQueue, queueIndex = newCurrentIndex) } } } override suspend fun moveInQueue(from: Int, to: Int) = locked { if (from !in queueTracks.indices || to !in queueTracks.indices || from == to) return@locked val cur = _state.value.queueIndex val moved = queueTracks.toMutableList().apply { val item = removeAt(from) add(to, item) } queueTracks = moved // Track the current row by position, not by URL, so duplicates stay right; // with nothing current (-1) nothing becomes current (Team 3 P3). val newCurrentIndex = when { cur < 0 -> cur from == cur -> to from < cur && to >= cur -> cur - 1 from > cur && to <= cur -> cur + 1 else -> cur } // Update libvlc's MediaList incrementally rather than rebuilding from // scratch, and never restart: libvlc's player keeps playing its current // Media through list edits (even when that Media's own row is moved), and // our index is authoritative for what comes next (see class doc). val list = currentMediaList if (list != null) { runCatching { list.media().remove(from) list.media().insert(to, moved[to].resourceUrl) } } else { rebuildMediaList(moved) } pendingStart?.let { p -> pendingStart = p.copy(index = newCurrentIndex) } _state.update { it.copy(queue = moved, queueIndex = newCurrentIndex) } } override suspend fun clearQueue() = locked { runCatching { listPlayer.controls().stop() } runCatching { currentMediaList?.media()?.clear() } queueTracks = emptyList() preShuffleSnapshot = if (_state.value.shuffleEnabled) emptyList() else null pendingStart = null seekOnPlayingMs = 0L _state.update { it.copy( queue = emptyList(), queueIndex = -1, current = null, isPlaying = false, isBuffering = false, isStopped = true, position = Duration.ZERO, duration = Duration.ZERO, ) } } // ---- Transport controls ---------------------------------------------------------------- override suspend fun play() = locked { if (queueTracks.isEmpty()) return@locked val p = pendingStart val idx = _state.value.queueIndex when { p != null -> playAtLocked(p.index, p.positionMs) idx !in queueTracks.indices -> playAtLocked(0) else -> when (runCatching { mediaPlayer.status().state() }.getOrNull()) { State.PAUSED, State.PLAYING, State.OPENING, State.BUFFERING -> { mediaPlayer.controls().setPause(false) _state.update { it.copy(isStopped = false) } } // Stopped / ended / error / nothing loaded: (re)start our current // row. listPlayer.play() would start libvlc's own idea of the current // item, which is row 0 after a list-player stop. else -> playAtLocked(idx) } } } /** Idempotent: pausing a paused player keeps it paused (Team 3 P1-1). */ override suspend fun pause() = locked { if (pendingStart != null) return@locked if (!libvlcIsActive()) return@locked if (runCatching { mediaPlayer.status().canPause() }.getOrDefault(false)) { mediaPlayer.controls().setPause(true) } else { // Still opening, or a stream that can't pause: libvlc's set_pause would // stop it anyway. Park it ourselves so Play resumes at the same place. val pos = if (seekOnPlayingMs > 0L) seekOnPlayingMs else runCatching { mediaPlayer.status().time() }.getOrDefault(0L) parkLocked(_state.value.queueIndex, pos) } } override suspend fun stop() = locked { stopLocked() } override suspend fun seekTo(position: Duration) = locked { val ms = position.inWholeMilliseconds.coerceAtLeast(0L) val p = pendingStart val idx = _state.value.queueIndex val vlcState = runCatching { mediaPlayer.status().state() }.getOrNull() when { p != null -> pendingStart = p.copy(positionMs = ms) // Started but not playing yet: libvlc may ignore a seek now; apply it // on the playing event instead. !_state.value.isPlaying && (vlcState == State.OPENING || vlcState == State.BUFFERING) -> seekOnPlayingMs = ms vlcState == State.PLAYING || vlcState == State.PAUSED || vlcState == State.BUFFERING -> mediaPlayer.controls().setTime(ms) // Stopped / ended / failed: remember it, Play starts there. idx in queueTracks.indices -> pendingStart = PendingStart(idx, ms) else -> return@locked } // The ticker sleeps while paused; show the new position right away. _state.update { it.copy(position = ms.milliseconds) } } override suspend fun next() = locked { val s = _state.value val n = manualNextIndex(s.queueIndex, s.repeatMode) ?: return@locked playAtLocked(n) } override suspend fun previous() = locked { val s = _state.value val cur = s.queueIndex val prev = when { queueTracks.isEmpty() -> return@locked cur - 1 >= 0 -> cur - 1 s.repeatMode == RepeatMode.ALL -> queueTracks.lastIndex else -> -1 } if (prev >= 0) { playAtLocked(prev) } else { // First track, no wrap: back to its start. val p = pendingStart if (p != null) pendingStart = p.copy(positionMs = 0L) else runCatching { mediaPlayer.controls().setTime(0L) } _state.update { it.copy(position = Duration.ZERO) } } } /** The slider value. libvlc's volume is applied by [startVolumeCollector] * (slider × ReplayGain), so RG is never dropped by a slider move. */ override suspend fun setVolume(volumePercent: Int) = locked { val clamped = volumePercent.coerceIn(0, 100) _state.update { it.copy(volumePercent = clamped) } } override suspend fun setRepeatMode(mode: RepeatMode) = locked { val vlcMode = when (mode) { RepeatMode.OFF -> PlaybackMode.DEFAULT RepeatMode.ALL -> PlaybackMode.LOOP RepeatMode.ONE -> PlaybackMode.REPEAT } listPlayer.controls().setMode(vlcMode) _state.update { it.copy(repeatMode = mode) } } override suspend fun setShuffle(enabled: Boolean) = locked { if (enabled == _state.value.shuffleEnabled) return@locked val cur = _state.value.queueIndex if (enabled) { // Save the pre-shuffle order so we can restore it on disable. preShuffleSnapshot = queueTracks.toList() if (cur !in queueTracks.indices) { val newOrder = queueTracks.shuffled() queueTracks = newOrder rebuildMediaList(newOrder) _state.update { it.copy(queue = newOrder, shuffleEnabled = true) } return@locked } // Shuffle everything *after* the current track; the head (and so libvlc's // current row) is untouched, so only the tail is replaced in libvlc's list // and the current track keeps playing (Team 3 P2-7). val head = queueTracks.subList(0, cur + 1) val tail = queueTracks.subList(cur + 1, queueTracks.size).shuffled() val newOrder = head + tail val list = currentMediaList if (list != null) { runCatching { for (i in queueTracks.lastIndex downTo cur + 1) list.media().remove(i) tail.forEach { list.media().add(it.resourceUrl) } } } else { rebuildMediaList(newOrder) } queueTracks = newOrder _state.update { it.copy(queue = newOrder, shuffleEnabled = true) } } else { // Back to the original order, but keep what changed while shuffled: // removed tracks stay removed, added tracks stay (at the end) (Team 3 P1-11). val snapshot = preShuffleSnapshot preShuffleSnapshot = null if (snapshot == null) { _state.update { it.copy(shuffleEnabled = false) } return@locked } val order = restorePreShuffleOrder(snapshot, queueTracks) val newOrder = order.map { queueTracks[it] } val newIndex = if (cur >= 0) order.indexOf(cur) else cur queueTracks = newOrder // Rebuilding the list doesn't interrupt the current track (see // rebuildMediaList); no play(newIndex), so no restart from 0:00. rebuildMediaList(newOrder) pendingStart?.let { p -> pendingStart = p.copy(index = newIndex) } _state.update { it.copy( queue = newOrder, queueIndex = newIndex, current = newOrder.getOrNull(newIndex), shuffleEnabled = false, ) } } } /** * Canonicalize an MRL for comparison. libvlc may hand us back a URL with different * percent-encoding than what we gave it (e.g. we pass `file with space.flac`, it * returns `file%20with%20space.flac`, or vice versa). We canonicalize by: * 1. Parsing as a URI so percent-decoding happens on path / query components * 2. Rebuilding with scheme/host/port/decoded-path/raw-query * If the input isn't a valid URI (shouldn't happen for HTTP MRLs but could for * `file://` paths with unusual characters), fall back to the raw string. */ private fun canonicalMrl(s: String): String { return runCatching { val uri = java.net.URI(s) buildString { uri.scheme?.let { append(it).append("://") } uri.host?.let { append(it) } if (uri.port >= 0) append(":").append(uri.port) // getRawPath returns the percent-encoded path; URLDecoder gives the // fully decoded form for comparison. uri.rawPath?.let { append(java.net.URLDecoder.decode(it, "UTF-8")) } uri.rawQuery?.let { append("?").append(it) } } }.getOrDefault(s) } // ---- Lifecycle ------------------------------------------------------------------------- /** Release libvlc. Idempotent. Waits (bounded) for an in-flight operation so no * call is inside libvlc while its players are freed; later calls are no-ops. */ fun release() { if (released) return val gotLock = runBlocking { withTimeoutOrNull(2_000L) { opLock.lock(); true } } == true try { if (released) return released = true tickerJob?.cancel() scope.cancel() runCatching { listPlayer.controls().stop() } runCatching { currentMediaList?.release() } runCatching { listPlayer.release() } runCatching { mediaPlayer.release() } runCatching { factory.release() } } finally { if (gotLock) opLock.unlock() } } private companion object { const val MAX_CONSECUTIVE_ERRORS = 5 const val ERROR_SKIP_GRACE_MS = 300L const val ADVANCE_CHECK_DELAY_MS = 400L const val DUPLICATE_EVENT_WINDOW_MS = 500L const val EXPLICIT_REQUEST_WINDOW_MS = 1_500L } } /** * The order to go back to when shuffle is turned off, as indices into [current]. * * [snapshot] is the queue as it was when shuffle went on. Tracks still queued come * back in that order; tracks removed while shuffled stay gone; tracks added while * shuffled follow, in their current order. Duplicates are matched copy-for-copy * (id + URL), so a track queued twice comes back twice. (Team 3 P1-11.) */ internal fun restorePreShuffleOrder( snapshot: List, current: List, ): List { fun key(t: DidlItem.Track) = t.id + '\u0000' + t.resourceUrl val buckets = HashMap>() current.forEachIndexed { i, t -> buckets.getOrPut(key(t)) { ArrayDeque() }.addLast(i) } val out = ArrayList(current.size) for (t in snapshot) buckets[key(t)]?.removeFirstOrNull()?.let(out::add) val used = out.toHashSet() current.indices.filterNotTo(out) { it in used } return out }