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

CastRenderer.kt

1044 lines · 52.1 KB

Changed in: 0.5.0

1package dev.stave.cast
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.domain.renderer.ScopedRenderer
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.update
18import kotlinx.coroutines.launch
19import kotlinx.coroutines.withContext
20import su.litvak.chromecast.api.v2.ChromeCast
21import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent
22import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener
23import su.litvak.chromecast.api.v2.MediaStatus
24import kotlin.time.Duration
25import kotlin.time.Duration.Companion.milliseconds
26
27/**
28 * A [Renderer] implementation backed by a Chromecast device (or speaker group)
29 * via the existing [CastIntegration]. The rest of the app treats this exactly
30 * like the local libvlc renderer or a remote UPnP renderer — load tracks, hit
31 * play, the audio comes out wherever the user picked.
32 *
33 * **Where this fits.** [RendererCoordinator] selects between three renderer
34 * kinds based on the user's `activeRendererUdn` setting:
35 * - `null` → local libvlc, audio out the laptop speakers
36 * - UPnP UDN → [dev.stave.upnp.renderer.RemoteUpnpRenderer] driving a
37 * remote AVTransport device
38 * - `cast:<deviceId>` → this class, driving a Chromecast or speaker group
39 *
40 * **Why not just reuse CastIntegration directly.** The app's playback layer
41 * speaks the [Renderer] surface (suspend functions, a [StateFlow] of
42 * [PlaybackState], a queue model). [CastIntegration] is a thinner wrapper that
43 * speaks the chromecast-java-api-v2 surface — fire-and-forget methods, no
44 * unified state, no queue. This adapter bridges the two.
45 *
46 * **Queue ownership.** chromecast-java-api-v2 does have a queue concept
47 * (`loadQueue`), but it's underspecified across receiver firmware versions and
48 * historically flaky for speaker groups. We keep our own list-of-tracks in
49 * memory and use single-track loads, advancing on the `IdleReason.FINISHED`
50 * event that the receiver sends when a track ends naturally. This matches the
51 * shape [dev.stave.upnp.renderer.RemoteUpnpRenderer] uses for the same reason
52 * (UPnP AVTransport has no protocol-level queue either).
53 *
54 * **State translation.** A [ChromeCastSpontaneousEventListener] subscribes to
55 * the active [su.litvak.chromecast.api.v2.ChromeCast] and turns each incoming
56 * [ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS] into a
57 * [PlaybackState] update. Position isn't evented — we poll it on a 500 ms
58 * ticker while playing, same as the UPnP remote does.
59 *
60 * **Lifecycle.** Construct with the device id, call [attach] to register the
61 * spontaneous-event listener and start the position ticker. Call [detach] when
62 * switching away from this renderer (the coordinator does this). [detach]
63 * unregisters the listener but does NOT disconnect the Cast session — the user
64 * picked another renderer, they didn't say "stop casting." If they want to end
65 * the session, the Cast UI surface should call [CastIntegration.disconnect]
66 * directly.
67 */
68class CastRenderer(
69 private val cast: CastIntegration,
70 /** The id used to identify the target device. Matches the id returned by
71 * [CastIntegration.devices] (which is the mDNS address). Used during
72 * [attach] to select the device on the underlying integration and as part
73 * of the `cast:<id>` UDN convention used in the renderer coordinator. */
74 val targetDeviceId: String,
75 /** Seed for [_state.volumePercent] before the first authoritative reading
76 * from the device arrives via [syncVolumeFromDevice]. Without this the
77 * default PlaybackState() value of 100 would be exposed to the UI during
78 * the window between construction and first sync (typically ~250-1000ms
79 * depending on receiver app boot), causing the meter to flash 100% before
80 * snapping to the device's actual level — visually identical to a
81 * volume blast even though we never sent a 100% command. The
82 * coordinator passes the outgoing renderer's last-known volume so the
83 * meter starts at a sensible value during the sync window. */
84 initialVolumePercent: Int = 100,
85) : ScopedRenderer {
86
87 private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
88
89 private val _state = MutableStateFlow(PlaybackState(volumePercent = initialVolumePercent))
90 override val state: StateFlow<PlaybackState> = _state.asStateFlow()
91
92 /** Local queue. Cast's own queueLoad is not used — see class kdoc.
93 * Read and written only on [queueCtx] (Team 3 P2-2): user actions and the
94 * track-end advance used to run on different IO threads, and a track
95 * ending while a row was removed could throw out of `queue.toList()`. */
96 private val queue: MutableList<DidlItem.Track> = mutableListOf()
97
98 /** One thread at a time for [queue] / [queueIndex]. FIFO. */
99 private val queueCtx = Dispatchers.IO.limitedParallelism(1)
100
101 /** mediaSessionId of the receiver's media we last advanced away from.
102 * The receiver's own FINISHED broadcast and a poll already in flight can
103 * both report the same track end; advancing on each skipped a track
104 * (Team 3 P2-3). Every LOAD gets a new mediaSessionId. */
105 @Volatile private var lastAdvancedMediaSessionId: Long = -1L
106
107 /** Index into [queue] of the currently-loaded track. -1 when nothing loaded. */
108 private var queueIndex: Int = -1
109
110 /** Last MediaStatus's IdleReason. Used to disambiguate "track ended naturally
111 * → advance" vs "user pressed stop → stay stopped". */
112 private var lastIdleReason: MediaStatus.IdleReason? = null
113
114 /**
115 * Timestamps of the most recent transport commands. Used to suppress
116 * contradictory state updates that arrive shortly after.
117 *
118 * The problem: after the user issues Stop or Pause, we optimistically
119 * set `_state.isPlaying = false` immediately. But the status poller
120 * (running every 500 ms while we think we're playing) often has a
121 * `getMediaStatus()` in flight already, OR the receiver hasn't yet
122 * processed the pause/stop command by the time the next poll happens.
123 * Either way, the poll returns `PLAYING`, [onMediaStatus] overwrites
124 * our `false` with `true`, and the UI flickers — the user sees the
125 * play icon return and assumes the Stop button didn't work. They press
126 * it again, second time looks like it succeeded (because by then the
127 * receiver actually has processed the original command).
128 *
129 * The fix: ignore poll-reported `PLAYING` within a short window after
130 * Stop/Pause, and ignore poll-reported `PAUSED`/`IDLE` within a short
131 * window after Play. The receiver acknowledges within ~500 ms in
132 * practice, so [TRANSPORT_SUPPRESS_MS] of 1500 ms covers it generously
133 * without trapping the UI in a stale state if the command genuinely
134 * failed (a real failure will show up after the window passes).
135 */
136 @Volatile private var lastPauseLikeCommandMs: Long = 0L
137 @Volatile private var lastPlayLikeCommandMs: Long = 0L
138
139 /** Timestamp of the most recent user-initiated [stop] call. Tracked
140 * separately from [lastPauseLikeCommandMs] because Stop and Pause have
141 * fundamentally different intent: Pause says "I might resume in a
142 * moment", while Stop says "I'm done listening, don't keep going."
143 *
144 * Used by the auto-advance fallback path in [onMediaStatus]: when the
145 * receiver reports IDLE with a NULL `idleReason` (some Chromecast
146 * firmwares and the chromecast-java-api-v2 library leave this unset
147 * for HTTP audio streams), we have to guess whether IDLE means
148 * "track ended naturally → advance" or "user pressed Stop → stay
149 * put". Within [USER_STOP_SUPPRESS_MS] of a user Stop, we choose the
150 * conservative answer; outside it, we advance. */
151 @Volatile private var lastUserStopMs: Long = 0L
152
153 /** Ticker that polls position while playing. Null when not active. */
154 private var positionPollerJob: Job? = null
155
156 /**
157 * Latches true the first time the user manually sets volume via [setVolume].
158 * Once set, the attach-time volume-sync loop stops overwriting the slider
159 * with the device's reported level.
160 *
161 * Why: the sync loop retries with backoff for several seconds because a
162 * Chromecast (especially a CCA group leader) is slow to report a volume
163 * after the receiver app launches. If the user adjusts the slider during
164 * that window, a late sync attempt would read the device's level — which
165 * for a group leader is often the group master (1.0 / 100%), NOT the
166 * per-member level the user just set — and clobber the user's value a few
167 * seconds after they set it. The user's explicit input is authoritative;
168 * once they've touched the slider, we never auto-overwrite it. */
169 @Volatile private var volumeUserOverride: Boolean = false
170
171 private var attached: Boolean = false
172
173 private val spontaneousListener = ChromeCastSpontaneousEventListener { event ->
174 if (!attached) return@ChromeCastSpontaneousEventListener
175 when (event.type) {
176 ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS -> {
177 val status = runCatching {
178 event.getData(MediaStatus::class.java)
179 }.getOrNull() ?: return@ChromeCastSpontaneousEventListener
180 onMediaStatus(status)
181 }
182 ChromeCastSpontaneousEvent.SpontaneousEventType.CLOSE -> {
183 // The library fires CLOSE whenever its TCP-TLS keepalive to
184 // the receiver times out. That happens for two very different
185 // reasons:
186 // 1. The receiver app legitimately shut down (user pressed
187 // Home on the TV, sleep timer fired on a speaker)
188 // 2. Transient network blip — receiver is still alive and,
189 // crucially, STILL PLAYING THE STREAM autonomously
190 //
191 // We can't distinguish the two from the CLOSE event alone.
192 // The user-visible symptom of confusing them was: music kept
193 // pouring out of the speaker, but our UI flipped to Stopped,
194 // the play button replaced pause, and the progress bar froze
195 // a frame before going stale. The desktop and the speaker
196 // told the user contradictory stories.
197 //
198 // The right answer is to NOT mutate playback state on CLOSE.
199 // The state flow simply freezes at its last-known values
200 // (the poller stops running once cast.session is null, so
201 // nothing else overwrites it either). The cast banner above
202 // the transport already shows "Reconnecting to X…" off
203 // CastIntegration.isReconnecting, so the user has the
204 // context. If the receiver really did shut down, the next
205 // observation (or the user pressing Stop) will reconcile.
206 System.err.println(
207 "[EarhornCast/Renderer] CLOSE event — freezing state, " +
208 "reconnect loop will handle resumption"
209 )
210 }
211 else -> { /* STATUS, APPEVENT, UNKNOWN — not interesting for transport */ }
212 }
213 }
214
215 /**
216 * Open the session against the target device and subscribe to its events.
217 * Idempotent. Returns immediately; session establishment happens asynchronously
218 * inside [CastIntegration] and the [state] flow updates when it completes.
219 */
220 fun attach() {
221 if (attached) return
222 attached = true
223
224 // Make sure the integration is connected to the device we're targeting.
225 // selectDevice() is a no-op when already on this device, and idempotent
226 // for the chromecast-java-api-v2 connect() call underneath.
227 cast.selectDevice(targetDeviceId)
228
229 // Start polling for media status immediately. This is the primary source
230 // of truth for UI state — see startStatusPoller() for the rationale.
231 // (We *also* register the spontaneous-event listener below, but treat it
232 // as a low-latency optimization rather than a requirement.)
233 startStatusPoller()
234
235 // Wait for the session to land, then register the spontaneous event
236 // listener on the underlying ChromeCast handle. The session may not
237 // exist yet at the moment attach() returns, so we observe the session
238 // flow until it does.
239 scope.launch {
240 cast.session.collect { sess ->
241 if (sess != null && sess.deviceId == targetDeviceId) {
242 // A session that (re)appears after a dropout: the receiver
243 // app was relaunched and holds nothing. Ask it; if it has
244 // no media, the next press reloads the current track. We
245 // never load on the reconnect itself (design decision).
246 runCatching { sess.backing.mediaStatus }
247 .onSuccess { st -> if (st?.media == null) hasMediaOnReceiver = false }
248 .onFailure { hasMediaOnReceiver = false }
249 runCatching {
250 sess.backing.registerListener(spontaneousListener)
251 }.onFailure { t ->
252 System.err.println(
253 "[EarhornCast/Renderer] registerListener failed: " +
254 "${t.javaClass.simpleName}: ${t.message}"
255 )
256 }
257 // Sync the slider to the device's ACTUAL volume. Without
258 // this the slider defaults to whatever PlaybackState's
259 // initial volumePercent is (typically 100), so the user
260 // sees "100%" while the device is actually at 30% or
261 // wherever it was left. First touch of the slider then
262 // snaps to 100% and audibly blasts.
263 //
264 // The first sync attempt usually fires before the
265 // receiver app has fully launched, so cc.status returns
266 // null or a stale value. Retry on a short backoff until
267 // we get a real reading or we've tried 8 times. The
268 // backoff caps at 2s so a genuinely slow boot still
269 // converges in under ~10s, and an instant boot
270 // converges on the first try.
271 launch {
272 var delay = INITIAL_VOLUME_SYNC_DELAY_MS
273 repeat(VOLUME_SYNC_MAX_ATTEMPTS) {
274 // Stop if the user has taken manual control of the
275 // slider — their value wins over any device read.
276 // Without this, a late sync attempt clobbers a
277 // just-set volume (CCA group leaders report the
278 // group master, not the level the user set).
279 if (volumeUserOverride) return@launch
280 if (syncVolumeFromDevice(sess.backing)) {
281 return@launch
282 }
283 delay(delay)
284 delay = (delay * 3 / 2).coerceAtMost(VOLUME_SYNC_MAX_DELAY_MS)
285 }
286 System.err.println(
287 "[EarhornCast/Renderer] volume sync exhausted retries — " +
288 "slider may not match receiver until user touches it"
289 )
290 }
291
292 // Burst of fast getMediaStatus calls on attach. The regular
293 // poller runs at 1.5s while paused, which is too slow for
294 // the reattach case: on cold-start / detach+reattach, the
295 // local `_state` starts empty (current = null) and we need
296 // a MEDIA_STATUS *with a media block* to populate it. The
297 // receiver only includes the media block when the status
298 // request lands during an active media session — most
299 // polls in steady state are transport-only. Hammering
300 // getMediaStatus() five times at 400ms gives us up to
301 // five shots inside the first 2 seconds, and the receiver
302 // typically returns the full block on at least one of
303 // those even when subsequent steady-state polls won't.
304 //
305 // Caps at 5 because past that, if the receiver still
306 // hasn't given us metadata, it's not because we asked too
307 // few times — it's because no media session is actually
308 // active to describe.
309 launch {
310 repeat(METADATA_BOOTSTRAP_ATTEMPTS) { attempt ->
311 // Stop hammering once we've got something — most
312 // of the time the first or second call succeeds
313 // and the remaining calls would be redundant.
314 if (_state.value.current != null) return@launch
315 delay(METADATA_BOOTSTRAP_INTERVAL_MS)
316 runCatching { sess.backing.mediaStatus }
317 .onSuccess { st -> if (st != null) onMediaStatus(st) }
318 .onFailure { t ->
319 System.err.println(
320 "[EarhornCast/Renderer] metadata bootstrap " +
321 "attempt ${attempt + 1} failed: ${t.message}"
322 )
323 }
324 }
325 }
326 }
327 }
328 }
329 }
330
331 /**
332 * Pull the device's current volume out of its receiver status and mirror
333 * it into [_state]. Returns true on success (a non-null level was read),
334 * false if the device didn't report one (receiver still launching, network
335 * blip, or a TV that doesn't expose volume).
336 *
337 * Best-effort within an attempt: if the status read throws, we silently
338 * leave the existing volumePercent untouched rather than fabricating a
339 * value. The caller retries.
340 */
341 private fun syncVolumeFromDevice(cc: ChromeCast): Boolean {
342 // Never overwrite a volume the user explicitly set. The caller's loop
343 // also checks this, but guarding here protects any future caller too.
344 if (volumeUserOverride) return true // report "done" so the loop stops
345 return runCatching {
346 val level = cc.status?.volume?.level ?: return@runCatching false
347 // Skia/JVM idiom: convert Number-typed level (libraries vary
348 // on Float vs Double) to a 0..100 Int via toDouble() to avoid
349 // a precision mismatch with the slider's Int contract.
350 val pct = (level.toDouble() * 100).toInt().coerceIn(0, 100)
351 _state.update { it.copy(volumePercent = pct) }
352 true
353 }.onFailure { t ->
354 System.err.println(
355 "[EarhornCast/Renderer] initial volume sync failed: ${t.message}"
356 )
357 }.getOrDefault(false)
358 }
359
360 /**
361 * Unregister the event listener and stop polling. Does NOT disconnect the
362 * Cast session — leaving the device playing is the whole point of "switch
363 * to another renderer." But it DOES release the desktop's "this is my
364 * chosen cast device" marker on the integration ([CastIntegration.releaseControl]),
365 * so the auto-reconnect path won't bring us back as the controller if the
366 * device blips off the network while we're not the active renderer.
367 * Idempotent.
368 */
369 override fun detach() {
370 if (!attached) return
371 attached = false
372 runCatching {
373 cast.session.value?.backing?.unregisterListener(spontaneousListener)
374 }
375 positionPollerJob?.cancel()
376 positionPollerJob = null
377 cast.releaseControl()
378 // Fresh attach next time should re-load on first play() rather than
379 // assume the receiver still has our last media; sessions don't
380 // necessarily survive detach.
381 hasMediaOnReceiver = false
382 scope.cancel()
383 }
384
385 // ---- Spontaneous event handling -----------------------------------------
386
387 private fun onMediaStatus(status: MediaStatus) {
388 // Translate PlayerState → our flags. CASCADE: BUFFERING and LOADING both
389 // map to isBuffering=true; IDLE means stopped or about to advance.
390 val playerState = status.playerState
391 val idleReason = status.idleReason
392 lastIdleReason = idleReason
393
394 val rawIsPlaying = playerState == MediaStatus.PlayerState.PLAYING
395 val isBuffering = playerState == MediaStatus.PlayerState.BUFFERING ||
396 playerState == MediaStatus.PlayerState.LOADING
397 val isPaused = playerState == MediaStatus.PlayerState.PAUSED
398 val rawIsStopped = playerState == MediaStatus.PlayerState.IDLE && !isBuffering
399
400 // Apply the transport-command suppression window. If we recently told
401 // the receiver to pause/stop, ignore a status that still says PLAYING
402 // (receiver hasn't processed our command yet). Symmetrically, if we
403 // recently told it to play, ignore a status that says PAUSED/STOPPED.
404 // See the kdoc on lastPauseLikeCommandMs for the full rationale.
405 val now = System.currentTimeMillis()
406 val withinPauseSuppress = now - lastPauseLikeCommandMs < TRANSPORT_SUPPRESS_MS
407 val withinPlaySuppress = now - lastPlayLikeCommandMs < TRANSPORT_SUPPRESS_MS
408 val isPlaying = when {
409 rawIsPlaying && withinPauseSuppress -> false // we just stopped/paused
410 !rawIsPlaying && withinPlaySuppress && !isBuffering -> true // we just played
411 else -> rawIsPlaying
412 }
413 val isStopped = rawIsStopped && !withinPlaySuppress
414
415 _state.update {
416 it.copy(
417 isPlaying = isPlaying,
418 isBuffering = isBuffering,
419 isStopped = isStopped && !isPaused,
420 position = (status.currentTime * 1000).toLong().milliseconds,
421 duration = (status.media?.duration?.times(1000))?.toLong()?.milliseconds
422 ?: it.duration,
423 error = null,
424 )
425 }
426
427 // Receiver-driven metadata fallback.
428 //
429 // Sender-mode CastRenderer historically ignored `media.metadata` on
430 // every MEDIA_STATUS — the design assumed "I drove the LOAD, so my
431 // local queue is the source of truth for what's playing." That's
432 // wrong for at least three real cases:
433 // 1. Cold-start auto-reattach. Settings persisted "cast:Foo".
434 // We reconnect on launch with an empty local queue. The
435 // receiver still has the previously-driven media loaded (the
436 // Cast session survives app restart). state.current stays
437 // null → "Nothing playing" gramophone glow despite audio.
438 // 2. Phone takes over. The user is also driving the same
439 // receiver from their phone. Receiver swaps to phone's track.
440 // Our local queue still points at our (now-irrelevant) last
441 // LOAD. State.current is stale.
442 // 3. clearQueue() while observing audible playback. The receiver
443 // didn't actually stop (user only cleared OUR queue), or
444 // another sender restarts it. Our state.current = null but
445 // audio plays on.
446 //
447 // The fix: when MEDIA_STATUS carries a media.metadata block, try to
448 // synthesize a track from it. Prefer the existing state.current when
449 // it has the SAME title (i.e., the receiver is echoing back the
450 // metadata we set on our own LOAD — keep the richer local-queue
451 // Track with full DIDL fields). Otherwise (state.current is null,
452 // OR the title differs because the receiver is playing something we
453 // didn't load) replace it with the receiver-synthesized Track.
454 //
455 // The brown-flash protection comes for free: MEDIA_STATUS broadcasts
456 // without a media block (PAUSE, SEEK, position-only refreshes) make
457 // [extractTrackFromMediaStatus] return null, the outer `if` skips,
458 // and state.current is preserved across the gap.
459 val receiverTrack = extractTrackFromMediaStatus(status)
460 if (receiverTrack != null) {
461 _state.update { state ->
462 val keep = state.current
463 when {
464 keep == null -> state.copy(current = receiverTrack)
465 keep.title.equals(receiverTrack.title, ignoreCase = true) ->
466 state // same track, keep the richer local version
467 else -> state.copy(current = receiverTrack)
468 }
469 }
470 }
471
472 // Auto-advance on natural track end.
473 //
474 // Primary detection: IDLE + IdleReason.FINISHED — reliable when the
475 // receiver firmware sets it. INTERRUPTED / CANCELLED / COMPLETED /
476 // ERROR mean stop here, never advance.
477 //
478 // Fallback: IDLE + null idleReason. The chromecast-java-api-v2
479 // library (and some Chromecast Audio firmwares) sometimes leave
480 // idleReason unset entirely when an HTTP audio stream ends — the
481 // status arrives as bare IDLE. Without a fallback we'd sit there
482 // forever after the first track. To avoid mis-firing on the
483 // user's own Stop command (which also produces IDLE), we require
484 // BOTH suppress windows to have passed:
485 //
486 // - transport suppress: covers transient IDLE during track-load
487 // handshake right after we issued play/load
488 // - user-stop suppress: covers IDLE from the user's own Stop
489 //
490 // Past both, bare IDLE means "track ended, please go to the next
491 // one." The asymmetry — we err on the side of NOT advancing if
492 // there's any doubt — is deliberate: a missed advance is mildly
493 // annoying, a false advance (playback resumes when the user wanted
494 // quiet) is a serious failure.
495 val withinUserStopSuppress = now - lastUserStopMs < USER_STOP_SUPPRESS_MS
496 // design decision 2026-09-04 (as on Android): advance ONLY when our
497 // own track reached its end. An IDLE with no reason is somebody's
498 // stop, not a finish.
499 val isTrackEnd = playerState == MediaStatus.PlayerState.IDLE &&
500 idleReason == MediaStatus.IdleReason.FINISHED
501 if (isTrackEnd && status.mediaSessionId != lastAdvancedMediaSessionId) {
502 lastAdvancedMediaSessionId = status.mediaSessionId
503 scope.launch(queueCtx) { advanceOnFinish() }
504 }
505 }
506
507 private suspend fun advanceOnFinish() {
508 val next = nextQueueIndex() ?: run {
509 // End of queue — respect the repeat mode the user set.
510 val mode = _state.value.repeatMode
511 if (mode == RepeatMode.ALL && queue.isNotEmpty()) {
512 queueIndex = 0
513 loadCurrent(autoplay = true)
514 } else {
515 _state.update { it.copy(isPlaying = false, isStopped = true) }
516 }
517 return
518 }
519 queueIndex = next
520 loadCurrent(autoplay = true)
521 }
522
523 private fun nextQueueIndex(): Int? {
524 if (queue.isEmpty()) return null
525 val nextIx = queueIndex + 1
526 return if (nextIx in queue.indices) nextIx else null
527 }
528
529 private fun startStatusPoller() {
530 // Cancel any prior poller — defensive in case attach() is called twice.
531 positionPollerJob?.cancel()
532 positionPollerJob = scope.launch {
533 // We poll cast.getMediaStatus() on a continuous cadence while attached.
534 // Two reasons we can't rely purely on the spontaneous-event listener:
535 // 1. The chromecast-java-api-v2 library only starts pushing
536 // MEDIA_STATUS events after the first getMediaStatus() call has
537 // bootstrapped the media-channel subscription. Polling
538 // guarantees state updates regardless.
539 // 2. Position isn't pushed at all — it has to be polled.
540 //
541 // Cadence is adaptive: tight (POSITION_POLL_INTERVAL_MS, 500 ms)
542 // while playing so the seek bar moves smoothly, looser
543 // (STATUS_POLL_INTERVAL_MS, 1500 ms) when paused/stopped to avoid
544 // hammering the device for no gain.
545 while (true) {
546 val s = cast.session.value
547 if (s != null && s.deviceId == targetDeviceId) {
548 runCatching {
549 s.backing.mediaStatus
550 }.onSuccess { status ->
551 if (status != null) onMediaStatus(status)
552 }.onFailure { t ->
553 // "No application running" is expected before launchApp
554 // completes — don't spam.
555 val msg = t.message.orEmpty()
556 if ("No application is running" !in msg &&
557 "invalid state" !in msg
558 ) {
559 System.err.println(
560 "[EarhornCast/Renderer] mediaStatus poll error: ${t.message}"
561 )
562 }
563 }
564 }
565 val nextInterval = if (_state.value.isPlaying)
566 POSITION_POLL_INTERVAL_MS else STATUS_POLL_INTERVAL_MS
567 delay(nextInterval)
568 }
569 }
570 }
571
572 // ---- Renderer interface --------------------------------------------------
573
574 override suspend fun load(
575 tracks: List<DidlItem.Track>,
576 startIndex: Int,
577 autoplay: Boolean,
578 ) = withContext(queueCtx) {
579 if (tracks.isEmpty()) return@withContext
580 queue.clear()
581 queue.addAll(tracks)
582 queueIndex = startIndex.coerceIn(0, queue.lastIndex)
583 publishQueue()
584 loadCurrent(autoplay = autoplay)
585 }
586
587 override suspend fun enqueue(tracks: List<DidlItem.Track>) = withContext(queueCtx) {
588 if (tracks.isEmpty()) return@withContext
589 val wasEmpty = queue.isEmpty()
590 queue.addAll(tracks)
591 publishQueue()
592 // Mirror VlcjLocalRenderer: enqueue into empty queue stages tracks but
593 // doesn't auto-play. The user said "queue", not "play."
594 if (wasEmpty) {
595 queueIndex = 0
596 // Don't loadCurrent — let the next play() invocation pick it up.
597 }
598 }
599
600 override suspend fun playNext(tracks: List<DidlItem.Track>) = withContext(queueCtx) {
601 if (tracks.isEmpty()) return@withContext
602 if (queue.isEmpty()) {
603 // playNext into empty queue mirrors load(autoplay=true).
604 queue.addAll(tracks)
605 queueIndex = 0
606 publishQueue()
607 loadCurrent(autoplay = true)
608 } else {
609 val insertAt = (queueIndex + 1).coerceAtMost(queue.size)
610 queue.addAll(insertAt, tracks)
611 publishQueue()
612 }
613 }
614
615 override suspend fun skipToQueueItem(index: Int) = withContext(queueCtx) {
616 if (index !in queue.indices) return@withContext
617 queueIndex = index
618 loadCurrent(autoplay = true)
619 }
620
621 override suspend fun removeFromQueue(index: Int) = withContext(queueCtx) {
622 if (index !in queue.indices) return@withContext
623 val wasCurrent = index == queueIndex
624 queue.removeAt(index)
625 when {
626 queue.isEmpty() -> {
627 queueIndex = -1
628 cast.stop()
629 _state.update { it.copy(isPlaying = false, isStopped = true, current = null) }
630 }
631 wasCurrent -> {
632 // Currently-playing track removed — advance into whatever's now
633 // at the same index, falling back to 0 if we removed the tail.
634 queueIndex = queueIndex.coerceAtMost(queue.lastIndex)
635 loadCurrent(autoplay = _state.value.isPlaying)
636 }
637 index < queueIndex -> {
638 queueIndex -= 1
639 }
640 }
641 publishQueue()
642 }
643
644 override suspend fun moveInQueue(from: Int, to: Int) = withContext(queueCtx) {
645 if (from !in queue.indices || to !in queue.indices || from == to) return@withContext
646 val track = queue.removeAt(from)
647 queue.add(to, track)
648 // Track queueIndex through the move so we don't lose the playhead.
649 queueIndex = when {
650 queueIndex == from -> to
651 from < queueIndex && to >= queueIndex -> queueIndex - 1
652 from > queueIndex && to <= queueIndex -> queueIndex + 1
653 else -> queueIndex
654 }
655 publishQueue()
656 }
657
658 override suspend fun clearQueue() = withContext(queueCtx) {
659 queue.clear()
660 queueIndex = -1
661 cast.stop()
662 hasMediaOnReceiver = false
663 _state.update {
664 it.copy(
665 isPlaying = false,
666 isStopped = true,
667 current = null,
668 queue = emptyList(),
669 queueIndex = -1,
670 position = Duration.ZERO,
671 )
672 }
673 }
674
675 /**
676 * Have we loaded media onto the receiver in this session yet?
677 *
678 * When the user attaches to a Cast device while a local renderer was
679 * driving the queue, the queue model migrates onto this renderer (we
680 * end up with `queue`/`queueIndex` populated) but the Cast RECEIVER
681 * has nothing — no [load] has been issued yet. If the user then hits
682 * Play, the naïve `cast.play()` SOAP-equivalent call goes out, the
683 * receiver sees "play what?" and silently does nothing, leaving the
684 * desktop UI showing a play attempt that never resolves. The fix is
685 * to track whether the receiver has been primed with the current
686 * track and have [play] fall back to [loadCurrent] when it hasn't.
687 *
688 * Reset on [detach] and on [stop], because both end the session's
689 * relationship with the receiver's current media. Set in
690 * [loadCurrent] right after a successful library load call.
691 */
692 @Volatile private var hasMediaOnReceiver: Boolean = false
693
694 override suspend fun play() = withContext(queueCtx) {
695 lastPlayLikeCommandMs = System.currentTimeMillis()
696 when {
697 // Empty-current + non-empty queue → freshly attached after
698 // migration, no track loaded yet. Start at the top.
699 queueIndex !in queue.indices && queue.isNotEmpty() -> {
700 queueIndex = 0
701 loadCurrent(autoplay = true)
702 }
703 // Valid current but receiver hasn't been primed yet → either
704 // a fresh attach onto a populated queue (the case the user
705 // hit: "play button does not work when connected to a
706 // chromecast, songs must be clicked on in the queue to start
707 // playing") OR a post-stop resume (stop clears the receiver's
708 // media so the next Play has nothing to act on). Load +
709 // autoplay rather than send Play to an empty receiver.
710 queueIndex in queue.indices && !hasMediaOnReceiver -> {
711 loadCurrent(autoplay = true)
712 }
713 // Normal resume-from-pause case — receiver has the current
714 // media loaded, just unpause.
715 else -> cast.play()
716 }
717 }
718
719 override suspend fun pause() {
720 lastPauseLikeCommandMs = System.currentTimeMillis()
721 cast.pause()
722 // Optimistic: mirror the pause locally so the UI flips immediately,
723 // even before the receiver acknowledges. onMediaStatus's suppression
724 // window keeps a stale PLAYING from clobbering this.
725 _state.update { it.copy(isPlaying = false) }
726 }
727
728 override suspend fun stop() {
729 lastPauseLikeCommandMs = System.currentTimeMillis()
730 lastUserStopMs = System.currentTimeMillis()
731 // design decision 2026-09-04: Stop lets go of the speaker, as on
732 // the phone — the group can use it again.
733 cast.disconnect()
734 // Reset position to zero alongside the playing/stopped flags so the
735 // progress bar visibly returns to the start of the track. Without
736 // this the slider stayed wherever the track happened to be when the
737 // user pressed Stop, which looked like a frozen UI — the user
738 // reported "stop is not resetting playbar to zero". Also clear
739 // hasMediaOnReceiver: the receiver's media handle is invalidated by
740 // our soft-stop (pause + seek 0 + clear-next), so the next Play
741 // needs to re-load rather than send Play to a now-empty receiver.
742 hasMediaOnReceiver = false
743 _state.update {
744 it.copy(
745 isPlaying = false,
746 isStopped = true,
747 position = Duration.ZERO,
748 )
749 }
750 }
751
752 override suspend fun seekTo(position: Duration) {
753 cast.seek(position.inWholeMilliseconds.toDouble() / 1000.0)
754 }
755
756 override suspend fun next() = withContext(queueCtx) {
757 val next = nextQueueIndex() ?: return@withContext
758 queueIndex = next
759 loadCurrent(autoplay = true)
760 }
761
762 override suspend fun previous() = withContext(queueCtx) {
763 if (queue.isEmpty()) return@withContext
764 val prev = (queueIndex - 1).coerceAtLeast(0)
765 queueIndex = prev
766 loadCurrent(autoplay = true)
767 }
768
769 override suspend fun setVolume(volumePercent: Int) {
770 // Mark that the user has taken manual control. The attach-time sync
771 // loop checks this and stops overwriting the slider with device reads.
772 volumeUserOverride = true
773 cast.setVolume((volumePercent.coerceIn(0, 100).toFloat()) / 100f)
774 _state.update { it.copy(volumePercent = volumePercent.coerceIn(0, 100)) }
775 }
776
777 override suspend fun setRepeatMode(mode: RepeatMode) {
778 // Local-only; we implement repeat logic in advanceOnFinish() against
779 // our own queue rather than trying to drive the receiver's RepeatMode.
780 // The receiver's RepeatMode applies to its own queue (which we don't use).
781 _state.update { it.copy(repeatMode = mode) }
782 }
783
784 override suspend fun setShuffle(enabled: Boolean) = withContext(queueCtx) {
785 // Mirror VlcjLocalRenderer: enabling saves the current order, disabling
786 // restores it. We don't shuffle aggressively into the future — only the
787 // tracks AFTER queueIndex get shuffled so the currently-playing track
788 // doesn't change underneath the user.
789 if (enabled == _state.value.shuffleEnabled) return@withContext
790 if (enabled) {
791 preShuffleSnapshot = queue.toList()
792 if (queue.size > queueIndex + 1) {
793 val head = queue.subList(0, queueIndex + 1).toList()
794 val tail = queue.subList(queueIndex + 1, queue.size).shuffled()
795 queue.clear()
796 queue.addAll(head)
797 queue.addAll(tail)
798 }
799 } else {
800 preShuffleSnapshot?.let {
801 queue.clear()
802 queue.addAll(it)
803 preShuffleSnapshot = null
804 // Re-resolve queueIndex against the restored order.
805 val currentUrl = _state.value.current?.resourceUrl
806 if (currentUrl != null) {
807 val ix = queue.indexOfFirst { it.resourceUrl == currentUrl }
808 if (ix >= 0) queueIndex = ix
809 }
810 }
811 }
812 _state.update { it.copy(shuffleEnabled = enabled) }
813 publishQueue()
814 }
815
816 private var preShuffleSnapshot: List<DidlItem.Track>? = null
817
818 // ---- Helpers -------------------------------------------------------------
819
820 /**
821 * Load the track at [queueIndex] onto the active Cast session.
822 * No-op if the queue is empty or no session is active.
823 */
824 private fun loadCurrent(autoplay: Boolean) {
825 val track = queue.getOrNull(queueIndex) ?: return
826 cast.load(track)
827 // Receiver now has media of its own (or will momentarily — load() is
828 // launched async inside CastIntegration). Subsequent play() calls
829 // can use cast.play() directly instead of needing to loadCurrent
830 // first; cleared again on stop() and detach().
831 hasMediaOnReceiver = true
832 if (!autoplay) {
833 // CastIntegration.load() autoplays by default. Pause immediately
834 // after to satisfy autoplay=false semantics. There's a brief moment
835 // where the receiver buffers and starts playing before we pause —
836 // not ideal, but the user-visible effect is acceptable for the
837 // single use of autoplay=false (UPnP SetAVTransportURI followed by
838 // an explicit Play).
839 scope.launch {
840 delay(BRIEF_DELAY_BEFORE_PAUSE_MS)
841 cast.pause()
842 }
843 }
844 _state.update {
845 it.copy(
846 current = track,
847 queueIndex = queueIndex,
848 queue = queue.toList(),
849 // Fresh track starts at zero — without this the slider would
850 // briefly carry over the old track's position until the first
851 // post-load poll lands.
852 position = Duration.ZERO,
853 )
854 }
855 }
856
857 private fun publishQueue() {
858 _state.update {
859 it.copy(
860 queue = queue.toList(),
861 queueIndex = queueIndex,
862 current = queue.getOrNull(queueIndex),
863 )
864 }
865 }
866
867 /**
868 * Synthesize a minimal [DidlItem.Track] from the receiver's reported
869 * media metadata. Returns null when there's no media block, no metadata,
870 * or no title — those signal a transport-only update (PAUSE / SEEK /
871 * position refresh) where we don't want to fabricate a Track at all.
872 *
873 * The chromecast-java-api-v2 library exposes `Media.metadata` as a
874 * `Map<String, Object>` populated from the receiver's JSON metadata
875 * payload. The standard Cast metadata keys for MUSIC_TRACK are `title`,
876 * `artist`, `albumName`, `albumArtist`, `images` (a list of maps with
877 * `url`), `trackNumber`, `discNumber`, `releaseDate`. For receivers that
878 * use the GENERIC metadata type (custom apps, some casual media
879 * receivers) the artist often arrives under `subtitle` instead — we
880 * walk the alternatives so common shapes all resolve.
881 *
882 * The returned Track carries [DidlItem.Track.resourceUrl] = the
883 * receiver's contentId so downstream consumers (the queue UI, MPRIS,
884 * lyrics lookup) have a stable identity. The track's `id` is the same
885 * string — observer-derived tracks have no DIDL provenance to draw
886 * from.
887 */
888 @Suppress("UNCHECKED_CAST")
889 private fun extractTrackFromMediaStatus(status: MediaStatus): DidlItem.Track? {
890 val media = status.media ?: return null
891 val meta = media.metadata as? Map<String, Any?> ?: run {
892 // Diagnostic: media block present but metadata field is null or not
893 // a String-keyed map. Some receivers send the metadata under a
894 // non-standard wrapper; if you see this line on a session where the
895 // gramophone glow persists, paste the rx ns=media payload from the
896 // raw socket logs and we'll teach the parser about the new shape.
897 System.err.println(
898 "[EarhornCast/Renderer] MEDIA_STATUS media block has no usable " +
899 "metadata map (url=${media.url} contentType=${media.contentType})"
900 )
901 return null
902 }
903 if (meta.isEmpty()) {
904 System.err.println(
905 "[EarhornCast/Renderer] MEDIA_STATUS metadata map is empty " +
906 "(url=${media.url}). Receiver sent media block but no fields."
907 )
908 return null
909 }
910
911 val title = (meta["title"] as? String)?.takeIf { it.isNotBlank() }
912 ?: run {
913 // Title is the one field we won't fabricate around. Most other
914 // metadata callers can render a track with just title + art,
915 // but no title means a placeholder that says "Nothing playing"
916 // is more honest than one that says "Unknown title."
917 System.err.println(
918 "[EarhornCast/Renderer] MEDIA_STATUS metadata has no title; " +
919 "keys present: ${meta.keys.joinToString()}"
920 )
921 return null
922 }
923 val artist = (meta["artist"] as? String)?.takeIf { it.isNotBlank() }
924 ?: (meta["subtitle"] as? String)?.takeIf { it.isNotBlank() }
925 ?: (meta["albumArtist"] as? String)?.takeIf { it.isNotBlank() }
926 val album = (meta["albumName"] as? String)?.takeIf { it.isNotBlank() }
927 ?: (meta["albumTitle"] as? String)?.takeIf { it.isNotBlank() }
928
929 val images = meta["images"] as? List<*>
930 val artUrl = (images?.firstOrNull() as? Map<*, *>)
931 ?.get("url") as? String
932
933 val contentId = media.url ?: ""
934 val duration = media.duration
935 ?.let { (it * 1000).toLong().milliseconds }
936 ?: Duration.ZERO
937
938 // Diagnostic: log the SUCCESSFUL extraction once per track change. If
939 // the symptom persists after this code lands, this is the line that
940 // tells us whether the receiver IS sending metadata that we ARE
941 // parsing correctly (and the bug is somewhere else, e.g. the UI not
942 // re-binding) or whether we're never getting here at all.
943 System.err.println(
944 "[EarhornCast/Renderer] extracted track from MEDIA_STATUS: " +
945 "title=$title artist=$artist album=$album art=${artUrl != null}"
946 )
947
948 return DidlItem.Track(
949 id = contentId,
950 parentId = "",
951 title = title,
952 artist = artist,
953 albumArtist = artist,
954 album = album,
955 composer = null,
956 genre = null,
957 date = (meta["releaseDate"] as? String)
958 ?: (meta["originalAirDate"] as? String),
959 trackNumber = (meta["trackNumber"] as? Number)?.toInt(),
960 discNumber = (meta["discNumber"] as? Number)?.toInt(),
961 duration = duration,
962 albumArtUri = artUrl,
963 resourceUrl = contentId,
964 mimeType = media.contentType,
965 sampleFrequency = null,
966 bitsPerSample = null,
967 channels = null,
968 bitrate = null,
969 size = null,
970 )
971 }
972
973 companion object {
974 /** How often we poll position from the receiver while playing.
975 * 500ms keeps the seek bar smooth without flooding the device. */
976 private const val POSITION_POLL_INTERVAL_MS = 1_000L
977
978 /** How often we poll for general media status when NOT playing — a
979 * looser cadence since neither the playhead nor the user expectations
980 * need sub-second resolution while paused/stopped. The poller can't
981 * go silent entirely though: the receiver may be in BUFFERING and
982 * about to transition to PLAYING, and we want the UI to react. */
983 private const val STATUS_POLL_INTERVAL_MS = 10_000L
984
985 /** Brief delay after `cast.load()` before issuing pause() for the
986 * autoplay=false case. Receiver needs a moment to enter PLAYING
987 * state for the pause to take effect. */
988 private const val BRIEF_DELAY_BEFORE_PAUSE_MS = 250L
989
990 /** How long after a transport command (play/pause/stop) we ignore
991 * contradictory playerState reports from the receiver. The
992 * receiver typically processes commands within a few hundred ms;
993 * 1500 ms is generous enough to cover slow Wi-Fi or busy speaker
994 * groups while still letting genuine failures surface promptly. */
995 private const val TRANSPORT_SUPPRESS_MS = 1500L
996
997 /** Used by the auto-advance fallback path in [onMediaStatus]: when
998 * the receiver reports IDLE with a NULL [MediaStatus.IdleReason]
999 * (which some firmwares do for HTTP audio streams instead of
1000 * setting FINISHED), we treat that as "track ended naturally and
1001 * should auto-advance" — UNLESS the user pressed Stop within this
1002 * window, in which case IDLE-without-reason was probably the
1003 * receiver acknowledging that Stop and we leave well enough
1004 * alone. 2000 ms covers the round-trip latency for a Stop command
1005 * plus a poll cycle, and is longer than [TRANSPORT_SUPPRESS_MS]
1006 * because the asymmetry matters: a false advance (we resume
1007 * playback when the user wanted silence) is a much worse failure
1008 * than a missed advance (the track just doesn't go to the next
1009 * one for a couple seconds). */
1010 private const val USER_STOP_SUPPRESS_MS = 2000L
1011
1012 /** Initial delay between attempts in the post-attach volume sync
1013 * retry loop. Grows by 1.5× each retry, capping at
1014 * VOLUME_SYNC_MAX_DELAY_MS. */
1015 private const val INITIAL_VOLUME_SYNC_DELAY_MS = 250L
1016
1017 /** Ceiling on the exponential backoff used for volume sync retries.
1018 * Keeps the worst-case wait between attempts bounded so a slow-
1019 * booting receiver still converges in seconds, not minutes. */
1020 private const val VOLUME_SYNC_MAX_DELAY_MS = 2_000L
1021
1022 /** Maximum number of times we retry volume sync after attach. With
1023 * the 250→2000ms backoff above this gives the receiver up to ~10
1024 * seconds of total wallclock to start reporting its volume level
1025 * before we give up and leave the slider showing whatever its
1026 * default is. */
1027 private const val VOLUME_SYNC_MAX_ATTEMPTS = 8
1028
1029 /** Interval between rapid getMediaStatus calls issued right after
1030 * attach to bootstrap the metadata into [_state.current]. 400ms is
1031 * short enough that the user sees the gramophone-glow replaced
1032 * within ~half a second on a happy-path reattach, and long enough
1033 * that we don't flood the receiver with overlapping requests in
1034 * pathological cases. */
1035 private const val METADATA_BOOTSTRAP_INTERVAL_MS = 400L
1036
1037 /** Number of metadata-bootstrap calls issued after attach (or until
1038 * we've populated state.current, whichever comes first). 5 × 400ms
1039 * = up to 2s of focused effort; past that, if the receiver hasn't
1040 * given us a media block, there isn't an active session to
1041 * describe and the regular poller takes over. */
1042 private const val METADATA_BOOTSTRAP_ATTEMPTS = 5
1043 }
1044}