RawCastObserver.kt
1523 lines · 84.0 KB
Changed in: 0.5.0
| 1 | package dev.stave.cast.raw |
| 2 | |
| 3 | import android.content.Context |
| 4 | import android.net.ConnectivityManager |
| 5 | import android.net.nsd.NsdManager |
| 6 | import android.net.wifi.WifiManager |
| 7 | import android.net.nsd.NsdServiceInfo |
| 8 | import dagger.hilt.android.qualifiers.ApplicationContext |
| 9 | import dev.stave.domain.model.DidlItem |
| 10 | import dev.stave.domain.renderer.PlaybackState |
| 11 | import kotlinx.coroutines.* |
| 12 | import kotlinx.coroutines.channels.Channel |
| 13 | import kotlinx.coroutines.sync.withLock |
| 14 | import kotlinx.coroutines.flow.* |
| 15 | import javax.inject.Inject |
| 16 | import javax.inject.Singleton |
| 17 | import kotlin.coroutines.resume |
| 18 | import kotlin.time.Duration |
| 19 | import 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 |
| 35 | class 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 | } |


