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

RawCastObserver.kt

1523 lines · 84.0 KB

Changed in: 0.5.0

1package dev.stave.cast.raw
2
3import android.content.Context
4import android.net.ConnectivityManager
5import android.net.nsd.NsdManager
6import android.net.wifi.WifiManager
7import android.net.nsd.NsdServiceInfo
8import dagger.hilt.android.qualifiers.ApplicationContext
9import dev.stave.domain.model.DidlItem
10import dev.stave.domain.renderer.PlaybackState
11import kotlinx.coroutines.*
12import kotlinx.coroutines.channels.Channel
13import kotlinx.coroutines.sync.withLock
14import kotlinx.coroutines.flow.*
15import javax.inject.Inject
16import javax.inject.Singleton
17import kotlin.coroutines.resume
18import kotlin.time.Duration
19import kotlin.time.Duration.Companion.seconds
20
21/**
22 * Discovers Chromecast devices on the LAN via mDNS (`_googlecast._tcp`) and
23 * connects to them using [RawCastClient] — no Cast SDK required.
24 *
25 * Usage from the TV:
26 * 1. Call [startDiscovery] to begin scanning.
27 * 2. Observe [devices] for available Chromecast devices.
28 * 3. Call [observe] with a device to connect and start receiving state.
29 * 4. Observe [playbackState] for metadata/position/transport state.
30 * 5. Call transport methods (play/pause/stop/next/previous/seek).
31 * 6. Call [disconnect] to detach.
32 * 7. Call [stopDiscovery] when leaving settings/picker.
33 */
34@Singleton
35class RawCastObserver @Inject constructor(
36 @ApplicationContext private val context: Context,
37 private val cd: dev.stave.upnp.ContentDirectoryClient,
38 private val settings: dev.stave.data.SettingsStore,
39 private val radio: dev.stave.cast.radio.RadioStatusClient,
40) {
41 /** Long-lived scope for persistence writes and reattach — must outlive
42 * the per-session [scope], which disconnect() cancels. */
43 private val persistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
44
45 /** Serializes reattach attempts. Two entry points exist (PlaybackService
46 * onCreate and MainActivity onStart) and at app reopen BOTH fire while
47 * observing is still false — without serialization the second attempt's
48 * observe() prologue tears down the first attempt's freshly-connected
49 * session (field-reported as "observing resumed, then reverted to the
50 * local queue seconds later"). With the mutex, the second caller waits,
51 * re-checks observing, and exits. */
52 private val reattachMutex = kotlinx.coroutines.sync.Mutex()
53
54 data class CastDevice(
55 val name: String,
56 val host: String,
57 val port: Int,
58 val id: String,
59 val model: String,
60 ) {
61 /** Whether this entry is a multi-room speaker group (rather than a single
62 * device). Cast advertises groups with model="Google Cast Group" in the
63 * `md` TXT record; everything else is a discrete speaker, display, or
64 * speaker-with-display. The picker uses this to label groups distinctly
65 * ("Speaker group" vs "Chromecast"/"Nest Audio"/etc.) so the user knows
66 * what they're observing. */
67 val isGroup: Boolean
68 get() = model.contains("Group", ignoreCase = true)
69 }
70
71 private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
72 val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()
73
74 private val _playbackState = MutableStateFlow(PlaybackState())
75 val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
76
77 private val _observing = MutableStateFlow(false)
78 private val _connectedDeviceName = MutableStateFlow<String?>(null)
79 val connectedDeviceName: StateFlow<String?> = _connectedDeviceName.asStateFlow()
80
81 /** The device the current session is attached to; null when not
82 * observing. Used by [observeOrRestore] to put a working session back
83 * when a manual pick fails. */
84 @Volatile private var currentDevice: CastDevice? = null
85 val observing: StateFlow<Boolean> = _observing.asStateFlow()
86
87 /** True while the observed receiver reports an active, non-IDLE media
88 * session (PLAYING, PAUSED, or BUFFERING all count — a paused session is
89 * still a session; voice "pause" must never look like "stopped").
90 *
91 * Deliberately flap-tolerant: a dropped TLS connection does NOT clear
92 * this — the last MEDIA_STATUS stands while the reconnect supervisor
93 * works, so a Wi-Fi blip or group-leader handoff reads as "still live".
94 * It only goes false on positive evidence (receiver reports IDLE / no
95 * media session) or when the observation ends entirely ([disconnect],
96 * including supervisor exhaustion). Consumers wanting "genuinely over"
97 * must ALSO debounce — see MainActivity's kiosk hand-back monitor. */
98 /** The most recent genuinely-new MediaStatus and the elapsedRealtime at
99 * which it arrived — the anchor for position extrapolation. */
100 @Volatile private var lastStatusRef: RawCastClient.MediaStatus? = null
101 @Volatile private var posAnchorElapsedMs = 0L
102 /** True when the receiver claims PLAYING but its reported position has
103 * not moved between consecutive statuses (~30 s apart) — a stalled
104 * file. The bar then holds at the reported position instead of
105 * advancing over silence, until musicnow's 90 s watchdog skips or the
106 * position moves again. */
107 @Volatile private var positionFrozen = false
108 @Volatile private var frozenSinceMs = 0L
109 /** Latched when a freeze persists past ZOMBIE_FREEZE_MS: the session is
110 * declared dead despite the receiver's PLAYING claim. An eternally
111 * polite zombie (healthy heartbeats, PLAYING statuses, frozen audio —
112 * Classical KC overnight, 2026-08-30) defeats every other escape:
113 * hand-back sees live=true, the migration checker sees no death and
114 * no reconnect failure. This is the escape. Cleared when position
115 * moves. Threshold sits far above musicnow's 90 s watchdog by design:
116 * the source-side skip should win whenever it exists. */
117 @Volatile private var zombieDeclared = false
118
119 /** Display position = anchored currentTime + wall time since the anchor
120 * while PLAYING; clamped to duration only when a duration exists (LIVE
121 * has none and must still advance). Paused/idle shows the anchored
122 * value unmodified. */
123 private fun extrapolatedPosition(ms: RawCastClient.MediaStatus): Duration {
124 val base = ms.currentTime.seconds
125 if (ms.playerState != "PLAYING" || positionFrozen) return base
126 val deltaMs = (android.os.SystemClock.elapsedRealtime() - posAnchorElapsedMs)
127 .coerceAtLeast(0)
128 val pos = base + (deltaMs / 1000.0).seconds
129 val dur = ms.duration.seconds
130 return if (dur > Duration.ZERO) pos.coerceAtMost(dur) else pos
131 }
132
133 /** Transport capabilities of the OBSERVED session, derived from the
134 * receiver's own supportedMediaCommands plus the LIVE judgment
135 * (streamType LIVE or no duration ⇒ seeking is meaningless even when
136 * the receiver claims COMMAND_SEEK — The Bridge reports seek:true on
137 * a LIVE stream). Null when not observing; local playback keeps its
138 * own rules. UI dims dead controls; the action layer refuses them. */
139 data class ObservedCaps(
140 val canPause: Boolean,
141 val canSeek: Boolean,
142 val canNext: Boolean,
143 val canPrev: Boolean,
144 val canShuffle: Boolean,
145 val canRepeat: Boolean,
146 val isLive: Boolean,
147 )
148
149 private val _observedCaps = MutableStateFlow<ObservedCaps?>(null)
150 val observedCaps: StateFlow<ObservedCaps?> = _observedCaps.asStateFlow()
151
152 private fun deriveCaps(ms: RawCastClient.MediaStatus, @Suppress("UNUSED_PARAMETER") queueSize: Int): ObservedCaps? {
153 if (ms.mediaSessionId == 0) return null
154 val c = ms.supportedMediaCommands
155 // Duration is the ONLY live discriminator. streamType is unusable
156 // in this house: musicnow casts library tracks as streamType LIVE
157 // with real durations (wire capture 2026-08-29 18:32, Sneaker
158 // Pimps, LIVE + 238 s).
159 val live = ms.duration <= 0.0
160 // DESIGN RULE (2026-08-29, final): THE ONLY THING THAT DIMS IS
161 // THE PROGRESS BAR. Buttons never dim, on any session. History of
162 // this field, kept so nobody re-walks it: v1 gated buttons on the
163 // receiver's supportedMediaCommands bitmask — which is a proven
164 // liar (274447 advertised on a 42-track queue with next/prev
165 // absent, while the wire honored both all week) and dimmed working
166 // controls fleet-wide. v2 derived from the observed queue. v3, this
167 // one, retires button-dimming entirely by maintainer order: a press
168 // on a control the session can't honor is a harmless no-op on the
169 // receiver, which is strictly better than a UI that guesses wrong.
170 // canSeek remains meaningful: it feeds ONLY the ProgressBar
171 // (scrubbing off + LIVE badge on duration-less sessions) and the
172 // seekTo action guard.
173 return ObservedCaps(
174 canPause = true,
175 canSeek = (c == 0L || c and 2L != 0L) && !live,
176 canNext = true,
177 canPrev = true,
178 canShuffle = true,
179 canRepeat = true,
180 isLive = live,
181 )
182 }
183
184 private val _sessionLive = MutableStateFlow(false)
185 val sessionLive: StateFlow<Boolean> = _sessionLive.asStateFlow()
186
187 /** F1 (maintainer spec 2026-09-02): the group this device could not
188 * find in ONE attempt (~20 s resolve + last-known-host dial), paired
189 * with a nonce so repeated failures of the same group re-emit. There
190 * is no retry lane anywhere. MainActivity collects it: if kiosk armed
191 * this device for the current cycle, it goes back to kiosk; otherwise
192 * it is told once and stays where it is.
193 * Cleared on attach and on every fresh request. */
194 private val _terminalObserveFailure = MutableStateFlow<Pair<String, Long>?>(null)
195 val terminalObserveFailure: StateFlow<Pair<String, Long>?> = _terminalObserveFailure.asStateFlow()
196 /** MainActivity clears the signal once it has acted on it, so a resumed
197 * activity cannot act on the same failure twice. */
198 fun consumeTerminalObserveFailure() { _terminalObserveFailure.value = null }
199
200 private var client: RawCastClient? = null
201 private var scope: CoroutineScope? = null
202 private var nsdManager: NsdManager? = null
203 private var discoveryListener: NsdManager.DiscoveryListener? = null
204 private var multicastLock: WifiManager.MulticastLock? = null
205
206 // ── Discovery ───────────────────────────────────────────────
207
208 /**
209 * Per-call resolve scope. `NsdManager.resolveService` on minSdk 26 cannot
210 * run multiple concurrent resolves — submitting a second resolve while one
211 * is in flight returns FAILURE_ALREADY_ACTIVE (error code 3), which silently
212 * drops the second device from the discovered list. With a Cast-rich LAN
213 * (groups + Nest Audio scattered around the group + a Shield or two) you'll
214 * get half a dozen onServiceFound callbacks within the same hundred-ms
215 * window, and all-but-one device vanishes from the picker.
216 *
217 * Fix: queue resolves through a single-consumer channel and process them
218 * one at a time, awaiting each completion before pulling the next. This is
219 * compatible across API levels (the API 33+ registerServiceInfoCallback
220 * variant would be drop-in, but we need to support older Android TV
221 * boxes too).
222 */
223 private var resolveQueue: Channel<NsdServiceInfo>? = null
224 private var resolveJob: Job? = null
225 private var discoveryScope: CoroutineScope? = null
226
227 /** Reference-counted (I.24 / review N.39, design decision
228 * 2026-09-02): a picker closing used to stop discovery under a
229 * by-name resolution or migration check that was still waiting on it.
230 * Now discovery ends only when every user has released it. */
231 private val discoveryUsers = java.util.concurrent.atomic.AtomicInteger(0)
232
233 fun startDiscovery() {
234 discoveryUsers.incrementAndGet()
235 if (discoveryListener != null) return
236 // Shield TV and some Android TV devices require a multicast lock
237 // for mDNS to work. Without it, NsdManager silently finds nothing.
238 if (multicastLock == null) {
239 // Maintainer, 2026-10-29: Ethernet-only boxes have no Wi-Fi service
240 // (getSystemService returns null) — the hard cast crashed here.
241 // No WifiManager → no multicast lock; discovery proceeds without (D13a).
242 val wifi = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager
243 multicastLock = wifi?.createMulticastLock("earhorn_cast_discovery")?.apply {
244 setReferenceCounted(false)
245 acquire()
246 }
247 }
248 val nsd = context.getSystemService(Context.NSD_SERVICE) as NsdManager
249 nsdManager = nsd
250
251 // Spin up the serial resolver. SupervisorJob so a single failed
252 // resolve doesn't poison the channel for the rest of the session.
253 val dscope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
254 discoveryScope = dscope
255 val queue = Channel<NsdServiceInfo>(capacity = Channel.UNLIMITED)
256 resolveQueue = queue
257 resolveJob = dscope.launch {
258 for (serviceInfo in queue) {
259 runCatching { resolveOne(nsd, serviceInfo) }
260 .onFailure {
261 android.util.Log.w(
262 "EarhornCast",
263 "resolveService threw for ${serviceInfo.serviceName}: ${it.message}",
264 )
265 }
266 }
267 }
268
269 val listener = object : NsdManager.DiscoveryListener {
270 override fun onDiscoveryStarted(serviceType: String) {}
271 override fun onDiscoveryStopped(serviceType: String) {}
272 override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
273 override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
274
275 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
276 // Push to the serial resolver instead of resolving inline.
277 // trySend is fine — channel is UNLIMITED so it never fails.
278 resolveQueue?.trySend(serviceInfo)
279 }
280
281 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
282 _devices.value = _devices.value.filter {
283 it.name != serviceInfo.serviceName
284 }
285 }
286 }
287 discoveryListener = listener
288 nsd.discoverServices("_googlecast._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
289 }
290
291 /**
292 * Wrap the callback-based [NsdManager.resolveService] in a suspend function
293 * so the consumer coroutine awaits completion before pulling the next item.
294 * Returns Unit either way — failures are logged and the device just doesn't
295 * appear (same as before, but now ONLY when the specific service failed,
296 * not because a concurrent resolve clobbered ours).
297 */
298 private suspend fun resolveOne(nsd: NsdManager, serviceInfo: NsdServiceInfo) {
299 suspendCancellableCoroutine<Unit> { cont ->
300 nsd.resolveService(serviceInfo, object : NsdManager.ResolveListener {
301 override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
302 android.util.Log.d(
303 "EarhornCast",
304 "resolve failed for ${info.serviceName} code=$errorCode",
305 )
306 if (cont.isActive) cont.resume(Unit)
307 }
308
309 override fun onServiceResolved(info: NsdServiceInfo) {
310 val host = info.host?.hostAddress
311 if (host == null) {
312 if (cont.isActive) cont.resume(Unit)
313 return
314 }
315 val port = info.port
316 val name = info.serviceName ?: "Chromecast"
317 val attrs = info.attributes
318 val id = attrs["id"]?.let { String(it) } ?: name
319 val model = attrs["md"]?.let { String(it) } ?: "Chromecast"
320
321 val device = CastDevice(
322 name = attrs["fn"]?.let { String(it) } ?: name,
323 host = host,
324 port = port,
325 id = id,
326 model = model,
327 )
328 _devices.value = (_devices.value.filter { it.id != device.id } + device)
329 .sortedWith(compareByDescending<CastDevice> { it.isGroup }.thenBy { it.name })
330 if (cont.isActive) cont.resume(Unit)
331 }
332 })
333 }
334 }
335
336 fun stopDiscovery() {
337 if (discoveryUsers.decrementAndGet() > 0) return
338 discoveryUsers.set(0)
339 discoveryListener?.let { listener ->
340 runCatching { nsdManager?.stopServiceDiscovery(listener) }
341 }
342 discoveryListener = null
343 // Drain and close the resolve queue, cancel the consumer. The discovery
344 // scope is canceled last so any in-flight resolveOne suspensions
345 // terminate cleanly via the cancellable continuation.
346 resolveQueue?.close()
347 resolveQueue = null
348 resolveJob?.cancel()
349 resolveJob = null
350 discoveryScope?.cancel()
351 discoveryScope = null
352 multicastLock?.release()
353 multicastLock = null
354 }
355
356 // ── Observe a Cast device ───────────────────────────────────
357
358 /**
359 * @param fromReattach true when called by [reattachIfPersisted] on app
360 * launch. Connect failures during reattach must NOT clear the persisted
361 * target — the common failure there is "phone hasn't rejoined Wi-Fi yet,"
362 * and clearing would permanently forget a session that's still playing.
363 * User-initiated observes (default) clear on failure as before.
364 */
365 /** An observe lineage is CURRENT while the shared fields still point at
366 * its client. Two overlapping observe() prologues (the picker calls
367 * this directly, mutex-free, while broadcast-driven requests hold the
368 * reattach mutex — and dual delivery lands two requests ~20 ms apart)
369 * can interleave disconnect-then-assign such that the loser's client
370 * is installed, overwritten, and ORPHANED: connected, running its own
371 * reconnect supervisor, invisible to every future disconnect(). Field
372 * evidence 2026-08-29 14:38–14:42: seven concurrent lineages
373 * handshaking the 0Up leader every few seconds — Earhorn manufacturing
374 * exactly the connection load the freeze investigation is chasing.
375 * Every long-lived loop in a lineage now checks this and
376 * self-destructs when stale. */
377 private fun stillCurrent(c: RawCastClient): Boolean = client === c
378
379 /** True when this DEVICE has no network at all — the screen's own
380 * Wi-Fi is down (router reboot, AP restart). In that state nothing
381 * can be concluded about the session: the receiver may be playing
382 * happily. Distinct from "the receiver is unreachable on a healthy
383 * network," which IS evidence. */
384 private fun networkDown(): Boolean = runCatching {
385 val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
386 cm.activeNetwork == null
387 }.getOrDefault(false)
388
389 @Volatile private var lastMigrationCheckMs = 0L
390
391 /** Group leadership migrates between physical devices (wire-proven
392 * 2026-08-29: 1UpNOut moved .240 → .87 mid-session; musicnow followed,
393 * the screen faithfully reattached to the zombie). On session-death
394 * evidence or reconnect failure, re-resolve the group name via mDNS:
395 * a DIFFERENT host means the leader moved — follow it through the
396 * existing idempotent byName path (jitter, grace, newest-wins). The
397 * SAME host means a genuine session end — do nothing, hand-back
398 * proceeds untouched. Rate-limited; discovery is started only if not
399 * already running and stopped again only if started here. */
400 fun maybeFollowLeaderMigration(reason: String) {
401 val name = _connectedDeviceName.value ?: return
402 val curHost = client?.host ?: return
403 val now = android.os.SystemClock.elapsedRealtime()
404 if (now - lastMigrationCheckMs < 10_000) return
405 lastMigrationCheckMs = now
406 persistScope.launch {
407 runCatching { startDiscovery() }
408 try {
409 val deadline = android.os.SystemClock.elapsedRealtime() + 6_000
410 while (android.os.SystemClock.elapsedRealtime() < deadline) {
411 val match = _devices.value.firstOrNull { it.name == name }
412 ?: _devices.value.firstOrNull { it.name.equals(name, ignoreCase = true) }
413 if (match != null) {
414 if (match.host != curHost) {
415 android.util.Log.i("EarhornCast",
416 "leader migration: '$name' now at ${match.host} " +
417 "(was $curHost, trigger=$reason) — following")
418 // force: the idempotent fast-path in
419 // requestObserveByName returns when we already
420 // observe this NAME — which is exactly the case
421 // here. Without the flag this line was a no-op in
422 // every build (I.1, design decision 2026-09-02).
423 requestObserveByName(name, force = true)
424 } else {
425 android.util.Log.i("EarhornCast",
426 "migration check ($reason): '$name' still at $curHost — no move")
427 }
428 return@launch
429 }
430 delay(500)
431 }
432 android.util.Log.i("EarhornCast",
433 "migration check ($reason): '$name' not resolved within 6s — no action")
434 } finally {
435 runCatching { stopDiscovery() }
436 }
437 }
438 }
439
440 /** Returns a Deferred that completes true once the session is attached
441 * (observing == true) or false if the initial connect failed. Callers
442 * that don't care may ignore it; the picker awaits it so a failed pick
443 * can put the previous session back (design decision 2026-09-02). */
444 suspend fun observe(device: CastDevice, fromReattach: Boolean = false): Deferred<Boolean> {
445 val outcome = CompletableDeferred<Boolean>()
446 disconnect(clearPersisted = false) // about to overwrite / retry — don't clobber
447 sessionMisses.clear()
448 statusMissCounts.clear()
449 val c = RawCastClient(device.host, device.port)
450 client = c
451
452 val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO)
453 scope = sc
454
455 // ── Detached bring-up ─────────────────────────────────────────
456 // The connect runs on the OBSERVER'S OWN scope, never the
457 // caller's. Callers are typically composition-scoped (the picker
458 // sheets' rememberCoroutineScope), and the UI's reaction to
459 // observation state can unmount them mid-connect. Release field
460 // log, verbatim: "observe: initial connect failed / The coroutine
461 // scope left the composition" — the NowPlaying-mounted picker's
462 // host recomposed, unmounted the sheet, and cancelled its own
463 // in-flight connect 2ms after the TLS handshake succeeded. The
464 // session's fate must be independent of any composable's
465 // lifetime.
466 //
467 // Relatedly: _observing flips true only AFTER the connect
468 // succeeds. Flipping it in the prologue was the self-destruct
469 // trigger — every screen reacted (NowPlaying collapsed to the
470 // "Connecting…" state, abandoning even a playing local track)
471 // before the session was real, which is exactly what unmounted
472 // the caller. Until the flip, the picker's own connecting
473 // spinner covers the wait.
474 val bringUp = sc.launch {
475 val connectResult = runCatching { c.connect() }
476 if (connectResult.isFailure) {
477 android.util.Log.w("EarhornCast",
478 "observe: initial connect to ${device.name} failed",
479 connectResult.exceptionOrNull())
480 // disconnect() cancels this very scope as part of
481 // cleanup; everything after the cancel up to the next
482 // suspension still runs, and there is none — safe.
483 // A failed attach never clears the persisted reattach
484 // target (I.12): each failed try in the by-name window used
485 // to erase it. Only an explicit disconnect clears it.
486 outcome.complete(false)
487 disconnect(clearPersisted = false)
488 return@launch
489 }
490 if (!stillCurrent(c)) {
491 android.util.Log.w("EarhornCast",
492 "observe: superseded during bring-up — self-destructing orphan lineage")
493 runCatching { c.disconnect() }
494 outcome.complete(false)
495 sc.cancel()
496 return@launch
497 }
498 _connectedDeviceName.value = device.name
499 currentDevice = device
500 _observing.value = true; _terminalObserveFailure.value = null
501 outcome.complete(true)
502 // Persist the target so a killed process (swipe-away, doze kill,
503 // system reclaim) can reattach on next launch, and the host so
504 // the cold-start rescue can dial it — on EVERY attach, not only
505 // the by-name path (I.11). persistScope, not sc — disconnect()
506 // cancels sc and must not race the write.
507 persistScope.launch {
508 runCatching { settings.setObservedCastDevice(serializeDevice(device)) }
509 runCatching { settings.setLastKnownHost(device.name, "${device.host}:${device.port}") }
510 }
511 // Guarantee PlaybackService exists and is foreground. Purely
512 // observing has no local playback, so nothing else creates the
513 // service — and without it, the process is frozen/killed the
514 // moment the user switches apps (the socket dies with it). The
515 // app is in the foreground here for user-initiated observes
516 // (they just tapped a device), so startForegroundService is
517 // permitted; during reattach the caller IS the service (or the
518 // Activity), which is equally fine.
519 runCatching {
520 androidx.core.content.ContextCompat.startForegroundService(
521 context,
522 android.content.Intent(
523 context, dev.stave.playback.PlaybackService::class.java,
524 ).setAction(dev.stave.playback.PlaybackService.ACTION_OBSERVE_KEEPALIVE),
525 )
526 }.onFailure {
527 android.util.Log.w("EarhornCast", "observe: keep-alive service start failed", it)
528 }
529
530 // ── Connection supervisor ─────────────────────────────────────
531 // When the TLS session drops mid-observation (CCA reboot, group
532 // leader handoff, Wi-Fi blip), reconnect with backoff. On success
533 // the same mediaSessionId usually survives and the periodic loop's
534 // queue-refetch safety net restores the queue. If all attempts
535 // fail, exit observer mode CLEANLY: observing=false makes every
536 // screen fall back to the local renderer state — the user stays
537 // exactly where they were (NowPlaying shows the local queue), no
538 // crash, no bounce to Browse.
539 sc.launch {
540 var sawConnected = false
541 c.connected.collect { conn ->
542 if (!stillCurrent(c)) {
543 android.util.Log.w("EarhornCast",
544 "supervisor: stale lineage detected — self-destructing")
545 runCatching { c.disconnect() }
546 sc.cancel()
547 return@collect
548 }
549 if (conn) {
550 sawConnected = true
551 return@collect
552 }
553 if (!sawConnected || !_observing.value) return@collect
554 android.util.Log.w("EarhornCast",
555 "observer connection lost — reconnecting")
556 var recovered = false
557 for (attempt in 1..4) {
558 kotlinx.coroutines.delay(attempt * 1_500L)
559 if (!_observing.value || !stillCurrent(c)) {
560 runCatching { c.disconnect() }
561 return@collect
562 }
563 val r = runCatching { c.connect() }
564 r.exceptionOrNull()?.let {
565 android.util.Log.w("EarhornCast",
566 "reconnect attempt $attempt failed", it)
567 // The host may simply no longer lead this group.
568 // Check once, early; a confirmed move supersedes
569 // this lineage via byName and the stale guards.
570 if (attempt == 1) maybeFollowLeaderMigration("reconnect-failure")
571 }
572 if (r.isSuccess) {
573 android.util.Log.i("EarhornCast",
574 "observer reconnected (attempt $attempt)")
575 recovered = true
576 break
577 }
578 }
579 if (!recovered && _observing.value && stillCurrent(c)) {
580 // EXHAUSTION NEVER ENDS ON AN EMPTY SCREEN. Maintainer
581 // rule, 2026-08-31, after two strand incidents: no
582 // scenario exists in which a screen dumped onto an
583 // empty local queue is the desired outcome of ANY
584 // failure. So: the observation is HELD (sticky Now
585 // Playing stays up) and this loop patiently retries
586 // forever — reconnecting the device, and periodically
587 // re-resolving the group for moved leaders. What
588 // distinguishes the two failure classes is ONLY the
589 // evidence flag: unreachable on a healthy network is
590 // session-death evidence, so sessionLive drops and an
591 // ARMED screen hands back to kiosk within 20 s (the one
592 // defensible exit); a whole-house outage is no
593 // evidence of anything, so sessionLive keeps its last
594 // value and hand-back stays quiet. Every other exit
595 // from this loop is a successful reattach, a
596 // supersession by a newer lineage, or hand-back's own
597 // teardown — never a dead screen.
598 val netDownAtExhaust = networkDown()
599 if (netDownAtExhaust) {
600 android.util.Log.w("EarhornCast",
601 "reconnect exhausted with NO network — whole-house outage; " +
602 "holding up to ${OUTAGE_HANDBACK_MS / 1000}s, then exiting to Haus")
603 // DESIGN SPEC 2026-08-31: a stream cut by a down
604 // router means the screen recognizes it and
605 // gracefully exits to Faveat kiosk. The hold exists
606 // only to ride sub-minute blips (AP roam, DHCP
607 // renew); past the grace the session is declared
608 // over and armed screens hand back — the launch is
609 // local and needs no network. Resumed music
610 // re-flips them via OBSERVE as always.
611 val outageStart = android.os.SystemClock.elapsedRealtime()
612 persistScope.launch {
613 kotlinx.coroutines.delay(OUTAGE_HANDBACK_MS)
614 if (_observing.value && stillCurrent(c) && networkDown()) {
615 android.util.Log.w("EarhornCast",
616 "outage exceeded ${OUTAGE_HANDBACK_MS / 1000}s — " +
617 "declaring session over; hand-back engages")
618 _sessionLive.value = false
619 }
620 }
621 } else {
622 // design decision 2026-09-02: exhaustion on a healthy
623 // network is the end of this observation, not an
624 // infinite "Waiting for…" hold (I.8). Signal it the
625 // same way a failed by-name attach does: an armed
626 // screen goes home to kiosk, an unarmed device is told
627 // once ("Couldn't reach <group>") and returns to
628 // browsing. Then release the session.
629 val failedName = _connectedDeviceName.value ?: c.host
630 android.util.Log.w("EarhornCast",
631 "reconnect exhausted on a healthy network — releasing '$failedName' and signalling")
632 _sessionLive.value = false
633 _terminalObserveFailure.value =
634 failedName to android.os.SystemClock.elapsedRealtime()
635 disconnect(clearPersisted = false)
636 return@collect
637 }
638 // Whole-house outage: hold, riding sub-minute blips, until
639 // the network returns or OUTAGE_HANDBACK_MS declares the
640 // session over (armed screens hand back then).
641 while (_observing.value && stillCurrent(c)) {
642 kotlinx.coroutines.delay(15_000)
643 if (networkDown()) continue
644 val rr = runCatching { c.connect() }
645 if (rr.isSuccess) {
646 android.util.Log.i("EarhornCast",
647 "reattached after outage hold — statuses will refresh state")
648 return@collect
649 }
650 maybeFollowLeaderMigration("exhaust-hold")
651 }
652 // Superseded or observation ended (hand-back, user,
653 // newer lineage) — tidy this lineage's socket only.
654 runCatching { c.disconnect() }
655 }
656 }
657 }
658
659 // Bridge MediaStatus + receiver queue → PlaybackState. The queue
660 // arrives via QUEUE_GET_ITEM_IDS / QUEUE_GET_ITEMS (fetched inside
661 // the client, refreshed on QUEUE_CHANGE broadcasts), so followers
662 // see the same live queue the sender does — including edits made
663 // on the sender while we observe. queueIndex is derived from the
664 // receiver's currentItemId, not a local counter, so it can never
665 // drift from what's actually audible.
666 // The enrichment cache is a third combine input: whenever a UPnP
667 // lookup lands, the state re-emits with year/trackNumber/format
668 // merged into current AND every queue row — so the phone's queue
669 // listing and Track Nº populate without any per-screen wiring.
670 sc.launch {
671 combine(c.mediaStatus, c.queueItems, enrichHits) { ms, qItems, hits ->
672 Triple(ms, qItems, hits)
673 }
674 .collect { (ms, qItems, hits) ->
675 // Re-anchor the position clock ONLY on a genuinely new
676 // playback report. Rebuilds caused by enrichment/queue
677 // emissions reuse the same ms object, and a RECEIVER_STATUS
678 // volume report produces a COPY with the same position;
679 // neither is a new report. statusSeq advances only on
680 // MEDIA_STATUS (review N.1, 2026-09-02: the volume copy
681 // used to count as "same position twice" and hold the bar).
682 if (ms.statusSeq != (lastStatusRef?.statusSeq ?: -1L)) {
683 val prev = lastStatusRef
684 // PLAYING *or* BUFFERING: the 2026-08-31 Zatara zombie
685 // froze in BUFFERING at 15884.356s and answered every
686 // reconnect with it for hours — a PLAYING-only gate
687 // (this code's, and musicnow's watchdog's, identically)
688 // never fires on it. Any active-claiming state with a
689 // pinned position counts toward the freeze.
690 val activeStates = setOf("PLAYING", "BUFFERING")
691 val frozenNow = prev != null &&
692 ms.playerState in activeStates && prev.playerState in activeStates &&
693 ms.mediaSessionId == prev.mediaSessionId &&
694 ms.currentTime > 0.0 && ms.currentTime == prev.currentTime
695 if (frozenNow != positionFrozen) {
696 positionFrozen = frozenNow
697 frozenSinceMs = if (frozenNow)
698 android.os.SystemClock.elapsedRealtime() else 0L
699 if (!frozenNow && zombieDeclared) {
700 zombieDeclared = false
701 android.util.Log.i("EarhornCast",
702 "position moving again — zombie declaration withdrawn")
703 }
704 android.util.Log.i("EarhornCast",
705 if (frozenNow) "position frozen at ${ms.currentTime}s while PLAYING — holding bar"
706 else "position moving again — resuming bar")
707 }
708 lastStatusRef = ms
709 posAnchorElapsedMs = android.os.SystemClock.elapsedRealtime()
710 }
711 observedItemIds = qItems.map { it.itemId }
712 val queueTracks = qItems.map { applyEnrichment(queueItemToTrack(it), hits) }
713 val qIndex = qItems.indexOfFirst { it.itemId == ms.currentItemId }
714 // Current track: prefer the fetched queue item matching
715 // currentItemId. currentItemId is present in EVERY
716 // status push (including the media-less transport-only
717 // ones), while the media/metadata block is frequently
718 // absent or half-populated during auto-advance — the
719 // old MediaStatus-only path left stale art/title on
720 // screen for several tracks until a fully-populated
721 // status happened to arrive. Queue items carry complete
722 // per-item metadata (incl. images) from the fetch, so
723 // art flips the moment the receiver reports the new
724 // item ID. MediaStatus remains the fallback before the
725 // queue is fetched.
726 val fromQueue = if (qIndex >= 0) queueTracks[qIndex] else null
727 val current = fromQueue ?: if (ms.title != null) applyEnrichment(DidlItem.Track(
728 id = ms.contentId ?: "",
729 parentId = "",
730 title = ms.title,
731 artist = ms.artist,
732 albumArtist = ms.artist,
733 album = ms.album,
734 composer = null,
735 genre = null,
736 date = null,
737 trackNumber = null,
738 discNumber = null,
739 duration = ms.duration.seconds,
740 albumArtUri = ms.albumArtUrl,
741 resourceUrl = ms.contentId ?: "",
742 mimeType = null,
743 sampleFrequency = null,
744 bitsPerSample = null,
745 channels = null,
746 bitrate = null,
747 size = null,
748 ), hits) else null
749 _playbackState.value = PlaybackState(
750 current = current,
751 queue = queueTracks,
752 queueIndex = qIndex,
753 isPlaying = ms.playerState == "PLAYING",
754 isBuffering = ms.playerState == "BUFFERING",
755 isStopped = ms.playerState == "IDLE",
756 position = extrapolatedPosition(ms),
757 duration = ms.duration.seconds,
758 volumePercent = (ms.volume * 100).toInt(),
759 repeatMode = when (ms.repeatMode) {
760 "REPEAT_ALL", "REPEAT_ALL_AND_SHUFFLE" -> dev.stave.domain.renderer.RepeatMode.ALL
761 "REPEAT_SINGLE" -> dev.stave.domain.renderer.RepeatMode.ONE
762 else -> dev.stave.domain.renderer.RepeatMode.OFF
763 },
764 shuffleEnabled = ms.repeatMode == "REPEAT_ALL_AND_SHUFFLE",
765 )
766 // Positive-evidence liveness for the kiosk hand-back
767 // monitor. IDLE with a lingering mediaSessionId (queue
768 // just finished, receiver not yet unloaded) counts as
769 // NOT live — "finished" is exactly the state hand-back
770 // exists for. Sub-second IDLE flaps at track boundaries
771 // are absorbed by the consumer-side debounce.
772 if (positionFrozen && !zombieDeclared && frozenSinceMs > 0 &&
773 android.os.SystemClock.elapsedRealtime() - frozenSinceMs > ZOMBIE_FREEZE_MS
774 ) {
775 zombieDeclared = true
776 android.util.Log.w("EarhornCast",
777 "${ms.playerState} with frozen position for ${ZOMBIE_FREEZE_MS / 1000}s — " +
778 "declaring session dead (zombie); hand-back and migration paths engage")
779 maybeFollowLeaderMigration("frozen-zombie")
780 }
781 val liveNow = !zombieDeclared &&
782 ms.mediaSessionId != 0 && ms.playerState != "IDLE"
783 if (_sessionLive.value && !liveNow && _observing.value) {
784 // Session just died under us. Either it genuinely
785 // ended (hand-back's case — the check is a no-op)
786 // or leadership moved and we hold the zombie's
787 // view (tonight's case — the check follows).
788 maybeFollowLeaderMigration("session-death")
789 }
790 _sessionLive.value = liveNow
791 _observedCaps.value =
792 if (_observing.value) deriveCaps(ms, qItems.size) else null
793 }
794 }
795
796 // (A second, older reconnect loop lived here — flat 5 s retry on
797 // the same `connected` flow the supervisor above watches. Each of
798 // its connect() calls began with disconnect(), tearing down the
799 // supervisor's fresh session and re-emitting a drop, so the two
800 // loops traded reconnects for the life of the observation; its
801 // failure branch blanked the screen. Found by review's full read,
802 // 2026-09-02. Removed; the supervisor is the only reconnect path.)
803
804 // Periodic status/queue safety loop.
805 // Multi-observer etiquette: several devices observing one group
806 // leader must not tick in lockstep (synchronized bursts) nor pull
807 // on every tick — the leader is also mixing multiroom audio on a
808 // small SoC, and four observers hammering GET_STATUS every 10s
809 // measurably competes with the audio pipeline. Two measures:
810 // 1. Random start jitter desynchronizes the tick phase across
811 // devices.
812 // 2. Pulls are gated on staleness: the receiver broadcasts
813 // MEDIA_STATUS / RECEIVER_STATUS on every transport, track,
814 // and volume change, so while things are happening we send
815 // nothing. A pull fires only after 25s of silence (position
816 // is locally interpolated between statuses, so a 25s resync
817 // cadence is invisible in the progress bar).
818 // Queue-fetch safety net stays: reconnect to the SAME session
819 // clears the queue without re-triggering the session-change
820 // branch; whenever we have a session but no queue, ask again.
821 sc.launch {
822 kotlinx.coroutines.delay(1_500 + (0..3_500).random().toLong())
823 if (c.queueItems.value.isEmpty() &&
824 c.mediaStatus.value.mediaSessionId != 0
825 ) {
826 runCatching { c.requestQueueItemIds() }
827 }
828 var silenceDeclared = false
829 while (true) {
830 kotlinx.coroutines.delay(10_000)
831 val statusAge = c.mediaStatusAgeMs()
832 if (statusAge > 25_000) {
833 runCatching { c.requestMediaStatus() }
834 }
835 // F2 (maintainer-authorized build, 2026-09-02; threshold the
836 // the maintainer's): a socket that answers heartbeats but stops
837 // sending MEDIA_STATUS starves the freeze detector, which
838 // compares consecutive statuses. Status AGE is the
839 // instrument the loop already holds. Past STATUS_SILENCE_MS
840 // on a session we believed live, that silence is
841 // session-death evidence: sessionLive drops, hand-back and
842 // the migration check run. Withdrawn automatically — the
843 // next real status recomputes sessionLive from the
844 // receiver's own claim.
845 if (!silenceDeclared && statusAge > STATUS_SILENCE_MS &&
846 _observing.value && stillCurrent(c) && _sessionLive.value
847 ) {
848 silenceDeclared = true
849 android.util.Log.w("EarhornCast",
850 "status silence ${statusAge / 1000}s on session " +
851 "${c.mediaStatus.value.mediaSessionId} — declaring session dead")
852 _sessionLive.value = false
853 maybeFollowLeaderMigration("status-silence")
854 } else if (silenceDeclared && statusAge <= STATUS_SILENCE_MS) {
855 silenceDeclared = false
856 android.util.Log.i("EarhornCast", "statuses resumed — silence declaration withdrawn")
857 }
858 if (c.receiverStatusAgeMs() > 25_000) {
859 runCatching { c.requestReceiverStatus() }
860 }
861 if (c.queueItems.value.isEmpty() &&
862 c.mediaStatus.value.mediaSessionId != 0
863 ) {
864 runCatching { c.requestQueueItemIds() }
865 }
866 }
867 }
868
869 // Position interpolation — monotonic anchor, not increment.
870 // Every genuinely NEW MEDIA_STATUS (pushed or polled) re-anchors the
871 // clock in the collector above; display position is always
872 // anchor-position + wall-time-since-anchor while PLAYING. This
873 // replaces a +=1s ticker that had two defects: (1) state rebuilds
874 // triggered by enrichment/queue emissions re-applied the LAST
875 // status's stale currentTime, snapping the bar backwards by up to a
876 // poll interval mid-track; (2) the tick was gated on duration > 0,
877 // so LIVE streams never advanced at all — frozen 30 s, then a jump.
878 sc.launch {
879 while (true) {
880 kotlinx.coroutines.delay(1_000)
881 val ms = lastStatusRef ?: continue
882 val s = _playbackState.value
883 if (s.isPlaying) {
884 _playbackState.value = s.copy(position = extrapolatedPosition(ms))
885 }
886 }
887 }
888
889 // ── Metadata enrichment worker ────────────────────────────────
890 // The Cast wire protocol carries title/artist/album/art but NOT
891 // year, track number, mime type, sample rate, or bit depth — those
892 // are UPnP-side facts the receiver never ships in MEDIA_STATUS.
893 // Look each observed track up on the user's preferred UPnP server
894 // and stash the match in [enrichHits]; the state builder above
895 // combines that cache in, so hits flow into current + queue rows
896 // for EVERY consumer (phone NowPlaying Track Nº, queue-row year/#N,
897 // TV metadata row, service notification) with zero per-screen
898 // wiring. This deliberately supersedes doing it per-ViewModel —
899 // the VM-level observedTrackEnriched only ever fed the TV branch,
900 // which is exactly why phone layouts shipped without year/Track Nº.
901 //
902 // Trigger is distinct-by-key: position ticks and cache updates
903 // don't restart the loop; only a genuine track/queue change does.
904 // Lookups run serially with a small gap — a 42-track queue is ~42
905 // searches over ~10s, once, then cached for the app's lifetime
906 // (hits) or the session (misses).
907 sc.launch {
908 _playbackState
909 .map { st ->
910 (listOfNotNull(st.current) + st.queue)
911 .mapNotNull { t -> enrichKeyOf(t)?.let { k -> k to t } }
912 }
913 .distinctUntilChanged { a, b -> a.map { it.first } == b.map { it.first } }
914 .collectLatest { pairs ->
915 // Automation (wall) screens: no UPnP engine runs on them
916 // (design decision 2026-09-02). musicnow's /status now
917 // carries the library fields for the track it is playing;
918 // one GET per track change, matched by title so a stale
919 // reply can't decorate the wrong song. Only the current
920 // track can be enriched this way — queue rows have no
921 // /status source and stay as the receiver reports them.
922 if (settings.kioskFlipEnabled.first()) {
923 val cur = _playbackState.value.current ?: return@collectLatest
924 val key = enrichKeyOf(cur) ?: return@collectLatest
925 if (enrichHits.value.containsKey(key) || key in sessionMisses) return@collectLatest
926 val group = _connectedDeviceName.value ?: return@collectLatest
927 // musicnow can be a beat behind the receiver: a title
928 // mismatch on the first read is transient, not a
929 // verdict. Retry on the next status for this track,
930 // up to STATUS_ENRICH_MAX_MISSES; only then write it
931 // off for the session (design decision 2026-09-02).
932 // The retry has to happen HERE: this collector re-runs
933 // only when the key list changes, and a new status for
934 // the same track produces the same list (review
935 // countersign of fc3c26e2). A key change cancels this
936 // collectLatest body, so the loop can't outlive the track.
937 while (true) {
938 val lib = radio.fetchLibraryTrack(group)
939 if (lib != null && lib.title.trim().equals(cur.title.trim(), ignoreCase = true)) {
940 enrichHits.value = enrichHits.value + (key to lib)
941 statusMissCounts.remove(key)
942 break
943 }
944 val misses = (statusMissCounts[key] ?: 0) + 1
945 statusMissCounts[key] = misses
946 if (misses >= STATUS_ENRICH_MAX_MISSES) {
947 sessionMisses += key
948 break
949 }
950 kotlinx.coroutines.delay(STATUS_ENRICH_RETRY_MS)
951 }
952 return@collectLatest
953 }
954 for ((key, t) in pairs) {
955 if (enrichHits.value.containsKey(key) || key in sessionMisses) continue
956 val udn = settings.preferredServerUdn.first() ?: return@collectLatest
957 val match = runCatching { lookupLocal(udn, t) }.getOrNull()
958 if (match != null) {
959 enrichHits.value = enrichHits.value + (key to match)
960 } else {
961 // Session-scoped negative cache: don't hammer the
962 // server re-searching a track it doesn't have on
963 // every QUEUE_CHANGE, but do retry next session
964 // (the miss may have been the server being away).
965 sessionMisses += key
966 }
967 kotlinx.coroutines.delay(150)
968 }
969 }
970 }
971 }
972 // Callers may await completion (the picker's connecting spinner
973 // keys on it). join() is caller-cancellable but NEVER cancels the
974 // bring-up itself — if the caller's composition dies mid-connect,
975 // the session simply continues without it.
976 runCatching { bringUp.join() }
977 // If bring-up ended without reaching either completion (scope
978 // cancelled by a newer request), report failure rather than hang.
979 if (!outcome.isCompleted) outcome.complete(false)
980 return outcome
981 }
982
983 /** Manual pick (design decision 2026-09-02): try [device]; if it cannot
984 * be reached, say so once and put the previous session back, so a
985 * mis-tap never leaves a working screen on nothing. Returns true only
986 * when the NEW device attached. */
987 suspend fun observeOrRestore(device: CastDevice): Boolean {
988 val previous = currentDevice
989 val ok = runCatching { observe(device).await() }.getOrDefault(false)
990 if (ok) return true
991 android.util.Log.w("EarhornCast",
992 "pick of '${device.name}' failed — restoring " +
993 (previous?.name ?: "nothing (no previous session)"))
994 withContext(Dispatchers.Main) {
995 android.widget.Toast.makeText(
996 context, "Couldn't reach ${device.name}", android.widget.Toast.LENGTH_LONG,
997 ).show()
998 }
999 if (previous != null && previous.id != device.id) {
1000 runCatching { observe(previous).await() }
1001 }
1002 return false
1003 }
1004
1005 /**
1006 * @param clearPersisted whether to also forget the persisted reattach
1007 * target. True for user-initiated disconnects ("stop observing" — the
1008 * user meant it, don't resurrect the session on next launch). False
1009 * for internal cleanup where the session may still be worth reattaching
1010 * (observe() prologue, reattach connect failures).
1011 */
1012 fun disconnect(clearPersisted: Boolean = true) {
1013 android.util.Log.i("EarhornCast",
1014 "disconnect: clearPersisted=$clearPersisted device='${_connectedDeviceName.value}'")
1015 scope?.cancel()
1016 scope = null
1017 client?.disconnect()
1018 client = null
1019 observedItemIds = emptyList()
1020 lastStatusRef = null
1021 positionFrozen = false
1022 frozenSinceMs = 0L
1023 zombieDeclared = false
1024 _observing.value = false
1025 _observedCaps.value = null
1026 _sessionLive.value = false
1027 _connectedDeviceName.value = null
1028 currentDevice = null
1029 if (clearPersisted) {
1030 persistScope.launch { runCatching { settings.setObservedCastDevice(null) } }
1031 }
1032 }
1033
1034 // ── Observe by group name (voice-cast auto-observe, E1/E2) ───
1035
1036 /** Friendly name of the group we are currently trying to reach via
1037 * [requestObserveByName], or null when no attempt is in flight. UI can
1038 * show a harmless "Connecting to X…" placeholder off this — it is the
1039 * ONLY user-visible artifact of a pending/failed attempt (E2: never an
1040 * error dialog). Cleared when the attempt lands, is superseded, or the
1041 * grace window expires. */
1042 private val _pendingTargetName = MutableStateFlow<String?>(null)
1043 val pendingTargetName: StateFlow<String?> = _pendingTargetName.asStateFlow()
1044
1045 private var byNameJob: Job? = null
1046
1047 /**
1048 * Attach to the live CCA session of the Cast device/group whose mDNS
1049 * friendly name (`fn` TXT record — identical to the Google Home spelling)
1050 * equals [groupName]. This backs the exported OBSERVE intent.
1051 *
1052 * Semantics (per the voice-cast memo, E2):
1053 * - Idempotent: already observing that group → no-op, no restart.
1054 * - Observing a different group → switch ([observe]'s prologue tears the
1055 * old session down).
1056 * - Group not discovered yet, or its cast races us by a second or two →
1057 * keep trying for [graceWindow] (discovery + connect retries). While
1058 * the receiver has no media session we simply sit connected showing
1059 * the connecting state; MEDIA_STATUS lands whenever the cast does.
1060 * - Window expires without a connect → give up SILENTLY. No dialog, no
1061 * toast; the pending flag clears and the app stays wherever it was.
1062 * - Newest wins: a fresh call cancels any in-flight attempt, so a fast
1063 * session move (playing on A, then B seconds later) converges on B —
1064 * the caller (MainActivity) simply forwards datagram-ordered launches.
1065 *
1066 * Fire-and-forget: runs on [persistScope] so it survives the launching
1067 * activity. Serialized against reattach via [reattachMutex] — both paths
1068 * funnel into [observe], whose prologue disconnects whatever came before;
1069 * unserialized, a boot-time reattach and an OBSERVE launch would tear
1070 * each other down (same failure the mutex was introduced for).
1071 *
1072 * Matching is exact on the UTF-8 name first (memo: "exactly as Google
1073 * Home spells it" — never ASCII-fold `HeißeBad`), with a case-insensitive
1074 * fallback only, since Google Home itself is case-preserving but
1075 * case-insensitive about duplicate names.
1076 */
1077 fun requestObserveByName(
1078 groupName: String,
1079 graceWindow: Duration = 20.seconds,
1080 refillRetryBudget: Boolean = true,
1081 /** Skip the already-observing fast-path. Used by leader migration,
1082 * where the name is the same and the host is what changed. */
1083 force: Boolean = false,
1084 ) {
1085 if (refillRetryBudget) _terminalObserveFailure.value = null
1086 val name = groupName.trim()
1087 if (name.isEmpty()) return
1088 android.util.Log.i("EarhornCast", "observeByName: request name='$name' " +
1089 "currently observing='${_connectedDeviceName.value}' pending='${_pendingTargetName.value}'")
1090 byNameJob?.cancel()
1091 byNameJob = persistScope.launch {
1092 // Idempotent fast-path — the common case for other screens is
1093 // "playing" re-announcements for the group we already follow.
1094 if (!force && _observing.value && _connectedDeviceName.value == name) return@launch
1095 // Attach jitter. Every screen in the group receives the same
1096 // "playing" event within milliseconds and every screen used to
1097 // open TLS+CONNECT to the group leader in the same instant —
1098 // exactly while the real sender (musicnow) is establishing its
1099 // own session. Receivers cap concurrent senders; a synchronized
1100 // burst at session-start is where a cast gets refused or torn
1101 // down mid-setup. Radio polling and reconnect backoff were
1102 // already jittered; the attach storm was not. 0–2000 ms spreads
1103 // the fleet across two seconds and is invisible next to the
1104 // grace window.
1105 delay(kotlin.random.Random.nextLong(0, 2_000))
1106 _pendingTargetName.value = name
1107 try {
1108 reattachMutex.withLock {
1109 // Re-check inside the lock; a reattach may have just
1110 // restored exactly this group.
1111 // Same fast-path as above; migration must pass it too
1112 // (review C.7, 2026-09-02: the first guard got `force`,
1113 // this one did not, and migration stayed a no-op).
1114 if (!force && _observing.value && _connectedDeviceName.value == name) return@withLock
1115 // Take a reference on discovery for the window; the
1116 // counter in start/stopDiscovery keeps it alive under us.
1117 runCatching { startDiscovery() }
1118 try {
1119 withTimeoutOrNull(graceWindow) {
1120 fun match(list: List<CastDevice>): CastDevice? =
1121 list.firstOrNull { it.name == name }
1122 ?: list.firstOrNull { it.name.equals(name, ignoreCase = true) }
1123 while (true) {
1124 val device = match(_devices.value)
1125 ?: match(_devices.first { match(it) != null })!!
1126 observe(device)
1127 if (_observing.value && _connectedDeviceName.value == device.name) {
1128 android.util.Log.i("EarhornCast",
1129 "observeByName: attached to ${device.name}")
1130 persistScope.launch {
1131 runCatching {
1132 settings.setLastKnownHost(
1133 device.name, "${device.host}:${device.port}")
1134 }
1135 }
1136 return@withTimeoutOrNull
1137 }
1138 // Connect failed (device rebooting, group
1139 // leader handoff). Retry while window remains.
1140 delay(1_500)
1141 }
1142 } ?: run {
1143 android.util.Log.i("EarhornCast",
1144 "observeByName: '$name' not resolved within $graceWindow")
1145 // COLD-START RESCUE (2026-09-01): a freshly
1146 // rebooted router can sit multicast-dead for
1147 // minutes, so mDNS resolution fails exactly when
1148 // a cold-started screen needs it — while warm
1149 // screens reconnect by host and never notice.
1150 // Give cold starts the same advantage: dial the
1151 // last host this group attached at. If the
1152 // leader moved, the migration machinery corrects
1153 // after attach; if the dial fails, bounded
1154 // re-resolution keeps trying — a screen never
1155 // ends dead on an empty queue (maintainer law).
1156 val remembered = runCatching {
1157 settings.lastKnownHosts.first()[name]
1158 }.getOrNull()
1159 val dialed = remembered?.let { hp ->
1160 val host = hp.substringBefore(':')
1161 val port = hp.substringAfter(':').toIntOrNull() ?: 8009
1162 android.util.Log.i("EarhornCast",
1163 "observeByName: trying last-known host $hp for '$name'")
1164 observe(CastDevice(name, host, port, id = "", model = ""))
1165 _observing.value && _connectedDeviceName.value == name
1166 } ?: false
1167 if (dialed) {
1168 _terminalObserveFailure.value = null
1169 android.util.Log.i("EarhornCast",
1170 "observeByName: attached to '$name' via last-known host")
1171 } else {
1172 // Maintainer spec 2026-09-02: ONE attempt.
1173 // A screen that cannot find its group in
1174 // ~20 s does not keep looking — a spinner
1175 // that never ends is a dead screen. The
1176 // signal below is the only exit; what it
1177 // does is MainActivity's call (kiosk if kiosk
1178 // armed this device this cycle, else say so
1179 // once and stay put).
1180 android.util.Log.w("EarhornCast",
1181 "observeByName: '$name' unreachable after one attempt — signalling")
1182 _terminalObserveFailure.value =
1183 name to android.os.SystemClock.elapsedRealtime()
1184 }
1185 }
1186 } finally {
1187 runCatching { stopDiscovery() }
1188 }
1189 }
1190 } finally {
1191 // Clear only our own pending marker — a newer request may
1192 // already have replaced it.
1193 _pendingTargetName.compareAndSet(name, null)
1194 }
1195 }
1196 }
1197
1198 // ── Reattach after process death ─────────────────────────────
1199
1200 /**
1201 * If the process previously died while observing (swipe-away, doze kill,
1202 * system reclaim), reconnect to the persisted device — but only keep the
1203 * session if the CCA actually still has active media. Called by
1204 * PlaybackService.onCreate, which is the first thing alive on any app
1205 * launch (the UI's MediaController bind creates it).
1206 *
1207 * Outcomes:
1208 * - No persisted target / already observing → no-op.
1209 * - Connect fails (Wi-Fi not up yet, device rebooted) → retry once after
1210 * 3s, then give up but KEEP the persisted target for the next launch.
1211 * - Connects but the receiver has no media session → the thing we were
1212 * following ended while we were gone. Disconnect and clear so the app
1213 * lands cleanly in local mode instead of showing an idle observer.
1214 */
1215 suspend fun reattachIfPersisted() = reattachMutex.withLock {
1216 // Re-check INSIDE the lock: the sibling entry point may have just
1217 // finished a successful reattach while we were waiting.
1218 if (_observing.value) return@withLock
1219 val serialized = runCatching { settings.observedCastDevice.first() }.getOrNull()
1220 ?: return@withLock
1221 val device = deserializeDevice(serialized) ?: run {
1222 persistScope.launch { runCatching { settings.setObservedCastDevice(null) } }
1223 return@withLock
1224 }
1225 android.util.Log.i("EarhornCast", "reattach: trying ${device.name} @ ${device.host}")
1226 for (attempt in 1..2) {
1227 observe(device, fromReattach = true)
1228 if (_observing.value) break
1229 if (attempt == 1) kotlinx.coroutines.delay(3_000)
1230 }
1231 if (!_observing.value) {
1232 android.util.Log.i("EarhornCast",
1233 "reattach: connect failed — keeping target for next launch")
1234 return@withLock
1235 }
1236 // Connected. Is anything actually playing/loaded? mediaSessionId
1237 // stays 0 until the receiver reports an active media session; give
1238 // the post-connect GET_STATUS round-trip time to land (groups route
1239 // media status via the leader and can be slower than solo devices).
1240 val probeClient = client
1241 val active = probeClient != null && withTimeoutOrNull(8_000) {
1242 probeClient.mediaStatus.first { it.mediaSessionId != 0 }
1243 } != null
1244 when {
1245 active ->
1246 android.util.Log.i("EarhornCast", "reattach: session live — observing resumed")
1247 // Only stand down on POSITIVE evidence of an idle receiver:
1248 // still observing, on the same live client we probed. If the
1249 // session died or was replaced meanwhile (client null/changed,
1250 // observing flipped), clearing persistence here would destroy
1251 // the very state a later reattach needs — never do that on
1252 // ambiguity (field-reported: cleared persistence made every
1253 // subsequent launch land silently in local mode).
1254 _observing.value && probeClient != null && client === probeClient -> {
1255 android.util.Log.i("EarhornCast",
1256 "reattach: no active media session on ${device.name} — standing down")
1257 disconnect(clearPersisted = true)
1258 }
1259 else ->
1260 android.util.Log.i("EarhornCast",
1261 "reattach: session state changed during probe — leaving persistence intact")
1262 }
1263 }
1264
1265 /** US-separator join — device names can contain any printable character,
1266 * so delimit with \u001F which can't appear in mDNS TXT values. */
1267 private fun serializeDevice(d: CastDevice): String =
1268 listOf(d.name, d.host, d.port.toString(), d.id, d.model).joinToString("\u001F")
1269
1270 private fun deserializeDevice(s: String): CastDevice? {
1271 val parts = s.split("\u001F")
1272 if (parts.size != 5) return null
1273 val port = parts[2].toIntOrNull() ?: return null
1274 return CastDevice(name = parts[0], host = parts[1], port = port, id = parts[3], model = parts[4])
1275 }
1276
1277 // ── Observed-metadata enrichment ─────────────────────────────
1278 // See the worker inside observe() for the full rationale. Hits persist
1279 // for the app's lifetime (this is a @Singleton — a track's year doesn't
1280 // change between sessions); misses are per-session (sessionMisses is
1281 // cleared in observe()) so a temporarily-absent server gets retried.
1282
1283 private val enrichHits = MutableStateFlow<Map<String, DidlItem.Track>>(emptyMap())
1284 /** Per-track count of /status title mismatches (automation screens). */
1285 private val statusMissCounts = java.util.concurrent.ConcurrentHashMap<String, Int>()
1286
1287 /** Concurrent set: cleared by observe() (caller's thread) while the
1288 * previous session's worker may still be draining post-cancel. */
1289 private val sessionMisses: MutableSet<String> =
1290 java.util.concurrent.ConcurrentHashMap.newKeySet()
1291
1292 /** Composite identity key. Includes album because two tracks by the same
1293 * artist with the same title (live + studio cuts) can differ in every
1294 * technical field. Null for blank titles — nothing to search on. */
1295 private fun enrichKeyOf(t: DidlItem.Track): String? {
1296 if (t.title.isBlank()) return null
1297 return "${t.title.trim().lowercase()}|${(t.artist ?: "").trim().lowercase()}|${(t.album ?: "").trim().lowercase()}"
1298 }
1299
1300 private fun applyEnrichment(t: DidlItem.Track, hits: Map<String, DidlItem.Track>): DidlItem.Track {
1301 val local = enrichKeyOf(t)?.let { hits[it] } ?: return t
1302 return t.copy(
1303 // Keep observed identity (id, title, artist, album, artUri) so
1304 // anything keyed on those still resolves to the observed entity.
1305 // Pull technical/derived fields from the library match.
1306 date = t.date ?: local.date,
1307 trackNumber = t.trackNumber ?: local.trackNumber,
1308 discNumber = t.discNumber ?: local.discNumber,
1309 genre = t.genre ?: local.genre,
1310 composer = t.composer ?: local.composer,
1311 mimeType = t.mimeType ?: local.mimeType,
1312 sampleFrequency = t.sampleFrequency ?: local.sampleFrequency,
1313 bitsPerSample = t.bitsPerSample ?: local.bitsPerSample,
1314 channels = t.channels ?: local.channels,
1315 bitrate = t.bitrate ?: local.bitrate,
1316 // parentId from local enables "More tracks from this album"
1317 // while observing — empty on raw-cast tracks otherwise.
1318 parentId = local.parentId.ifBlank { t.parentId },
1319 )
1320 }
1321
1322 private suspend fun lookupLocal(udn: String, observed: DidlItem.Track): DidlItem.Track? {
1323 val title = observed.title.takeIf { it.isNotBlank() } ?: return null
1324 val artist = observed.artist?.takeIf { it.isNotBlank() }
1325 val criteria = buildString {
1326 append("upnp:class derivedfrom \"object.item.audioItem\"")
1327 append(" and dc:title = \"${escapeForCriteria(title)}\"")
1328 if (artist != null) append(" and upnp:artist = \"${escapeForCriteria(artist)}\"")
1329 }
1330 return when (val r = cd.search(udn, criteria, maxResults = 5L)) {
1331 is dev.stave.upnp.BrowseResult.Success ->
1332 r.items.filterIsInstance<DidlItem.Track>().let { hits ->
1333 val obsAlbum = observed.album?.trim()?.lowercase()
1334 if (obsAlbum != null) {
1335 hits.firstOrNull { it.album?.trim()?.lowercase() == obsAlbum }
1336 ?: hits.firstOrNull()
1337 } else hits.firstOrNull()
1338 }
1339 is dev.stave.upnp.BrowseResult.Failure -> null
1340 }
1341 }
1342
1343 /** Same escape rules as ContentDirectoryClient's private one. */
1344 private fun escapeForCriteria(s: String): String =
1345 s.replace("\\", "\\\\").replace("\"", "\\\"")
1346
1347 // ── Transport commands ───────────────────────────────────────
1348
1349 suspend fun play() { client?.play() }
1350 suspend fun pause() { client?.pause() }
1351 suspend fun stop() { client?.stop() }
1352 suspend fun next() { client?.next() }
1353 suspend fun previous() { client?.previous() }
1354 suspend fun seek(position: Duration) {
1355 client?.seek(position.inWholeMilliseconds / 1000.0)
1356 }
1357 suspend fun setVolume(percent: Int) {
1358 client?.setVolume(percent / 100.0)
1359 }
1360 /** Repeat/shuffle for the OBSERVED session (I.15). Cast has one axis;
1361 * shuffle is expressed as REPEAT_ALL_AND_SHUFFLE. */
1362 suspend fun setRepeatMode(mode: dev.stave.domain.renderer.RepeatMode) {
1363 client?.setRepeatMode(when (mode) {
1364 dev.stave.domain.renderer.RepeatMode.OFF -> "REPEAT_OFF"
1365 dev.stave.domain.renderer.RepeatMode.ALL -> "REPEAT_ALL"
1366 dev.stave.domain.renderer.RepeatMode.ONE -> "REPEAT_SINGLE"
1367 })
1368 }
1369 suspend fun setShuffle(on: Boolean) {
1370 client?.setRepeatMode(if (on) "REPEAT_ALL_AND_SHUFFLE" else "REPEAT_OFF")
1371 }
1372
1373 // ── Observed queue ───────────────────────────────────────────
1374
1375 /** Receiver-side item IDs in playback order — index-aligned with the
1376 * published PlaybackState.queue so UI indices map to protocol IDs. */
1377 private var observedItemIds: List<Int> = emptyList()
1378
1379 /** Jump the observed session to queue position [index]. Sends
1380 * QUEUE_UPDATE with the corresponding item ID — position-only, no
1381 * content change, so the sender's queue copy stays valid. */
1382 suspend fun jumpToQueueItem(index: Int) {
1383 val itemId = observedItemIds.getOrNull(index) ?: return
1384 client?.queueJump(itemId)
1385 }
1386
1387 /** Map a receiver queue item to the app's Track model. The id and
1388 * resourceUrl fall back to a synthetic per-item value so LazyColumn
1389 * keys stay unique even when the same content appears twice in the
1390 * queue (contentId alone would collide). */
1391 private fun queueItemToTrack(q: RawCastClient.QueueItem): DidlItem.Track =
1392 DidlItem.Track(
1393 id = "cast-item-${q.itemId}",
1394 parentId = "",
1395 title = q.title ?: "Track ${q.itemId}",
1396 artist = q.artist,
1397 albumArtist = q.artist,
1398 album = q.album,
1399 composer = null,
1400 genre = null,
1401 date = null,
1402 trackNumber = null,
1403 discNumber = null,
1404 duration = q.durationSeconds.seconds,
1405 albumArtUri = q.albumArtUrl,
1406 resourceUrl = q.contentId ?: "cast-item-${q.itemId}",
1407 mimeType = null,
1408 sampleFrequency = null,
1409 bitsPerSample = null,
1410 channels = null,
1411 bitrate = null,
1412 size = null,
1413 )
1414
1415 // ── Idle-session release (leak hygiene, memo v2 + retraction) ─
1416 //
1417 // An idle observer holds a TLS connection against the Cast group leader
1418 // for no one's benefit. NOTE (voice-team retraction, 2026-08-22): the
1419 // "~30 standing connections" figure that originally motivated this was
1420 // fabricated — no measurement was ever performed, and observer load is
1421 // an UNVERIFIED hypothesis for the leader freezes, not an established
1422 // contributor. What IS code-confirmed, from this source: kiosk hand-back
1423 // releases at hand-back time, but its monitor is Activity-RESUMED-scoped,
1424 // so two paths leak connections indefinitely:
1425 // 1. Daydream-preempted screens — screensaver takes the foreground
1426 // before the music ends, no hand-back ever fires.
1427 // 2. Manual observes with no returnTo (phones/tablets) left
1428 // backgrounded.
1429 // Magnitude unknown. This watchdog closes both leaks as hygiene,
1430 // application-scoped so it covers every observing-idle state regardless
1431 // of how it arose.
1432 //
1433 // Eligible = observing, receiver reports no live session, no attach
1434 // attempt pending, and the UI is not RESUMED (a person watching an idle
1435 // Now Playing keeps the session; when the UI is foreground the hand-back
1436 // monitor owns the decision exactly as today). Continuously eligible for
1437 // SESSION_DEAD_DEBOUNCE_MS — the same debounce the hand-back monitor
1438 // uses, which is what makes "genuinely dead" mean one thing everywhere:
1439 // long enough to outlast the reconnect supervisor (~15 s worst case) and
1440 // to absorb track-boundary IDLE flaps, both of which would otherwise
1441 // release mid-playlist on a daydreamed screen. Then a full silent
1442 // disconnect(clearPersisted=true): connection, supervisor, keep-alive
1443 // service (via the observing collector), and the reattach target. No
1444 // focus change, no toast. A later OBSERVE rebuilds from scratch; a
1445 // manual reopen shows local state.
1446
1447 /** Whether Earhorn's activity is RESUMED. Fed by MainActivity; used only
1448 * to hold idle sessions a person is actually looking at. */
1449 private val _uiForeground = MutableStateFlow(false)
1450
1451 /** Whether Earhorn's activity is RESUMED. Also gates radio /status
1452 * polling — the memo requires reads only while Now Playing is
1453 * genuinely on screen. */
1454 val uiForeground: StateFlow<Boolean> = _uiForeground.asStateFlow()
1455
1456 fun setUiForeground(foreground: Boolean) {
1457 _uiForeground.value = foreground
1458 }
1459
1460 /** True while an in-app panel (Dom-Etha) is open. A person using the
1461 * panel counts as present: hand-back must not yank the screen out from
1462 * under them when the music happens to stop. */
1463 private val _uiPanelOpen = MutableStateFlow(false)
1464 val uiPanelOpen: StateFlow<Boolean> = _uiPanelOpen.asStateFlow()
1465
1466 fun setUiPanelOpen(open: Boolean) {
1467 _uiPanelOpen.value = open
1468 }
1469
1470 /** Declared LAST in the class deliberately: property initializers run in
1471 * declaration order, and this coroutine touches most of the flows above
1472 * — declaring it earlier would race their construction. */
1473 @Suppress("unused")
1474 private val idleReleaseWatchdog = persistScope.launch {
1475 combine(
1476 _observing, _sessionLive, _pendingTargetName, _uiForeground,
1477 ) { obs, live, pending, fg ->
1478 obs && !live && pending == null && !fg
1479 }
1480 .distinctUntilChanged()
1481 .collectLatest { eligible ->
1482 if (!eligible) return@collectLatest
1483 delay(SESSION_DEAD_DEBOUNCE_MS)
1484 android.util.Log.i(
1485 "EarhornCast",
1486 "idle release: session dead ${SESSION_DEAD_DEBOUNCE_MS / 1000}s " +
1487 "with UI backgrounded — releasing observer",
1488 )
1489 disconnect(clearPersisted = true)
1490 }
1491 }
1492
1493 companion object {
1494 /** The single fleet-wide definition of "the session is genuinely
1495 * dead, not flapping": continuously absent for this long. Used by
1496 * the idle-release watchdog above AND MainActivity's kiosk
1497 * hand-back monitor, so backgrounded release and foreground
1498 * hand-back convict on identical evidence. Floor: must exceed the
1499 * reconnect supervisor's ~15 s worst case plus first-status latency
1500 * on groups. */
1501 const val SESSION_DEAD_DEBOUNCE_MS = 20_000L
1502
1503 /** Frozen-while-PLAYING duration after which the session is
1504 * declared dead. Deliberately far above musicnow's 90 s
1505 * library-track watchdog so the source-side skip always wins
1506 * when it exists. */
1507 const val ZOMBIE_FREEZE_MS = 300_000L
1508
1509 /** Whole-network outage longer than this declares the session over
1510 * (maintainer spec: exit to kiosk, don't impersonate a live screen).
1511 * Short enough that a router reboot ends on the screensaver, long
1512 * enough that AP roams and DHCP renews never trigger it. */
1513 const val OUTAGE_HANDBACK_MS = 60_000L
1514
1515 /** F2: no MEDIA_STATUS for this long on a live session is
1516 * session-death evidence. the maintainer's number, 2026-09-02. */
1517 const val STATUS_SILENCE_MS = 150_000L
1518 /** /status enrichment: mismatches tolerated per track before giving up. */
1519 const val STATUS_ENRICH_MAX_MISSES = 3
1520 /** Wait between /status enrichment retries for the same track. */
1521 const val STATUS_ENRICH_RETRY_MS = 5_000L
1522 }
1523}