package dev.stave.cast.raw
import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.wifi.WifiManager
import android.net.nsd.NsdServiceInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.PlaybackState
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Discovers Chromecast devices on the LAN via mDNS (`_googlecast._tcp`) and
* connects to them using [RawCastClient] — no Cast SDK required.
*
* Usage from the TV:
* 1. Call [startDiscovery] to begin scanning.
* 2. Observe [devices] for available Chromecast devices.
* 3. Call [observe] with a device to connect and start receiving state.
* 4. Observe [playbackState] for metadata/position/transport state.
* 5. Call transport methods (play/pause/stop/next/previous/seek).
* 6. Call [disconnect] to detach.
* 7. Call [stopDiscovery] when leaving settings/picker.
*/
@Singleton
class RawCastObserver @Inject constructor(
@ApplicationContext private val context: Context,
private val cd: dev.stave.upnp.ContentDirectoryClient,
private val settings: dev.stave.data.SettingsStore,
private val radio: dev.stave.cast.radio.RadioStatusClient,
) {
/** Long-lived scope for persistence writes and reattach — must outlive
* the per-session [scope], which disconnect() cancels. */
private val persistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Serializes reattach attempts. Two entry points exist (PlaybackService
* onCreate and MainActivity onStart) and at app reopen BOTH fire while
* observing is still false — without serialization the second attempt's
* observe() prologue tears down the first attempt's freshly-connected
* session (field-reported as "observing resumed, then reverted to the
* local queue seconds later"). With the mutex, the second caller waits,
* re-checks observing, and exits. */
private val reattachMutex = kotlinx.coroutines.sync.Mutex()
data class CastDevice(
val name: String,
val host: String,
val port: Int,
val id: String,
val model: String,
) {
/** Whether this entry is a multi-room speaker group (rather than a single
* device). Cast advertises groups with model="Google Cast Group" in the
* `md` TXT record; everything else is a discrete speaker, display, or
* speaker-with-display. The picker uses this to label groups distinctly
* ("Speaker group" vs "Chromecast"/"Nest Audio"/etc.) so the user knows
* what they're observing. */
val isGroup: Boolean
get() = model.contains("Group", ignoreCase = true)
}
private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
private val _observing = MutableStateFlow(false)
private val _connectedDeviceName = MutableStateFlow<String?>(null)
val connectedDeviceName: StateFlow<String?> = _connectedDeviceName.asStateFlow()
/** The device the current session is attached to; null when not
* observing. Used by [observeOrRestore] to put a working session back
* when a manual pick fails. */
@Volatile private var currentDevice: CastDevice? = null
val observing: StateFlow<Boolean> = _observing.asStateFlow()
/** True while the observed receiver reports an active, non-IDLE media
* session (PLAYING, PAUSED, or BUFFERING all count — a paused session is
* still a session; voice "pause" must never look like "stopped").
*
* Deliberately flap-tolerant: a dropped TLS connection does NOT clear
* this — the last MEDIA_STATUS stands while the reconnect supervisor
* works, so a Wi-Fi blip or group-leader handoff reads as "still live".
* It only goes false on positive evidence (receiver reports IDLE / no
* media session) or when the observation ends entirely ([disconnect],
* including supervisor exhaustion). Consumers wanting "genuinely over"
* must ALSO debounce — see MainActivity's kiosk hand-back monitor. */
/** The most recent genuinely-new MediaStatus and the elapsedRealtime at
* which it arrived — the anchor for position extrapolation. */
@Volatile private var lastStatusRef: RawCastClient.MediaStatus? = null
@Volatile private var posAnchorElapsedMs = 0L
/** True when the receiver claims PLAYING but its reported position has
* not moved between consecutive statuses (~30 s apart) — a stalled
* file. The bar then holds at the reported position instead of
* advancing over silence, until musicnow's 90 s watchdog skips or the
* position moves again. */
@Volatile private var positionFrozen = false
@Volatile private var frozenSinceMs = 0L
/** Latched when a freeze persists past ZOMBIE_FREEZE_MS: the session is
* declared dead despite the receiver's PLAYING claim. An eternally
* polite zombie (healthy heartbeats, PLAYING statuses, frozen audio —
* Classical KC overnight, 2026-08-30) defeats every other escape:
* hand-back sees live=true, the migration checker sees no death and
* no reconnect failure. This is the escape. Cleared when position
* moves. Threshold sits far above musicnow's 90 s watchdog by design:
* the source-side skip should win whenever it exists. */
@Volatile private var zombieDeclared = false
/** Display position = anchored currentTime + wall time since the anchor
* while PLAYING; clamped to duration only when a duration exists (LIVE
* has none and must still advance). Paused/idle shows the anchored
* value unmodified. */
private fun extrapolatedPosition(ms: RawCastClient.MediaStatus): Duration {
val base = ms.currentTime.seconds
if (ms.playerState != "PLAYING" || positionFrozen) return base
val deltaMs = (android.os.SystemClock.elapsedRealtime() - posAnchorElapsedMs)
.coerceAtLeast(0)
val pos = base + (deltaMs / 1000.0).seconds
val dur = ms.duration.seconds
return if (dur > Duration.ZERO) pos.coerceAtMost(dur) else pos
}
/** Transport capabilities of the OBSERVED session, derived from the
* receiver's own supportedMediaCommands plus the LIVE judgment
* (streamType LIVE or no duration ⇒ seeking is meaningless even when
* the receiver claims COMMAND_SEEK — The Bridge reports seek:true on
* a LIVE stream). Null when not observing; local playback keeps its
* own rules. UI dims dead controls; the action layer refuses them. */
data class ObservedCaps(
val canPause: Boolean,
val canSeek: Boolean,
val canNext: Boolean,
val canPrev: Boolean,
val canShuffle: Boolean,
val canRepeat: Boolean,
val isLive: Boolean,
)
private val _observedCaps = MutableStateFlow<ObservedCaps?>(null)
val observedCaps: StateFlow<ObservedCaps?> = _observedCaps.asStateFlow()
private fun deriveCaps(ms: RawCastClient.MediaStatus, @Suppress("UNUSED_PARAMETER") queueSize: Int): ObservedCaps? {
if (ms.mediaSessionId == 0) return null
val c = ms.supportedMediaCommands
// Duration is the ONLY live discriminator. streamType is unusable
// in this house: musicnow casts library tracks as streamType LIVE
// with real durations (wire capture 2026-08-29 18:32, Sneaker
// Pimps, LIVE + 238 s).
val live = ms.duration <= 0.0
// DESIGN RULE (2026-08-29, final): THE ONLY THING THAT DIMS IS
// THE PROGRESS BAR. Buttons never dim, on any session. History of
// this field, kept so nobody re-walks it: v1 gated buttons on the
// receiver's supportedMediaCommands bitmask — which is a proven
// liar (274447 advertised on a 42-track queue with next/prev
// absent, while the wire honored both all week) and dimmed working
// controls fleet-wide. v2 derived from the observed queue. v3, this
// one, retires button-dimming entirely by maintainer order: a press
// on a control the session can't honor is a harmless no-op on the
// receiver, which is strictly better than a UI that guesses wrong.
// canSeek remains meaningful: it feeds ONLY the ProgressBar
// (scrubbing off + LIVE badge on duration-less sessions) and the
// seekTo action guard.
return ObservedCaps(
canPause = true,
canSeek = (c == 0L || c and 2L != 0L) && !live,
canNext = true,
canPrev = true,
canShuffle = true,
canRepeat = true,
isLive = live,
)
}
private val _sessionLive = MutableStateFlow(false)
val sessionLive: StateFlow<Boolean> = _sessionLive.asStateFlow()
/** F1 (maintainer spec 2026-09-02): the group this device could not
* find in ONE attempt (~20 s resolve + last-known-host dial), paired
* with a nonce so repeated failures of the same group re-emit. There
* is no retry lane anywhere. MainActivity collects it: if kiosk armed
* this device for the current cycle, it goes back to kiosk; otherwise
* it is told once and stays where it is.
* Cleared on attach and on every fresh request. */
private val _terminalObserveFailure = MutableStateFlow<Pair<String, Long>?>(null)
val terminalObserveFailure: StateFlow<Pair<String, Long>?> = _terminalObserveFailure.asStateFlow()
/** MainActivity clears the signal once it has acted on it, so a resumed
* activity cannot act on the same failure twice. */
fun consumeTerminalObserveFailure() { _terminalObserveFailure.value = null }
private var client: RawCastClient? = null
private var scope: CoroutineScope? = null
private var nsdManager: NsdManager? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var multicastLock: WifiManager.MulticastLock? = null
// ── Discovery ───────────────────────────────────────────────
/**
* Per-call resolve scope. `NsdManager.resolveService` on minSdk 26 cannot
* run multiple concurrent resolves — submitting a second resolve while one
* is in flight returns FAILURE_ALREADY_ACTIVE (error code 3), which silently
* drops the second device from the discovered list. With a Cast-rich LAN
* (groups + Nest Audio scattered around the group + a Shield or two) you'll
* get half a dozen onServiceFound callbacks within the same hundred-ms
* window, and all-but-one device vanishes from the picker.
*
* Fix: queue resolves through a single-consumer channel and process them
* one at a time, awaiting each completion before pulling the next. This is
* compatible across API levels (the API 33+ registerServiceInfoCallback
* variant would be drop-in, but we need to support older Android TV
* boxes too).
*/
private var resolveQueue: Channel<NsdServiceInfo>? = null
private var resolveJob: Job? = null
private var discoveryScope: CoroutineScope? = null
/** Reference-counted (I.24 / review N.39, design decision
* 2026-09-02): a picker closing used to stop discovery under a
* by-name resolution or migration check that was still waiting on it.
* Now discovery ends only when every user has released it. */
private val discoveryUsers = java.util.concurrent.atomic.AtomicInteger(0)
fun startDiscovery() {
discoveryUsers.incrementAndGet()
if (discoveryListener != null) return
// Shield TV and some Android TV devices require a multicast lock
// for mDNS to work. Without it, NsdManager silently finds nothing.
if (multicastLock == null) {
// Maintainer, 2026-10-29: Ethernet-only boxes have no Wi-Fi service
// (getSystemService returns null) — the hard cast crashed here.
// No WifiManager → no multicast lock; discovery proceeds without (D13a).
val wifi = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager
multicastLock = wifi?.createMulticastLock("earhorn_cast_discovery")?.apply {
setReferenceCounted(false)
acquire()
}
}
val nsd = context.getSystemService(Context.NSD_SERVICE) as NsdManager
nsdManager = nsd
// Spin up the serial resolver. SupervisorJob so a single failed
// resolve doesn't poison the channel for the rest of the session.
val dscope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
discoveryScope = dscope
val queue = Channel<NsdServiceInfo>(capacity = Channel.UNLIMITED)
resolveQueue = queue
resolveJob = dscope.launch {
for (serviceInfo in queue) {
runCatching { resolveOne(nsd, serviceInfo) }
.onFailure {
android.util.Log.w(
"EarhornCast",
"resolveService threw for ${serviceInfo.serviceName}: ${it.message}",
)
}
}
}
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
// Push to the serial resolver instead of resolving inline.
// trySend is fine — channel is UNLIMITED so it never fails.
resolveQueue?.trySend(serviceInfo)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
_devices.value = _devices.value.filter {
it.name != serviceInfo.serviceName
}
}
}
discoveryListener = listener
nsd.discoverServices("_googlecast._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
}
/**
* Wrap the callback-based [NsdManager.resolveService] in a suspend function
* so the consumer coroutine awaits completion before pulling the next item.
* Returns Unit either way — failures are logged and the device just doesn't
* appear (same as before, but now ONLY when the specific service failed,
* not because a concurrent resolve clobbered ours).
*/
private suspend fun resolveOne(nsd: NsdManager, serviceInfo: NsdServiceInfo) {
suspendCancellableCoroutine<Unit> { cont ->
nsd.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
android.util.Log.d(
"EarhornCast",
"resolve failed for ${info.serviceName} code=$errorCode",
)
if (cont.isActive) cont.resume(Unit)
}
override fun onServiceResolved(info: NsdServiceInfo) {
val host = info.host?.hostAddress
if (host == null) {
if (cont.isActive) cont.resume(Unit)
return
}
val port = info.port
val name = info.serviceName ?: "Chromecast"
val attrs = info.attributes
val id = attrs["id"]?.let { String(it) } ?: name
val model = attrs["md"]?.let { String(it) } ?: "Chromecast"
val device = CastDevice(
name = attrs["fn"]?.let { String(it) } ?: name,
host = host,
port = port,
id = id,
model = model,
)
_devices.value = (_devices.value.filter { it.id != device.id } + device)
.sortedWith(compareByDescending<CastDevice> { it.isGroup }.thenBy { it.name })
if (cont.isActive) cont.resume(Unit)
}
})
}
}
fun stopDiscovery() {
if (discoveryUsers.decrementAndGet() > 0) return
discoveryUsers.set(0)
discoveryListener?.let { listener ->
runCatching { nsdManager?.stopServiceDiscovery(listener) }
}
discoveryListener = null
// Drain and close the resolve queue, cancel the consumer. The discovery
// scope is canceled last so any in-flight resolveOne suspensions
// terminate cleanly via the cancellable continuation.
resolveQueue?.close()
resolveQueue = null
resolveJob?.cancel()
resolveJob = null
discoveryScope?.cancel()
discoveryScope = null
multicastLock?.release()
multicastLock = null
}
// ── Observe a Cast device ───────────────────────────────────
/**
* @param fromReattach true when called by [reattachIfPersisted] on app
* launch. Connect failures during reattach must NOT clear the persisted
* target — the common failure there is "phone hasn't rejoined Wi-Fi yet,"
* and clearing would permanently forget a session that's still playing.
* User-initiated observes (default) clear on failure as before.
*/
/** An observe lineage is CURRENT while the shared fields still point at
* its client. Two overlapping observe() prologues (the picker calls
* this directly, mutex-free, while broadcast-driven requests hold the
* reattach mutex — and dual delivery lands two requests ~20 ms apart)
* can interleave disconnect-then-assign such that the loser's client
* is installed, overwritten, and ORPHANED: connected, running its own
* reconnect supervisor, invisible to every future disconnect(). Field
* evidence 2026-08-29 14:38–14:42: seven concurrent lineages
* handshaking the 0Up leader every few seconds — Earhorn manufacturing
* exactly the connection load the freeze investigation is chasing.
* Every long-lived loop in a lineage now checks this and
* self-destructs when stale. */
private fun stillCurrent(c: RawCastClient): Boolean = client === c
/** True when this DEVICE has no network at all — the screen's own
* Wi-Fi is down (router reboot, AP restart). In that state nothing
* can be concluded about the session: the receiver may be playing
* happily. Distinct from "the receiver is unreachable on a healthy
* network," which IS evidence. */
private fun networkDown(): Boolean = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
cm.activeNetwork == null
}.getOrDefault(false)
@Volatile private var lastMigrationCheckMs = 0L
/** Group leadership migrates between physical devices (wire-proven
* 2026-08-29: 1UpNOut moved .240 → .87 mid-session; musicnow followed,
* the screen faithfully reattached to the zombie). On session-death
* evidence or reconnect failure, re-resolve the group name via mDNS:
* a DIFFERENT host means the leader moved — follow it through the
* existing idempotent byName path (jitter, grace, newest-wins). The
* SAME host means a genuine session end — do nothing, hand-back
* proceeds untouched. Rate-limited; discovery is started only if not
* already running and stopped again only if started here. */
fun maybeFollowLeaderMigration(reason: String) {
val name = _connectedDeviceName.value ?: return
val curHost = client?.host ?: return
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastMigrationCheckMs < 10_000) return
lastMigrationCheckMs = now
persistScope.launch {
runCatching { startDiscovery() }
try {
val deadline = android.os.SystemClock.elapsedRealtime() + 6_000
while (android.os.SystemClock.elapsedRealtime() < deadline) {
val match = _devices.value.firstOrNull { it.name == name }
?: _devices.value.firstOrNull { it.name.equals(name, ignoreCase = true) }
if (match != null) {
if (match.host != curHost) {
android.util.Log.i("EarhornCast",
"leader migration: '$name' now at ${match.host} " +
"(was $curHost, trigger=$reason) — following")
// force: the idempotent fast-path in
// requestObserveByName returns when we already
// observe this NAME — which is exactly the case
// here. Without the flag this line was a no-op in
// every build (I.1, design decision 2026-09-02).
requestObserveByName(name, force = true)
} else {
android.util.Log.i("EarhornCast",
"migration check ($reason): '$name' still at $curHost — no move")
}
return@launch
}
delay(500)
}
android.util.Log.i("EarhornCast",
"migration check ($reason): '$name' not resolved within 6s — no action")
} finally {
runCatching { stopDiscovery() }
}
}
}
/** Returns a Deferred that completes true once the session is attached
* (observing == true) or false if the initial connect failed. Callers
* that don't care may ignore it; the picker awaits it so a failed pick
* can put the previous session back (design decision 2026-09-02). */
suspend fun observe(device: CastDevice, fromReattach: Boolean = false): Deferred<Boolean> {
val outcome = CompletableDeferred<Boolean>()
disconnect(clearPersisted = false) // about to overwrite / retry — don't clobber
sessionMisses.clear()
statusMissCounts.clear()
val c = RawCastClient(device.host, device.port)
client = c
val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope = sc
// ── Detached bring-up ─────────────────────────────────────────
// The connect runs on the OBSERVER'S OWN scope, never the
// caller's. Callers are typically composition-scoped (the picker
// sheets' rememberCoroutineScope), and the UI's reaction to
// observation state can unmount them mid-connect. Release field
// log, verbatim: "observe: initial connect failed / The coroutine
// scope left the composition" — the NowPlaying-mounted picker's
// host recomposed, unmounted the sheet, and cancelled its own
// in-flight connect 2ms after the TLS handshake succeeded. The
// session's fate must be independent of any composable's
// lifetime.
//
// Relatedly: _observing flips true only AFTER the connect
// succeeds. Flipping it in the prologue was the self-destruct
// trigger — every screen reacted (NowPlaying collapsed to the
// "Connecting…" state, abandoning even a playing local track)
// before the session was real, which is exactly what unmounted
// the caller. Until the flip, the picker's own connecting
// spinner covers the wait.
val bringUp = sc.launch {
val connectResult = runCatching { c.connect() }
if (connectResult.isFailure) {
android.util.Log.w("EarhornCast",
"observe: initial connect to ${device.name} failed",
connectResult.exceptionOrNull())
// disconnect() cancels this very scope as part of
// cleanup; everything after the cancel up to the next
// suspension still runs, and there is none — safe.
// A failed attach never clears the persisted reattach
// target (I.12): each failed try in the by-name window used
// to erase it. Only an explicit disconnect clears it.
outcome.complete(false)
disconnect(clearPersisted = false)
return@launch
}
if (!stillCurrent(c)) {
android.util.Log.w("EarhornCast",
"observe: superseded during bring-up — self-destructing orphan lineage")
runCatching { c.disconnect() }
outcome.complete(false)
sc.cancel()
return@launch
}
_connectedDeviceName.value = device.name
currentDevice = device
_observing.value = true; _terminalObserveFailure.value = null
outcome.complete(true)
// Persist the target so a killed process (swipe-away, doze kill,
// system reclaim) can reattach on next launch, and the host so
// the cold-start rescue can dial it — on EVERY attach, not only
// the by-name path (I.11). persistScope, not sc — disconnect()
// cancels sc and must not race the write.
persistScope.launch {
runCatching { settings.setObservedCastDevice(serializeDevice(device)) }
runCatching { settings.setLastKnownHost(device.name, "${device.host}:${device.port}") }
}
// Guarantee PlaybackService exists and is foreground. Purely
// observing has no local playback, so nothing else creates the
// service — and without it, the process is frozen/killed the
// moment the user switches apps (the socket dies with it). The
// app is in the foreground here for user-initiated observes
// (they just tapped a device), so startForegroundService is
// permitted; during reattach the caller IS the service (or the
// Activity), which is equally fine.
runCatching {
androidx.core.content.ContextCompat.startForegroundService(
context,
android.content.Intent(
context, dev.stave.playback.PlaybackService::class.java,
).setAction(dev.stave.playback.PlaybackService.ACTION_OBSERVE_KEEPALIVE),
)
}.onFailure {
android.util.Log.w("EarhornCast", "observe: keep-alive service start failed", it)
}
// ── Connection supervisor ─────────────────────────────────────
// When the TLS session drops mid-observation (CCA reboot, group
// leader handoff, Wi-Fi blip), reconnect with backoff. On success
// the same mediaSessionId usually survives and the periodic loop's
// queue-refetch safety net restores the queue. If all attempts
// fail, exit observer mode CLEANLY: observing=false makes every
// screen fall back to the local renderer state — the user stays
// exactly where they were (NowPlaying shows the local queue), no
// crash, no bounce to Browse.
sc.launch {
var sawConnected = false
c.connected.collect { conn ->
if (!stillCurrent(c)) {
android.util.Log.w("EarhornCast",
"supervisor: stale lineage detected — self-destructing")
runCatching { c.disconnect() }
sc.cancel()
return@collect
}
if (conn) {
sawConnected = true
return@collect
}
if (!sawConnected || !_observing.value) return@collect
android.util.Log.w("EarhornCast",
"observer connection lost — reconnecting")
var recovered = false
for (attempt in 1..4) {
kotlinx.coroutines.delay(attempt * 1_500L)
if (!_observing.value || !stillCurrent(c)) {
runCatching { c.disconnect() }
return@collect
}
val r = runCatching { c.connect() }
r.exceptionOrNull()?.let {
android.util.Log.w("EarhornCast",
"reconnect attempt $attempt failed", it)
// The host may simply no longer lead this group.
// Check once, early; a confirmed move supersedes
// this lineage via byName and the stale guards.
if (attempt == 1) maybeFollowLeaderMigration("reconnect-failure")
}
if (r.isSuccess) {
android.util.Log.i("EarhornCast",
"observer reconnected (attempt $attempt)")
recovered = true
break
}
}
if (!recovered && _observing.value && stillCurrent(c)) {
// EXHAUSTION NEVER ENDS ON AN EMPTY SCREEN. Maintainer
// rule, 2026-08-31, after two strand incidents: no
// scenario exists in which a screen dumped onto an
// empty local queue is the desired outcome of ANY
// failure. So: the observation is HELD (sticky Now
// Playing stays up) and this loop patiently retries
// forever — reconnecting the device, and periodically
// re-resolving the group for moved leaders. What
// distinguishes the two failure classes is ONLY the
// evidence flag: unreachable on a healthy network is
// session-death evidence, so sessionLive drops and an
// ARMED screen hands back to kiosk within 20 s (the one
// defensible exit); a whole-house outage is no
// evidence of anything, so sessionLive keeps its last
// value and hand-back stays quiet. Every other exit
// from this loop is a successful reattach, a
// supersession by a newer lineage, or hand-back's own
// teardown — never a dead screen.
val netDownAtExhaust = networkDown()
if (netDownAtExhaust) {
android.util.Log.w("EarhornCast",
"reconnect exhausted with NO network — whole-house outage; " +
"holding up to ${OUTAGE_HANDBACK_MS / 1000}s, then exiting to Haus")
// DESIGN SPEC 2026-08-31: a stream cut by a down
// router means the screen recognizes it and
// gracefully exits to Faveat kiosk. The hold exists
// only to ride sub-minute blips (AP roam, DHCP
// renew); past the grace the session is declared
// over and armed screens hand back — the launch is
// local and needs no network. Resumed music
// re-flips them via OBSERVE as always.
val outageStart = android.os.SystemClock.elapsedRealtime()
persistScope.launch {
kotlinx.coroutines.delay(OUTAGE_HANDBACK_MS)
if (_observing.value && stillCurrent(c) && networkDown()) {
android.util.Log.w("EarhornCast",
"outage exceeded ${OUTAGE_HANDBACK_MS / 1000}s — " +
"declaring session over; hand-back engages")
_sessionLive.value = false
}
}
} else {
// design decision 2026-09-02: exhaustion on a healthy
// network is the end of this observation, not an
// infinite "Waiting for…" hold (I.8). Signal it the
// same way a failed by-name attach does: an armed
// screen goes home to kiosk, an unarmed device is told
// once ("Couldn't reach <group>") and returns to
// browsing. Then release the session.
val failedName = _connectedDeviceName.value ?: c.host
android.util.Log.w("EarhornCast",
"reconnect exhausted on a healthy network — releasing '$failedName' and signalling")
_sessionLive.value = false
_terminalObserveFailure.value =
failedName to android.os.SystemClock.elapsedRealtime()
disconnect(clearPersisted = false)
return@collect
}
// Whole-house outage: hold, riding sub-minute blips, until
// the network returns or OUTAGE_HANDBACK_MS declares the
// session over (armed screens hand back then).
while (_observing.value && stillCurrent(c)) {
kotlinx.coroutines.delay(15_000)
if (networkDown()) continue
val rr = runCatching { c.connect() }
if (rr.isSuccess) {
android.util.Log.i("EarhornCast",
"reattached after outage hold — statuses will refresh state")
return@collect
}
maybeFollowLeaderMigration("exhaust-hold")
}
// Superseded or observation ended (hand-back, user,
// newer lineage) — tidy this lineage's socket only.
runCatching { c.disconnect() }
}
}
}
// Bridge MediaStatus + receiver queue → PlaybackState. The queue
// arrives via QUEUE_GET_ITEM_IDS / QUEUE_GET_ITEMS (fetched inside
// the client, refreshed on QUEUE_CHANGE broadcasts), so followers
// see the same live queue the sender does — including edits made
// on the sender while we observe. queueIndex is derived from the
// receiver's currentItemId, not a local counter, so it can never
// drift from what's actually audible.
// The enrichment cache is a third combine input: whenever a UPnP
// lookup lands, the state re-emits with year/trackNumber/format
// merged into current AND every queue row — so the phone's queue
// listing and Track Nº populate without any per-screen wiring.
sc.launch {
combine(c.mediaStatus, c.queueItems, enrichHits) { ms, qItems, hits ->
Triple(ms, qItems, hits)
}
.collect { (ms, qItems, hits) ->
// Re-anchor the position clock ONLY on a genuinely new
// playback report. Rebuilds caused by enrichment/queue
// emissions reuse the same ms object, and a RECEIVER_STATUS
// volume report produces a COPY with the same position;
// neither is a new report. statusSeq advances only on
// MEDIA_STATUS (review N.1, 2026-09-02: the volume copy
// used to count as "same position twice" and hold the bar).
if (ms.statusSeq != (lastStatusRef?.statusSeq ?: -1L)) {
val prev = lastStatusRef
// PLAYING *or* BUFFERING: the 2026-08-31 Zatara zombie
// froze in BUFFERING at 15884.356s and answered every
// reconnect with it for hours — a PLAYING-only gate
// (this code's, and musicnow's watchdog's, identically)
// never fires on it. Any active-claiming state with a
// pinned position counts toward the freeze.
val activeStates = setOf("PLAYING", "BUFFERING")
val frozenNow = prev != null &&
ms.playerState in activeStates && prev.playerState in activeStates &&
ms.mediaSessionId == prev.mediaSessionId &&
ms.currentTime > 0.0 && ms.currentTime == prev.currentTime
if (frozenNow != positionFrozen) {
positionFrozen = frozenNow
frozenSinceMs = if (frozenNow)
android.os.SystemClock.elapsedRealtime() else 0L
if (!frozenNow && zombieDeclared) {
zombieDeclared = false
android.util.Log.i("EarhornCast",
"position moving again — zombie declaration withdrawn")
}
android.util.Log.i("EarhornCast",
if (frozenNow) "position frozen at ${ms.currentTime}s while PLAYING — holding bar"
else "position moving again — resuming bar")
}
lastStatusRef = ms
posAnchorElapsedMs = android.os.SystemClock.elapsedRealtime()
}
observedItemIds = qItems.map { it.itemId }
val queueTracks = qItems.map { applyEnrichment(queueItemToTrack(it), hits) }
val qIndex = qItems.indexOfFirst { it.itemId == ms.currentItemId }
// Current track: prefer the fetched queue item matching
// currentItemId. currentItemId is present in EVERY
// status push (including the media-less transport-only
// ones), while the media/metadata block is frequently
// absent or half-populated during auto-advance — the
// old MediaStatus-only path left stale art/title on
// screen for several tracks until a fully-populated
// status happened to arrive. Queue items carry complete
// per-item metadata (incl. images) from the fetch, so
// art flips the moment the receiver reports the new
// item ID. MediaStatus remains the fallback before the
// queue is fetched.
val fromQueue = if (qIndex >= 0) queueTracks[qIndex] else null
val current = fromQueue ?: if (ms.title != null) applyEnrichment(DidlItem.Track(
id = ms.contentId ?: "",
parentId = "",
title = ms.title,
artist = ms.artist,
albumArtist = ms.artist,
album = ms.album,
composer = null,
genre = null,
date = null,
trackNumber = null,
discNumber = null,
duration = ms.duration.seconds,
albumArtUri = ms.albumArtUrl,
resourceUrl = ms.contentId ?: "",
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
), hits) else null
_playbackState.value = PlaybackState(
current = current,
queue = queueTracks,
queueIndex = qIndex,
isPlaying = ms.playerState == "PLAYING",
isBuffering = ms.playerState == "BUFFERING",
isStopped = ms.playerState == "IDLE",
position = extrapolatedPosition(ms),
duration = ms.duration.seconds,
volumePercent = (ms.volume * 100).toInt(),
repeatMode = when (ms.repeatMode) {
"REPEAT_ALL", "REPEAT_ALL_AND_SHUFFLE" -> dev.stave.domain.renderer.RepeatMode.ALL
"REPEAT_SINGLE" -> dev.stave.domain.renderer.RepeatMode.ONE
else -> dev.stave.domain.renderer.RepeatMode.OFF
},
shuffleEnabled = ms.repeatMode == "REPEAT_ALL_AND_SHUFFLE",
)
// Positive-evidence liveness for the kiosk hand-back
// monitor. IDLE with a lingering mediaSessionId (queue
// just finished, receiver not yet unloaded) counts as
// NOT live — "finished" is exactly the state hand-back
// exists for. Sub-second IDLE flaps at track boundaries
// are absorbed by the consumer-side debounce.
if (positionFrozen && !zombieDeclared && frozenSinceMs > 0 &&
android.os.SystemClock.elapsedRealtime() - frozenSinceMs > ZOMBIE_FREEZE_MS
) {
zombieDeclared = true
android.util.Log.w("EarhornCast",
"${ms.playerState} with frozen position for ${ZOMBIE_FREEZE_MS / 1000}s — " +
"declaring session dead (zombie); hand-back and migration paths engage")
maybeFollowLeaderMigration("frozen-zombie")
}
val liveNow = !zombieDeclared &&
ms.mediaSessionId != 0 && ms.playerState != "IDLE"
if (_sessionLive.value && !liveNow && _observing.value) {
// Session just died under us. Either it genuinely
// ended (hand-back's case — the check is a no-op)
// or leadership moved and we hold the zombie's
// view (tonight's case — the check follows).
maybeFollowLeaderMigration("session-death")
}
_sessionLive.value = liveNow
_observedCaps.value =
if (_observing.value) deriveCaps(ms, qItems.size) else null
}
}
// (A second, older reconnect loop lived here — flat 5 s retry on
// the same `connected` flow the supervisor above watches. Each of
// its connect() calls began with disconnect(), tearing down the
// supervisor's fresh session and re-emitting a drop, so the two
// loops traded reconnects for the life of the observation; its
// failure branch blanked the screen. Found by review's full read,
// 2026-09-02. Removed; the supervisor is the only reconnect path.)
// Periodic status/queue safety loop.
// Multi-observer etiquette: several devices observing one group
// leader must not tick in lockstep (synchronized bursts) nor pull
// on every tick — the leader is also mixing multiroom audio on a
// small SoC, and four observers hammering GET_STATUS every 10s
// measurably competes with the audio pipeline. Two measures:
// 1. Random start jitter desynchronizes the tick phase across
// devices.
// 2. Pulls are gated on staleness: the receiver broadcasts
// MEDIA_STATUS / RECEIVER_STATUS on every transport, track,
// and volume change, so while things are happening we send
// nothing. A pull fires only after 25s of silence (position
// is locally interpolated between statuses, so a 25s resync
// cadence is invisible in the progress bar).
// Queue-fetch safety net stays: reconnect to the SAME session
// clears the queue without re-triggering the session-change
// branch; whenever we have a session but no queue, ask again.
sc.launch {
kotlinx.coroutines.delay(1_500 + (0..3_500).random().toLong())
if (c.queueItems.value.isEmpty() &&
c.mediaStatus.value.mediaSessionId != 0
) {
runCatching { c.requestQueueItemIds() }
}
var silenceDeclared = false
while (true) {
kotlinx.coroutines.delay(10_000)
val statusAge = c.mediaStatusAgeMs()
if (statusAge > 25_000) {
runCatching { c.requestMediaStatus() }
}
// F2 (maintainer-authorized build, 2026-09-02; threshold the
// the maintainer's): a socket that answers heartbeats but stops
// sending MEDIA_STATUS starves the freeze detector, which
// compares consecutive statuses. Status AGE is the
// instrument the loop already holds. Past STATUS_SILENCE_MS
// on a session we believed live, that silence is
// session-death evidence: sessionLive drops, hand-back and
// the migration check run. Withdrawn automatically — the
// next real status recomputes sessionLive from the
// receiver's own claim.
if (!silenceDeclared && statusAge > STATUS_SILENCE_MS &&
_observing.value && stillCurrent(c) && _sessionLive.value
) {
silenceDeclared = true
android.util.Log.w("EarhornCast",
"status silence ${statusAge / 1000}s on session " +
"${c.mediaStatus.value.mediaSessionId} — declaring session dead")
_sessionLive.value = false
maybeFollowLeaderMigration("status-silence")
} else if (silenceDeclared && statusAge <= STATUS_SILENCE_MS) {
silenceDeclared = false
android.util.Log.i("EarhornCast", "statuses resumed — silence declaration withdrawn")
}
if (c.receiverStatusAgeMs() > 25_000) {
runCatching { c.requestReceiverStatus() }
}
if (c.queueItems.value.isEmpty() &&
c.mediaStatus.value.mediaSessionId != 0
) {
runCatching { c.requestQueueItemIds() }
}
}
}
// Position interpolation — monotonic anchor, not increment.
// Every genuinely NEW MEDIA_STATUS (pushed or polled) re-anchors the
// clock in the collector above; display position is always
// anchor-position + wall-time-since-anchor while PLAYING. This
// replaces a +=1s ticker that had two defects: (1) state rebuilds
// triggered by enrichment/queue emissions re-applied the LAST
// status's stale currentTime, snapping the bar backwards by up to a
// poll interval mid-track; (2) the tick was gated on duration > 0,
// so LIVE streams never advanced at all — frozen 30 s, then a jump.
sc.launch {
while (true) {
kotlinx.coroutines.delay(1_000)
val ms = lastStatusRef ?: continue
val s = _playbackState.value
if (s.isPlaying) {
_playbackState.value = s.copy(position = extrapolatedPosition(ms))
}
}
}
// ── Metadata enrichment worker ────────────────────────────────
// The Cast wire protocol carries title/artist/album/art but NOT
// year, track number, mime type, sample rate, or bit depth — those
// are UPnP-side facts the receiver never ships in MEDIA_STATUS.
// Look each observed track up on the user's preferred UPnP server
// and stash the match in [enrichHits]; the state builder above
// combines that cache in, so hits flow into current + queue rows
// for EVERY consumer (phone NowPlaying Track Nº, queue-row year/#N,
// TV metadata row, service notification) with zero per-screen
// wiring. This deliberately supersedes doing it per-ViewModel —
// the VM-level observedTrackEnriched only ever fed the TV branch,
// which is exactly why phone layouts shipped without year/Track Nº.
//
// Trigger is distinct-by-key: position ticks and cache updates
// don't restart the loop; only a genuine track/queue change does.
// Lookups run serially with a small gap — a 42-track queue is ~42
// searches over ~10s, once, then cached for the app's lifetime
// (hits) or the session (misses).
sc.launch {
_playbackState
.map { st ->
(listOfNotNull(st.current) + st.queue)
.mapNotNull { t -> enrichKeyOf(t)?.let { k -> k to t } }
}
.distinctUntilChanged { a, b -> a.map { it.first } == b.map { it.first } }
.collectLatest { pairs ->
// Automation (wall) screens: no UPnP engine runs on them
// (design decision 2026-09-02). musicnow's /status now
// carries the library fields for the track it is playing;
// one GET per track change, matched by title so a stale
// reply can't decorate the wrong song. Only the current
// track can be enriched this way — queue rows have no
// /status source and stay as the receiver reports them.
if (settings.kioskFlipEnabled.first()) {
val cur = _playbackState.value.current ?: return@collectLatest
val key = enrichKeyOf(cur) ?: return@collectLatest
if (enrichHits.value.containsKey(key) || key in sessionMisses) return@collectLatest
val group = _connectedDeviceName.value ?: return@collectLatest
// musicnow can be a beat behind the receiver: a title
// mismatch on the first read is transient, not a
// verdict. Retry on the next status for this track,
// up to STATUS_ENRICH_MAX_MISSES; only then write it
// off for the session (design decision 2026-09-02).
// The retry has to happen HERE: this collector re-runs
// only when the key list changes, and a new status for
// the same track produces the same list (review
// countersign of fc3c26e2). A key change cancels this
// collectLatest body, so the loop can't outlive the track.
while (true) {
val lib = radio.fetchLibraryTrack(group)
if (lib != null && lib.title.trim().equals(cur.title.trim(), ignoreCase = true)) {
enrichHits.value = enrichHits.value + (key to lib)
statusMissCounts.remove(key)
break
}
val misses = (statusMissCounts[key] ?: 0) + 1
statusMissCounts[key] = misses
if (misses >= STATUS_ENRICH_MAX_MISSES) {
sessionMisses += key
break
}
kotlinx.coroutines.delay(STATUS_ENRICH_RETRY_MS)
}
return@collectLatest
}
for ((key, t) in pairs) {
if (enrichHits.value.containsKey(key) || key in sessionMisses) continue
val udn = settings.preferredServerUdn.first() ?: return@collectLatest
val match = runCatching { lookupLocal(udn, t) }.getOrNull()
if (match != null) {
enrichHits.value = enrichHits.value + (key to match)
} else {
// Session-scoped negative cache: don't hammer the
// server re-searching a track it doesn't have on
// every QUEUE_CHANGE, but do retry next session
// (the miss may have been the server being away).
sessionMisses += key
}
kotlinx.coroutines.delay(150)
}
}
}
}
// Callers may await completion (the picker's connecting spinner
// keys on it). join() is caller-cancellable but NEVER cancels the
// bring-up itself — if the caller's composition dies mid-connect,
// the session simply continues without it.
runCatching { bringUp.join() }
// If bring-up ended without reaching either completion (scope
// cancelled by a newer request), report failure rather than hang.
if (!outcome.isCompleted) outcome.complete(false)
return outcome
}
/** Manual pick (design decision 2026-09-02): try [device]; if it cannot
* be reached, say so once and put the previous session back, so a
* mis-tap never leaves a working screen on nothing. Returns true only
* when the NEW device attached. */
suspend fun observeOrRestore(device: CastDevice): Boolean {
val previous = currentDevice
val ok = runCatching { observe(device).await() }.getOrDefault(false)
if (ok) return true
android.util.Log.w("EarhornCast",
"pick of '${device.name}' failed — restoring " +
(previous?.name ?: "nothing (no previous session)"))
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
context, "Couldn't reach ${device.name}", android.widget.Toast.LENGTH_LONG,
).show()
}
if (previous != null && previous.id != device.id) {
runCatching { observe(previous).await() }
}
return false
}
/**
* @param clearPersisted whether to also forget the persisted reattach
* target. True for user-initiated disconnects ("stop observing" — the
* user meant it, don't resurrect the session on next launch). False
* for internal cleanup where the session may still be worth reattaching
* (observe() prologue, reattach connect failures).
*/
fun disconnect(clearPersisted: Boolean = true) {
android.util.Log.i("EarhornCast",
"disconnect: clearPersisted=$clearPersisted device='${_connectedDeviceName.value}'")
scope?.cancel()
scope = null
client?.disconnect()
client = null
observedItemIds = emptyList()
lastStatusRef = null
positionFrozen = false
frozenSinceMs = 0L
zombieDeclared = false
_observing.value = false
_observedCaps.value = null
_sessionLive.value = false
_connectedDeviceName.value = null
currentDevice = null
if (clearPersisted) {
persistScope.launch { runCatching { settings.setObservedCastDevice(null) } }
}
}
// ── Observe by group name (voice-cast auto-observe, E1/E2) ───
/** Friendly name of the group we are currently trying to reach via
* [requestObserveByName], or null when no attempt is in flight. UI can
* show a harmless "Connecting to X…" placeholder off this — it is the
* ONLY user-visible artifact of a pending/failed attempt (E2: never an
* error dialog). Cleared when the attempt lands, is superseded, or the
* grace window expires. */
private val _pendingTargetName = MutableStateFlow<String?>(null)
val pendingTargetName: StateFlow<String?> = _pendingTargetName.asStateFlow()
private var byNameJob: Job? = null
/**
* Attach to the live CCA session of the Cast device/group whose mDNS
* friendly name (`fn` TXT record — identical to the Google Home spelling)
* equals [groupName]. This backs the exported OBSERVE intent.
*
* Semantics (per the voice-cast memo, E2):
* - Idempotent: already observing that group → no-op, no restart.
* - Observing a different group → switch ([observe]'s prologue tears the
* old session down).
* - Group not discovered yet, or its cast races us by a second or two →
* keep trying for [graceWindow] (discovery + connect retries). While
* the receiver has no media session we simply sit connected showing
* the connecting state; MEDIA_STATUS lands whenever the cast does.
* - Window expires without a connect → give up SILENTLY. No dialog, no
* toast; the pending flag clears and the app stays wherever it was.
* - Newest wins: a fresh call cancels any in-flight attempt, so a fast
* session move (playing on A, then B seconds later) converges on B —
* the caller (MainActivity) simply forwards datagram-ordered launches.
*
* Fire-and-forget: runs on [persistScope] so it survives the launching
* activity. Serialized against reattach via [reattachMutex] — both paths
* funnel into [observe], whose prologue disconnects whatever came before;
* unserialized, a boot-time reattach and an OBSERVE launch would tear
* each other down (same failure the mutex was introduced for).
*
* Matching is exact on the UTF-8 name first (memo: "exactly as Google
* Home spells it" — never ASCII-fold `HeißeBad`), with a case-insensitive
* fallback only, since Google Home itself is case-preserving but
* case-insensitive about duplicate names.
*/
fun requestObserveByName(
groupName: String,
graceWindow: Duration = 20.seconds,
refillRetryBudget: Boolean = true,
/** Skip the already-observing fast-path. Used by leader migration,
* where the name is the same and the host is what changed. */
force: Boolean = false,
) {
if (refillRetryBudget) _terminalObserveFailure.value = null
val name = groupName.trim()
if (name.isEmpty()) return
android.util.Log.i("EarhornCast", "observeByName: request name='$name' " +
"currently observing='${_connectedDeviceName.value}' pending='${_pendingTargetName.value}'")
byNameJob?.cancel()
byNameJob = persistScope.launch {
// Idempotent fast-path — the common case for other screens is
// "playing" re-announcements for the group we already follow.
if (!force && _observing.value && _connectedDeviceName.value == name) return@launch
// Attach jitter. Every screen in the group receives the same
// "playing" event within milliseconds and every screen used to
// open TLS+CONNECT to the group leader in the same instant —
// exactly while the real sender (musicnow) is establishing its
// own session. Receivers cap concurrent senders; a synchronized
// burst at session-start is where a cast gets refused or torn
// down mid-setup. Radio polling and reconnect backoff were
// already jittered; the attach storm was not. 0–2000 ms spreads
// the fleet across two seconds and is invisible next to the
// grace window.
delay(kotlin.random.Random.nextLong(0, 2_000))
_pendingTargetName.value = name
try {
reattachMutex.withLock {
// Re-check inside the lock; a reattach may have just
// restored exactly this group.
// Same fast-path as above; migration must pass it too
// (review C.7, 2026-09-02: the first guard got `force`,
// this one did not, and migration stayed a no-op).
if (!force && _observing.value && _connectedDeviceName.value == name) return@withLock
// Take a reference on discovery for the window; the
// counter in start/stopDiscovery keeps it alive under us.
runCatching { startDiscovery() }
try {
withTimeoutOrNull(graceWindow) {
fun match(list: List<CastDevice>): CastDevice? =
list.firstOrNull { it.name == name }
?: list.firstOrNull { it.name.equals(name, ignoreCase = true) }
while (true) {
val device = match(_devices.value)
?: match(_devices.first { match(it) != null })!!
observe(device)
if (_observing.value && _connectedDeviceName.value == device.name) {
android.util.Log.i("EarhornCast",
"observeByName: attached to ${device.name}")
persistScope.launch {
runCatching {
settings.setLastKnownHost(
device.name, "${device.host}:${device.port}")
}
}
return@withTimeoutOrNull
}
// Connect failed (device rebooting, group
// leader handoff). Retry while window remains.
delay(1_500)
}
} ?: run {
android.util.Log.i("EarhornCast",
"observeByName: '$name' not resolved within $graceWindow")
// COLD-START RESCUE (2026-09-01): a freshly
// rebooted router can sit multicast-dead for
// minutes, so mDNS resolution fails exactly when
// a cold-started screen needs it — while warm
// screens reconnect by host and never notice.
// Give cold starts the same advantage: dial the
// last host this group attached at. If the
// leader moved, the migration machinery corrects
// after attach; if the dial fails, bounded
// re-resolution keeps trying — a screen never
// ends dead on an empty queue (maintainer law).
val remembered = runCatching {
settings.lastKnownHosts.first()[name]
}.getOrNull()
val dialed = remembered?.let { hp ->
val host = hp.substringBefore(':')
val port = hp.substringAfter(':').toIntOrNull() ?: 8009
android.util.Log.i("EarhornCast",
"observeByName: trying last-known host $hp for '$name'")
observe(CastDevice(name, host, port, id = "", model = ""))
_observing.value && _connectedDeviceName.value == name
} ?: false
if (dialed) {
_terminalObserveFailure.value = null
android.util.Log.i("EarhornCast",
"observeByName: attached to '$name' via last-known host")
} else {
// Maintainer spec 2026-09-02: ONE attempt.
// A screen that cannot find its group in
// ~20 s does not keep looking — a spinner
// that never ends is a dead screen. The
// signal below is the only exit; what it
// does is MainActivity's call (kiosk if kiosk
// armed this device this cycle, else say so
// once and stay put).
android.util.Log.w("EarhornCast",
"observeByName: '$name' unreachable after one attempt — signalling")
_terminalObserveFailure.value =
name to android.os.SystemClock.elapsedRealtime()
}
}
} finally {
runCatching { stopDiscovery() }
}
}
} finally {
// Clear only our own pending marker — a newer request may
// already have replaced it.
_pendingTargetName.compareAndSet(name, null)
}
}
}
// ── Reattach after process death ─────────────────────────────
/**
* If the process previously died while observing (swipe-away, doze kill,
* system reclaim), reconnect to the persisted device — but only keep the
* session if the CCA actually still has active media. Called by
* PlaybackService.onCreate, which is the first thing alive on any app
* launch (the UI's MediaController bind creates it).
*
* Outcomes:
* - No persisted target / already observing → no-op.
* - Connect fails (Wi-Fi not up yet, device rebooted) → retry once after
* 3s, then give up but KEEP the persisted target for the next launch.
* - Connects but the receiver has no media session → the thing we were
* following ended while we were gone. Disconnect and clear so the app
* lands cleanly in local mode instead of showing an idle observer.
*/
suspend fun reattachIfPersisted() = reattachMutex.withLock {
// Re-check INSIDE the lock: the sibling entry point may have just
// finished a successful reattach while we were waiting.
if (_observing.value) return@withLock
val serialized = runCatching { settings.observedCastDevice.first() }.getOrNull()
?: return@withLock
val device = deserializeDevice(serialized) ?: run {
persistScope.launch { runCatching { settings.setObservedCastDevice(null) } }
return@withLock
}
android.util.Log.i("EarhornCast", "reattach: trying ${device.name} @ ${device.host}")
for (attempt in 1..2) {
observe(device, fromReattach = true)
if (_observing.value) break
if (attempt == 1) kotlinx.coroutines.delay(3_000)
}
if (!_observing.value) {
android.util.Log.i("EarhornCast",
"reattach: connect failed — keeping target for next launch")
return@withLock
}
// Connected. Is anything actually playing/loaded? mediaSessionId
// stays 0 until the receiver reports an active media session; give
// the post-connect GET_STATUS round-trip time to land (groups route
// media status via the leader and can be slower than solo devices).
val probeClient = client
val active = probeClient != null && withTimeoutOrNull(8_000) {
probeClient.mediaStatus.first { it.mediaSessionId != 0 }
} != null
when {
active ->
android.util.Log.i("EarhornCast", "reattach: session live — observing resumed")
// Only stand down on POSITIVE evidence of an idle receiver:
// still observing, on the same live client we probed. If the
// session died or was replaced meanwhile (client null/changed,
// observing flipped), clearing persistence here would destroy
// the very state a later reattach needs — never do that on
// ambiguity (field-reported: cleared persistence made every
// subsequent launch land silently in local mode).
_observing.value && probeClient != null && client === probeClient -> {
android.util.Log.i("EarhornCast",
"reattach: no active media session on ${device.name} — standing down")
disconnect(clearPersisted = true)
}
else ->
android.util.Log.i("EarhornCast",
"reattach: session state changed during probe — leaving persistence intact")
}
}
/** US-separator join — device names can contain any printable character,
* so delimit with \u001F which can't appear in mDNS TXT values. */
private fun serializeDevice(d: CastDevice): String =
listOf(d.name, d.host, d.port.toString(), d.id, d.model).joinToString("\u001F")
private fun deserializeDevice(s: String): CastDevice? {
val parts = s.split("\u001F")
if (parts.size != 5) return null
val port = parts[2].toIntOrNull() ?: return null
return CastDevice(name = parts[0], host = parts[1], port = port, id = parts[3], model = parts[4])
}
// ── Observed-metadata enrichment ─────────────────────────────
// See the worker inside observe() for the full rationale. Hits persist
// for the app's lifetime (this is a @Singleton — a track's year doesn't
// change between sessions); misses are per-session (sessionMisses is
// cleared in observe()) so a temporarily-absent server gets retried.
private val enrichHits = MutableStateFlow<Map<String, DidlItem.Track>>(emptyMap())
/** Per-track count of /status title mismatches (automation screens). */
private val statusMissCounts = java.util.concurrent.ConcurrentHashMap<String, Int>()
/** Concurrent set: cleared by observe() (caller's thread) while the
* previous session's worker may still be draining post-cancel. */
private val sessionMisses: MutableSet<String> =
java.util.concurrent.ConcurrentHashMap.newKeySet()
/** Composite identity key. Includes album because two tracks by the same
* artist with the same title (live + studio cuts) can differ in every
* technical field. Null for blank titles — nothing to search on. */
private fun enrichKeyOf(t: DidlItem.Track): String? {
if (t.title.isBlank()) return null
return "${t.title.trim().lowercase()}|${(t.artist ?: "").trim().lowercase()}|${(t.album ?: "").trim().lowercase()}"
}
private fun applyEnrichment(t: DidlItem.Track, hits: Map<String, DidlItem.Track>): DidlItem.Track {
val local = enrichKeyOf(t)?.let { hits[it] } ?: return t
return t.copy(
// Keep observed identity (id, title, artist, album, artUri) so
// anything keyed on those still resolves to the observed entity.
// Pull technical/derived fields from the library match.
date = t.date ?: local.date,
trackNumber = t.trackNumber ?: local.trackNumber,
discNumber = t.discNumber ?: local.discNumber,
genre = t.genre ?: local.genre,
composer = t.composer ?: local.composer,
mimeType = t.mimeType ?: local.mimeType,
sampleFrequency = t.sampleFrequency ?: local.sampleFrequency,
bitsPerSample = t.bitsPerSample ?: local.bitsPerSample,
channels = t.channels ?: local.channels,
bitrate = t.bitrate ?: local.bitrate,
// parentId from local enables "More tracks from this album"
// while observing — empty on raw-cast tracks otherwise.
parentId = local.parentId.ifBlank { t.parentId },
)
}
private suspend fun lookupLocal(udn: String, observed: DidlItem.Track): DidlItem.Track? {
val title = observed.title.takeIf { it.isNotBlank() } ?: return null
val artist = observed.artist?.takeIf { it.isNotBlank() }
val criteria = buildString {
append("upnp:class derivedfrom \"object.item.audioItem\"")
append(" and dc:title = \"${escapeForCriteria(title)}\"")
if (artist != null) append(" and upnp:artist = \"${escapeForCriteria(artist)}\"")
}
return when (val r = cd.search(udn, criteria, maxResults = 5L)) {
is dev.stave.upnp.BrowseResult.Success ->
r.items.filterIsInstance<DidlItem.Track>().let { hits ->
val obsAlbum = observed.album?.trim()?.lowercase()
if (obsAlbum != null) {
hits.firstOrNull { it.album?.trim()?.lowercase() == obsAlbum }
?: hits.firstOrNull()
} else hits.firstOrNull()
}
is dev.stave.upnp.BrowseResult.Failure -> null
}
}
/** Same escape rules as ContentDirectoryClient's private one. */
private fun escapeForCriteria(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
// ── Transport commands ───────────────────────────────────────
suspend fun play() { client?.play() }
suspend fun pause() { client?.pause() }
suspend fun stop() { client?.stop() }
suspend fun next() { client?.next() }
suspend fun previous() { client?.previous() }
suspend fun seek(position: Duration) {
client?.seek(position.inWholeMilliseconds / 1000.0)
}
suspend fun setVolume(percent: Int) {
client?.setVolume(percent / 100.0)
}
/** Repeat/shuffle for the OBSERVED session (I.15). Cast has one axis;
* shuffle is expressed as REPEAT_ALL_AND_SHUFFLE. */
suspend fun setRepeatMode(mode: dev.stave.domain.renderer.RepeatMode) {
client?.setRepeatMode(when (mode) {
dev.stave.domain.renderer.RepeatMode.OFF -> "REPEAT_OFF"
dev.stave.domain.renderer.RepeatMode.ALL -> "REPEAT_ALL"
dev.stave.domain.renderer.RepeatMode.ONE -> "REPEAT_SINGLE"
})
}
suspend fun setShuffle(on: Boolean) {
client?.setRepeatMode(if (on) "REPEAT_ALL_AND_SHUFFLE" else "REPEAT_OFF")
}
// ── Observed queue ───────────────────────────────────────────
/** Receiver-side item IDs in playback order — index-aligned with the
* published PlaybackState.queue so UI indices map to protocol IDs. */
private var observedItemIds: List<Int> = emptyList()
/** Jump the observed session to queue position [index]. Sends
* QUEUE_UPDATE with the corresponding item ID — position-only, no
* content change, so the sender's queue copy stays valid. */
suspend fun jumpToQueueItem(index: Int) {
val itemId = observedItemIds.getOrNull(index) ?: return
client?.queueJump(itemId)
}
/** Map a receiver queue item to the app's Track model. The id and
* resourceUrl fall back to a synthetic per-item value so LazyColumn
* keys stay unique even when the same content appears twice in the
* queue (contentId alone would collide). */
private fun queueItemToTrack(q: RawCastClient.QueueItem): DidlItem.Track =
DidlItem.Track(
id = "cast-item-${q.itemId}",
parentId = "",
title = q.title ?: "Track ${q.itemId}",
artist = q.artist,
albumArtist = q.artist,
album = q.album,
composer = null,
genre = null,
date = null,
trackNumber = null,
discNumber = null,
duration = q.durationSeconds.seconds,
albumArtUri = q.albumArtUrl,
resourceUrl = q.contentId ?: "cast-item-${q.itemId}",
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)
// ── Idle-session release (leak hygiene, memo v2 + retraction) ─
//
// An idle observer holds a TLS connection against the Cast group leader
// for no one's benefit. NOTE (voice-team retraction, 2026-08-22): the
// "~30 standing connections" figure that originally motivated this was
// fabricated — no measurement was ever performed, and observer load is
// an UNVERIFIED hypothesis for the leader freezes, not an established
// contributor. What IS code-confirmed, from this source: kiosk hand-back
// releases at hand-back time, but its monitor is Activity-RESUMED-scoped,
// so two paths leak connections indefinitely:
// 1. Daydream-preempted screens — screensaver takes the foreground
// before the music ends, no hand-back ever fires.
// 2. Manual observes with no returnTo (phones/tablets) left
// backgrounded.
// Magnitude unknown. This watchdog closes both leaks as hygiene,
// application-scoped so it covers every observing-idle state regardless
// of how it arose.
//
// Eligible = observing, receiver reports no live session, no attach
// attempt pending, and the UI is not RESUMED (a person watching an idle
// Now Playing keeps the session; when the UI is foreground the hand-back
// monitor owns the decision exactly as today). Continuously eligible for
// SESSION_DEAD_DEBOUNCE_MS — the same debounce the hand-back monitor
// uses, which is what makes "genuinely dead" mean one thing everywhere:
// long enough to outlast the reconnect supervisor (~15 s worst case) and
// to absorb track-boundary IDLE flaps, both of which would otherwise
// release mid-playlist on a daydreamed screen. Then a full silent
// disconnect(clearPersisted=true): connection, supervisor, keep-alive
// service (via the observing collector), and the reattach target. No
// focus change, no toast. A later OBSERVE rebuilds from scratch; a
// manual reopen shows local state.
/** Whether Earhorn's activity is RESUMED. Fed by MainActivity; used only
* to hold idle sessions a person is actually looking at. */
private val _uiForeground = MutableStateFlow(false)
/** Whether Earhorn's activity is RESUMED. Also gates radio /status
* polling — the memo requires reads only while Now Playing is
* genuinely on screen. */
val uiForeground: StateFlow<Boolean> = _uiForeground.asStateFlow()
fun setUiForeground(foreground: Boolean) {
_uiForeground.value = foreground
}
/** True while an in-app panel (Dom-Etha) is open. A person using the
* panel counts as present: hand-back must not yank the screen out from
* under them when the music happens to stop. */
private val _uiPanelOpen = MutableStateFlow(false)
val uiPanelOpen: StateFlow<Boolean> = _uiPanelOpen.asStateFlow()
fun setUiPanelOpen(open: Boolean) {
_uiPanelOpen.value = open
}
/** Declared LAST in the class deliberately: property initializers run in
* declaration order, and this coroutine touches most of the flows above
* — declaring it earlier would race their construction. */
@Suppress("unused")
private val idleReleaseWatchdog = persistScope.launch {
combine(
_observing, _sessionLive, _pendingTargetName, _uiForeground,
) { obs, live, pending, fg ->
obs && !live && pending == null && !fg
}
.distinctUntilChanged()
.collectLatest { eligible ->
if (!eligible) return@collectLatest
delay(SESSION_DEAD_DEBOUNCE_MS)
android.util.Log.i(
"EarhornCast",
"idle release: session dead ${SESSION_DEAD_DEBOUNCE_MS / 1000}s " +
"with UI backgrounded — releasing observer",
)
disconnect(clearPersisted = true)
}
}
companion object {
/** The single fleet-wide definition of "the session is genuinely
* dead, not flapping": continuously absent for this long. Used by
* the idle-release watchdog above AND MainActivity's kiosk
* hand-back monitor, so backgrounded release and foreground
* hand-back convict on identical evidence. Floor: must exceed the
* reconnect supervisor's ~15 s worst case plus first-status latency
* on groups. */
const val SESSION_DEAD_DEBOUNCE_MS = 20_000L
/** Frozen-while-PLAYING duration after which the session is
* declared dead. Deliberately far above musicnow's 90 s
* library-track watchdog so the source-side skip always wins
* when it exists. */
const val ZOMBIE_FREEZE_MS = 300_000L
/** Whole-network outage longer than this declares the session over
* (maintainer spec: exit to kiosk, don't impersonate a live screen).
* Short enough that a router reboot ends on the screensaver, long
* enough that AP roams and DHCP renews never trigger it. */
const val OUTAGE_HANDBACK_MS = 60_000L
/** F2: no MEDIA_STATUS for this long on a live session is
* session-death evidence. the maintainer's number, 2026-09-02. */
const val STATUS_SILENCE_MS = 150_000L
/** /status enrichment: mismatches tolerated per track before giving up. */
const val STATUS_ENRICH_MAX_MISSES = 3
/** Wait between /status enrichment retries for the same track. */
const val STATUS_ENRICH_RETRY_MS = 5_000L
}
}