RawCastObserver.kt
Each line, with the release that last changed it.
| 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 | } |


