package dev.stave.cast.raw
import dev.stave.cast.CastDevice
import dev.stave.cast.CastIntegration
import dev.stave.data.SettingsStore
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.PlaybackState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Watches a Chromecast/CCA-group session driven by some *other* sender
* (typically the Earhorn phone app) and exposes its state as a [PlaybackState]
* flow the desktop UI can render.
*
* Discovery is delegated to [CastIntegration] — it already runs a JmDNS scan for
* `_googlecast._tcp.` services and exposes a [CastIntegration.devices] flow. The
* picker passes the user-selected entry to [observe], we pull `host` and `port`
* off the [CastDevice], and open a raw TLS connection via [RawCastClient]. No
* parallel discovery stack.
*
* Two distinct stacks for the desktop:
* - **Sender** (existing): [CastIntegration] + `CastRenderer` + `CastStreamProxy`.
* Driven by the [RendererCoordinator]'s active delegate. Used when the user
* wants the desktop itself to *cast to* a group.
* - **Observer** (this class): [RawCastClient] over its own TLS socket. Does
* NOT touch the coordinator's active delegate. Used when the user wants the
* desktop to *follow* a group that another sender is driving.
*
* They are mutually exclusive in the UI (a single "is the user observing?"
* flag, [observing]) but architecturally independent — calling [observe] does
* not disturb the renderer pipeline.
*
* Ported from the Android TV implementation. The Android version did its own
* mDNS via `NsdManager` and held a `MulticastLock`; both are unnecessary on
* desktop where the JVM mDNS layer just works and we have a working discovery
* pipeline already.
*
* **Follow survives restart.** The followed device id is persisted
* ([SettingsStore.followedCastId]) on every [observe] and cleared only by
* [disconnect] — which is exclusively user-initiated ("stop following" /
* eyeball toggle). At construction, [restorePersistedFollow] waits for the
* persisted device to show up in discovery and resumes the follow, unless the
* user does something renderer-related first (picks a renderer, follows
* something else, or stops). This is the deliberate mirror image of the
* sender-side policy: `cast:` sender selections are dropped at settings load
* so a launch never auto-ATTACHES to a group (attaching grabs the transport),
* while a follow — being a passive overlay — is safe and useful to resume.
*/
class RawCastObserver(
/** Reused for discovery. We don't drive the sender stack from here; the
* observer is orthogonal. */
private val castIntegration: CastIntegration,
/** musicnow's /status, for the song a radio station is playing. */
private val radio: dev.stave.cast.radio.RadioStatusClient,
/** Persistence for the follow-across-restarts contract (see class KDoc) —
* reads/writes [SettingsStore.followedCastId] and reads
* [SettingsStore.activeRendererUdn] as the "user picked a renderer this
* session" supersede signal for the startup restore. */
private val settings: SettingsStore,
) : CastObserverApi {
/** Devices visible on the LAN that the user can pick to follow. Pass-through
* from [CastIntegration.devices] — saves a duplicate JmDNS stack and keeps
* the picker showing one list regardless of which mode the user picks. */
val devices: StateFlow<List<CastDevice>> = castIntegration.devices
private val _playbackState = MutableStateFlow(PlaybackState())
/** The station overlay from musicnow, or null. Applied on top of the
* receiver's static station title in [playbackState] (the maintainer's
* answer 2, 2026-09-04). */
@Volatile private var radioNow: dev.stave.cast.radio.RadioStatusClient.RadioNow? = null
/** Playback state mapped from raw [RawCastClient.MediaStatus] into the
* domain [PlaybackState] shape. Reads identical to `renderer.state` from
* the UI's perspective. */
override val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
private val _observing = MutableStateFlow(false)
/** True while connected to a Cast device in observer mode. UI surfaces gate
* their `tvTrack` / `tvState` resolution on this flag. */
override val observing: StateFlow<Boolean> = _observing.asStateFlow()
private val _connectedDeviceName = MutableStateFlow<String?>(null)
/** Friendly name of the currently-observed device, or null when not
* observing. Used by the picker to show the active follow target. */
override val connectedDeviceName: StateFlow<String?> = _connectedDeviceName.asStateFlow()
private val _sessionLive = MutableStateFlow(false)
/** See [CastObserverApi.sessionLive]. Raw: recomputed on every status,
* dropped by the silence watchdog and the frozen-position latch. */
override val sessionLive: StateFlow<Boolean> = _sessionLive.asStateFlow()
private val _pendingTargetName = MutableStateFlow<String?>(null)
/** Friendly name of the device a manual follow pick ([observeOrRestore])
* is still connecting to, or null. The picker shows "Connecting…" on
* that row and ignores further picks until the outcome (ported from
* Android 0.4.21/0.4.22). Bounded by [ATTACH_TIMEOUT_MS]. */
val pendingTargetName: StateFlow<String?> = _pendingTargetName.asStateFlow()
@Volatile private var client: RawCastClient? = null
@Volatile private var scope: CoroutineScope? = null
/** Serializes session bring-up and session-replacing teardowns: a startup
* restore, a manual pick and a leader move used to interleave their
* writes to [client]/[scope] around the suspending connect(), leaking a
* session whose collectors kept writing [playbackState]. */
private val sessionMutex = Mutex()
/** Bumped by every [teardownSession]. A bring-up that finds it changed
* after its connect() was superseded (the user stopped following
* meanwhile — [disconnect] is non-suspending, so it can't take the lock)
* and must not publish its session. */
private val sessionGen = java.util.concurrent.atomic.AtomicInteger()
/** Outcome of one bring-up. [Superseded] is not a failure: the user (or a
* newer request) ended this attempt, so nothing is reported. */
private enum class AttachOutcome { Attached, Superseded, Failed }
// ── Session liveness (Android F2 on the glass + freeze latch) ─────
/** The receiver claims PLAYING/BUFFERING but its position didn't move
* between two consecutive reports. The bar holds instead of advancing
* over silence. */
@Volatile private var positionFrozen = false
@Volatile private var frozenSinceMs = 0L
/** A freeze that outlasted [ZOMBIE_FREEZE_MS]: the session is dead despite
* its PLAYING claim (Classical KC overnight, 2026-08-30). Withdrawn
* when the position moves again. */
@Volatile private var zombieDeclared = false
/** No playback report for [STATUS_SILENCE_MS]. Withdrawn by the next one. */
@Volatile private var silenceDeclared = false
/** Lifetime scope for cross-session concerns: the startup follow-restore
* watcher and persistence writes from the non-suspend [disconnect]. Never
* cancelled — this is a process-scoped singleton, and [scope] (which dies
* with each observation) is deliberately NOT used for these, because a
* session teardown must not abort the persistence write recording it. */
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** The one-shot startup restore (see [restorePersistedFollow]). Cancelled by
* any manual [observe] or [disconnect] — a user action supersedes it. */
private var restoreJob: Job? = null
// ── Follower-queue state ─────────────────────────────────────
//
// The receiver's queue, assembled from the wire traffic RawCastClient
// surfaces as QueueEvents. Mutated ONLY inside the single serialized
// queue-event collector in [observe] (so no lock is needed for writes);
// published as immutable @Volatile snapshots so the mediaStatus bridge
// and [jumpToQueueItem] can read consistently from any thread.
/** Receiver's ordered item-id list — the QUEUE_ITEM_IDS response. */
@Volatile private var queueOrder: List<Int> = emptyList()
/** Fetched metadata per itemId. Survivors are retained across refetches
* (per the shared spec) so the list doesn't flash empty on QUEUE_CHANGE. */
@Volatile private var queueItemsById: Map<Int, DidlItem.Track> = emptyMap()
/** Debounce for QUEUE_CHANGE-triggered refetches — coalesces the burst a
* driving sender produces during a bulk edit into one id-list round trip. */
private var queueChangeDebounce: kotlinx.coroutines.Job? = null
/** Fail-safe: if the device hasn't volunteered a volume reading within
* the bootstrap window, force the [setVolume] gate open anyway so the
* user gets back the ability to drive the slider. The first successful
* SET_VOLUME will likely trigger a RECEIVER_STATUS reply that includes
* the volume sub-object, latching volumeKnown for real on the client
* side; this is just here to make sure we don't strand the user on
* devices that never broadcast volume on their own initiative. */
private val _forceVolumeInputAllowed = MutableStateFlow(false)
/** Wakes the musicnow poller: session content changed, the musicnow
* address changed, or the user asked for a refresh. Declared before
* `init` so the startup follow-restore can never see it uninitialized. Conflated — several wakes before the loop runs are one. */
private val radioWake = kotlinx.coroutines.channels.Channel<Unit>(kotlinx.coroutines.channels.Channel.CONFLATED)
/** Name of a group that could not be reached, for the UI to show once. */
val lastFailure = MutableStateFlow<String?>(null)
fun consumeFailure() { lastFailure.value = null }
init {
restoreJob = lifecycleScope.launch { restorePersistedFollow() }
}
// ── Startup follow restore ──────────────────────────────────
/**
* If a follow was active when the app last ran, resume it. One-shot per
* launch: waits (indefinitely — a speaker that's powered off tonight may
* be on tomorrow, and this coroutine is the only thing waiting) until the
* persisted device appears in discovery, then re-observes it. Because
* Follow is a passive overlay, resuming at launch is safe: it reads the
* group's session without touching its transport — unlike a sender attach,
* which is exactly what startup must never do.
*
* The wait aborts, leaving the persisted id in place only where noted, if
* the user acts first:
* - a manual [observe] or [disconnect] cancels [restoreJob] outright
* (observe re-persists its own target; disconnect clears);
* - the persisted follow id changing under us (shouldn't happen from
* another writer today, but the check makes the watcher self-consistent)
* aborts;
* - the user picking any renderer this session aborts WITHOUT clearing
* the persisted id. Rationale: they launched the app and immediately
* started driving something — having the UI flip to "Following X"
* thirty seconds later because X finally answered mDNS would be the
* same kind of startup ambush this feature set exists to kill. The
* follow still restores on the NEXT launch if they leave it be.
* [SettingsStore.activeRendererUdn] is compared against its value at
* launch rather than against null so that a persisted UPnP renderer
* selection (which legitimately survives restart) doesn't count as a
* user action.
*
* A failed re-observe (device answered mDNS but refused TLS, mid-reboot,
* etc.) logs, resets session state, and keeps the persisted id so the next
* launch tries again.
*
* Last-known-host rescue (Android parity): a freshly rebooted router can
* sit multicast-dead for minutes, so mDNS fails exactly when a cold start
* needs it. If discovery hasn't found the device after
* [LAST_KNOWN_HOST_GRACE_MS], dial the host it last attached at
* ([SettingsStore.lastKnownHosts]) once, through [observe]. If the group's
* leader has since moved, the moved-leader handling corrects it once
* discovery sees the group. If the dial fails, the persisted id stays and
* the wait for discovery carries on.
*/
private suspend fun restorePersistedFollow() {
val target = settings.followedCastId.value ?: return
val rendererAtLaunch = settings.activeRendererUdn.value
System.err.println(
"[EarhornCast/RawObserver] persisted follow for '$target' — " +
"will resume once the device is discovered",
)
val probes = combine(
castIntegration.devices,
settings.followedCastId,
settings.activeRendererUdn,
) { devs, follow, rendererNow ->
when {
follow != target -> RestoreProbe.Superseded
rendererNow != rendererAtLaunch -> RestoreProbe.Superseded
else -> {
val found = devs.firstOrNull { it.id == target }
if (found != null) RestoreProbe.Found(found) else RestoreProbe.Waiting
}
}
}
val probe = withTimeoutOrNull(LAST_KNOWN_HOST_GRACE_MS) {
probes.first { it !is RestoreProbe.Waiting }
} ?: run {
if (rescueByLastKnownHost(target, rendererAtLaunch)) return
probes.first { it !is RestoreProbe.Waiting }
}
val device = (probe as? RestoreProbe.Found)?.device ?: run {
System.err.println(
"[EarhornCast/RawObserver] follow restore for '$target' " +
"superseded by a user action this session",
)
return
}
if (_observing.value) return // user beat us to it between emissions
// observe() cancels restoreJob as its first act (a manual pick must
// supersede a pending restore). We're INSIDE restoreJob right now, so
// null the handle first — otherwise observe() would cancel the very
// coroutine running it, and the restore would die mid-flight at its
// next suspension point.
restoreJob = null
try {
observe(device)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// observe() has already reset its half-initialized session
// (teardownSession, not disconnect, so the persisted id stays).
// Tearing down again here, outside the session lock, could kill
// a manual pick that started in the meantime.
System.err.println(
"[EarhornCast/RawObserver] follow restore: connect to " +
"'${device.friendlyName}' failed " +
"(${e.javaClass.simpleName}: ${e.message}); keeping the " +
"persisted follow so the next launch retries",
)
}
}
/**
* The restore's one dial of [target]'s last known host. Returns true when
* there is nothing left to wait for (it attached, or a follow is already
* running); false to keep waiting for discovery. Same supersede rules as
* the discovery wait. Goes through [observe] (session lock, generation
* check), bounded by [ATTACH_TIMEOUT_MS].
*/
private suspend fun rescueByLastKnownHost(target: String, rendererAtLaunch: String?): Boolean {
val known = settings.lastKnownHosts.value[target] ?: run {
System.err.println(
"[EarhornCast/RawObserver] '$target' not discovered after " +
"${LAST_KNOWN_HOST_GRACE_MS / 1000}s and no last known host — still waiting",
)
return false
}
if (settings.followedCastId.value != target ||
settings.activeRendererUdn.value != rendererAtLaunch
) return false // the probe reports the supersede
if (_observing.value) return true // the user beat us to it
val name = known.friendlyName.ifBlank { target }
System.err.println(
"[EarhornCast/RawObserver] '$name' not discovered after " +
"${LAST_KNOWN_HOST_GRACE_MS / 1000}s — trying its last known host",
)
val device = CastDevice(
id = target,
friendlyName = name,
isGroup = false,
backing = su.litvak.chromecast.api.v2.ChromeCast(known.host, known.port),
)
// As in the discovery path below: observe() cancels restoreJob, and
// we are running inside it, so drop the handle first.
val self = kotlinx.coroutines.currentCoroutineContext()[Job]
restoreJob = null
val attached = try {
withTimeoutOrNull(ATTACH_TIMEOUT_MS) { observe(device) } ?: false
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println(
"[EarhornCast/RawObserver] last known host for '$name' failed " +
"(${e.javaClass.simpleName}: ${e.message}); keeping the follow, " +
"waiting for discovery",
)
false
}
if (attached) {
System.err.println("[EarhornCast/RawObserver] attached to '$name' via its last known host")
return true
}
// Still waiting: let a manual pick or stop cancel the wait again.
if (restoreJob == null) restoreJob = self
return _observing.value
}
/** Tri-state result of one probe of the restore-watcher's combined flow. */
private sealed interface RestoreProbe {
data object Waiting : RestoreProbe
data object Superseded : RestoreProbe
data class Found(val device: CastDevice) : RestoreProbe
}
// ── Observe a Cast device ───────────────────────────────────
/**
* Begin observing [device]. Tears down any prior observation, opens a fresh
* TLS connection, and starts pushing into [playbackState].
*
* This class doesn't touch the local renderer: the UI surfaces
* (NowPlayingPane / MPRIS / transport buttons) flip to the observed
* session because `tvState`/`tvTrack` resolve to it while [observing] is
* true. NowPlayingViewModel pauses local playback when following starts
* (ported from Android: otherwise both would be audible with no way to
* reach the local controls).
*
* Returns true when the session attached. Throws when the connection
* fails (after resetting the session; the persisted id stays). Returns
* false if the device has no resolvable host, or if the user stopped
* following while the connection was being made.
*
* Serialized: concurrent calls (startup restore, manual pick, leader
* move) run one after another, never interleaved.
*/
suspend fun observe(device: CastDevice): Boolean = observeOutcome(device) == AttachOutcome.Attached
private suspend fun observeOutcome(device: CastDevice): AttachOutcome {
// A manual pick supersedes any pending startup restore. (The restore
// path itself nulls restoreJob before calling observe(), so this can
// never cancel the coroutine it's running in.)
restoreJob?.cancel()
restoreJob = null
return sessionMutex.withLock { observeLocked(device) }
}
/** Session bring-up. Caller holds [sessionMutex]. */
private suspend fun observeLocked(device: CastDevice): AttachOutcome {
// Recycle any prior session via teardownSession, NOT disconnect():
// disconnect() clears the persisted follow id, and switching from
// device A to device B must not pass through a persisted-null window
// an ill-timed crash could freeze into "follow nothing next launch."
teardownSession()
val gen = sessionGen.get()
val host = device.host ?: run {
System.err.println(
"[EarhornCast/RawObserver] cannot observe ${device.friendlyName}: no host",
)
return AttachOutcome.Failed
}
// Persist the follow target: this is what lets a follow survive an app
// restart (see class KDoc). Recorded before connect() so that intent —
// not connection success — is what's remembered; if the device drops
// the handshake now, the next launch still knows to retry it.
settings.setFollowedCastId(device.id)
if (gen != sessionGen.get()) return AttachOutcome.Superseded
val c = RawCastClient(host, device.port)
val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = c
scope = sc
_observing.value = true
_connectedDeviceName.value = device.friendlyName
try {
c.connect()
} catch (t: Throwable) {
if (gen != sessionGen.get()) {
// disconnect() ran during the connect and already reset
// everything (closing the socket is what made connect throw).
c.disconnect()
sc.cancel()
if (t is CancellationException) throw t
return AttachOutcome.Superseded
}
teardownSession()
throw t
}
if (gen != sessionGen.get()) {
// The user stopped following while we connected. disconnect() may
// have run before client/scope were published, so clean up here.
c.disconnect()
sc.cancel()
if (client === c) teardownSession()
return AttachOutcome.Superseded
}
// Remember where it answered, for the startup rescue when discovery
// is dead (Android parity). On the lifetime scope so the session lock
// isn't held across the disk write, as on Android.
lifecycleScope.launch {
settings.setLastKnownHost(device.id, device.friendlyName, host, device.port)
}
// Bridge MediaStatus → PlaybackState. Note `current` is null until the
// first MEDIA_STATUS with a non-blank title arrives; the VM bridges
// this gap with a `lastObservedTrack` flow so the UI doesn't blink
// back to a stale local-renderer track between cast broadcasts.
//
// Volume is a special case: we ONLY mirror `ms.volume` into the
// playback-state when the client has confirmed it received an
// authoritative reading from the wire (`volumeKnown = true`). Until
// then, leave the prior value in [_playbackState] alone — the
// PlaybackState() default of 100% would otherwise be presented to
// the UI as if it were the truth, causing the "click anywhere on
// the slider → audible jump from real-5% to clicked-30%" symptom
// that feels like a volume blast.
var lastSeq = -1L
var prevReport: RawCastClient.MediaStatus? = null
sc.launch {
c.mediaStatus.collect { ms ->
// A body already running when teardown cancels this session
// must not write the old device's state over the reset.
if (client !== c) return@collect
// Only a real MEDIA_STATUS (new statusSeq) carries a fresh
// position. A RECEIVER_STATUS volume copy repeats the old one;
// taking it used to snap the bar back to the stale value
// every few seconds (ported from Android N.1, 2026-09-04).
val freshReport = ms.statusSeq != lastSeq
val now = System.currentTimeMillis()
if (freshReport) {
lastSeq = ms.statusSeq
trackFreeze(prevReport, ms, now)
prevReport = ms
if (silenceDeclared) {
silenceDeclared = false
System.err.println("[EarhornCast/RawObserver] statuses resumed — silence declaration withdrawn")
}
}
_playbackState.update { prior -> buildState(ms, prior, freshReport) }
// Freeze latch (Android, 2026-08-30): PLAYING/BUFFERING with a
// pinned position for ZOMBIE_FREEZE_MS = dead session.
if (positionFrozen && !zombieDeclared && frozenSinceMs > 0 &&
now - frozenSinceMs > ZOMBIE_FREEZE_MS
) {
zombieDeclared = true
System.err.println(
"[EarhornCast/RawObserver] ${ms.playerState} with frozen position for " +
"${ZOMBIE_FREEZE_MS / 1000}s — declaring session dead",
)
movedLeader(device, host)?.let { followMovedLeader(c, it, "frozen session") }
}
// Positive-evidence liveness. IDLE with a lingering session
// (queue finished) is NOT live. Track-boundary IDLE flaps are
// absorbed by the consumer's 20 s debounce.
if (client === c) {
_sessionLive.value = !zombieDeclared && !silenceDeclared &&
ms.mediaSessionId != 0 && ms.playerState != "IDLE"
}
}
}
// musicnow is polled only while the session is live (below); a
// session coming (back) to life re-arms the poller at once.
sc.launch {
_sessionLive.collect { live -> if (live) radioWake.trySend(Unit) }
}
// ── Follower-queue manager ────────────────────────────────
// Single serialized collector for all wire-level queue traffic. Every
// mutation of [queueOrder]/[queueItemsById] happens here (one logical
// thread, no locking); reads elsewhere see immutable snapshots.
//
// Implementation parameters match the shared cross-platform spec so
// desktop and Android behave identically: batch size 20 for
// QUEUE_GET_ITEMS; publish progressively as batches arrive rather
// than waiting for completeness; debounce QUEUE_CHANGE-triggered
// refetches by 300 ms to coalesce bursts during bulk sender edits;
// on mediaSessionId change (new load = new queue) clear everything
// and refetch; on a refetch, retain buffered items whose ids survive
// so the list doesn't flash empty.
sc.launch {
c.queueEvents.collect { ev ->
when (ev) {
is RawCastClient.QueueEvent.SessionChanged -> {
queueChangeDebounce?.cancel()
queueOrder = emptyList()
queueItemsById = emptyMap()
publishQueue(c)
runCatching { c.requestQueueItemIds() }
}
is RawCastClient.QueueEvent.ItemIds -> {
val idSet = ev.itemIds.toSet()
queueOrder = ev.itemIds
// Retain survivors so the rows the user is looking at
// don't blank out while the delta refetches.
queueItemsById = queueItemsById.filterKeys { it in idSet }
publishQueue(c)
val missing = ev.itemIds.filter { it !in queueItemsById }
missing.chunked(QUEUE_FETCH_BATCH).forEach { batch ->
runCatching { c.requestQueueItems(batch) }
}
}
is RawCastClient.QueueEvent.Items -> {
if (ev.items.isNotEmpty()) {
val merged = queueItemsById.toMutableMap()
ev.items.forEach { merged[it.itemId] = it.toTrack() }
queueItemsById = merged
publishQueue(c)
}
}
is RawCastClient.QueueEvent.Change -> {
// The receiver broadcasts one of these for EVERY sender
// edit; a bulk edit produces a burst. Debounce so the
// burst coalesces into a single id-list round trip.
queueChangeDebounce?.cancel()
queueChangeDebounce = sc.launch {
delay(QUEUE_CHANGE_DEBOUNCE_MS)
runCatching { c.requestQueueItemIds() }
}
}
}
}
}
// Reconnect supervisor (ported from Android, 2026-09-04). One attempt
// after 5 s and then a blank screen was the old rule; now up to
// RECONNECT_ATTEMPTS, RECONNECT_GAP_MS apart, keeping the last state
// on screen. If the group's leader moved (same name, new host in
// discovery) follow it. If every attempt fails, release with a
// message instead of holding "buffering" forever.
sc.launch {
c.connected.collect { connected ->
if (connected || !_observing.value) return@collect
_playbackState.update { it.copy(isBuffering = true, isPlaying = false) }
var attempt = 0
while (_observing.value && client === c && attempt < RECONNECT_ATTEMPTS) {
attempt++
delay(RECONNECT_GAP_MS)
if (!_observing.value || client !== c) return@collect
val moved = movedLeader(device, host)
if (moved != null) {
followMovedLeader(c, moved, "connection lost")
return@collect
}
try {
c.connect()
return@collect
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println(
"[EarhornCast/RawObserver] reconnect attempt $attempt to '${device.friendlyName}' " +
"failed (${e.javaClass.simpleName}: ${e.message})",
)
}
}
if (_observing.value && client === c) {
System.err.println("[EarhornCast/RawObserver] '${device.friendlyName}' unreachable after $RECONNECT_ATTEMPTS attempts — releasing")
lastFailure.value = device.friendlyName
releaseSession(c)
}
}
}
// Silence watchdog (Android F2): a session that has sent no playback
// report for STATUS_SILENCE_MS is dead. sessionLive drops (the screen
// shows "Waiting for <device>…" after the consumer's debounce); the
// last state stays in [playbackState] and the next real status
// withdraws the declaration. Polling continues so a recovered leader
// is picked up, and a moved leader is followed.
sc.launch {
var lastSeq = -1L
var silentSince = System.currentTimeMillis()
while (true) {
delay(10_000)
val seq = c.mediaStatus.value.statusSeq
val now = System.currentTimeMillis()
if (seq != lastSeq) { lastSeq = seq; silentSince = now }
else if (!silenceDeclared && now - silentSince > STATUS_SILENCE_MS &&
_sessionLive.value && client === c
) {
System.err.println("[EarhornCast/RawObserver] no playback report for ${STATUS_SILENCE_MS / 1000}s — session dead")
silenceDeclared = true
_sessionLive.value = false
movedLeader(device, host)?.let { followMovedLeader(c, it, "status silence") }
}
}
}
// Station overlay from musicnow. Android's discipline (0.4.20
// F-series): a failed read keeps the last good overlay and backs off
// (doubling, capped at 60 s) instead of blanking it; NULL_READS_BEFORE_LATCH
// consecutive station:null reads latch the poller off so a library
// session doesn't poll musicnow forever. A latched poller re-arms when
// the session's content changes, or immediately on
// [requestStatusRefresh] — the in-app "refresh" for the station overlay.
//
// A changed overlay is applied by rebuilding the state directly from
// the client's current MediaStatus. The old path asked the receiver
// for a fresh MEDIA_STATUS and relied on the collector to rebuild, but
// the overlay never reached the screen when no new status arrived.
sc.launch {
c.mediaStatus
.map { it.contentId to it.title }
.distinctUntilChanged()
.drop(1)
.collect { radioWake.trySend(Unit) }
}
sc.launch {
settings.musicnowStatusUrl.drop(1).collect { radioWake.trySend(Unit) }
}
sc.launch {
delay(kotlin.random.Random.nextLong(0, 3_000)) // de-sync the fleet's polls
var nullReads = 0
var failures = 0
var latched = false
while (isActive) {
// Android audit 2026-09-02: never poll musicnow for a session
// that isn't live (dead, finished, or not yet reporting). The
// sessionLive collector above sends a wake when it comes back.
if (!_sessionLive.value) {
radioWake.receive()
if (!_sessionLive.value) continue
nullReads = 0; failures = 0; latched = false
delay(kotlin.random.Random.nextLong(0, 3_000)) // keep the fleet de-synced
continue
}
val group = _connectedDeviceName.value ?: device.friendlyName
when (val r = radio.poll(group)) {
is dev.stave.cast.radio.RadioStatusClient.Poll.Station -> {
nullReads = 0; failures = 0
applyRadio(c, r.now)
}
dev.stave.cast.radio.RadioStatusClient.Poll.NoStation -> {
failures = 0
if (++nullReads >= dev.stave.cast.radio.RadioStatusClient.NULL_READS_BEFORE_LATCH) {
applyRadio(c, null)
latched = true
}
}
dev.stave.cast.radio.RadioStatusClient.Poll.Failed -> failures++
dev.stave.cast.radio.RadioStatusClient.Poll.Disabled -> {
applyRadio(c, null)
latched = true
}
}
val woke = if (latched) {
radioWake.receive(); true
} else {
val interval = if (failures == 0) dev.stave.cast.radio.RadioStatusClient.POLL_INTERVAL_MS
else (dev.stave.cast.radio.RadioStatusClient.POLL_INTERVAL_MS shl failures.coerceAtMost(3))
.coerceAtMost(dev.stave.cast.radio.RadioStatusClient.MAX_BACKOFF_MS)
withTimeoutOrNull(interval) { radioWake.receive() } != null
}
if (woke) { nullReads = 0; failures = 0; latched = false }
}
}
// Android's rates: media status every 10 s; receiver status only when
// the last one is older than 25 s (was both every 3 s).
sc.launch {
while (true) {
delay(10_000)
runCatching { c.requestMediaStatus() }
if (System.currentTimeMillis() - c.lastReceiverStatusAt > 25_000L) {
runCatching { c.requestReceiverStatus() }
}
}
}
// Position interpolation — tick progress locally every second while
// playing so the seek bar moves smoothly between MEDIA_STATUS updates
// (which arrive irregularly, every few seconds at best).
// update{} so a status that lands between the read and the write
// isn't overwritten by the old track. Holds while the position is
// frozen or the session is declared dead (no bar running over silence).
sc.launch {
while (true) {
delay(1_000)
if (client !== c) break
if (positionFrozen || zombieDeclared || silenceDeclared) continue
_playbackState.update { s ->
if (s.isPlaying && s.duration > Duration.ZERO) {
s.copy(position = (s.position + 1.seconds).coerceAtMost(s.duration))
} else s
}
}
}
// Volume bootstrap. The initial CONNECT + GET_STATUS to NS_RECEIVER
// that c.connect() fires should already include the device's volume
// in its response, but on some devices (and on every CCA group
// leader we've tested) the very first RECEIVER_STATUS response
// comes back without a `volume` sub-object — the receiver only
// includes it on subsequent broadcasts triggered by an actual
// volume change. If we relied on those, an idle session could go
// minutes without ever giving us an authoritative volume reading,
// and the slider would sit at the default 100% the whole time.
//
// Hit NS_RECEIVER GET_STATUS on an exponential-ish backoff until
// volumeKnown latches true (caps at 8 attempts ≈ 12s of wallclock).
// The cost is at most 8 small JSON exchanges; once the latch
// closes, the loop exits and we rely on spontaneous broadcasts +
// the regular periodic refresh for ongoing updates.
sc.launch {
var delay = VOLUME_BOOTSTRAP_INITIAL_DELAY_MS
repeat(VOLUME_BOOTSTRAP_MAX_ATTEMPTS) {
if (c.mediaStatus.value.volumeKnown) return@launch
delay(delay)
if (c.mediaStatus.value.volumeKnown) return@launch
runCatching { c.requestReceiverStatus() }
delay = (delay * 3 / 2).coerceAtMost(VOLUME_BOOTSTRAP_MAX_DELAY_MS)
}
if (!c.mediaStatus.value.volumeKnown) {
System.err.println(
"[EarhornCast/RawObserver] gave up bootstrapping device volume " +
"after $VOLUME_BOOTSTRAP_MAX_ATTEMPTS attempts; opening " +
"the volume input gate so the user can still drive the " +
"slider. First successful SET_VOLUME should cause the " +
"device to broadcast its volume and latch volumeKnown."
)
_forceVolumeInputAllowed.value = true
}
}
return AttachOutcome.Attached
}
/** Update the freeze state from two consecutive playback reports (ported
* from Android). PLAYING *or* BUFFERING counts: the 2026-08-31 zombie
* froze in BUFFERING. */
private fun trackFreeze(prev: RawCastClient.MediaStatus?, ms: RawCastClient.MediaStatus, now: Long) {
val active = setOf("PLAYING", "BUFFERING")
val frozenNow = prev != null &&
ms.playerState in active && prev.playerState in active &&
ms.mediaSessionId == prev.mediaSessionId &&
ms.currentTime > 0.0 && ms.currentTime == prev.currentTime
if (frozenNow == positionFrozen) return
positionFrozen = frozenNow
frozenSinceMs = if (frozenNow) now else 0L
if (!frozenNow && zombieDeclared) {
zombieDeclared = false
System.err.println("[EarhornCast/RawObserver] position moving again — frozen-session declaration withdrawn")
}
}
/** The same group under a different host in discovery: its leader moved. */
private fun movedLeader(device: CastDevice, host: String): CastDevice? =
castIntegration.devices.value.firstOrNull {
it.friendlyName == device.friendlyName && it.host != null && it.host != host
}
/** Follow a moved group leader. Runs on [lifecycleScope], NOT the session
* scope: the new observe tears the current session down first, which
* cancelled the very coroutine that asked for the move, so the follow
* silently ended instead of moving (review P1-4). Skipped if [c]'s
* session has already been replaced. */
private fun followMovedLeader(c: RawCastClient, moved: CastDevice, reason: String) {
System.err.println("[EarhornCast/RawObserver] leader for '${moved.friendlyName}' moved to ${moved.host} ($reason) — following")
lifecycleScope.launch {
val outcome = try {
sessionMutex.withLock {
if (client !== c) AttachOutcome.Superseded else observeLocked(moved)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println("[EarhornCast/RawObserver] following the moved leader failed (${e.javaClass.simpleName}: ${e.message})")
AttachOutcome.Failed
}
if (outcome == AttachOutcome.Failed) lastFailure.value = moved.friendlyName
}
}
/** End [c]'s session (under the lock, so it can't hit a newer one). */
private fun releaseSession(c: RawCastClient) {
lifecycleScope.launch {
sessionMutex.withLock { if (client === c) teardownSession() }
}
}
/** Manual pick (design decision 2026-09-04): try [device]; if it cannot
* be reached within [ATTACH_TIMEOUT_MS], put the previous follow back and
* report the name. While it runs, [pendingTargetName] names [device] and
* further picks are ignored (return false). Returns true only when
* [device] attached. */
suspend fun observeOrRestore(device: CastDevice): Boolean {
if (!_pendingTargetName.compareAndSet(null, device.friendlyName)) {
System.err.println(
"[EarhornCast/RawObserver] pick of '${device.friendlyName}' ignored — " +
"still connecting to '${_pendingTargetName.value}'",
)
return false
}
val previous = castIntegration.devices.value.firstOrNull { it.friendlyName == _connectedDeviceName.value }
val persistedBefore = settings.followedCastId.value
val outcome = try {
withTimeoutOrNull(ATTACH_TIMEOUT_MS) { observeOutcome(device) } ?: run {
System.err.println("[EarhornCast/RawObserver] '${device.friendlyName}' did not answer within ${ATTACH_TIMEOUT_MS / 1000}s")
AttachOutcome.Failed
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println("[EarhornCast/RawObserver] pick of '${device.friendlyName}' failed (${e.javaClass.simpleName}: ${e.message})")
AttachOutcome.Failed
} finally {
_pendingTargetName.compareAndSet(device.friendlyName, null)
}
when (outcome) {
AttachOutcome.Attached -> return true
AttachOutcome.Superseded -> return false // the user stopped following meanwhile
AttachOutcome.Failed -> Unit
}
System.err.println("[EarhornCast/RawObserver] pick of '${device.friendlyName}' failed — restoring ${previous?.friendlyName ?: "nothing"}")
lastFailure.value = device.friendlyName
val alreadyBack = previous != null && _observing.value && _connectedDeviceName.value == previous.friendlyName
if (previous != null && previous.id != device.id && !alreadyBack) {
try {
observeOutcome(previous)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println("[EarhornCast/RawObserver] restoring '${previous.friendlyName}' failed (${e.javaClass.simpleName}: ${e.message})")
}
} else if (previous == null && settings.followedCastId.value == device.id) {
// Nothing to go back to: don't auto-follow, at the next launch, a
// device the user just couldn't reach. Put back what was persisted.
settings.setFollowedCastId(persistedBefore)
}
return false
}
/**
* Stop observing AND forget the persisted follow target. Idempotent — safe
* to call when not observing.
*
* This is the user-facing stop (the "Stop following" row and the eyeball
* toggle are its only callers): an explicit stop means "don't resume this
* on the next launch either," so the persisted id is cleared. Internal
* paths that must keep the persisted id — session recycling at the top of
* [observe], failed-restore cleanup — use [teardownSession] directly.
* Nothing on the app-shutdown path calls this (deliberately: the process
* dying while following is precisely the case the restore exists for).
*/
override fun disconnect() {
restoreJob?.cancel()
restoreJob = null
teardownSession()
// Non-suspend interface method, so the write hops onto the lifetime
// scope — NOT the per-session scope, which teardownSession just killed.
lifecycleScope.launch { settings.setFollowedCastId(null) }
}
/** Tear down the observation session (socket, collectors, state flows)
* without touching the persisted follow id. Idempotent. */
private fun teardownSession() {
sessionGen.incrementAndGet()
scope?.cancel()
scope = null
client?.disconnect()
client = null
_observing.value = false
_connectedDeviceName.value = null
_sessionLive.value = false
positionFrozen = false
frozenSinceMs = 0L
zombieDeclared = false
silenceDeclared = false
_playbackState.value = PlaybackState()
// Follower-queue state belongs to the session we just left.
queueChangeDebounce = null
queueOrder = emptyList()
queueItemsById = emptyMap()
// Reset the fail-safe — the next device we attach to gets its own
// fresh bootstrap window. Without this, observing device A (which
// timed out) followed by device B would skip the gate for B even
// though we have no authoritative volume from B yet.
_forceVolumeInputAllowed.value = false
radioNow = null
}
// ── Transport commands (forwarded to the underlying client) ──
//
// Each command optimistically updates [_playbackState] before the wire
// round-trip — the next MEDIA_STATUS will reconfirm or correct as needed.
// Without this, rapid play/pause taps would read the LAST-confirmed state
// (which lags by 150-500ms behind the user's intent) and dispatch the
// wrong command, e.g. tap-Pause → state still says PLAYING → tap-Pause
// again → sends a second PAUSE instead of resuming. Updating eagerly lets
// the toggle logic in NowPlayingViewModel/MprisIntegration compute against
// the user's most recent intent on every press.
// update{} throughout: these run on the caller's thread while the
// MEDIA_STATUS collector writes the same flow; a plain read-modify-write
// could put the previous track back for a poll cycle.
override suspend fun play() {
_playbackState.update { it.copy(isPlaying = true, isStopped = false, isBuffering = false) }
client?.play()
}
override suspend fun pause() {
_playbackState.update { it.copy(isPlaying = false, isBuffering = false) }
client?.pause()
}
override suspend fun stop() {
_playbackState.update { it.copy(isPlaying = false, isStopped = true, isBuffering = false) }
client?.stop()
}
override suspend fun next() {
client?.next()
}
override suspend fun previous() {
client?.previous()
}
override suspend fun seek(position: Duration) {
// A session with no duration is a live stream (radio): there is
// nothing to seek, and the receiver ignores it (Android: duration is
// the only reliable LIVE discriminator).
if (_playbackState.value.duration <= Duration.ZERO) {
System.err.println("[EarhornCast/RawObserver] seek refused: live stream")
return
}
_playbackState.update { it.copy(position = position) }
client?.seek(position.inWholeMilliseconds / 1000.0)
}
/** Repeat/shuffle for the OBSERVED session. Cast has one axis; shuffle
* is expressed as REPEAT_ALL_AND_SHUFFLE. */
override 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"
})
}
override suspend fun setShuffle(on: Boolean) {
client?.setRepeatMode(if (on) "REPEAT_ALL_AND_SHUFFLE" else "REPEAT_OFF")
}
override suspend fun setVolume(percent: Int) {
val c = client ?: return
// Refuse to send SET_VOLUME until we've heard an authoritative
// volume reading from the device — OR the bootstrap timed out and
// we've force-opened the gate to keep the user unblocked.
// Otherwise a user click on the (visually unsynced) slider would
// push e.g. SET_VOLUME{0.30} to a device actually at 5% — a 6x
// jump that's audibly indistinguishable from a "blast." Once the
// bootstrap loop in observe() latches volumeKnown, this gate
// opens and the user's input is honored normally.
if (!c.mediaStatus.value.volumeKnown && !_forceVolumeInputAllowed.value) {
System.err.println(
"[EarhornCast/RawObserver] setVolume($percent) ignored: device " +
"volume not yet known. Slider input will be honored once the " +
"device reports its current level."
)
return
}
val clamped = percent.coerceIn(0, 100)
_playbackState.update { it.copy(volumePercent = clamped) }
c.setVolume(clamped / 100.0)
}
/**
* Jump the observed session to the queue row at [index]. Maps the index
* back to the receiver's itemId (the id order is the source of truth for
* position) and sends a position-only QUEUE_UPDATE. Optimistically moves
* the local highlight; the next MEDIA_STATUS's currentItemId reconfirms
* or corrects. Display-and-jump is the follower's entire queue contract —
* see [CastObserverApi.jumpToQueueItem] for why editing is prohibited.
*/
override suspend fun jumpToQueueItem(index: Int) {
val c = client ?: return
val itemId = queueOrder.getOrNull(index) ?: return
_playbackState.update { it.copy(queueIndex = index) }
c.queueJumpTo(itemId)
}
/** Apply a new station overlay (or clear it) and republish immediately. */
private fun applyRadio(c: RawCastClient, now: dev.stave.cast.radio.RadioStatusClient.RadioNow?) {
if (now == radioNow) return
radioNow = now
if (client !== c) return
// Swap only the displayed track. Transport fields stay as they are, so
// an optimistic play/pause the user just pressed isn't rolled back to
// the last receiver report. update{} keeps this atomic against the
// MEDIA_STATUS collector, which writes the same flow.
_playbackState.update { prior ->
prior.copy(current = buildState(c.mediaStatus.value, prior, freshReport = false).current)
}
}
/** Map one [RawCastClient.MediaStatus] (plus the current station overlay)
* to the domain [PlaybackState]. [freshReport] is false for a rebuild that
* isn't driven by a new MEDIA_STATUS, so the interpolated position is kept. */
private fun buildState(ms: RawCastClient.MediaStatus, prior: PlaybackState, freshReport: Boolean): PlaybackState {
val rn = radioNow
// The overlay applies ONLY to sessions that ARE radio (no duration),
// as on Android: a stale overlay, kept through musicnow failures by
// design, otherwise dressed library playback on the same group in
// the station's song (Android, 2026-08-29). A station that loads with
// no title of its own still shows the overlay.
val overlay = rn?.title != null && ms.duration <= 0.0 && ms.mediaSessionId != 0
// Current track: prefer the fetched queue item for the receiver's
// currentItemId (ported from Android). currentItemId rides on every
// status push, while the media block is often missing or half-filled
// during auto-advance, and the client keeps title/artist sticky — so
// the MEDIA_STATUS path left the previous title up for a track or
// more. Queue items carry the full per-item metadata, art included.
val fromQueue = if (overlay) null else ms.currentItemId?.let { queueItemsById[it] }
return PlaybackState(
current = if (fromQueue != null) fromQueue.copy(
// Same identity as the MEDIA_STATUS path (the content id), so
// enrichment/lyrics keys don't depend on which path built it.
id = fromQueue.resourceUrl.ifBlank { fromQueue.id },
duration = fromQueue.duration ?: ms.duration.seconds,
) else if (overlay || !ms.title.isNullOrBlank()) DidlItem.Track(
id = if (overlay) "station:${rn!!.station}|${rn.title}|${rn.artist ?: ""}" else ms.contentId ?: "",
parentId = "",
title = if (overlay) rn!!.title!! else ms.title ?: "",
artist = if (overlay) rn!!.artist else ms.artist,
albumArtist = if (overlay) rn!!.artist else ms.artist,
album = if (overlay) rn!!.station else ms.album,
composer = null,
genre = null,
date = null,
trackNumber = null,
discNumber = null,
duration = ms.duration.seconds,
albumArtUri = gateArt(if (overlay) (rn!!.albumArtUrl ?: ms.albumArtUrl) else ms.albumArtUrl),
resourceUrl = if (overlay) "station:${rn!!.station}|${rn.title}|${rn.artist ?: ""}" else ms.contentId ?: "",
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
) else null,
// Assembled follower queue (see the queue-event collector
// below). The index is derived from the receiver's
// authoritative currentItemId cross-referenced against the
// fetched id order — NEVER counted locally, so it cannot
// drift from what's audible.
queue = buildQueueTracks(),
queueIndex = ms.currentItemId?.let { queueOrder.indexOf(it) } ?: -1,
isPlaying = ms.playerState == "PLAYING",
isBuffering = ms.playerState == "BUFFERING",
isStopped = ms.playerState == "IDLE",
position = if (freshReport) ms.currentTime.seconds else prior.position,
duration = ms.duration.seconds,
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",
volumePercent = if (ms.volumeKnown)
(ms.volume * 100).toInt()
else
prior.volumePercent,
)
}
/** Immutable snapshot of the follower queue as display tracks. Ids whose
* metadata hasn't arrived yet render as placeholders (progressive
* publishing) so row positions stay stable while batches land. */
private fun buildQueueTracks(): List<DidlItem.Track> {
val order = queueOrder
if (order.isEmpty()) return emptyList()
val items = queueItemsById
return order.map { id -> items[id] ?: placeholderTrack(id) }
}
/** Republish the queue, and the current track with it (it may come from
* a queue item that just arrived). Transport fields are left alone so an
* optimistic play/pause isn't rolled back. */
private fun publishQueue(c: RawCastClient) {
if (client !== c) return
_playbackState.update { prior ->
val built = buildState(c.mediaStatus.value, prior, freshReport = false)
prior.copy(current = built.current, queue = built.queue, queueIndex = built.queueIndex)
}
}
/** Followed-session art comes from whatever app is casting (often a
* Google/Spotify CDN). Pass it through only when it is on the LAN or the
* user allows external calls (review 4 #5). */
private fun gateArt(url: String?): String? =
url?.takeIf { dev.stave.net.UrlPolicy.isLanUrl(it) || settings.allowExternalCalls.value }
/** Map one wire-level queue item to the domain Track the UI renders.
* The id is keyed on the receiver's itemId — unique per queue slot, so
* the same track queued twice yields two distinct row keys. */
private fun RawCastClient.QueueItemLite.toTrack(): DidlItem.Track =
DidlItem.Track(
id = "cast-queue:$itemId",
parentId = "",
title = title ?: contentId?.substringAfterLast('/') ?: "Unknown track",
artist = artist,
albumArtist = artist,
album = album,
composer = null,
genre = null,
date = null,
trackNumber = null,
discNumber = null,
duration = durationSeconds?.seconds,
albumArtUri = gateArt(albumArtUrl),
resourceUrl = contentId ?: "",
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)
private fun placeholderTrack(itemId: Int): DidlItem.Track =
DidlItem.Track(
id = "cast-queue:$itemId",
parentId = "",
title = "…",
artist = null,
albumArtist = null,
album = null,
composer = null,
genre = null,
date = null,
trackNumber = null,
discNumber = null,
duration = null,
albumArtUri = null,
resourceUrl = "",
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)
/** Force a MEDIA_STATUS refresh. The periodic timer already does this
* while observing; this is the manual trigger for surfaces that want
* fresh data immediately (e.g. after the user re-focuses the window).
* Also re-reads musicnow's /status now, re-arming a latched poller. */
override suspend fun requestStatusRefresh() {
radioWake.trySend(Unit)
client?.requestMediaStatus()
}
companion object {
/** Starting delay before the first volume-bootstrap retry. The
* initial CONNECT + GET_STATUS that c.connect() fires usually
* delivers volume on its own response, so the first retry only
* needs to fire after enough time for that to complete. */
private const val VOLUME_BOOTSTRAP_INITIAL_DELAY_MS = 300L
/** Cap on the exponential-ish backoff between retries. Bounds the
* worst-case wait between calls so a slow / temporarily-laggy
* device still gets re-asked within seconds. */
private const val VOLUME_BOOTSTRAP_MAX_DELAY_MS = 2_500L
/** Maximum number of RECEIVER_STATUS requests before giving up.
* With the 300→2500ms backoff, 8 attempts ≈ 12s of wallclock —
* past that, the device clearly isn't going to volunteer a
* volume reading and the user can still drive the slider; we
* just won't have the initial sync. */
private const val VOLUME_BOOTSTRAP_MAX_ATTEMPTS = 8
/** QUEUE_GET_ITEMS batch size — shared cross-platform value so
* desktop and Android generate identical wire traffic patterns. */
private const val QUEUE_FETCH_BATCH = 20
/** Debounce window for QUEUE_CHANGE-triggered id-list refetches.
* Coalesces the broadcast burst a driving sender produces during a
* bulk edit into a single round trip. Shared cross-platform value. */
private const val QUEUE_CHANGE_DEBOUNCE_MS = 300L
private const val RECONNECT_ATTEMPTS = 4
private const val RECONNECT_GAP_MS = 5_000L
/** Android F2: no playback report for this long = dead session. */
private const val STATUS_SILENCE_MS = 150_000L
/** PLAYING/BUFFERING with a pinned position this long = dead session
* (Android's freeze latch; far above musicnow's 90 s skip watchdog
* by design, so the source-side skip wins when it exists). */
const val ZOMBIE_FREEZE_MS = 300_000L
/** How long a manual follow pick may take before it counts as failed
* (Android's attach window). */
const val ATTACH_TIMEOUT_MS = 20_000L
/** How long the startup restore waits for discovery before dialling the
* followed device's last known host (Android's 20 s grace). */
const val LAST_KNOWN_HOST_GRACE_MS = 20_000L
}
}