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

VlcjLocalRenderer.kt

981 lines · 44.7 KB

Changed in: 0.5.0

1package dev.stave.playback
2
3import dev.stave.domain.model.DidlItem
4import dev.stave.domain.renderer.PlaybackState
5import dev.stave.domain.renderer.Renderer
6import dev.stave.domain.renderer.RepeatMode
7import dev.stave.ui.events.UiEvents
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.Job
11import kotlinx.coroutines.SupervisorJob
12import kotlinx.coroutines.cancel
13import kotlinx.coroutines.delay
14import kotlinx.coroutines.flow.MutableStateFlow
15import kotlinx.coroutines.flow.StateFlow
16import kotlinx.coroutines.flow.asStateFlow
17import kotlinx.coroutines.flow.combine
18import kotlinx.coroutines.flow.distinctUntilChanged
19import kotlinx.coroutines.flow.map
20import kotlinx.coroutines.flow.update
21import kotlinx.coroutines.launch
22import kotlinx.coroutines.runBlocking
23import kotlinx.coroutines.sync.Mutex
24import kotlinx.coroutines.sync.withLock
25import kotlinx.coroutines.withTimeoutOrNull
26import uk.co.caprica.vlcj.factory.MediaPlayerFactory
27import uk.co.caprica.vlcj.media.MediaRef
28import uk.co.caprica.vlcj.medialist.MediaList
29import uk.co.caprica.vlcj.player.base.MediaPlayer
30import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
31import uk.co.caprica.vlcj.player.base.State
32import uk.co.caprica.vlcj.player.list.MediaListPlayer
33import uk.co.caprica.vlcj.player.list.MediaListPlayerEventAdapter
34import uk.co.caprica.vlcj.player.list.PlaybackMode
35import java.util.concurrent.atomic.AtomicInteger
36import kotlin.math.roundToInt
37import kotlin.time.Duration
38import kotlin.time.Duration.Companion.milliseconds
39
40/**
41 * Desktop Renderer backed by libvlc via VLCJ.
42 *
43 * Design notes:
44 * - `MediaListPlayer` plays the queue and advances to the next item on its own.
45 * - We keep our own [MediaList] reference because in VLCJ 4.x, `MediaListPlayer.list()`
46 * is read-only — mutation (add/remove) happens on the MediaList we own and then we
47 * push a fresh `MediaListRef` into the player.
48 * - **Our `queueIndex` is the source of truth, not libvlc's.** libvlc's list player
49 * remembers the playing item as a plain index ("path") that only changes when it
50 * plays something; list edits (`remove`/`insert`/`set_media_list`) don't move it
51 * (libvlc 3.0 `media_list_player.c`: `mlist_item_deleted` is a no-op and
52 * `set_media_list` keeps `current_playing_item_path`). After a reorder that moves
53 * the current track, libvlc's own auto-advance can pick the wrong next track, or
54 * stop early. So: every explicit jump goes through [playAtLocked] (`play(index)`),
55 * Next/Previous are computed from our index, and [onLibvlcMediaChanged] /
56 * [scheduleAdvanceCheck] put libvlc back on our track when its auto-advance
57 * disagrees. That lets reorders (drag, shuffle on/off) leave the current track
58 * playing instead of restarting it.
59 * - Index tracking uses `MediaPlayer.mediaChanged` and `MediaListPlayer.nextItem`
60 * (both fire for one change; [onLibvlcMediaChanged] de-duplicates them).
61 * - PlaybackState semantics mirror the Android Media3 `LocalRenderer`: `isStopped`
62 * vs paused-mid-track, enqueue-into-empty-queue does NOT auto-play, playNext into
63 * empty queue DOES auto-play.
64 * - **Pause is `setPause(true)`, never `pause()`.** vlcj `controls().pause()` is
65 * libvlc's *toggle*: pausing an already-paused player resumed it (MPRIS/UPnP
66 * `Pause` twice, the coordinator's double pause when switching to cast, Stop after
67 * Pause). And libvlc's `set_pause(1)` *stops* the input when it can't pause yet
68 * (still opening, or a live stream): `if (can_pause) PAUSE else input_Stop()`.
69 * So a "prepared but not started" track (session restore, `load(autoplay=false)`,
70 * Stop) is kept as [pendingStart] — libvlc isn't started at all until Play, and
71 * the saved position is applied on the first `playing` event.
72 * - Unplayable tracks (Android PlaybackService rule, Android parity, 2026-10-29): libvlc's
73 * list player only advances on EndReached, so an error leaves the queue stuck.
74 * We skip to the next track with a message, and stop after
75 * [MAX_CONSECUTIVE_ERRORS] failures in a row (reset once something plays).
76 */
77class VlcjLocalRenderer(
78 private val settings: dev.stave.data.SettingsStore,
79 private val uiEvents: UiEvents,
80) : Renderer {
81
82 private val factory: MediaPlayerFactory = MediaPlayerFactory(
83 "--no-video",
84 "--quiet",
85 "--intf=dummy",
86 )
87
88 private val listPlayer: MediaListPlayer = factory.mediaPlayers().newMediaListPlayer()
89 private val mediaPlayer: MediaPlayer = factory.mediaPlayers().newMediaPlayer().also {
90 listPlayer.mediaPlayer().setMediaPlayer(it)
91 }
92
93 // We own the MediaList so we can mutate it directly (add/remove). After mutation we
94 // re-push a fresh MediaListRef to the player via attachMediaList().
95 private var currentMediaList: MediaList? = null
96
97 private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
98 private val _state = MutableStateFlow(PlaybackState())
99 override val state: StateFlow<PlaybackState> = _state.asStateFlow()
100
101 /** Serialises every public operation (UI, MPRIS, the LAN renderer service, the
102 * coordinator and our own error/advance corrections all call in from different
103 * threads). Bodies never suspend while holding it. */
104 private val opLock = Mutex()
105 @Volatile private var released = false
106
107 @Volatile private var queueTracks: List<DidlItem.Track> = emptyList()
108 /** Saved copy of queueTracks at the moment shuffle was enabled, so we can restore
109 * the original order when shuffle is turned off. Null when shuffle is off. */
110 private var preShuffleSnapshot: List<DidlItem.Track>? = null
111 private var tickerJob: Job? = null
112 private var loggedMediaChanged: Boolean = false
113
114 /** A track that is selected but deliberately NOT started in libvlc (see class doc). */
115 private data class PendingStart(val index: Int, val positionMs: Long)
116 @Volatile private var pendingStart: PendingStart? = null
117 /** Position to jump to once libvlc reports `playing` for the track we just started. */
118 @Volatile private var seekOnPlayingMs: Long = 0L
119
120 /** Bookkeeping for [onLibvlcMediaChanged]: what we last asked libvlc to play, and
121 * the last media change libvlc reported. */
122 @Volatile private var lastRequestedIndex: Int = -1
123 @Volatile private var lastRequestAtMs: Long = 0L
124 private val changeLock = Any()
125 private var lastChangeMrl: String? = null
126 @Volatile private var lastChangeAtMs: Long = 0L
127
128 private val consecutiveErrors = AtomicInteger(0)
129
130 /** The libvlc volume last applied (user slider × ReplayGain), re-applied on each
131 * `playing` event because some audio outputs (PulseAudio) reset the stream volume
132 * when a new stream starts. */
133 @Volatile private var appliedVolume: Int = 100
134
135 init {
136 mediaPlayer.events().addMediaPlayerEventListener(object : MediaPlayerEventAdapter() {
137 override fun opening(mp: MediaPlayer) {
138 _state.update { it.copy(isBuffering = true) }
139 }
140 override fun playing(mp: MediaPlayer) {
141 consecutiveErrors.set(0)
142 _state.update { it.copy(isPlaying = true, isBuffering = false, isStopped = false, error = null) }
143 val seek = seekOnPlayingMs
144 val vol = appliedVolume
145 // Never call back into libvlc from its own event thread; vlcj's submit()
146 // runs these on the player's executor.
147 mp.submit {
148 runCatching { mp.audio().setVolume(vol) }
149 if (seek > 0L) {
150 runCatching { mp.controls().setTime(seek) }
151 seekOnPlayingMs = 0L
152 }
153 }
154 }
155 override fun paused(mp: MediaPlayer) {
156 _state.update { it.copy(isPlaying = false, isBuffering = false) }
157 }
158 override fun stopped(mp: MediaPlayer) {
159 _state.update { it.copy(isPlaying = false, isBuffering = false) }
160 }
161 override fun buffering(mp: MediaPlayer, newCache: Float) {
162 _state.update { it.copy(isBuffering = newCache < 100f) }
163 }
164 override fun finished(mp: MediaPlayer) {
165 // The last item playing out IS stopped (Android C.2 rule).
166 val s = _state.value
167 val atEnd = autoNextIndex(s.queueIndex, s.repeatMode) == null
168 _state.update { it.copy(isPlaying = false, isBuffering = false, isStopped = atEnd || it.isStopped) }
169 if (!atEnd) scheduleAdvanceCheck(s.queueIndex)
170 }
171 override fun lengthChanged(mp: MediaPlayer, newLength: Long) {
172 _state.update { it.copy(duration = newLength.milliseconds) }
173 }
174 override fun error(mp: MediaPlayer) {
175 val s = _state.value
176 val title = s.current?.title?.takeIf { it.isNotBlank() }
177 _state.update {
178 it.copy(
179 isPlaying = false,
180 isBuffering = false,
181 error = if (title != null) "Couldn't play ‘$title’" else "Couldn't play this track",
182 )
183 }
184 val failedIndex = s.queueIndex
185 val errorAt = System.currentTimeMillis()
186 scope.launch { onPlaybackError(failedIndex, title, errorAt) }
187 }
188
189 /**
190 * Fires when the MediaListPlayer swaps in a new track. The player's own
191 * `media().info().mrl()` is the most portable way to get the new URL across
192 * VLCJ 4.x minor versions. We canonicalize both sides before comparing —
193 * libvlc may re-encode percent escapes on the URL we hand in, so raw `==`
194 * on the strings was returning -1 and leaving state frozen while VLC kept
195 * advancing through tracks.
196 */
197 override fun mediaChanged(mp: MediaPlayer, media: MediaRef) {
198 if (!loggedMediaChanged) {
199 System.err.println("[Earhorn] mediaChanged event fired at least once.")
200 loggedMediaChanged = true
201 }
202 val mrl = runCatching { mp.media().info().mrl() }.getOrNull() ?: return
203 onLibvlcMediaChanged(mrl)
204 }
205 })
206
207 // MediaListPlayer events — fire alongside mediaChanged when the list player
208 // swaps items (version-dependent in VLCJ 4.x). Listen to both so at least one
209 // path works; onLibvlcMediaChanged de-duplicates.
210 listPlayer.events().addMediaListPlayerEventListener(object : MediaListPlayerEventAdapter() {
211 override fun nextItem(listPlayer: MediaListPlayer, item: MediaRef) {
212 val mrl = runCatching {
213 item.newMedia()?.let { m -> try { m.info().mrl() } finally { m.release() } }
214 }.getOrNull()
215 ?: runCatching { mediaPlayer.media().info().mrl() }.getOrNull()
216 if (mrl == null) {
217 System.err.println("[Earhorn] nextItem fired but MRL was null")
218 return
219 }
220 onLibvlcMediaChanged(mrl)
221 }
222 })
223
224 startTicker()
225 startVolumeCollector()
226 }
227
228 // ---- libvlc → our index ---------------------------------------------------------------
229
230 /**
231 * libvlc reports it is now on [mrl]. Work out which queue row that is and keep our
232 * index authoritative:
233 * 1. Right after our own `play(index)`: accept it if it is the row we asked for;
234 * otherwise it's a stale event from a superseded request — ignore it.
235 * 2. Otherwise it's libvlc's own auto-advance. We expect [autoNextIndex]. If the
236 * MRL is that row (duplicates allowed: we pick the expected one, not the first
237 * copy — P2-6), move there. If libvlc picked something else (its index went
238 * stale after a reorder), put it on our track. If we expected the end of the
239 * queue, stop.
240 */
241 private fun onLibvlcMediaChanged(mrl: String) {
242 val now = System.currentTimeMillis()
243 val target = canonicalMrl(mrl)
244 synchronized(changeLock) {
245 // mediaChanged and nextItem both fire for one change.
246 if (target == lastChangeMrl && now - lastChangeAtMs < DUPLICATE_EVENT_WINDOW_MS) return
247 lastChangeMrl = target
248 lastChangeAtMs = now
249
250 val tracks = queueTracks
251 var matches = tracks.indices.filter { canonicalMrl(tracks[it].resourceUrl) == target }
252 if (matches.isEmpty()) {
253 val tail = target.substringAfterLast('/')
254 matches = tracks.indices.filter {
255 canonicalMrl(tracks[it].resourceUrl).substringAfterLast('/') == tail
256 }
257 }
258 if (matches.isEmpty()) {
259 System.err.println("[Earhorn] no queue match for MRL=$mrl (target=$target)")
260 return
261 }
262 val s = _state.value
263 val cur = s.queueIndex
264
265 if (now - lastRequestAtMs < EXPLICIT_REQUEST_WINDOW_MS) {
266 val req = lastRequestedIndex
267 if (req in matches && req != cur) moveStateTo(req, 0L)
268 return
269 }
270
271 val expected = autoNextIndex(cur, s.repeatMode)
272 when {
273 expected != null && expected in matches -> moveStateTo(expected, 0L)
274 expected != null -> {
275 System.err.println("[Earhorn] libvlc advanced to the wrong row; correcting to $expected")
276 moveStateTo(expected, 0L)
277 scope.launch {
278 locked {
279 if (_state.value.queueIndex == expected && lastRequestAtMs < now) {
280 playAtLocked(expected)
281 }
282 }
283 }
284 }
285 else -> {
286 // We think the queue has ended, libvlc thinks otherwise.
287 scope.launch {
288 locked {
289 if (lastRequestAtMs < now && _state.value.queueIndex == cur) {
290 runCatching { listPlayer.controls().stop() }
291 _state.update { it.copy(isPlaying = false, isBuffering = false, isStopped = true) }
292 }
293 }
294 }
295 }
296 }
297 }
298 }
299
300 /** After a track finished: if libvlc didn't move on by itself (its index went past
301 * the end of a list we reordered), play our next track. */
302 private fun scheduleAdvanceCheck(finishedIndex: Int) {
303 val finishedAt = System.currentTimeMillis()
304 scope.launch {
305 delay(ADVANCE_CHECK_DELAY_MS)
306 locked {
307 val s = _state.value
308 if (s.queueIndex != finishedIndex || pendingStart != null) return@locked
309 if (lastChangeAtMs >= finishedAt || lastRequestAtMs >= finishedAt) return@locked
310 val next = autoNextIndex(finishedIndex, s.repeatMode) ?: return@locked
311 playAtLocked(next)
312 }
313 }
314 }
315
316 /** Where playback goes when the current track ends (repeat-aware). */
317 private fun autoNextIndex(cur: Int, mode: RepeatMode): Int? {
318 val last = queueTracks.lastIndex
319 if (last < 0 || cur < 0) return null
320 return when {
321 mode == RepeatMode.ONE -> cur
322 cur + 1 <= last -> cur + 1
323 mode == RepeatMode.ALL -> 0
324 else -> null
325 }
326 }
327
328 /** Where Next/skip goes. Repeat ONE doesn't trap Next (Media3 navigation rule). */
329 private fun manualNextIndex(cur: Int, mode: RepeatMode): Int? {
330 val last = queueTracks.lastIndex
331 if (last < 0) return null
332 return when {
333 cur < 0 -> 0
334 cur + 1 <= last -> cur + 1
335 mode == RepeatMode.ALL -> 0
336 else -> null
337 }
338 }
339
340 private fun moveStateTo(index: Int, positionMs: Long) {
341 val t = queueTracks.getOrNull(index)
342 _state.update {
343 it.copy(
344 queueIndex = index,
345 current = t,
346 position = positionMs.milliseconds,
347 duration = t?.duration ?: Duration.ZERO,
348 )
349 }
350 }
351
352 private suspend inline fun locked(block: () -> Unit) {
353 opLock.withLock { if (!released) block() }
354 }
355
356 // ---- Unplayable tracks (Team 2 #25) ---------------------------------------------------
357
358 private suspend fun onPlaybackError(failedIndex: Int, title: String?, errorAtMs: Long) {
359 // libvlc may still be tearing the failed input down; also lets an auto-advance
360 // or a user action that raced the error win.
361 delay(ERROR_SKIP_GRACE_MS)
362 locked {
363 val name = title?.let { "‘$it’" } ?: "a track"
364 val n = consecutiveErrors.incrementAndGet()
365 if (n >= MAX_CONSECUTIVE_ERRORS) {
366 consecutiveErrors.set(0)
367 stopLocked()
368 uiEvents.post("Playback stopped — $MAX_CONSECUTIVE_ERRORS tracks in a row couldn't be played.")
369 return@locked
370 }
371 val s = _state.value
372 val movedOn = s.queueIndex != failedIndex || pendingStart != null ||
373 lastChangeAtMs > errorAtMs || lastRequestAtMs > errorAtMs
374 val next = if (failedIndex in queueTracks.indices) manualNextIndex(failedIndex, s.repeatMode) else null
375 if (movedOn || next == null) {
376 if (next == null) consecutiveErrors.set(0)
377 uiEvents.post("Couldn't play $name.")
378 return@locked
379 }
380 uiEvents.post("Couldn't play $name — skipped.")
381 playAtLocked(next)
382 }
383 }
384
385 // ---- Ticker / volume --------------------------------------------------------------------
386
387 private fun startTicker() {
388 tickerJob?.cancel()
389 tickerJob = scope.launch {
390 while (true) {
391 // Sleep while nothing plays (the maintainer's I.39 rule): the old
392 // loop read VLC twenty times a second for the life of the app.
393 if (!_state.value.isPlaying) { delay(1_000); continue }
394 // While a start position is still being applied, libvlc reports ~0;
395 // keep showing the target instead of flashing 0:00.
396 if (seekOnPlayingMs <= 0L) {
397 val ms = runCatching { mediaPlayer.status().time() }.getOrDefault(-1L)
398 if (ms >= 0L) {
399 _state.update { it.copy(position = ms.milliseconds) }
400 }
401 }
402 // 50ms ticker for tight lyric-highlight sync. libvlc's status().time()
403 // call is cheap and Compose doesn't recompose faster than the display's
404 // refresh rate anyway. Going lower than this yields diminishing returns
405 // (Compose coalesces recompositions) while going higher introduces
406 // perceptible lag on short lyric phrases.
407 delay(50)
408 }
409 }
410 }
411
412 /**
413 * Applies the libvlc volume = the user's slider × the ReplayGain factor.
414 *
415 * The slider ([PlaybackState.volumePercent], 0..100) is the ceiling. ReplayGain only
416 * attenuates relative to it: the RG factor (gain + pre-amp, in dB → linear) is
417 * clamped to ≤ 1, exactly as Android clamps Media3's player volume to [0, 1]. The
418 * old code set libvlc to `linear*100` and ignored the slider, so at 20 % with
419 * +3 dB pre-amp and a −2 dB track the player went to 112 % — several times louder
420 * than the slider — on every track change, and the next setVolume dropped RG
421 * again (Team 3 P1-2). With RG OFF the factor is 1, so libvlc follows the slider.
422 *
423 * Recomputed whenever the track, the slider, the RG mode or the pre-amp changes.
424 */
425 private fun startVolumeCollector() {
426 scope.launch {
427 combine(
428 _state.map { it.current }.distinctUntilChanged { a, b -> a?.id == b?.id },
429 _state.map { it.volumePercent }.distinctUntilChanged(),
430 settings.replayGainMode,
431 settings.replayGainPreAmpDb,
432 ) { track, userVolume, mode, preAmp ->
433 val factor = replayGainFactor(track, mode, preAmp)
434 (userVolume * factor).roundToInt().coerceIn(0, 100)
435 }.distinctUntilChanged().collect { v ->
436 appliedVolume = v
437 if (!released) runCatching { mediaPlayer.audio().setVolume(v) }
438 }
439 }
440 }
441
442 /**
443 * ReplayGain multiplier in [0, 1] for [track]. 1 (no change) when RG is OFF or
444 * there is no track. Pre-amp still applies to untagged tracks (a user preference
445 * independent of tags), matching Android.
446 *
447 * Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN from the track's extras map
448 * (case-insensitively — tag keys vary across containers). ALBUM mode falls back to
449 * TRACK when the album tag is absent, so users who chose ALBUM don't hit unity
450 * volume on half their library just because some files only have per-track tags.
451 */
452 private fun replayGainFactor(
453 track: DidlItem.Track?,
454 mode: dev.stave.data.ReplayGainMode,
455 preAmpDb: Float,
456 ): Double {
457 if (mode == dev.stave.data.ReplayGainMode.OFF || track == null) return 1.0
458 val key = when (mode) {
459 dev.stave.data.ReplayGainMode.TRACK -> "REPLAYGAIN_TRACK_GAIN"
460 dev.stave.data.ReplayGainMode.ALBUM -> "REPLAYGAIN_ALBUM_GAIN"
461 dev.stave.data.ReplayGainMode.OFF -> return 1.0
462 }
463 val rgGainRaw = track.extras.entries
464 .firstOrNull { it.key.equals(key, ignoreCase = true) }
465 ?.value
466 ?: track.extras.entries
467 .firstOrNull { it.key.equals("REPLAYGAIN_TRACK_GAIN", ignoreCase = true) }
468 ?.value
469 val rgGainDb = rgGainRaw?.let { parseRgGain(it) } ?: 0f
470 val totalDb = rgGainDb + preAmpDb
471 val linear = Math.pow(10.0, totalDb / 20.0)
472 return when {
473 linear.isNaN() || linear.isInfinite() -> 1.0
474 else -> linear.coerceIn(0.0, 1.0)
475 }
476 }
477
478 /** Parse an RG tag like "-7.22 dB", "-7.22dB", "-7.22", "+3.5 dB". Returns 0f if
479 * unparseable — tag authors get creative, we stay defensive. */
480 private fun parseRgGain(raw: String): Float {
481 val match = Regex("""[+-]?\d+(?:\.\d+)?""").find(raw.trim())
482 return match?.value?.toFloatOrNull() ?: 0f
483 }
484
485 // ---- libvlc list plumbing ---------------------------------------------------------------
486
487 /** Push the currently-owned MediaList into the MediaListPlayer via a ref. */
488 private fun attachMediaList(list: MediaList) {
489 val ref = list.newMediaListRef()
490 try {
491 listPlayer.list().setMediaList(ref)
492 } finally {
493 ref.release()
494 }
495 }
496
497 /** Replace libvlc's list. Does NOT stop what is playing: `set_media_list` leaves
498 * the media player alone (its index goes stale, which [onLibvlcMediaChanged]
499 * handles). */
500 private fun rebuildMediaList(tracks: List<DidlItem.Track>) {
501 currentMediaList?.release()
502 val list = factory.media().newMediaList()
503 tracks.forEach { list.media().add(it.resourceUrl) }
504 currentMediaList = list
505 attachMediaList(list)
506 // NB: do NOT reset the playback mode here — repeat settings (LOOP/REPEAT) must
507 // survive queue mutations so an enqueue or reorder doesn't silently turn off
508 // repeat-all that the user enabled earlier.
509 }
510
511 /** Start [index] in libvlc, optionally at [startMs]. The only place that calls
512 * `listPlayer.play(index)`. State is updated first (P2-6). */
513 private fun playAtLocked(index: Int, startMs: Long = 0L) {
514 if (index !in queueTracks.indices) return
515 pendingStart = null
516 seekOnPlayingMs = startMs.coerceAtLeast(0L)
517 moveStateTo(index, startMs.coerceAtLeast(0L))
518 _state.update { it.copy(isStopped = false, error = null) }
519 lastRequestedIndex = index
520 lastRequestAtMs = System.currentTimeMillis()
521 listPlayer.controls().play(index)
522 // play() can block while libvlc tears the previous input down; the media
523 // change event it queued must still count as ours.
524 lastRequestAtMs = System.currentTimeMillis()
525 }
526
527 /** Stop libvlc and keep [index] selected, to start at [positionMs] on Play. */
528 private fun parkLocked(index: Int, positionMs: Long) {
529 runCatching { listPlayer.controls().stop() }
530 seekOnPlayingMs = 0L
531 pendingStart = if (index in queueTracks.indices) PendingStart(index, positionMs.coerceAtLeast(0L)) else null
532 _state.update { it.copy(isPlaying = false, isBuffering = false, position = positionMs.coerceAtLeast(0L).milliseconds) }
533 }
534
535 /** Same semantics as Android LocalRenderer.stop(): the next Play restarts the
536 * current track from 0. isStopped flips so the NowPlayingBar hides. A real libvlc
537 * stop (releases the stream), not a pause toggle (Team 3 P1-1). */
538 private fun stopLocked() {
539 parkLocked(_state.value.queueIndex, 0L)
540 _state.update { it.copy(isStopped = true, position = Duration.ZERO) }
541 }
542
543 private fun libvlcIsActive(): Boolean = when (runCatching { mediaPlayer.status().state() }.getOrNull()) {
544 State.OPENING, State.BUFFERING, State.PLAYING -> true
545 else -> false
546 }
547
548 // ---- Queue load / mutation -------------------------------------------------------------
549
550 override suspend fun load(tracks: List<DidlItem.Track>, startIndex: Int, autoplay: Boolean) = locked {
551 consecutiveErrors.set(0)
552 if (!autoplay || tracks.isEmpty()) runCatching { listPlayer.controls().stop() }
553 queueTracks = tracks
554 preShuffleSnapshot = if (_state.value.shuffleEnabled) tracks else null
555 rebuildMediaList(tracks)
556 pendingStart = null
557 seekOnPlayingMs = 0L
558 val clamped = startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0))
559 _state.update {
560 it.copy(
561 queue = tracks,
562 queueIndex = if (tracks.isNotEmpty()) clamped else -1,
563 current = tracks.getOrNull(clamped),
564 position = Duration.ZERO,
565 duration = tracks.getOrNull(clamped)?.duration ?: Duration.ZERO,
566 isStopped = tracks.isEmpty(),
567 isPlaying = false, // goes true on the playing event (if autoplay)
568 isBuffering = false,
569 error = null,
570 )
571 }
572 if (tracks.isNotEmpty()) {
573 if (autoplay) {
574 playAtLocked(clamped)
575 } else {
576 // Session restore / migrate-to-local without autoplay: select the
577 // track but don't start libvlc. play()+setPause(true) raced the
578 // demuxer: before it reports can_pause, libvlc's set_pause *stops* the
579 // input, which lost the restored position (Team 3 P2-8). seekTo()
580 // while pending just moves the start position.
581 pendingStart = PendingStart(clamped, 0L)
582 }
583 }
584 }
585
586 override suspend fun enqueue(tracks: List<DidlItem.Track>) = locked {
587 if (tracks.isEmpty()) return@locked
588 if (queueTracks.isEmpty()) {
589 // Seed the queue but do NOT auto-play — matches Android LocalRenderer.enqueue.
590 runCatching { listPlayer.controls().stop() }
591 queueTracks = tracks
592 rebuildMediaList(tracks)
593 pendingStart = PendingStart(0, 0L)
594 _state.update {
595 it.copy(
596 queue = queueTracks,
597 queueIndex = 0,
598 current = queueTracks.firstOrNull(),
599 position = Duration.ZERO,
600 duration = queueTracks.firstOrNull()?.duration ?: Duration.ZERO,
601 isStopped = true,
602 )
603 }
604 } else {
605 val newQueue = queueTracks + tracks
606 queueTracks = newQueue
607 val list = currentMediaList
608 if (list != null) {
609 tracks.forEach { t -> list.media().add(t.resourceUrl) }
610 }
611 _state.update { it.copy(queue = newQueue) }
612 }
613 }
614
615 override suspend fun playNext(tracks: List<DidlItem.Track>) = locked {
616 if (tracks.isEmpty()) return@locked
617 if (queueTracks.isEmpty()) {
618 // Empty queue + "play next" ≡ "play now". Auto-start.
619 queueTracks = tracks
620 rebuildMediaList(tracks)
621 _state.update { it.copy(queue = tracks) }
622 playAtLocked(0)
623 } else {
624 val current = _state.value.queueIndex.coerceAtLeast(0)
625 val insertAt = (current + 1).coerceAtMost(queueTracks.size)
626 val newQueue = queueTracks.toMutableList().apply { addAll(insertAt, tracks) }
627 queueTracks = newQueue
628 val list = currentMediaList
629 if (list != null) {
630 tracks.forEachIndexed { i, t -> list.media().insert(insertAt + i, t.resourceUrl) }
631 }
632 _state.update { it.copy(queue = newQueue) }
633 }
634 }
635
636 override suspend fun skipToQueueItem(index: Int) = locked {
637 if (index !in queueTracks.indices) return@locked
638 playAtLocked(index)
639 }
640
641 override suspend fun removeFromQueue(index: Int) = locked {
642 if (index !in queueTracks.indices) return@locked
643 val cur = _state.value.queueIndex
644 val wasCurrent = index == cur
645 val wasActive = wasCurrent && pendingStart == null && libvlcIsActive()
646 val newQueue = queueTracks.toMutableList().apply { removeAt(index) }
647 queueTracks = newQueue
648 runCatching { currentMediaList?.media()?.remove(index) }
649
650 if (newQueue.isEmpty()) {
651 runCatching { listPlayer.controls().stop() }
652 pendingStart = null
653 _state.update {
654 it.copy(
655 queue = emptyList(),
656 queueIndex = -1,
657 current = null,
658 isStopped = true,
659 isPlaying = false,
660 isBuffering = false,
661 position = Duration.ZERO,
662 )
663 }
664 } else if (wasCurrent) {
665 val newIndex = index.coerceAtMost(newQueue.lastIndex)
666 _state.update { it.copy(queue = newQueue) }
667 if (wasActive) {
668 playAtLocked(newIndex)
669 } else {
670 // Removing the paused/stopped track must not start the next one
671 // (Team 3 P3; CastRenderer uses autoplay = isPlaying the same way).
672 val wasStopped = _state.value.isStopped
673 parkLocked(newIndex, 0L)
674 moveStateTo(newIndex, 0L)
675 _state.update { it.copy(isStopped = wasStopped) }
676 }
677 } else {
678 val newCurrentIndex = if (cur >= 0 && index < cur) cur - 1 else cur
679 pendingStart?.let { p -> if (index < p.index) pendingStart = p.copy(index = p.index - 1) }
680 _state.update { it.copy(queue = newQueue, queueIndex = newCurrentIndex) }
681 }
682 }
683
684 override suspend fun moveInQueue(from: Int, to: Int) = locked {
685 if (from !in queueTracks.indices || to !in queueTracks.indices || from == to) return@locked
686
687 val cur = _state.value.queueIndex
688 val moved = queueTracks.toMutableList().apply {
689 val item = removeAt(from)
690 add(to, item)
691 }
692 queueTracks = moved
693 // Track the current row by position, not by URL, so duplicates stay right;
694 // with nothing current (-1) nothing becomes current (Team 3 P3).
695 val newCurrentIndex = when {
696 cur < 0 -> cur
697 from == cur -> to
698 from < cur && to >= cur -> cur - 1
699 from > cur && to <= cur -> cur + 1
700 else -> cur
701 }
702
703 // Update libvlc's MediaList incrementally rather than rebuilding from
704 // scratch, and never restart: libvlc's player keeps playing its current
705 // Media through list edits (even when that Media's own row is moved), and
706 // our index is authoritative for what comes next (see class doc).
707 val list = currentMediaList
708 if (list != null) {
709 runCatching {
710 list.media().remove(from)
711 list.media().insert(to, moved[to].resourceUrl)
712 }
713 } else {
714 rebuildMediaList(moved)
715 }
716 pendingStart?.let { p -> pendingStart = p.copy(index = newCurrentIndex) }
717 _state.update { it.copy(queue = moved, queueIndex = newCurrentIndex) }
718 }
719
720 override suspend fun clearQueue() = locked {
721 runCatching { listPlayer.controls().stop() }
722 runCatching { currentMediaList?.media()?.clear() }
723 queueTracks = emptyList()
724 preShuffleSnapshot = if (_state.value.shuffleEnabled) emptyList() else null
725 pendingStart = null
726 seekOnPlayingMs = 0L
727 _state.update {
728 it.copy(
729 queue = emptyList(),
730 queueIndex = -1,
731 current = null,
732 isPlaying = false,
733 isBuffering = false,
734 isStopped = true,
735 position = Duration.ZERO,
736 duration = Duration.ZERO,
737 )
738 }
739 }
740
741 // ---- Transport controls ----------------------------------------------------------------
742
743 override suspend fun play() = locked {
744 if (queueTracks.isEmpty()) return@locked
745 val p = pendingStart
746 val idx = _state.value.queueIndex
747 when {
748 p != null -> playAtLocked(p.index, p.positionMs)
749 idx !in queueTracks.indices -> playAtLocked(0)
750 else -> when (runCatching { mediaPlayer.status().state() }.getOrNull()) {
751 State.PAUSED, State.PLAYING, State.OPENING, State.BUFFERING -> {
752 mediaPlayer.controls().setPause(false)
753 _state.update { it.copy(isStopped = false) }
754 }
755 // Stopped / ended / error / nothing loaded: (re)start our current
756 // row. listPlayer.play() would start libvlc's own idea of the current
757 // item, which is row 0 after a list-player stop.
758 else -> playAtLocked(idx)
759 }
760 }
761 }
762
763 /** Idempotent: pausing a paused player keeps it paused (Team 3 P1-1). */
764 override suspend fun pause() = locked {
765 if (pendingStart != null) return@locked
766 if (!libvlcIsActive()) return@locked
767 if (runCatching { mediaPlayer.status().canPause() }.getOrDefault(false)) {
768 mediaPlayer.controls().setPause(true)
769 } else {
770 // Still opening, or a stream that can't pause: libvlc's set_pause would
771 // stop it anyway. Park it ourselves so Play resumes at the same place.
772 val pos = if (seekOnPlayingMs > 0L) seekOnPlayingMs
773 else runCatching { mediaPlayer.status().time() }.getOrDefault(0L)
774 parkLocked(_state.value.queueIndex, pos)
775 }
776 }
777
778 override suspend fun stop() = locked { stopLocked() }
779
780 override suspend fun seekTo(position: Duration) = locked {
781 val ms = position.inWholeMilliseconds.coerceAtLeast(0L)
782 val p = pendingStart
783 val idx = _state.value.queueIndex
784 val vlcState = runCatching { mediaPlayer.status().state() }.getOrNull()
785 when {
786 p != null -> pendingStart = p.copy(positionMs = ms)
787 // Started but not playing yet: libvlc may ignore a seek now; apply it
788 // on the playing event instead.
789 !_state.value.isPlaying && (vlcState == State.OPENING || vlcState == State.BUFFERING) ->
790 seekOnPlayingMs = ms
791 vlcState == State.PLAYING || vlcState == State.PAUSED || vlcState == State.BUFFERING ->
792 mediaPlayer.controls().setTime(ms)
793 // Stopped / ended / failed: remember it, Play starts there.
794 idx in queueTracks.indices -> pendingStart = PendingStart(idx, ms)
795 else -> return@locked
796 }
797 // The ticker sleeps while paused; show the new position right away.
798 _state.update { it.copy(position = ms.milliseconds) }
799 }
800
801 override suspend fun next() = locked {
802 val s = _state.value
803 val n = manualNextIndex(s.queueIndex, s.repeatMode) ?: return@locked
804 playAtLocked(n)
805 }
806
807 override suspend fun previous() = locked {
808 val s = _state.value
809 val cur = s.queueIndex
810 val prev = when {
811 queueTracks.isEmpty() -> return@locked
812 cur - 1 >= 0 -> cur - 1
813 s.repeatMode == RepeatMode.ALL -> queueTracks.lastIndex
814 else -> -1
815 }
816 if (prev >= 0) {
817 playAtLocked(prev)
818 } else {
819 // First track, no wrap: back to its start.
820 val p = pendingStart
821 if (p != null) pendingStart = p.copy(positionMs = 0L)
822 else runCatching { mediaPlayer.controls().setTime(0L) }
823 _state.update { it.copy(position = Duration.ZERO) }
824 }
825 }
826
827 /** The slider value. libvlc's volume is applied by [startVolumeCollector]
828 * (slider × ReplayGain), so RG is never dropped by a slider move. */
829 override suspend fun setVolume(volumePercent: Int) = locked {
830 val clamped = volumePercent.coerceIn(0, 100)
831 _state.update { it.copy(volumePercent = clamped) }
832 }
833
834 override suspend fun setRepeatMode(mode: RepeatMode) = locked {
835 val vlcMode = when (mode) {
836 RepeatMode.OFF -> PlaybackMode.DEFAULT
837 RepeatMode.ALL -> PlaybackMode.LOOP
838 RepeatMode.ONE -> PlaybackMode.REPEAT
839 }
840 listPlayer.controls().setMode(vlcMode)
841 _state.update { it.copy(repeatMode = mode) }
842 }
843
844 override suspend fun setShuffle(enabled: Boolean) = locked {
845 if (enabled == _state.value.shuffleEnabled) return@locked
846 val cur = _state.value.queueIndex
847
848 if (enabled) {
849 // Save the pre-shuffle order so we can restore it on disable.
850 preShuffleSnapshot = queueTracks.toList()
851
852 if (cur !in queueTracks.indices) {
853 val newOrder = queueTracks.shuffled()
854 queueTracks = newOrder
855 rebuildMediaList(newOrder)
856 _state.update { it.copy(queue = newOrder, shuffleEnabled = true) }
857 return@locked
858 }
859 // Shuffle everything *after* the current track; the head (and so libvlc's
860 // current row) is untouched, so only the tail is replaced in libvlc's list
861 // and the current track keeps playing (Team 3 P2-7).
862 val head = queueTracks.subList(0, cur + 1)
863 val tail = queueTracks.subList(cur + 1, queueTracks.size).shuffled()
864 val newOrder = head + tail
865 val list = currentMediaList
866 if (list != null) {
867 runCatching {
868 for (i in queueTracks.lastIndex downTo cur + 1) list.media().remove(i)
869 tail.forEach { list.media().add(it.resourceUrl) }
870 }
871 } else {
872 rebuildMediaList(newOrder)
873 }
874 queueTracks = newOrder
875 _state.update { it.copy(queue = newOrder, shuffleEnabled = true) }
876 } else {
877 // Back to the original order, but keep what changed while shuffled:
878 // removed tracks stay removed, added tracks stay (at the end) (Team 3 P1-11).
879 val snapshot = preShuffleSnapshot
880 preShuffleSnapshot = null
881 if (snapshot == null) {
882 _state.update { it.copy(shuffleEnabled = false) }
883 return@locked
884 }
885 val order = restorePreShuffleOrder(snapshot, queueTracks)
886 val newOrder = order.map { queueTracks[it] }
887 val newIndex = if (cur >= 0) order.indexOf(cur) else cur
888 queueTracks = newOrder
889 // Rebuilding the list doesn't interrupt the current track (see
890 // rebuildMediaList); no play(newIndex), so no restart from 0:00.
891 rebuildMediaList(newOrder)
892 pendingStart?.let { p -> pendingStart = p.copy(index = newIndex) }
893 _state.update {
894 it.copy(
895 queue = newOrder,
896 queueIndex = newIndex,
897 current = newOrder.getOrNull(newIndex),
898 shuffleEnabled = false,
899 )
900 }
901 }
902 }
903
904 /**
905 * Canonicalize an MRL for comparison. libvlc may hand us back a URL with different
906 * percent-encoding than what we gave it (e.g. we pass `file with space.flac`, it
907 * returns `file%20with%20space.flac`, or vice versa). We canonicalize by:
908 * 1. Parsing as a URI so percent-decoding happens on path / query components
909 * 2. Rebuilding with scheme/host/port/decoded-path/raw-query
910 * If the input isn't a valid URI (shouldn't happen for HTTP MRLs but could for
911 * `file://` paths with unusual characters), fall back to the raw string.
912 */
913 private fun canonicalMrl(s: String): String {
914 return runCatching {
915 val uri = java.net.URI(s)
916 buildString {
917 uri.scheme?.let { append(it).append("://") }
918 uri.host?.let { append(it) }
919 if (uri.port >= 0) append(":").append(uri.port)
920 // getRawPath returns the percent-encoded path; URLDecoder gives the
921 // fully decoded form for comparison.
922 uri.rawPath?.let {
923 append(java.net.URLDecoder.decode(it, "UTF-8"))
924 }
925 uri.rawQuery?.let { append("?").append(it) }
926 }
927 }.getOrDefault(s)
928 }
929
930 // ---- Lifecycle -------------------------------------------------------------------------
931
932 /** Release libvlc. Idempotent. Waits (bounded) for an in-flight operation so no
933 * call is inside libvlc while its players are freed; later calls are no-ops. */
934 fun release() {
935 if (released) return
936 val gotLock = runBlocking { withTimeoutOrNull(2_000L) { opLock.lock(); true } } == true
937 try {
938 if (released) return
939 released = true
940 tickerJob?.cancel()
941 scope.cancel()
942 runCatching { listPlayer.controls().stop() }
943 runCatching { currentMediaList?.release() }
944 runCatching { listPlayer.release() }
945 runCatching { mediaPlayer.release() }
946 runCatching { factory.release() }
947 } finally {
948 if (gotLock) opLock.unlock()
949 }
950 }
951
952 private companion object {
953 const val MAX_CONSECUTIVE_ERRORS = 5
954 const val ERROR_SKIP_GRACE_MS = 300L
955 const val ADVANCE_CHECK_DELAY_MS = 400L
956 const val DUPLICATE_EVENT_WINDOW_MS = 500L
957 const val EXPLICIT_REQUEST_WINDOW_MS = 1_500L
958 }
959}
960
961/**
962 * The order to go back to when shuffle is turned off, as indices into [current].
963 *
964 * [snapshot] is the queue as it was when shuffle went on. Tracks still queued come
965 * back in that order; tracks removed while shuffled stay gone; tracks added while
966 * shuffled follow, in their current order. Duplicates are matched copy-for-copy
967 * (id + URL), so a track queued twice comes back twice. (Team 3 P1-11.)
968 */
969internal fun restorePreShuffleOrder(
970 snapshot: List<DidlItem.Track>,
971 current: List<DidlItem.Track>,
972): List<Int> {
973 fun key(t: DidlItem.Track) = t.id + '\u0000' + t.resourceUrl
974 val buckets = HashMap<String, ArrayDeque<Int>>()
975 current.forEachIndexed { i, t -> buckets.getOrPut(key(t)) { ArrayDeque() }.addLast(i) }
976 val out = ArrayList<Int>(current.size)
977 for (t in snapshot) buckets[key(t)]?.removeFirstOrNull()?.let(out::add)
978 val used = out.toHashSet()
979 current.indices.filterNotTo(out) { it in used }
980 return out
981}