VlcjLocalRenderer.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.playback |
| 2 | ||
| 3 | import dev.stave.domain.model.DidlItem | |
| 4 | import dev.stave.domain.renderer.PlaybackState | |
| 5 | import dev.stave.domain.renderer.Renderer | |
| 6 | import dev.stave.domain.renderer.RepeatMode | |
| 7 | import dev.stave.ui.events.UiEvents | |
| 8 | import kotlinx.coroutines.CoroutineScope | |
| 9 | import kotlinx.coroutines.Dispatchers | |
| 10 | import kotlinx.coroutines.Job | |
| 11 | import kotlinx.coroutines.SupervisorJob | |
| 12 | import kotlinx.coroutines.cancel | |
| 13 | import kotlinx.coroutines.delay | |
| 14 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 15 | import kotlinx.coroutines.flow.StateFlow | |
| 16 | import kotlinx.coroutines.flow.asStateFlow | |
| 17 | import kotlinx.coroutines.flow.combine | |
| 18 | import kotlinx.coroutines.flow.distinctUntilChanged | |
| 19 | import kotlinx.coroutines.flow.map | |
| 20 | import kotlinx.coroutines.flow.update | |
| 21 | import kotlinx.coroutines.launch | |
| 22 | import kotlinx.coroutines.runBlocking | |
| 23 | import kotlinx.coroutines.sync.Mutex | |
| 24 | import kotlinx.coroutines.sync.withLock | |
| 25 | import kotlinx.coroutines.withTimeoutOrNull | |
| 26 | import uk.co.caprica.vlcj.factory.MediaPlayerFactory | |
| 27 | import uk.co.caprica.vlcj.media.MediaRef | |
| 28 | import uk.co.caprica.vlcj.medialist.MediaList | |
| 29 | import uk.co.caprica.vlcj.player.base.MediaPlayer | |
| 30 | import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter | |
| 31 | import uk.co.caprica.vlcj.player.base.State | |
| 32 | import uk.co.caprica.vlcj.player.list.MediaListPlayer | |
| 33 | import uk.co.caprica.vlcj.player.list.MediaListPlayerEventAdapter | |
| 34 | import uk.co.caprica.vlcj.player.list.PlaybackMode | |
| 35 | import java.util.concurrent.atomic.AtomicInteger | |
| 36 | import kotlin.math.roundToInt | |
| 37 | import kotlin.time.Duration | |
| 38 | import 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 | */ | |
| 77 | class 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 | */ | |
| 969 | internal 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 | } |


