EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

VlcjLocalRenderer.kt

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<PlaybackState> = _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<DidlItem.Track> = 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<DidlItem.Track>? = 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<DidlItem.Track>) {
        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<DidlItem.Track>, 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<DidlItem.Track>) = 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<DidlItem.Track>) = 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<DidlItem.Track>,
    current: List<DidlItem.Track>,
): List<Int> {
    fun key(t: DidlItem.Track) = t.id + '\u0000' + t.resourceUrl
    val buckets = HashMap<String, ArrayDeque<Int>>()
    current.forEachIndexed { i, t -> buckets.getOrPut(key(t)) { ArrayDeque() }.addLast(i) }
    val out = ArrayList<Int>(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
}