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

RawCastObserver.kt

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
    }
}