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

CastIntegration.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.cast
2
3import dev.stave.domain.model.DidlItem
4import kotlinx.coroutines.CoroutineScope
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.Job
7import kotlinx.coroutines.SupervisorJob
8import kotlinx.coroutines.cancel
9import kotlinx.coroutines.channels.Channel
10import kotlinx.coroutines.delay
11import kotlinx.coroutines.flow.MutableStateFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asStateFlow
14import kotlinx.coroutines.flow.first
15import kotlinx.coroutines.isActive
16import kotlinx.coroutines.launch
17import kotlinx.coroutines.withTimeoutOrNull
18import su.litvak.chromecast.api.v2.ChromeCast
19import su.litvak.chromecast.api.v2.ChromeCasts
20import su.litvak.chromecast.api.v2.ChromeCastsListener
21
22/**
23 * Desktop Chromecast integration. Mirrors the StateFlow surface of the Android
24 * [dev.stave.cast.CastIntegration] so any picker UI ported from Android can
25 * subscribe to the same flows without rewriting.
26 *
27 * The Android side is built on Google Play Services' CastFramework. There is no
28 * desktop equivalent of that, so this port uses
29 * [de.sfuhrm:chromecast-java-api-v2](https://github.com/sfuhrm/chromecast-java-api-v2),
30 * the actively-maintained fork of vitalidze/chromecast-java-api-v2. It speaks
31 * the Cast protobuf wire protocol directly and discovers devices via jmDNS.
32 * The fork keeps the original's `su.litvak.chromecast.api.v2.*` package names
33 * (only Maven coordinates differ), so imports below look "wrong" but resolve
34 * correctly. Different surface from Google's SDK (no MediaRouter, no
35 * SessionManager), same conceptual model: find devices, open a session,
36 * load media, control playback.
37 *
38 * **Scope of this port** (mark of completion at the bottom of each line):
39 * - Device discovery via jmDNS ✓ working
40 * - Session lifecycle (connect / disconnect) ✓ working
41 * - Media load / play / pause / stop / seek ✓ wraps the lib
42 * - Volume ✓ wraps the lib
43 * - Stream proxy for speaker-group sources ✓ ported from Android
44 * - Renderer-level wiring (a CastRenderer impl
45 * of [dev.stave.domain.renderer.Renderer] that
46 * this integration sits behind so the rest of
47 * the app can treat Cast as just another
48 * renderer choice) ✗ NOT WIRED YET — see TODO at bottom
49 * - Picker UI ✗ NOT YET — desktop component to be added
50 *
51 * Until the renderer wiring lands, the methods on this class work in isolation
52 * (you could call [load] directly with a track and audio will play on the
53 * selected device), but the app's UI does not yet expose any way to do so.
54 * That's the next session's work.
55 */
56class CastIntegration {
57
58 /** True if the chromecast-java-api-v2 library and jmDNS are both healthy.
59 * Currently always true on desktop — if the lib weren't on the classpath
60 * this class wouldn't load. Kept for API parity with the Android side
61 * which has to deal with Play Services being absent. */
62 val available: Boolean = true
63
64 private val streamProxy = CastStreamProxy()
65
66 // Single-thread coroutine scope for discovery callbacks (which arrive from
67 // jmDNS's worker thread) and for connection-retry timers. Cast is bursty —
68 // most activity is "user clicked a thing, do that thing, wait for events" —
69 // so a single thread is plenty and keeps the state transitions ordered.
70 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
71
72 private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
73 val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()
74
75 private val _session = MutableStateFlow<CastSession?>(null)
76 val session: StateFlow<CastSession?> = _session.asStateFlow()
77
78 /** True iff a Cast session is currently active. Convenience derived from
79 * [session]; callers can observe this to gate local audio output. */
80 private val _isCasting = MutableStateFlow(false)
81 val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow()
82
83 private val _castingToGroup = MutableStateFlow(false)
84 val castingToGroup: StateFlow<Boolean> = _castingToGroup.asStateFlow()
85
86 private val _diagnostic = MutableStateFlow(DiagnosticState())
87 val diagnostic: StateFlow<DiagnosticState> = _diagnostic.asStateFlow()
88
89 /** True while the picked device has gone off the network and we're waiting
90 * for it to come back so we can re-establish the session. UI can collect
91 * this flow to show a small "Reconnecting…" hint without losing the
92 * user's renderer selection. Settles back to false either when the device
93 * returns and we reconnect, or when the user explicitly picks something else. */
94 private val _isReconnecting = MutableStateFlow(false)
95 val isReconnecting: StateFlow<Boolean> = _isReconnecting.asStateFlow()
96
97 /** The device the user has asked us to cast to, regardless of whether the
98 * session is currently established. Set by [selectDevice], cleared by
99 * [disconnect]. When the device disappears from discovery, we keep this
100 * set so the auto-reconnect path in [discoveryListener] can re-establish
101 * the session the moment it returns. */
102 @Volatile
103 private var desiredDeviceId: String? = null
104
105 private val discoveryListener = object : ChromeCastsListener {
106 override fun newChromeCastDiscovered(cc: ChromeCast) {
107 refreshDevices()
108 // Auto-reconnect: if this is the device the user picked and we're
109 // not already on it (because it had blipped off the network),
110 // re-establish the session transparently.
111 val wanted = desiredDeviceId
112 if (wanted != null && cc.name == wanted && _session.value == null) {
113 System.err.println(
114 "[EarhornCast] desired device $wanted reappeared — auto-reconnecting"
115 )
116 _isReconnecting.value = false
117 selectDevice(wanted)
118 }
119 }
120 override fun chromeCastRemoved(cc: ChromeCast) {
121 refreshDevices()
122 // If the active session is on this device, mark the session as
123 // suspended (drop the handle, but keep desiredDeviceId so we can
124 // reconnect when the device returns). Don't clear desiredDeviceId
125 // here — only an explicit user disconnect should do that.
126 if (_session.value?.deviceId == cc.name) {
127 System.err.println(
128 "[EarhornCast] active device ${cc.name} dropped from network " +
129 "— marking session suspended, will reconnect when it returns"
130 )
131 _session.value = null
132 _isCasting.value = false
133 if (desiredDeviceId == cc.name) {
134 _isReconnecting.value = true
135 startReconnectLoop()
136 }
137 }
138 }
139 }
140
141 /**
142 * Background coroutine that periodically retries connecting to
143 * [desiredDeviceId] while [_isReconnecting] is true. Exists because we
144 * can't rely on jmDNS to fire a fresh `newChromeCastDiscovered` event
145 * within a reasonable time window after a device blips — the mDNS TTL
146 * is typically 60s+, but we want to recover faster. If the device is
147 * actually back on the network, [selectDevice] will find it and the
148 * loop exits when [_session] goes non-null.
149 *
150 * This also addresses the "banner stuck on Reconnecting while audio is
151 * still playing through the speaker" symptom: the receiver app
152 * continues playing autonomously after we lose control, so the audio
153 * is fine but our session.value is null. Without this loop, we'd sit
154 * indefinitely with isReconnecting=true until the next mDNS event.
155 */
156 private var reconnectJob: Job? = null
157
158 private fun startReconnectLoop() {
159 if (reconnectJob?.isActive == true) return
160 reconnectJob = scope.launch {
161 // Iteration counter — we kick jmDNS every RECONNECT_RESCAN_EVERY
162 // attempts because the cheap "did the device pop back into
163 // ChromeCasts.get()" check on its own waits forever when jmDNS
164 // itself has evicted the device from its local cache (mDNS TTL
165 // passed without a re-announce arriving, multicast packet was
166 // dropped, network interface flapped, VPN tunnel reshuffled
167 // routes). The manual Rescan button does the same stop+start
168 // discovery dance and reliably brings devices back; doing it
169 // automatically inside the reconnect loop turns the user's
170 // "music keeps playing but my desktop lost the device, let me
171 // press Rescan" workflow into a transparent recovery they
172 // shouldn't have to know about.
173 var attempt = 0
174 val startedAt = System.currentTimeMillis()
175 while (isActive) {
176 val wanted = desiredDeviceId
177 if (wanted == null || _session.value != null) break
178 delay(RECONNECT_RETRY_INTERVAL_MS)
179 val again = desiredDeviceId
180 if (again == null || _session.value != null) break
181
182 // Suspended-forever guard (sender bug class §4.4): a session
183 // that drops and never resumes must not leave the app in
184 // "casting" limbo indefinitely. After the timeout, force-exit
185 // the reconnecting state — banner clears, the app is cleanly
186 // out of the casting state, and the user can re-pick without
187 // a restart. desiredDeviceId is deliberately KEPT: if the
188 // device reappears on mDNS later, discoveryListener's passive
189 // auto-reconnect path still re-establishes the session
190 // transparently — we only stop the active retry burn and the
191 // stuck banner, not the "come back when you can" intent.
192 if (System.currentTimeMillis() - startedAt > RECONNECT_GIVE_UP_MS) {
193 System.err.println(
194 "[EarhornCast] reconnect loop: device $again still gone " +
195 "after ${RECONNECT_GIVE_UP_MS / 1000}s — exiting " +
196 "casting state (passive auto-reconnect stays armed)"
197 )
198 _isReconnecting.value = false
199 break
200 }
201 attempt++
202
203 if (attempt % RECONNECT_RESCAN_EVERY == 0) {
204 System.err.println(
205 "[EarhornCast] reconnect loop attempt $attempt: " +
206 "kicking jmDNS discovery to flush stale mDNS cache"
207 )
208 runCatching { ChromeCasts.stopDiscovery() }
209 // Same 300 ms socket-shutter delay as rescan() — anything
210 // shorter and jmDNS sometimes restarts against half-closed
211 // sockets and the next round of discovery never fires.
212 delay(300)
213 runCatching { ChromeCasts.startDiscovery() }
214 // Give jmDNS a beat to receive the announcement-burst
215 // that follows a fresh socket join before we look in
216 // ChromeCasts.get() for our device.
217 delay(2000)
218 refreshDevices()
219 }
220
221 val cc = ChromeCasts.get().firstOrNull { it.name == again }
222 if (cc != null) {
223 System.err.println(
224 "[EarhornCast] reconnect loop: device $again is visible " +
225 "again on attempt $attempt — retrying selectDevice"
226 )
227 selectDevice(again)
228 // selectDevice runs asynchronously; let its completion
229 // either set _session.value (we exit on next loop iter)
230 // or fail (we keep retrying).
231 }
232 }
233 }
234 }
235
236 init {
237 ChromeCasts.registerListener(discoveryListener)
238 runCatching { ChromeCasts.startDiscovery() }
239 .onFailure {
240 System.err.println("[EarhornCast] startDiscovery failed: ${it.message}")
241 }
242 refreshDevices()
243 }
244
245 private fun refreshDevices() {
246 val snapshot = runCatching { ChromeCasts.get().toList() }.getOrDefault(emptyList())
247 val mapped = snapshot.map { cc ->
248 CastDevice(
249 // Use the mDNS service name (cc.name) as the stable id, NOT the
250 // IP address. Chromecast speaker groups are hosted ON one of
251 // their member devices — the group advertises with the host
252 // device's IP. Two CastDevice entries (the host + the group)
253 // would collide if we keyed by IP, so the picker would mark
254 // BOTH as active when the user selected either one (and worse,
255 // selectDevice() lookup would non-deterministically resolve to
256 // whichever came first in the snapshot).
257 //
258 // The mDNS service name is guaranteed unique per registered
259 // service and is what the user already sees in displayName as
260 // a fallback when no friendly name is set. The user-visible
261 // 'cast:<id>' UDN in SettingsStore now uses the service name,
262 // not the IP. Any previously-persisted 'cast:<ip>' value won't
263 // resolve and will be silently ignored — acceptable one-time
264 // migration cost.
265 id = cc.name ?: cc.address ?: return@map null,
266 friendlyName = displayName(cc),
267 isGroup = isGroupHeuristic(cc),
268 backing = cc,
269 )
270 }.filterNotNull()
271 _devices.value = mapped
272 _diagnostic.value = _diagnostic.value.copy(
273 rawRouteCount = snapshot.size,
274 filteredDeviceCount = mapped.size,
275 tick = _diagnostic.value.tick + 1,
276 rawRouteNames = mapped.take(6).map { it.friendlyName },
277 )
278 }
279
280 /**
281 * Resolve a human-readable label for a discovered Cast device.
282 *
283 * The chromecast-java-api-v2 library exposes two name accessors on
284 * [ChromeCast] with different semantics:
285 * - [ChromeCast.getName] returns the **mDNS service name** — something
286 * like "Chromecast-Audio-b99f8e6d28bbaa9cc8c34939a4ce85d9". Always
287 * non-null after construction. *NOT* user-friendly; never shown.
288 * - [ChromeCast.getTitle] returns the **"fn" TXT record property**, which
289 * is the user-set name from the Google Home app ("Living Room",
290 * "a room", "HeißeBad", …). May be null/empty if the device wasn't
291 * configured or the property isn't advertised over mDNS for some reason.
292 *
293 * Resolution order:
294 * 1. `cc.title` if non-blank — the canonical user-set name. This is the
295 * common path; almost every cast device has a friendly name.
296 * 2. `cc.address` — the IP address. Not pretty, but it's at least useful
297 * networking info (helps with diagnosis if the user can't tell which
298 * device they're looking at). Strongly preferred over the technical
299 * mDNS name like "Chromecast-Audio-b99f8e6d…" which is gibberish to
300 * the user and tells them nothing actionable.
301 */
302 private fun displayName(cc: ChromeCast): String {
303 val title = cc.title?.trim().orEmpty()
304 if (title.isNotEmpty()) return title
305 return cc.address ?: "Cast device"
306 }
307
308 /** Speaker groups don't always advertise themselves distinctly on mDNS;
309 * the most reliable signal in chromecast-java-api-v2 is the model
310 * field (model name "Google Cast Group" etc.) and the "type"/
311 * "capabilities" txt records. We check model name with a few known
312 * patterns and fall back to false. False negatives are OK — the
313 * consequence is just that the stream proxy doesn't kick in for
314 * groups misidentified as singles, which means a possible Range-
315 * request glitch on the first track. */
316 private fun isGroupHeuristic(cc: ChromeCast): Boolean {
317 val model = (cc.model ?: "").lowercase()
318 return "group" in model
319 }
320
321 /** Connect to a device by its id (as returned in [devices]). The session
322 * flow updates once the connection succeeds. */
323 fun selectDevice(deviceId: String) {
324 // Remember the user's choice for the auto-reconnect path. Set BEFORE
325 // we launch the work so a near-immediate network blip still has the
326 // desired-device hint by the time discoveryListener fires.
327 desiredDeviceId = deviceId
328
329 scope.launch {
330 val cc = ChromeCasts.get().firstOrNull { it.name == deviceId }
331 if (cc == null) {
332 System.err.println("[EarhornCast] selectDevice: no device with id=$deviceId")
333 // Not on the network right now — but desiredDeviceId is set, so
334 // discoveryListener.newChromeCastDiscovered will pick it up when
335 // it appears. Show "reconnecting" until then. The reconnect
336 // loop also retries periodically so we don't depend on jmDNS
337 // to fire a fresh discovery event.
338 _isReconnecting.value = true
339 startReconnectLoop()
340 return@launch
341 }
342 // Stale-state cleanup (sender bug class §4.4, "connect while
343 // zombie-casting"): if we think we're casting but the backing
344 // session's socket is actually dead — a drop jmDNS never told us
345 // about, a receiver reboot, a suspend/resume — reset the flags
346 // BEFORE the already-connected check below. Without this, the
347 // no-op branch would see deviceId match + _isCasting still true
348 // and return, leaving the user stuck re-clicking a dead entry
349 // with no recovery short of restarting the app.
350 val zombie = _session.value
351 if (zombie != null && _isCasting.value &&
352 !runCatching { zombie.backing.isConnected }.getOrDefault(false)
353 ) {
354 System.err.println(
355 "[EarhornCast] selectDevice: session to ${zombie.friendlyName} " +
356 "is a zombie (flags say casting, socket says dead) — " +
357 "resetting state before connecting"
358 )
359 _session.value = null
360 _isCasting.value = false
361 }
362
363 // Already connected — for real, socket verified? No-op.
364 val current = _session.value
365 if (current?.deviceId == deviceId && _isCasting.value &&
366 runCatching { current.backing.isConnected }.getOrDefault(false)
367 ) {
368 _isReconnecting.value = false
369 return@launch
370 }
371
372 // Tear the previous device's session down first, inline (Team 3
373 // P1-6). NOT disconnect(): that clears desiredDeviceId — now this
374 // device, so it would never auto-reconnect after a blip — and nulls
375 // _session from a separate coroutine, which could land after the
376 // connect below and drop the NEW session (and its proxy links).
377 // Skip if the only reason we had no session was a transient drop of
378 // the same target device.
379 if (current != null && current.deviceId != deviceId) {
380 runCatching { current.backing.stopApp() }
381 runCatching { current.backing.disconnect() }
382 if (_session.compareAndSet(current, null)) {
383 _isCasting.value = false
384 _castingToGroup.value = false
385 }
386 }
387
388 try {
389 if (!cc.isConnected) cc.connect()
390 // Default media receiver. We don't currently use a custom app id,
391 // so the stock receiver handles audio playback.
392 cc.launchApp(DEFAULT_MEDIA_RECEIVER_APP_ID)
393 _session.value = CastSession(
394 deviceId = cc.name ?: deviceId,
395 friendlyName = displayName(cc),
396 isGroup = isGroupHeuristic(cc),
397 backing = cc,
398 )
399 _castingToGroup.value = isGroupHeuristic(cc)
400 _isCasting.value = true
401 _isReconnecting.value = false
402 // Reconnect loop, if any, is done.
403 reconnectJob?.cancel()
404 reconnectJob = null
405 } catch (e: Exception) {
406 System.err.println("[EarhornCast] connect failed: ${e.message}")
407 _session.value = null
408 _isCasting.value = false
409 // Leave _isReconnecting true if the user still wants this
410 // device — we'll try again when discovery sees it next.
411 // Also start the periodic retry loop so we self-heal even
412 // without a fresh mDNS event.
413 _isReconnecting.value = (desiredDeviceId == deviceId)
414 if (_isReconnecting.value) startReconnectLoop()
415 }
416 }
417 }
418
419 /** Disconnect the current session if any. This is the explicit user-driven
420 * teardown — it clears [desiredDeviceId] so the auto-reconnect path
421 * doesn't bring the session back up after the user said "stop." */
422 fun disconnect() {
423 // Clear immediately so a near-simultaneous discovery event doesn't
424 // race us into reconnecting.
425 desiredDeviceId = null
426 _isReconnecting.value = false
427 reconnectJob?.cancel()
428 reconnectJob = null
429 scope.launch {
430 val s = _session.value ?: return@launch
431 runCatching { s.backing.stopApp() }
432 runCatching { s.backing.disconnect() }
433 // Only if it is still THAT session: a pick made meanwhile (Stop,
434 // then re-pick at once) may already have a new one, and nulling it
435 // — or stopping the proxy its links point at — would break it.
436 if (_session.compareAndSet(s, null)) {
437 _isCasting.value = false
438 _castingToGroup.value = false
439 streamProxy.stop()
440 }
441 }
442 }
443
444 /**
445 * Release the desktop's "this is my chosen cast device" marker without
446 * tearing down the receiver-side session. Used when the user switches the
447 * active renderer to local or to a UPnP remote: the cast device should
448 * keep playing (distributed playback is the whole point), but the desktop
449 * stops being its remote control — which means we also stop auto-
450 * reconnecting if the device blips off the network.
451 *
452 * If the user re-picks the same cast device later, [selectDevice] is
453 * called again and the session is re-established. The receiver-side
454 * session that may still be running on the device is detected and reused.
455 */
456 fun releaseControl() {
457 desiredDeviceId = null
458 _isReconnecting.value = false
459 }
460
461 /**
462 * Wait for an active cast session to land, returning it once available.
463 * Returns null if the timeout elapses (typically meaning the user picked a
464 * device that isn't on the network or the connect handshake failed).
465 *
466 * This is the key fix for the "I picked cast and the music kept playing
467 * on local" failure mode: transport operations called right after
468 * [selectDevice] would otherwise race the async connect/launchApp work
469 * and silently drop because `_session.value` was still null. Waiting
470 * (briefly) guarantees we issue commands AGAINST the receiver instead of
471 * into the void.
472 */
473 private suspend fun ensureSession(): CastSession? {
474 _session.value?.let { return it }
475 return withTimeoutOrNull(SESSION_WAIT_TIMEOUT_MS) {
476 _session.first { it != null }
477 }
478 }
479
480 /** Load a single track on the current Cast session. If the session targets
481 * a speaker group, the URL is rewritten through the local stream proxy
482 * so the group leader gets well-behaved HTTP responses. */
483 fun load(track: DidlItem.Track) {
484 scope.launch {
485 val s = ensureSession() ?: run {
486 System.err.println("[EarhornCast] load: no session within timeout")
487 return@launch
488 }
489 val cc = s.backing
490 // The proxy listens on the address this computer uses to reach the
491 // group (never every interface); when there is none it says so and
492 // the group fetches from the server directly.
493 val url = if (_castingToGroup.value) {
494 streamProxy.proxyUrl(track.resourceUrl, cc.address) ?: track.resourceUrl
495 } else {
496 track.resourceUrl
497 }
498 try {
499 cc.load(
500 /* mediaTitle = */ track.title,
501 /* thumb = */ track.albumArtUri,
502 /* url = */ url,
503 /* contentType = */ track.mimeType ?: "audio/mpeg",
504 )
505 // Bootstrap the media-channel subscription. The chromecast-java-api-v2
506 // library only starts pushing spontaneous MEDIA_STATUS events to
507 // registered listeners after getMediaStatus() has been called at
508 // least once for the running app — calling it here ensures the
509 // listener registered in CastRenderer actually receives events
510 // for THIS load. Without this call, some receivers stay silent and
511 // the UI shows a frozen play/pause + position bar.
512 runCatching { cc.mediaStatus }
513 .onFailure { t ->
514 System.err.println(
515 "[EarhornCast] post-load getMediaStatus failed " +
516 "(media events may not fire): ${t.message}"
517 )
518 }
519 } catch (e: Exception) {
520 System.err.println("[EarhornCast] load failed: ${e.message}")
521 }
522 }
523 }
524
525 fun play() {
526 scope.launch {
527 val s = ensureSession() ?: return@launch
528 runCatching { s.backing.play() }
529 .onFailure { t ->
530 System.err.println("[EarhornCast] play failed: ${t.message}")
531 }
532 }
533 }
534
535 fun pause() {
536 scope.launch {
537 val s = ensureSession() ?: return@launch
538 runCatching { s.backing.pause() }
539 .onFailure { t ->
540 System.err.println("[EarhornCast] pause failed: ${t.message}")
541 }
542 }
543 }
544
545 /**
546 * Soft-stop: pause and rewind to start.
547 *
548 * The earlier implementation called `cc.stopApp()` which fully kills the
549 * receiver app on the device. On Chromecast Audio firmware, killing the
550 * receiver also briefly drops the device's mDNS advertisement — our
551 * discovery listener fires `chromeCastRemoved`, the device disappears
552 * from the picker, and the user can't re-select it for the mDNS-TTL
553 * window (often 30+ seconds).
554 *
555 * Pause + seek(0) achieves the user-visible "Stop" semantic (audio
556 * silences, playhead returns to start, the session is in a state where
557 * Play resumes the same track from 0) without taking the device offline.
558 * The receiver stays alive in the same media session, and the device
559 * stays visible in the picker.
560 *
561 * Explicit teardown (e.g. user picks "Stop and switch" in the renderer
562 * confirmation dialog) still goes through [disconnect], which does kill
563 * the receiver — that's the right semantic there.
564 */
565 fun stop() {
566 scope.launch {
567 val s = ensureSession() ?: return@launch
568 val backing = s.backing
569 runCatching { backing.pause() }
570 .onFailure { t -> System.err.println("[EarhornCast] stop/pause: ${t.message}") }
571 // Breathing room: some Chromecast Audio firmware refuses two
572 // media-channel commands issued back-to-back (the second sees a
573 // stale mediaSessionId from before the first was processed).
574 // ~120 ms is enough to clear that window in practice while still
575 // feeling instant to the user.
576 delay(STOP_INTER_COMMAND_DELAY_MS)
577 runCatching { backing.seek(0.0) }
578 .onFailure { t -> System.err.println("[EarhornCast] stop/seek: ${t.message}") }
579 }
580 }
581
582 /**
583 * Volume changes are debounced through a single-worker pattern.
584 *
585 * The volume slider in Compose can fire onValueChange dozens of times per
586 * second during a drag. Each [su.litvak.chromecast.api.v2.ChromeCast.setVolume]
587 * call is a network round-trip to the device — sending all of them is both
588 * laggy (the device processes them serially) and noisy. Debouncing keeps
589 * only the most recent target volume; the worker drains it on a rate-
590 * limited cadence so the device hears a manageable rate of updates and
591 * the final volume always matches the slider's final position.
592 */
593 /** Latest requested level; CONFLATED keeps only the newest. One worker
594 * lives as long as [scope], so a value sent just as the previous one
595 * finished can't be lost (the old start-a-worker-if-idle check could miss
596 * it, dropping the slider's final position — Team 3 P2-14). */
597 private val volumeRequests = Channel<Float>(Channel.CONFLATED)
598
599 private val volumeWorker: Job = scope.launch {
600 for (target in volumeRequests) {
601 val s = _session.value
602 if (s != null) {
603 runCatching { s.backing.setVolume(target) }
604 .onFailure { t ->
605 System.err.println("[EarhornCast] setVolume failed: ${t.message}")
606 }
607 }
608 // No session: the update is dropped. (We don't wait, unlike
609 // ensureSession() in other ops: volume changes pile up so fast
610 // during a drag that even a 100ms wait would back up.)
611 delay(VOLUME_RATE_LIMIT_MS)
612 }
613 }
614
615 fun setVolume(level: Float) {
616 volumeRequests.trySend(level.coerceIn(0f, 1f))
617 }
618
619 fun seek(seconds: Double) {
620 scope.launch {
621 val s = ensureSession() ?: return@launch
622 runCatching { s.backing.seek(seconds) }
623 .onFailure { t ->
624 System.err.println("[EarhornCast] seek failed: ${t.message}")
625 }
626 }
627 }
628
629 /**
630 * Poll the receiver for its current MediaStatus. Used by [CastRenderer] as a
631 * fallback to spontaneous events, which the chromecast-java-api-v2 library
632 * doesn't always reliably deliver. Returns null if no session is active or
633 * if the receiver had nothing to report. Runs on the IO dispatcher via
634 * [scope]; result is delivered via the [onStatus] callback rather than
635 * returned directly so the suspend-vs-non-suspend boundary is in one place.
636 *
637 * Failures are logged once at the WARN level rather than thrown — a single
638 * dropped poll shouldn't crash the renderer; the next poll catches up. The
639 * common transient failure ("No application is running in ChromeCast") just
640 * means the receiver hasn't launched the app yet.
641 */
642 fun pollMediaStatus(onStatus: (su.litvak.chromecast.api.v2.MediaStatus) -> Unit) {
643 scope.launch {
644 val s = _session.value ?: return@launch
645 val status = runCatching { s.backing.mediaStatus }
646 .onFailure { t ->
647 // Don't spam — the "no application running" error is common
648 // right after launchApp and right after stopApp. We only log
649 // if the message looks like a real transport problem.
650 val msg = t.message.orEmpty()
651 if ("No application is running" !in msg && "invalid state" !in msg) {
652 System.err.println("[EarhornCast] pollMediaStatus: ${t.message}")
653 }
654 }
655 .getOrNull() ?: return@launch
656 onStatus(status)
657 }
658 }
659
660 /**
661 * Release jmDNS discovery and synchronously tear down the active cast
662 * session. Call once at app shutdown.
663 *
664 * Note: this method runs SYNCHRONOUSLY on the calling thread (not on the
665 * integration's [scope]). Shutdown-path callers — including the JVM
666 * shutdown hook — rely on this: if we launched the teardown asynchronously
667 * the JVM could exit before the stopApp/disconnect packets actually go
668 * out, leaving the receiver app running and audible on the user's speakers
669 * with no way for this desktop to stop it. The synchronous path uses
670 * bounded I/O (the library's `stopApp()` and `disconnect()` open and close
671 * a TCP-TLS connection — a few hundred ms in the worst case).
672 */
673 fun release() {
674 runCatching { ChromeCasts.unregisterListener(discoveryListener) }
675 runCatching { ChromeCasts.stopDiscovery() }
676 // Clear flags first so any concurrent observer doesn't fight us during
677 // the synchronous teardown below.
678 desiredDeviceId = null
679 _isReconnecting.value = false
680 val s = _session.value
681 if (s != null) {
682 runCatching { s.backing.stopApp() }
683 .onFailure { System.err.println("[EarhornCast] shutdown stopApp: ${it.message}") }
684 runCatching { s.backing.disconnect() }
685 .onFailure { System.err.println("[EarhornCast] shutdown disconnect: ${it.message}") }
686 _session.value = null
687 _isCasting.value = false
688 _castingToGroup.value = false
689 }
690 streamProxy.stop()
691 // Cancel the scope last so any in-flight launches (e.g. polls) wind
692 // down. Don't bother joining — they'll be GC'd as the JVM exits.
693 runCatching { scope.cancel() }
694 }
695
696 /**
697 * Force a fresh round of cast device discovery without restarting the app.
698 *
699 * jmDNS is the discovery backend (via the chromecast-java-api-v2 library),
700 * and it can get wedged under conditions that DON'T involve any code
701 * change on our side:
702 *
703 * - Network interface coming up/down (Wi-Fi handoff, dock plug-in)
704 * - VPN tunnel binding before mDNS multicast was joined on the LAN side,
705 * so jmDNS ends up subscribed on the wrong interface
706 * - The chromecast device itself rebooting and re-announcing while we
707 * missed the SSDP packet
708 * - Stale sockets left over from a previous JVM (gradle daemon)
709 *
710 * The symptom is always the same: ChromeCasts.get() returns an empty list
711 * (or a stale subset) even though the devices are demonstrably alive
712 * elsewhere on the network. Calling stopDiscovery + startDiscovery
713 * teardown and rebuilds jmDNS's sockets, after which devices repopulate
714 * within the usual 2-5 second mDNS window.
715 *
716 * Asynchronous because jmDNS needs a beat between stop and start for its
717 * threads to wind down; doing it synchronously sometimes leaves the
718 * library in a half-initialized state where the next discovery callback
719 * fires against a closed socket. Listener subscribers (Settings,
720 * renderer picker) automatically see the new device list via the
721 * existing StateFlow plumbing — no caller cleanup needed.
722 */
723 fun rescan() {
724 scope.launch {
725 System.err.println(
726 "[EarhornCast] rescan: stopping discovery (current snapshot size = ${_devices.value.size})"
727 )
728 runCatching { ChromeCasts.stopDiscovery() }
729 .onFailure { System.err.println("[EarhornCast] rescan stopDiscovery: ${it.message}") }
730 // jmDNS internals need a beat to fully shutter sockets before we
731 // bring them back up. 300ms is empirically enough on Linux without
732 // making the user wait visibly.
733 delay(300)
734 runCatching { ChromeCasts.startDiscovery() }
735 .onFailure { System.err.println("[EarhornCast] rescan startDiscovery: ${it.message}") }
736 // First device announcements should arrive within 1-2 seconds;
737 // give jmDNS a generous window before we publish whatever it has
738 // collected so the user sees a populated list on the first refresh.
739 delay(2500)
740 refreshDevices()
741 System.err.println(
742 "[EarhornCast] rescan complete: new snapshot size = ${_devices.value.size}"
743 )
744 }
745 }
746
747 companion object {
748 // Stock Google Cast default media receiver application id. Lets us send
749 // audio URLs without registering a custom receiver in the Cast Developer
750 // Console. This is also what Android's CastIntegration uses.
751 private const val DEFAULT_MEDIA_RECEIVER_APP_ID = "CC1AD845"
752
753 /**
754 * How long [ensureSession] waits for a session to become non-null before
755 * giving up. Long enough that a fresh connect + launchApp handshake
756 * (which takes 200-800 ms on typical Cast Audio firmware) lands
757 * comfortably; short enough that a truly absent device doesn't make
758 * the user wait forever for a single transport op to time out.
759 */
760 private const val SESSION_WAIT_TIMEOUT_MS = 5_000L
761
762 /**
763 * Minimum delay between consecutive setVolume calls hitting the
764 * receiver. The Compose volume slider fires onValueChange dozens of
765 * times per second during a drag; without rate-limiting the receiver
766 * gets flooded, slider feels laggy, and final volume can land
767 * incorrectly because of queue ordering. 80 ms gives ~12 updates/sec
768 * — fast enough to feel responsive, slow enough that the device
769 * keeps up with each one.
770 */
771 private const val VOLUME_RATE_LIMIT_MS = 80L
772
773 /**
774 * Inter-command delay between the pause and seek issued by [stop].
775 * See the comment at the call site — exists to keep some Chromecast
776 * Audio firmware from rejecting the second command for using a
777 * mediaSessionId that's still being torn down by the first.
778 */
779 private const val STOP_INTER_COMMAND_DELAY_MS = 120L
780
781 /**
782 * How often the reconnect loop retries [selectDevice] while we have a
783 * pending desiredDeviceId but no session. 1.5 s gives a brief network
784 * blip time to recover transparently — the user sees the
785 * "Reconnecting…" banner for under two seconds and the controls
786 * resume — without flooding jmDNS or the device with reconnect
787 * attempts on a persistent outage.
788 */
789 private const val RECONNECT_RETRY_INTERVAL_MS = 1_500L
790
791 /** How long the ACTIVE reconnect loop runs before force-exiting the
792 * suspended/reconnecting state (sender bug class §4.4). Matches the
793 * cross-platform 15s suspended→ended timeout. Passive reconnect on
794 * mDNS reappearance is unaffected — desiredDeviceId stays set. */
795 private const val RECONNECT_GIVE_UP_MS = 15_000L
796
797 /**
798 * Every this-many reconnect attempts (so every
799 * RECONNECT_RETRY_INTERVAL_MS × RECONNECT_RESCAN_EVERY ≈ 4.5 s),
800 * stop+restart jmDNS discovery to flush a possibly-stale local mDNS
801 * cache. The cheap `ChromeCasts.get().firstOrNull { it.name == … }`
802 * check by itself waits forever in the case where jmDNS evicted the
803 * device entry (mDNS TTL expired without a re-announce, multicast
804 * packet was dropped on a flaky LAN, a VPN tunnel coming up
805 * shuffled route priorities). 3 attempts is small enough that the
806 * user doesn't sit through ages of dead waiting before we try the
807 * heavier remedy, and large enough that a clean recovery from a
808 * blip happens via the cheap path without paying the rescan cost.
809 */
810 private const val RECONNECT_RESCAN_EVERY = 3
811 }
812}
813
814// ─── data shapes ────────────────────────────────────────────────────────────
815
816/** A discoverable Cast device. Mirrors the Android side's [CastDevice]
817 * but additionally holds the underlying [ChromeCast] handle so the
818 * integration can act on it without re-resolving by id. */
819data class CastDevice(
820 val id: String,
821 val friendlyName: String,
822 val isGroup: Boolean,
823 val backing: ChromeCast,
824) {
825 /** IP address of the device's HTTP/cast endpoint. Pulled from the
826 * backing [ChromeCast] for use by the observer-mode RawCastClient,
827 * which needs to open a TLS socket directly to the device's :8009
828 * port without going through chromecast-java-api-v2. */
829 val host: String? get() = backing.address
830 /** Cast control port — always 8009 on stock receivers but read from
831 * the discovery record to be safe. The observer client falls back to
832 * 8009 if this is unavailable for any reason. */
833 val port: Int get() = backing.port.takeIf { it > 0 } ?: 8009
834}
835
836/** An active Cast session — i.e. we've launched the receiver app on a
837 * device and the connection is healthy. */
838data class CastSession(
839 val deviceId: String,
840 val friendlyName: String,
841 val isGroup: Boolean,
842 val backing: ChromeCast,
843)
844
845/** Diagnostic surface for the picker UI. Same fields as the Android side,
846 * minus the Play-Services-specific ones (which have no meaning on desktop). */
847data class DiagnosticState(
848 val rawRouteCount: Int = 0,
849 val filteredDeviceCount: Int = 0,
850 val tick: Int = 0,
851 val rawRouteNames: List<String> = emptyList(),
852)
853
854/* TODO(next session): wire this into the renderer pipeline.
855 *
856 * 1. Add a `CastRenderer : Renderer` that delegates load/play/pause/etc to
857 * a [CastIntegration]. It would also coordinate with [VlcjLocalRenderer]
858 * so local audio mutes the moment a session opens and resumes when the
859 * session ends. The simplest model: a "primary" renderer that the rest
860 * of the app talks to is a coordinator wrapping both, and switches its
861 * delegate based on isCasting.
862 *
863 * 2. Surface the picker in StaveApp.kt — a Cast button in the toolbar that
864 * opens a Compose Desktop port of CastPickerSheet.kt, showing the
865 * [devices] StateFlow.
866 *
867 * 3. Track-end handling: chromecast-java-api-v2 exposes session events via
868 * ChromeCastSpontaneousEventListener — we'll need to wire that up so
869 * "remote track finished" advances our local queue index just like
870 * VlcjLocalRenderer's MediaListPlayer nextItem event does.
871 *
872 * 4. Reconnect-after-network-blip — the Android version has retry logic.
873 * chromecast-java-api-v2 may need similar; depends on observed behavior
874 * with real hardware.
875 */