package dev.stave.cast
import dev.stave.domain.model.DidlItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.ChromeCasts
import su.litvak.chromecast.api.v2.ChromeCastsListener
/**
* Desktop Chromecast integration. Mirrors the StateFlow surface of the Android
* [dev.stave.cast.CastIntegration] so any picker UI ported from Android can
* subscribe to the same flows without rewriting.
*
* The Android side is built on Google Play Services' CastFramework. There is no
* desktop equivalent of that, so this port uses
* [de.sfuhrm:chromecast-java-api-v2](https://github.com/sfuhrm/chromecast-java-api-v2),
* the actively-maintained fork of vitalidze/chromecast-java-api-v2. It speaks
* the Cast protobuf wire protocol directly and discovers devices via jmDNS.
* The fork keeps the original's `su.litvak.chromecast.api.v2.*` package names
* (only Maven coordinates differ), so imports below look "wrong" but resolve
* correctly. Different surface from Google's SDK (no MediaRouter, no
* SessionManager), same conceptual model: find devices, open a session,
* load media, control playback.
*
* **Scope of this port** (mark of completion at the bottom of each line):
* - Device discovery via jmDNS ✓ working
* - Session lifecycle (connect / disconnect) ✓ working
* - Media load / play / pause / stop / seek ✓ wraps the lib
* - Volume ✓ wraps the lib
* - Stream proxy for speaker-group sources ✓ ported from Android
* - Renderer-level wiring (a CastRenderer impl
* of [dev.stave.domain.renderer.Renderer] that
* this integration sits behind so the rest of
* the app can treat Cast as just another
* renderer choice) ✗ NOT WIRED YET — see TODO at bottom
* - Picker UI ✗ NOT YET — desktop component to be added
*
* Until the renderer wiring lands, the methods on this class work in isolation
* (you could call [load] directly with a track and audio will play on the
* selected device), but the app's UI does not yet expose any way to do so.
* That's the next session's work.
*/
class CastIntegration {
/** True if the chromecast-java-api-v2 library and jmDNS are both healthy.
* Currently always true on desktop — if the lib weren't on the classpath
* this class wouldn't load. Kept for API parity with the Android side
* which has to deal with Play Services being absent. */
val available: Boolean = true
private val streamProxy = CastStreamProxy()
// Single-thread coroutine scope for discovery callbacks (which arrive from
// jmDNS's worker thread) and for connection-retry timers. Cast is bursty —
// most activity is "user clicked a thing, do that thing, wait for events" —
// so a single thread is plenty and keeps the state transitions ordered.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()
private val _session = MutableStateFlow<CastSession?>(null)
val session: StateFlow<CastSession?> = _session.asStateFlow()
/** True iff a Cast session is currently active. Convenience derived from
* [session]; callers can observe this to gate local audio output. */
private val _isCasting = MutableStateFlow(false)
val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow()
private val _castingToGroup = MutableStateFlow(false)
val castingToGroup: StateFlow<Boolean> = _castingToGroup.asStateFlow()
private val _diagnostic = MutableStateFlow(DiagnosticState())
val diagnostic: StateFlow<DiagnosticState> = _diagnostic.asStateFlow()
/** True while the picked device has gone off the network and we're waiting
* for it to come back so we can re-establish the session. UI can collect
* this flow to show a small "Reconnecting…" hint without losing the
* user's renderer selection. Settles back to false either when the device
* returns and we reconnect, or when the user explicitly picks something else. */
private val _isReconnecting = MutableStateFlow(false)
val isReconnecting: StateFlow<Boolean> = _isReconnecting.asStateFlow()
/** The device the user has asked us to cast to, regardless of whether the
* session is currently established. Set by [selectDevice], cleared by
* [disconnect]. When the device disappears from discovery, we keep this
* set so the auto-reconnect path in [discoveryListener] can re-establish
* the session the moment it returns. */
@Volatile
private var desiredDeviceId: String? = null
private val discoveryListener = object : ChromeCastsListener {
override fun newChromeCastDiscovered(cc: ChromeCast) {
refreshDevices()
// Auto-reconnect: if this is the device the user picked and we're
// not already on it (because it had blipped off the network),
// re-establish the session transparently.
val wanted = desiredDeviceId
if (wanted != null && cc.name == wanted && _session.value == null) {
System.err.println(
"[EarhornCast] desired device $wanted reappeared — auto-reconnecting"
)
_isReconnecting.value = false
selectDevice(wanted)
}
}
override fun chromeCastRemoved(cc: ChromeCast) {
refreshDevices()
// If the active session is on this device, mark the session as
// suspended (drop the handle, but keep desiredDeviceId so we can
// reconnect when the device returns). Don't clear desiredDeviceId
// here — only an explicit user disconnect should do that.
if (_session.value?.deviceId == cc.name) {
System.err.println(
"[EarhornCast] active device ${cc.name} dropped from network " +
"— marking session suspended, will reconnect when it returns"
)
_session.value = null
_isCasting.value = false
if (desiredDeviceId == cc.name) {
_isReconnecting.value = true
startReconnectLoop()
}
}
}
}
/**
* Background coroutine that periodically retries connecting to
* [desiredDeviceId] while [_isReconnecting] is true. Exists because we
* can't rely on jmDNS to fire a fresh `newChromeCastDiscovered` event
* within a reasonable time window after a device blips — the mDNS TTL
* is typically 60s+, but we want to recover faster. If the device is
* actually back on the network, [selectDevice] will find it and the
* loop exits when [_session] goes non-null.
*
* This also addresses the "banner stuck on Reconnecting while audio is
* still playing through the speaker" symptom: the receiver app
* continues playing autonomously after we lose control, so the audio
* is fine but our session.value is null. Without this loop, we'd sit
* indefinitely with isReconnecting=true until the next mDNS event.
*/
private var reconnectJob: Job? = null
private fun startReconnectLoop() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
// Iteration counter — we kick jmDNS every RECONNECT_RESCAN_EVERY
// attempts because the cheap "did the device pop back into
// ChromeCasts.get()" check on its own waits forever when jmDNS
// itself has evicted the device from its local cache (mDNS TTL
// passed without a re-announce arriving, multicast packet was
// dropped, network interface flapped, VPN tunnel reshuffled
// routes). The manual Rescan button does the same stop+start
// discovery dance and reliably brings devices back; doing it
// automatically inside the reconnect loop turns the user's
// "music keeps playing but my desktop lost the device, let me
// press Rescan" workflow into a transparent recovery they
// shouldn't have to know about.
var attempt = 0
val startedAt = System.currentTimeMillis()
while (isActive) {
val wanted = desiredDeviceId
if (wanted == null || _session.value != null) break
delay(RECONNECT_RETRY_INTERVAL_MS)
val again = desiredDeviceId
if (again == null || _session.value != null) break
// Suspended-forever guard (sender bug class §4.4): a session
// that drops and never resumes must not leave the app in
// "casting" limbo indefinitely. After the timeout, force-exit
// the reconnecting state — banner clears, the app is cleanly
// out of the casting state, and the user can re-pick without
// a restart. desiredDeviceId is deliberately KEPT: if the
// device reappears on mDNS later, discoveryListener's passive
// auto-reconnect path still re-establishes the session
// transparently — we only stop the active retry burn and the
// stuck banner, not the "come back when you can" intent.
if (System.currentTimeMillis() - startedAt > RECONNECT_GIVE_UP_MS) {
System.err.println(
"[EarhornCast] reconnect loop: device $again still gone " +
"after ${RECONNECT_GIVE_UP_MS / 1000}s — exiting " +
"casting state (passive auto-reconnect stays armed)"
)
_isReconnecting.value = false
break
}
attempt++
if (attempt % RECONNECT_RESCAN_EVERY == 0) {
System.err.println(
"[EarhornCast] reconnect loop attempt $attempt: " +
"kicking jmDNS discovery to flush stale mDNS cache"
)
runCatching { ChromeCasts.stopDiscovery() }
// Same 300 ms socket-shutter delay as rescan() — anything
// shorter and jmDNS sometimes restarts against half-closed
// sockets and the next round of discovery never fires.
delay(300)
runCatching { ChromeCasts.startDiscovery() }
// Give jmDNS a beat to receive the announcement-burst
// that follows a fresh socket join before we look in
// ChromeCasts.get() for our device.
delay(2000)
refreshDevices()
}
val cc = ChromeCasts.get().firstOrNull { it.name == again }
if (cc != null) {
System.err.println(
"[EarhornCast] reconnect loop: device $again is visible " +
"again on attempt $attempt — retrying selectDevice"
)
selectDevice(again)
// selectDevice runs asynchronously; let its completion
// either set _session.value (we exit on next loop iter)
// or fail (we keep retrying).
}
}
}
}
init {
ChromeCasts.registerListener(discoveryListener)
runCatching { ChromeCasts.startDiscovery() }
.onFailure {
System.err.println("[EarhornCast] startDiscovery failed: ${it.message}")
}
refreshDevices()
}
private fun refreshDevices() {
val snapshot = runCatching { ChromeCasts.get().toList() }.getOrDefault(emptyList())
val mapped = snapshot.map { cc ->
CastDevice(
// Use the mDNS service name (cc.name) as the stable id, NOT the
// IP address. Chromecast speaker groups are hosted ON one of
// their member devices — the group advertises with the host
// device's IP. Two CastDevice entries (the host + the group)
// would collide if we keyed by IP, so the picker would mark
// BOTH as active when the user selected either one (and worse,
// selectDevice() lookup would non-deterministically resolve to
// whichever came first in the snapshot).
//
// The mDNS service name is guaranteed unique per registered
// service and is what the user already sees in displayName as
// a fallback when no friendly name is set. The user-visible
// 'cast:<id>' UDN in SettingsStore now uses the service name,
// not the IP. Any previously-persisted 'cast:<ip>' value won't
// resolve and will be silently ignored — acceptable one-time
// migration cost.
id = cc.name ?: cc.address ?: return@map null,
friendlyName = displayName(cc),
isGroup = isGroupHeuristic(cc),
backing = cc,
)
}.filterNotNull()
_devices.value = mapped
_diagnostic.value = _diagnostic.value.copy(
rawRouteCount = snapshot.size,
filteredDeviceCount = mapped.size,
tick = _diagnostic.value.tick + 1,
rawRouteNames = mapped.take(6).map { it.friendlyName },
)
}
/**
* Resolve a human-readable label for a discovered Cast device.
*
* The chromecast-java-api-v2 library exposes two name accessors on
* [ChromeCast] with different semantics:
* - [ChromeCast.getName] returns the **mDNS service name** — something
* like "Chromecast-Audio-b99f8e6d28bbaa9cc8c34939a4ce85d9". Always
* non-null after construction. *NOT* user-friendly; never shown.
* - [ChromeCast.getTitle] returns the **"fn" TXT record property**, which
* is the user-set name from the Google Home app ("Living Room",
* "a room", "HeißeBad", …). May be null/empty if the device wasn't
* configured or the property isn't advertised over mDNS for some reason.
*
* Resolution order:
* 1. `cc.title` if non-blank — the canonical user-set name. This is the
* common path; almost every cast device has a friendly name.
* 2. `cc.address` — the IP address. Not pretty, but it's at least useful
* networking info (helps with diagnosis if the user can't tell which
* device they're looking at). Strongly preferred over the technical
* mDNS name like "Chromecast-Audio-b99f8e6d…" which is gibberish to
* the user and tells them nothing actionable.
*/
private fun displayName(cc: ChromeCast): String {
val title = cc.title?.trim().orEmpty()
if (title.isNotEmpty()) return title
return cc.address ?: "Cast device"
}
/** Speaker groups don't always advertise themselves distinctly on mDNS;
* the most reliable signal in chromecast-java-api-v2 is the model
* field (model name "Google Cast Group" etc.) and the "type"/
* "capabilities" txt records. We check model name with a few known
* patterns and fall back to false. False negatives are OK — the
* consequence is just that the stream proxy doesn't kick in for
* groups misidentified as singles, which means a possible Range-
* request glitch on the first track. */
private fun isGroupHeuristic(cc: ChromeCast): Boolean {
val model = (cc.model ?: "").lowercase()
return "group" in model
}
/** Connect to a device by its id (as returned in [devices]). The session
* flow updates once the connection succeeds. */
fun selectDevice(deviceId: String) {
// Remember the user's choice for the auto-reconnect path. Set BEFORE
// we launch the work so a near-immediate network blip still has the
// desired-device hint by the time discoveryListener fires.
desiredDeviceId = deviceId
scope.launch {
val cc = ChromeCasts.get().firstOrNull { it.name == deviceId }
if (cc == null) {
System.err.println("[EarhornCast] selectDevice: no device with id=$deviceId")
// Not on the network right now — but desiredDeviceId is set, so
// discoveryListener.newChromeCastDiscovered will pick it up when
// it appears. Show "reconnecting" until then. The reconnect
// loop also retries periodically so we don't depend on jmDNS
// to fire a fresh discovery event.
_isReconnecting.value = true
startReconnectLoop()
return@launch
}
// Stale-state cleanup (sender bug class §4.4, "connect while
// zombie-casting"): if we think we're casting but the backing
// session's socket is actually dead — a drop jmDNS never told us
// about, a receiver reboot, a suspend/resume — reset the flags
// BEFORE the already-connected check below. Without this, the
// no-op branch would see deviceId match + _isCasting still true
// and return, leaving the user stuck re-clicking a dead entry
// with no recovery short of restarting the app.
val zombie = _session.value
if (zombie != null && _isCasting.value &&
!runCatching { zombie.backing.isConnected }.getOrDefault(false)
) {
System.err.println(
"[EarhornCast] selectDevice: session to ${zombie.friendlyName} " +
"is a zombie (flags say casting, socket says dead) — " +
"resetting state before connecting"
)
_session.value = null
_isCasting.value = false
}
// Already connected — for real, socket verified? No-op.
val current = _session.value
if (current?.deviceId == deviceId && _isCasting.value &&
runCatching { current.backing.isConnected }.getOrDefault(false)
) {
_isReconnecting.value = false
return@launch
}
// Tear the previous device's session down first, inline (Team 3
// P1-6). NOT disconnect(): that clears desiredDeviceId — now this
// device, so it would never auto-reconnect after a blip — and nulls
// _session from a separate coroutine, which could land after the
// connect below and drop the NEW session (and its proxy links).
// Skip if the only reason we had no session was a transient drop of
// the same target device.
if (current != null && current.deviceId != deviceId) {
runCatching { current.backing.stopApp() }
runCatching { current.backing.disconnect() }
if (_session.compareAndSet(current, null)) {
_isCasting.value = false
_castingToGroup.value = false
}
}
try {
if (!cc.isConnected) cc.connect()
// Default media receiver. We don't currently use a custom app id,
// so the stock receiver handles audio playback.
cc.launchApp(DEFAULT_MEDIA_RECEIVER_APP_ID)
_session.value = CastSession(
deviceId = cc.name ?: deviceId,
friendlyName = displayName(cc),
isGroup = isGroupHeuristic(cc),
backing = cc,
)
_castingToGroup.value = isGroupHeuristic(cc)
_isCasting.value = true
_isReconnecting.value = false
// Reconnect loop, if any, is done.
reconnectJob?.cancel()
reconnectJob = null
} catch (e: Exception) {
System.err.println("[EarhornCast] connect failed: ${e.message}")
_session.value = null
_isCasting.value = false
// Leave _isReconnecting true if the user still wants this
// device — we'll try again when discovery sees it next.
// Also start the periodic retry loop so we self-heal even
// without a fresh mDNS event.
_isReconnecting.value = (desiredDeviceId == deviceId)
if (_isReconnecting.value) startReconnectLoop()
}
}
}
/** Disconnect the current session if any. This is the explicit user-driven
* teardown — it clears [desiredDeviceId] so the auto-reconnect path
* doesn't bring the session back up after the user said "stop." */
fun disconnect() {
// Clear immediately so a near-simultaneous discovery event doesn't
// race us into reconnecting.
desiredDeviceId = null
_isReconnecting.value = false
reconnectJob?.cancel()
reconnectJob = null
scope.launch {
val s = _session.value ?: return@launch
runCatching { s.backing.stopApp() }
runCatching { s.backing.disconnect() }
// Only if it is still THAT session: a pick made meanwhile (Stop,
// then re-pick at once) may already have a new one, and nulling it
// — or stopping the proxy its links point at — would break it.
if (_session.compareAndSet(s, null)) {
_isCasting.value = false
_castingToGroup.value = false
streamProxy.stop()
}
}
}
/**
* Release the desktop's "this is my chosen cast device" marker without
* tearing down the receiver-side session. Used when the user switches the
* active renderer to local or to a UPnP remote: the cast device should
* keep playing (distributed playback is the whole point), but the desktop
* stops being its remote control — which means we also stop auto-
* reconnecting if the device blips off the network.
*
* If the user re-picks the same cast device later, [selectDevice] is
* called again and the session is re-established. The receiver-side
* session that may still be running on the device is detected and reused.
*/
fun releaseControl() {
desiredDeviceId = null
_isReconnecting.value = false
}
/**
* Wait for an active cast session to land, returning it once available.
* Returns null if the timeout elapses (typically meaning the user picked a
* device that isn't on the network or the connect handshake failed).
*
* This is the key fix for the "I picked cast and the music kept playing
* on local" failure mode: transport operations called right after
* [selectDevice] would otherwise race the async connect/launchApp work
* and silently drop because `_session.value` was still null. Waiting
* (briefly) guarantees we issue commands AGAINST the receiver instead of
* into the void.
*/
private suspend fun ensureSession(): CastSession? {
_session.value?.let { return it }
return withTimeoutOrNull(SESSION_WAIT_TIMEOUT_MS) {
_session.first { it != null }
}
}
/** Load a single track on the current Cast session. If the session targets
* a speaker group, the URL is rewritten through the local stream proxy
* so the group leader gets well-behaved HTTP responses. */
fun load(track: DidlItem.Track) {
scope.launch {
val s = ensureSession() ?: run {
System.err.println("[EarhornCast] load: no session within timeout")
return@launch
}
val cc = s.backing
// The proxy listens on the address this computer uses to reach the
// group (never every interface); when there is none it says so and
// the group fetches from the server directly.
val url = if (_castingToGroup.value) {
streamProxy.proxyUrl(track.resourceUrl, cc.address) ?: track.resourceUrl
} else {
track.resourceUrl
}
try {
cc.load(
/* mediaTitle = */ track.title,
/* thumb = */ track.albumArtUri,
/* url = */ url,
/* contentType = */ track.mimeType ?: "audio/mpeg",
)
// Bootstrap the media-channel subscription. The chromecast-java-api-v2
// library only starts pushing spontaneous MEDIA_STATUS events to
// registered listeners after getMediaStatus() has been called at
// least once for the running app — calling it here ensures the
// listener registered in CastRenderer actually receives events
// for THIS load. Without this call, some receivers stay silent and
// the UI shows a frozen play/pause + position bar.
runCatching { cc.mediaStatus }
.onFailure { t ->
System.err.println(
"[EarhornCast] post-load getMediaStatus failed " +
"(media events may not fire): ${t.message}"
)
}
} catch (e: Exception) {
System.err.println("[EarhornCast] load failed: ${e.message}")
}
}
}
fun play() {
scope.launch {
val s = ensureSession() ?: return@launch
runCatching { s.backing.play() }
.onFailure { t ->
System.err.println("[EarhornCast] play failed: ${t.message}")
}
}
}
fun pause() {
scope.launch {
val s = ensureSession() ?: return@launch
runCatching { s.backing.pause() }
.onFailure { t ->
System.err.println("[EarhornCast] pause failed: ${t.message}")
}
}
}
/**
* Soft-stop: pause and rewind to start.
*
* The earlier implementation called `cc.stopApp()` which fully kills the
* receiver app on the device. On Chromecast Audio firmware, killing the
* receiver also briefly drops the device's mDNS advertisement — our
* discovery listener fires `chromeCastRemoved`, the device disappears
* from the picker, and the user can't re-select it for the mDNS-TTL
* window (often 30+ seconds).
*
* Pause + seek(0) achieves the user-visible "Stop" semantic (audio
* silences, playhead returns to start, the session is in a state where
* Play resumes the same track from 0) without taking the device offline.
* The receiver stays alive in the same media session, and the device
* stays visible in the picker.
*
* Explicit teardown (e.g. user picks "Stop and switch" in the renderer
* confirmation dialog) still goes through [disconnect], which does kill
* the receiver — that's the right semantic there.
*/
fun stop() {
scope.launch {
val s = ensureSession() ?: return@launch
val backing = s.backing
runCatching { backing.pause() }
.onFailure { t -> System.err.println("[EarhornCast] stop/pause: ${t.message}") }
// Breathing room: some Chromecast Audio firmware refuses two
// media-channel commands issued back-to-back (the second sees a
// stale mediaSessionId from before the first was processed).
// ~120 ms is enough to clear that window in practice while still
// feeling instant to the user.
delay(STOP_INTER_COMMAND_DELAY_MS)
runCatching { backing.seek(0.0) }
.onFailure { t -> System.err.println("[EarhornCast] stop/seek: ${t.message}") }
}
}
/**
* Volume changes are debounced through a single-worker pattern.
*
* The volume slider in Compose can fire onValueChange dozens of times per
* second during a drag. Each [su.litvak.chromecast.api.v2.ChromeCast.setVolume]
* call is a network round-trip to the device — sending all of them is both
* laggy (the device processes them serially) and noisy. Debouncing keeps
* only the most recent target volume; the worker drains it on a rate-
* limited cadence so the device hears a manageable rate of updates and
* the final volume always matches the slider's final position.
*/
/** Latest requested level; CONFLATED keeps only the newest. One worker
* lives as long as [scope], so a value sent just as the previous one
* finished can't be lost (the old start-a-worker-if-idle check could miss
* it, dropping the slider's final position — Team 3 P2-14). */
private val volumeRequests = Channel<Float>(Channel.CONFLATED)
private val volumeWorker: Job = scope.launch {
for (target in volumeRequests) {
val s = _session.value
if (s != null) {
runCatching { s.backing.setVolume(target) }
.onFailure { t ->
System.err.println("[EarhornCast] setVolume failed: ${t.message}")
}
}
// No session: the update is dropped. (We don't wait, unlike
// ensureSession() in other ops: volume changes pile up so fast
// during a drag that even a 100ms wait would back up.)
delay(VOLUME_RATE_LIMIT_MS)
}
}
fun setVolume(level: Float) {
volumeRequests.trySend(level.coerceIn(0f, 1f))
}
fun seek(seconds: Double) {
scope.launch {
val s = ensureSession() ?: return@launch
runCatching { s.backing.seek(seconds) }
.onFailure { t ->
System.err.println("[EarhornCast] seek failed: ${t.message}")
}
}
}
/**
* Poll the receiver for its current MediaStatus. Used by [CastRenderer] as a
* fallback to spontaneous events, which the chromecast-java-api-v2 library
* doesn't always reliably deliver. Returns null if no session is active or
* if the receiver had nothing to report. Runs on the IO dispatcher via
* [scope]; result is delivered via the [onStatus] callback rather than
* returned directly so the suspend-vs-non-suspend boundary is in one place.
*
* Failures are logged once at the WARN level rather than thrown — a single
* dropped poll shouldn't crash the renderer; the next poll catches up. The
* common transient failure ("No application is running in ChromeCast") just
* means the receiver hasn't launched the app yet.
*/
fun pollMediaStatus(onStatus: (su.litvak.chromecast.api.v2.MediaStatus) -> Unit) {
scope.launch {
val s = _session.value ?: return@launch
val status = runCatching { s.backing.mediaStatus }
.onFailure { t ->
// Don't spam — the "no application running" error is common
// right after launchApp and right after stopApp. We only log
// if the message looks like a real transport problem.
val msg = t.message.orEmpty()
if ("No application is running" !in msg && "invalid state" !in msg) {
System.err.println("[EarhornCast] pollMediaStatus: ${t.message}")
}
}
.getOrNull() ?: return@launch
onStatus(status)
}
}
/**
* Release jmDNS discovery and synchronously tear down the active cast
* session. Call once at app shutdown.
*
* Note: this method runs SYNCHRONOUSLY on the calling thread (not on the
* integration's [scope]). Shutdown-path callers — including the JVM
* shutdown hook — rely on this: if we launched the teardown asynchronously
* the JVM could exit before the stopApp/disconnect packets actually go
* out, leaving the receiver app running and audible on the user's speakers
* with no way for this desktop to stop it. The synchronous path uses
* bounded I/O (the library's `stopApp()` and `disconnect()` open and close
* a TCP-TLS connection — a few hundred ms in the worst case).
*/
fun release() {
runCatching { ChromeCasts.unregisterListener(discoveryListener) }
runCatching { ChromeCasts.stopDiscovery() }
// Clear flags first so any concurrent observer doesn't fight us during
// the synchronous teardown below.
desiredDeviceId = null
_isReconnecting.value = false
val s = _session.value
if (s != null) {
runCatching { s.backing.stopApp() }
.onFailure { System.err.println("[EarhornCast] shutdown stopApp: ${it.message}") }
runCatching { s.backing.disconnect() }
.onFailure { System.err.println("[EarhornCast] shutdown disconnect: ${it.message}") }
_session.value = null
_isCasting.value = false
_castingToGroup.value = false
}
streamProxy.stop()
// Cancel the scope last so any in-flight launches (e.g. polls) wind
// down. Don't bother joining — they'll be GC'd as the JVM exits.
runCatching { scope.cancel() }
}
/**
* Force a fresh round of cast device discovery without restarting the app.
*
* jmDNS is the discovery backend (via the chromecast-java-api-v2 library),
* and it can get wedged under conditions that DON'T involve any code
* change on our side:
*
* - Network interface coming up/down (Wi-Fi handoff, dock plug-in)
* - VPN tunnel binding before mDNS multicast was joined on the LAN side,
* so jmDNS ends up subscribed on the wrong interface
* - The chromecast device itself rebooting and re-announcing while we
* missed the SSDP packet
* - Stale sockets left over from a previous JVM (gradle daemon)
*
* The symptom is always the same: ChromeCasts.get() returns an empty list
* (or a stale subset) even though the devices are demonstrably alive
* elsewhere on the network. Calling stopDiscovery + startDiscovery
* teardown and rebuilds jmDNS's sockets, after which devices repopulate
* within the usual 2-5 second mDNS window.
*
* Asynchronous because jmDNS needs a beat between stop and start for its
* threads to wind down; doing it synchronously sometimes leaves the
* library in a half-initialized state where the next discovery callback
* fires against a closed socket. Listener subscribers (Settings,
* renderer picker) automatically see the new device list via the
* existing StateFlow plumbing — no caller cleanup needed.
*/
fun rescan() {
scope.launch {
System.err.println(
"[EarhornCast] rescan: stopping discovery (current snapshot size = ${_devices.value.size})"
)
runCatching { ChromeCasts.stopDiscovery() }
.onFailure { System.err.println("[EarhornCast] rescan stopDiscovery: ${it.message}") }
// jmDNS internals need a beat to fully shutter sockets before we
// bring them back up. 300ms is empirically enough on Linux without
// making the user wait visibly.
delay(300)
runCatching { ChromeCasts.startDiscovery() }
.onFailure { System.err.println("[EarhornCast] rescan startDiscovery: ${it.message}") }
// First device announcements should arrive within 1-2 seconds;
// give jmDNS a generous window before we publish whatever it has
// collected so the user sees a populated list on the first refresh.
delay(2500)
refreshDevices()
System.err.println(
"[EarhornCast] rescan complete: new snapshot size = ${_devices.value.size}"
)
}
}
companion object {
// Stock Google Cast default media receiver application id. Lets us send
// audio URLs without registering a custom receiver in the Cast Developer
// Console. This is also what Android's CastIntegration uses.
private const val DEFAULT_MEDIA_RECEIVER_APP_ID = "CC1AD845"
/**
* How long [ensureSession] waits for a session to become non-null before
* giving up. Long enough that a fresh connect + launchApp handshake
* (which takes 200-800 ms on typical Cast Audio firmware) lands
* comfortably; short enough that a truly absent device doesn't make
* the user wait forever for a single transport op to time out.
*/
private const val SESSION_WAIT_TIMEOUT_MS = 5_000L
/**
* Minimum delay between consecutive setVolume calls hitting the
* receiver. The Compose volume slider fires onValueChange dozens of
* times per second during a drag; without rate-limiting the receiver
* gets flooded, slider feels laggy, and final volume can land
* incorrectly because of queue ordering. 80 ms gives ~12 updates/sec
* — fast enough to feel responsive, slow enough that the device
* keeps up with each one.
*/
private const val VOLUME_RATE_LIMIT_MS = 80L
/**
* Inter-command delay between the pause and seek issued by [stop].
* See the comment at the call site — exists to keep some Chromecast
* Audio firmware from rejecting the second command for using a
* mediaSessionId that's still being torn down by the first.
*/
private const val STOP_INTER_COMMAND_DELAY_MS = 120L
/**
* How often the reconnect loop retries [selectDevice] while we have a
* pending desiredDeviceId but no session. 1.5 s gives a brief network
* blip time to recover transparently — the user sees the
* "Reconnecting…" banner for under two seconds and the controls
* resume — without flooding jmDNS or the device with reconnect
* attempts on a persistent outage.
*/
private const val RECONNECT_RETRY_INTERVAL_MS = 1_500L
/** How long the ACTIVE reconnect loop runs before force-exiting the
* suspended/reconnecting state (sender bug class §4.4). Matches the
* cross-platform 15s suspended→ended timeout. Passive reconnect on
* mDNS reappearance is unaffected — desiredDeviceId stays set. */
private const val RECONNECT_GIVE_UP_MS = 15_000L
/**
* Every this-many reconnect attempts (so every
* RECONNECT_RETRY_INTERVAL_MS × RECONNECT_RESCAN_EVERY ≈ 4.5 s),
* stop+restart jmDNS discovery to flush a possibly-stale local mDNS
* cache. The cheap `ChromeCasts.get().firstOrNull { it.name == … }`
* check by itself waits forever in the case where jmDNS evicted the
* device entry (mDNS TTL expired without a re-announce, multicast
* packet was dropped on a flaky LAN, a VPN tunnel coming up
* shuffled route priorities). 3 attempts is small enough that the
* user doesn't sit through ages of dead waiting before we try the
* heavier remedy, and large enough that a clean recovery from a
* blip happens via the cheap path without paying the rescan cost.
*/
private const val RECONNECT_RESCAN_EVERY = 3
}
}
// ─── data shapes ────────────────────────────────────────────────────────────
/** A discoverable Cast device. Mirrors the Android side's [CastDevice]
* but additionally holds the underlying [ChromeCast] handle so the
* integration can act on it without re-resolving by id. */
data class CastDevice(
val id: String,
val friendlyName: String,
val isGroup: Boolean,
val backing: ChromeCast,
) {
/** IP address of the device's HTTP/cast endpoint. Pulled from the
* backing [ChromeCast] for use by the observer-mode RawCastClient,
* which needs to open a TLS socket directly to the device's :8009
* port without going through chromecast-java-api-v2. */
val host: String? get() = backing.address
/** Cast control port — always 8009 on stock receivers but read from
* the discovery record to be safe. The observer client falls back to
* 8009 if this is unavailable for any reason. */
val port: Int get() = backing.port.takeIf { it > 0 } ?: 8009
}
/** An active Cast session — i.e. we've launched the receiver app on a
* device and the connection is healthy. */
data class CastSession(
val deviceId: String,
val friendlyName: String,
val isGroup: Boolean,
val backing: ChromeCast,
)
/** Diagnostic surface for the picker UI. Same fields as the Android side,
* minus the Play-Services-specific ones (which have no meaning on desktop). */
data class DiagnosticState(
val rawRouteCount: Int = 0,
val filteredDeviceCount: Int = 0,
val tick: Int = 0,
val rawRouteNames: List<String> = emptyList(),
)
/* TODO(next session): wire this into the renderer pipeline.
*
* 1. Add a `CastRenderer : Renderer` that delegates load/play/pause/etc to
* a [CastIntegration]. It would also coordinate with [VlcjLocalRenderer]
* so local audio mutes the moment a session opens and resumes when the
* session ends. The simplest model: a "primary" renderer that the rest
* of the app talks to is a coordinator wrapping both, and switches its
* delegate based on isCasting.
*
* 2. Surface the picker in StaveApp.kt — a Cast button in the toolbar that
* opens a Compose Desktop port of CastPickerSheet.kt, showing the
* [devices] StateFlow.
*
* 3. Track-end handling: chromecast-java-api-v2 exposes session events via
* ChromeCastSpontaneousEventListener — we'll need to wire that up so
* "remote track finished" advances our local queue index just like
* VlcjLocalRenderer's MediaListPlayer nextItem event does.
*
* 4. Reconnect-after-network-blip — the Android version has retry logic.
* chromecast-java-api-v2 may need similar; depends on observed behavior
* with real hardware.
*/