CastIntegration.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.cast |
| 2 | ||
| 3 | import dev.stave.domain.model.DidlItem | |
| 4 | import kotlinx.coroutines.CoroutineScope | |
| 5 | import kotlinx.coroutines.Dispatchers | |
| 6 | import kotlinx.coroutines.Job | |
| 7 | import kotlinx.coroutines.SupervisorJob | |
| 8 | import kotlinx.coroutines.cancel | |
| 9 | import kotlinx.coroutines.channels.Channel | |
| 10 | import kotlinx.coroutines.delay | |
| 11 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 12 | import kotlinx.coroutines.flow.StateFlow | |
| 13 | import kotlinx.coroutines.flow.asStateFlow | |
| 14 | import kotlinx.coroutines.flow.first | |
| 15 | import kotlinx.coroutines.isActive | |
| 16 | import kotlinx.coroutines.launch | |
| 17 | import kotlinx.coroutines.withTimeoutOrNull | |
| 18 | import su.litvak.chromecast.api.v2.ChromeCast | |
| 19 | import su.litvak.chromecast.api.v2.ChromeCasts | |
| 20 | import su.litvak.chromecast.api.v2.ChromeCastsListener | |
| 21 | ||
| 22 | /** | |
| 23 | * Desktop Chromecast integration. Mirrors the StateFlow surface of the Android | |
| 24 | * [dev.stave.cast.CastIntegration] so any picker UI ported from Android can | |
| 25 | * subscribe to the same flows without rewriting. | |
| 26 | * | |
| 27 | * The Android side is built on Google Play Services' CastFramework. There is no | |
| 28 | * desktop equivalent of that, so this port uses | |
| 29 | * [de.sfuhrm:chromecast-java-api-v2](https://github.com/sfuhrm/chromecast-java-api-v2), | |
| 30 | * the actively-maintained fork of vitalidze/chromecast-java-api-v2. It speaks | |
| 31 | * the Cast protobuf wire protocol directly and discovers devices via jmDNS. | |
| 32 | * The fork keeps the original's `su.litvak.chromecast.api.v2.*` package names | |
| 33 | * (only Maven coordinates differ), so imports below look "wrong" but resolve | |
| 34 | * correctly. Different surface from Google's SDK (no MediaRouter, no | |
| 35 | * SessionManager), same conceptual model: find devices, open a session, | |
| 36 | * load media, control playback. | |
| 37 | * | |
| 38 | * **Scope of this port** (mark of completion at the bottom of each line): | |
| 39 | * - Device discovery via jmDNS ✓ working | |
| 40 | * - Session lifecycle (connect / disconnect) ✓ working | |
| 41 | * - Media load / play / pause / stop / seek ✓ wraps the lib | |
| 42 | * - Volume ✓ wraps the lib | |
| 43 | * - Stream proxy for speaker-group sources ✓ ported from Android | |
| 44 | * - Renderer-level wiring (a CastRenderer impl | |
| 45 | * of [dev.stave.domain.renderer.Renderer] that | |
| 46 | * this integration sits behind so the rest of | |
| 47 | * the app can treat Cast as just another | |
| 48 | * renderer choice) ✗ NOT WIRED YET — see TODO at bottom | |
| 49 | * - Picker UI ✗ NOT YET — desktop component to be added | |
| 50 | * | |
| 51 | * Until the renderer wiring lands, the methods on this class work in isolation | |
| 52 | * (you could call [load] directly with a track and audio will play on the | |
| 53 | * selected device), but the app's UI does not yet expose any way to do so. | |
| 54 | * That's the next session's work. | |
| 55 | */ | |
| 56 | class CastIntegration { | |
| 57 | ||
| 58 | /** True if the chromecast-java-api-v2 library and jmDNS are both healthy. | |
| 59 | * Currently always true on desktop — if the lib weren't on the classpath | |
| 60 | * this class wouldn't load. Kept for API parity with the Android side | |
| 61 | * which has to deal with Play Services being absent. */ | |
| 62 | val available: Boolean = true | |
| 63 | ||
| 64 | private val streamProxy = CastStreamProxy() | |
| 65 | ||
| 66 | // Single-thread coroutine scope for discovery callbacks (which arrive from | |
| 67 | // jmDNS's worker thread) and for connection-retry timers. Cast is bursty — | |
| 68 | // most activity is "user clicked a thing, do that thing, wait for events" — | |
| 69 | // so a single thread is plenty and keeps the state transitions ordered. | |
| 70 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) | |
| 71 | ||
| 72 | private val _devices = MutableStateFlow<List<CastDevice>>(emptyList()) | |
| 73 | val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow() | |
| 74 | ||
| 75 | private val _session = MutableStateFlow<CastSession?>(null) | |
| 76 | val session: StateFlow<CastSession?> = _session.asStateFlow() | |
| 77 | ||
| 78 | /** True iff a Cast session is currently active. Convenience derived from | |
| 79 | * [session]; callers can observe this to gate local audio output. */ | |
| 80 | private val _isCasting = MutableStateFlow(false) | |
| 81 | val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow() | |
| 82 | ||
| 83 | private val _castingToGroup = MutableStateFlow(false) | |
| 84 | val castingToGroup: StateFlow<Boolean> = _castingToGroup.asStateFlow() | |
| 85 | ||
| 86 | private val _diagnostic = MutableStateFlow(DiagnosticState()) | |
| 87 | val diagnostic: StateFlow<DiagnosticState> = _diagnostic.asStateFlow() | |
| 88 | ||
| 89 | /** True while the picked device has gone off the network and we're waiting | |
| 90 | * for it to come back so we can re-establish the session. UI can collect | |
| 91 | * this flow to show a small "Reconnecting…" hint without losing the | |
| 92 | * user's renderer selection. Settles back to false either when the device | |
| 93 | * returns and we reconnect, or when the user explicitly picks something else. */ | |
| 94 | private val _isReconnecting = MutableStateFlow(false) | |
| 95 | val isReconnecting: StateFlow<Boolean> = _isReconnecting.asStateFlow() | |
| 96 | ||
| 97 | /** The device the user has asked us to cast to, regardless of whether the | |
| 98 | * session is currently established. Set by [selectDevice], cleared by | |
| 99 | * [disconnect]. When the device disappears from discovery, we keep this | |
| 100 | * set so the auto-reconnect path in [discoveryListener] can re-establish | |
| 101 | * the session the moment it returns. */ | |
| 102 | @Volatile | |
| 103 | private var desiredDeviceId: String? = null | |
| 104 | ||
| 105 | private val discoveryListener = object : ChromeCastsListener { | |
| 106 | override fun newChromeCastDiscovered(cc: ChromeCast) { | |
| 107 | refreshDevices() | |
| 108 | // Auto-reconnect: if this is the device the user picked and we're | |
| 109 | // not already on it (because it had blipped off the network), | |
| 110 | // re-establish the session transparently. | |
| 111 | val wanted = desiredDeviceId | |
| 112 | if (wanted != null && cc.name == wanted && _session.value == null) { | |
| 113 | System.err.println( | |
| 114 | "[EarhornCast] desired device $wanted reappeared — auto-reconnecting" | |
| 115 | ) | |
| 116 | _isReconnecting.value = false | |
| 117 | selectDevice(wanted) | |
| 118 | } | |
| 119 | } | |
| 120 | override fun chromeCastRemoved(cc: ChromeCast) { | |
| 121 | refreshDevices() | |
| 122 | // If the active session is on this device, mark the session as | |
| 123 | // suspended (drop the handle, but keep desiredDeviceId so we can | |
| 124 | // reconnect when the device returns). Don't clear desiredDeviceId | |
| 125 | // here — only an explicit user disconnect should do that. | |
| 126 | if (_session.value?.deviceId == cc.name) { | |
| 127 | System.err.println( | |
| 128 | "[EarhornCast] active device ${cc.name} dropped from network " + | |
| 129 | "— marking session suspended, will reconnect when it returns" | |
| 130 | ) | |
| 131 | _session.value = null | |
| 132 | _isCasting.value = false | |
| 133 | if (desiredDeviceId == cc.name) { | |
| 134 | _isReconnecting.value = true | |
| 135 | startReconnectLoop() | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Background coroutine that periodically retries connecting to | |
| 143 | * [desiredDeviceId] while [_isReconnecting] is true. Exists because we | |
| 144 | * can't rely on jmDNS to fire a fresh `newChromeCastDiscovered` event | |
| 145 | * within a reasonable time window after a device blips — the mDNS TTL | |
| 146 | * is typically 60s+, but we want to recover faster. If the device is | |
| 147 | * actually back on the network, [selectDevice] will find it and the | |
| 148 | * loop exits when [_session] goes non-null. | |
| 149 | * | |
| 150 | * This also addresses the "banner stuck on Reconnecting while audio is | |
| 151 | * still playing through the speaker" symptom: the receiver app | |
| 152 | * continues playing autonomously after we lose control, so the audio | |
| 153 | * is fine but our session.value is null. Without this loop, we'd sit | |
| 154 | * indefinitely with isReconnecting=true until the next mDNS event. | |
| 155 | */ | |
| 156 | private var reconnectJob: Job? = null | |
| 157 | ||
| 158 | private fun startReconnectLoop() { | |
| 159 | if (reconnectJob?.isActive == true) return | |
| 160 | reconnectJob = scope.launch { | |
| 161 | // Iteration counter — we kick jmDNS every RECONNECT_RESCAN_EVERY | |
| 162 | // attempts because the cheap "did the device pop back into | |
| 163 | // ChromeCasts.get()" check on its own waits forever when jmDNS | |
| 164 | // itself has evicted the device from its local cache (mDNS TTL | |
| 165 | // passed without a re-announce arriving, multicast packet was | |
| 166 | // dropped, network interface flapped, VPN tunnel reshuffled | |
| 167 | // routes). The manual Rescan button does the same stop+start | |
| 168 | // discovery dance and reliably brings devices back; doing it | |
| 169 | // automatically inside the reconnect loop turns the user's | |
| 170 | // "music keeps playing but my desktop lost the device, let me | |
| 171 | // press Rescan" workflow into a transparent recovery they | |
| 172 | // shouldn't have to know about. | |
| 173 | var attempt = 0 | |
| 174 | val startedAt = System.currentTimeMillis() | |
| 175 | while (isActive) { | |
| 176 | val wanted = desiredDeviceId | |
| 177 | if (wanted == null || _session.value != null) break | |
| 178 | delay(RECONNECT_RETRY_INTERVAL_MS) | |
| 179 | val again = desiredDeviceId | |
| 180 | if (again == null || _session.value != null) break | |
| 181 | ||
| 182 | // Suspended-forever guard (sender bug class §4.4): a session | |
| 183 | // that drops and never resumes must not leave the app in | |
| 184 | // "casting" limbo indefinitely. After the timeout, force-exit | |
| 185 | // the reconnecting state — banner clears, the app is cleanly | |
| 186 | // out of the casting state, and the user can re-pick without | |
| 187 | // a restart. desiredDeviceId is deliberately KEPT: if the | |
| 188 | // device reappears on mDNS later, discoveryListener's passive | |
| 189 | // auto-reconnect path still re-establishes the session | |
| 190 | // transparently — we only stop the active retry burn and the | |
| 191 | // stuck banner, not the "come back when you can" intent. | |
| 192 | if (System.currentTimeMillis() - startedAt > RECONNECT_GIVE_UP_MS) { | |
| 193 | System.err.println( | |
| 194 | "[EarhornCast] reconnect loop: device $again still gone " + | |
| 195 | "after ${RECONNECT_GIVE_UP_MS / 1000}s — exiting " + | |
| 196 | "casting state (passive auto-reconnect stays armed)" | |
| 197 | ) | |
| 198 | _isReconnecting.value = false | |
| 199 | break | |
| 200 | } | |
| 201 | attempt++ | |
| 202 | ||
| 203 | if (attempt % RECONNECT_RESCAN_EVERY == 0) { | |
| 204 | System.err.println( | |
| 205 | "[EarhornCast] reconnect loop attempt $attempt: " + | |
| 206 | "kicking jmDNS discovery to flush stale mDNS cache" | |
| 207 | ) | |
| 208 | runCatching { ChromeCasts.stopDiscovery() } | |
| 209 | // Same 300 ms socket-shutter delay as rescan() — anything | |
| 210 | // shorter and jmDNS sometimes restarts against half-closed | |
| 211 | // sockets and the next round of discovery never fires. | |
| 212 | delay(300) | |
| 213 | runCatching { ChromeCasts.startDiscovery() } | |
| 214 | // Give jmDNS a beat to receive the announcement-burst | |
| 215 | // that follows a fresh socket join before we look in | |
| 216 | // ChromeCasts.get() for our device. | |
| 217 | delay(2000) | |
| 218 | refreshDevices() | |
| 219 | } | |
| 220 | ||
| 221 | val cc = ChromeCasts.get().firstOrNull { it.name == again } | |
| 222 | if (cc != null) { | |
| 223 | System.err.println( | |
| 224 | "[EarhornCast] reconnect loop: device $again is visible " + | |
| 225 | "again on attempt $attempt — retrying selectDevice" | |
| 226 | ) | |
| 227 | selectDevice(again) | |
| 228 | // selectDevice runs asynchronously; let its completion | |
| 229 | // either set _session.value (we exit on next loop iter) | |
| 230 | // or fail (we keep retrying). | |
| 231 | } | |
| 232 | } | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | init { | |
| 237 | ChromeCasts.registerListener(discoveryListener) | |
| 238 | runCatching { ChromeCasts.startDiscovery() } | |
| 239 | .onFailure { | |
| 240 | System.err.println("[EarhornCast] startDiscovery failed: ${it.message}") | |
| 241 | } | |
| 242 | refreshDevices() | |
| 243 | } | |
| 244 | ||
| 245 | private fun refreshDevices() { | |
| 246 | val snapshot = runCatching { ChromeCasts.get().toList() }.getOrDefault(emptyList()) | |
| 247 | val mapped = snapshot.map { cc -> | |
| 248 | CastDevice( | |
| 249 | // Use the mDNS service name (cc.name) as the stable id, NOT the | |
| 250 | // IP address. Chromecast speaker groups are hosted ON one of | |
| 251 | // their member devices — the group advertises with the host | |
| 252 | // device's IP. Two CastDevice entries (the host + the group) | |
| 253 | // would collide if we keyed by IP, so the picker would mark | |
| 254 | // BOTH as active when the user selected either one (and worse, | |
| 255 | // selectDevice() lookup would non-deterministically resolve to | |
| 256 | // whichever came first in the snapshot). | |
| 257 | // | |
| 258 | // The mDNS service name is guaranteed unique per registered | |
| 259 | // service and is what the user already sees in displayName as | |
| 260 | // a fallback when no friendly name is set. The user-visible | |
| 261 | // 'cast:<id>' UDN in SettingsStore now uses the service name, | |
| 262 | // not the IP. Any previously-persisted 'cast:<ip>' value won't | |
| 263 | // resolve and will be silently ignored — acceptable one-time | |
| 264 | // migration cost. | |
| 265 | id = cc.name ?: cc.address ?: return@map null, | |
| 266 | friendlyName = displayName(cc), | |
| 267 | isGroup = isGroupHeuristic(cc), | |
| 268 | backing = cc, | |
| 269 | ) | |
| 270 | }.filterNotNull() | |
| 271 | _devices.value = mapped | |
| 272 | _diagnostic.value = _diagnostic.value.copy( | |
| 273 | rawRouteCount = snapshot.size, | |
| 274 | filteredDeviceCount = mapped.size, | |
| 275 | tick = _diagnostic.value.tick + 1, | |
| 276 | rawRouteNames = mapped.take(6).map { it.friendlyName }, | |
| 277 | ) | |
| 278 | } | |
| 279 | ||
| 280 | /** | |
| 281 | * Resolve a human-readable label for a discovered Cast device. | |
| 282 | * | |
| 283 | * The chromecast-java-api-v2 library exposes two name accessors on | |
| 284 | * [ChromeCast] with different semantics: | |
| 285 | * - [ChromeCast.getName] returns the **mDNS service name** — something | |
| 286 | * like "Chromecast-Audio-b99f8e6d28bbaa9cc8c34939a4ce85d9". Always | |
| 287 | * non-null after construction. *NOT* user-friendly; never shown. | |
| 288 | * - [ChromeCast.getTitle] returns the **"fn" TXT record property**, which | |
| 289 | * is the user-set name from the Google Home app ("Living Room", | |
| 290 | * "a room", "HeißeBad", …). May be null/empty if the device wasn't | |
| 291 | * configured or the property isn't advertised over mDNS for some reason. | |
| 292 | * | |
| 293 | * Resolution order: | |
| 294 | * 1. `cc.title` if non-blank — the canonical user-set name. This is the | |
| 295 | * common path; almost every cast device has a friendly name. | |
| 296 | * 2. `cc.address` — the IP address. Not pretty, but it's at least useful | |
| 297 | * networking info (helps with diagnosis if the user can't tell which | |
| 298 | * device they're looking at). Strongly preferred over the technical | |
| 299 | * mDNS name like "Chromecast-Audio-b99f8e6d…" which is gibberish to | |
| 300 | * the user and tells them nothing actionable. | |
| 301 | */ | |
| 302 | private fun displayName(cc: ChromeCast): String { | |
| 303 | val title = cc.title?.trim().orEmpty() | |
| 304 | if (title.isNotEmpty()) return title | |
| 305 | return cc.address ?: "Cast device" | |
| 306 | } | |
| 307 | ||
| 308 | /** Speaker groups don't always advertise themselves distinctly on mDNS; | |
| 309 | * the most reliable signal in chromecast-java-api-v2 is the model | |
| 310 | * field (model name "Google Cast Group" etc.) and the "type"/ | |
| 311 | * "capabilities" txt records. We check model name with a few known | |
| 312 | * patterns and fall back to false. False negatives are OK — the | |
| 313 | * consequence is just that the stream proxy doesn't kick in for | |
| 314 | * groups misidentified as singles, which means a possible Range- | |
| 315 | * request glitch on the first track. */ | |
| 316 | private fun isGroupHeuristic(cc: ChromeCast): Boolean { | |
| 317 | val model = (cc.model ?: "").lowercase() | |
| 318 | return "group" in model | |
| 319 | } | |
| 320 | ||
| 321 | /** Connect to a device by its id (as returned in [devices]). The session | |
| 322 | * flow updates once the connection succeeds. */ | |
| 323 | fun selectDevice(deviceId: String) { | |
| 324 | // Remember the user's choice for the auto-reconnect path. Set BEFORE | |
| 325 | // we launch the work so a near-immediate network blip still has the | |
| 326 | // desired-device hint by the time discoveryListener fires. | |
| 327 | desiredDeviceId = deviceId | |
| 328 | ||
| 329 | scope.launch { | |
| 330 | val cc = ChromeCasts.get().firstOrNull { it.name == deviceId } | |
| 331 | if (cc == null) { | |
| 332 | System.err.println("[EarhornCast] selectDevice: no device with id=$deviceId") | |
| 333 | // Not on the network right now — but desiredDeviceId is set, so | |
| 334 | // discoveryListener.newChromeCastDiscovered will pick it up when | |
| 335 | // it appears. Show "reconnecting" until then. The reconnect | |
| 336 | // loop also retries periodically so we don't depend on jmDNS | |
| 337 | // to fire a fresh discovery event. | |
| 338 | _isReconnecting.value = true | |
| 339 | startReconnectLoop() | |
| 340 | return@launch | |
| 341 | } | |
| 342 | // Stale-state cleanup (sender bug class §4.4, "connect while | |
| 343 | // zombie-casting"): if we think we're casting but the backing | |
| 344 | // session's socket is actually dead — a drop jmDNS never told us | |
| 345 | // about, a receiver reboot, a suspend/resume — reset the flags | |
| 346 | // BEFORE the already-connected check below. Without this, the | |
| 347 | // no-op branch would see deviceId match + _isCasting still true | |
| 348 | // and return, leaving the user stuck re-clicking a dead entry | |
| 349 | // with no recovery short of restarting the app. | |
| 350 | val zombie = _session.value | |
| 351 | if (zombie != null && _isCasting.value && | |
| 352 | !runCatching { zombie.backing.isConnected }.getOrDefault(false) | |
| 353 | ) { | |
| 354 | System.err.println( | |
| 355 | "[EarhornCast] selectDevice: session to ${zombie.friendlyName} " + | |
| 356 | "is a zombie (flags say casting, socket says dead) — " + | |
| 357 | "resetting state before connecting" | |
| 358 | ) | |
| 359 | _session.value = null | |
| 360 | _isCasting.value = false | |
| 361 | } | |
| 362 | ||
| 363 | // Already connected — for real, socket verified? No-op. | |
| 364 | val current = _session.value | |
| 365 | if (current?.deviceId == deviceId && _isCasting.value && | |
| 366 | runCatching { current.backing.isConnected }.getOrDefault(false) | |
| 367 | ) { | |
| 368 | _isReconnecting.value = false | |
| 369 | return@launch | |
| 370 | } | |
| 371 | ||
| 372 | // Tear the previous device's session down first, inline (Team 3 | |
| 373 | // P1-6). NOT disconnect(): that clears desiredDeviceId — now this | |
| 374 | // device, so it would never auto-reconnect after a blip — and nulls | |
| 375 | // _session from a separate coroutine, which could land after the | |
| 376 | // connect below and drop the NEW session (and its proxy links). | |
| 377 | // Skip if the only reason we had no session was a transient drop of | |
| 378 | // the same target device. | |
| 379 | if (current != null && current.deviceId != deviceId) { | |
| 380 | runCatching { current.backing.stopApp() } | |
| 381 | runCatching { current.backing.disconnect() } | |
| 382 | if (_session.compareAndSet(current, null)) { | |
| 383 | _isCasting.value = false | |
| 384 | _castingToGroup.value = false | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | try { | |
| 389 | if (!cc.isConnected) cc.connect() | |
| 390 | // Default media receiver. We don't currently use a custom app id, | |
| 391 | // so the stock receiver handles audio playback. | |
| 392 | cc.launchApp(DEFAULT_MEDIA_RECEIVER_APP_ID) | |
| 393 | _session.value = CastSession( | |
| 394 | deviceId = cc.name ?: deviceId, | |
| 395 | friendlyName = displayName(cc), | |
| 396 | isGroup = isGroupHeuristic(cc), | |
| 397 | backing = cc, | |
| 398 | ) | |
| 399 | _castingToGroup.value = isGroupHeuristic(cc) | |
| 400 | _isCasting.value = true | |
| 401 | _isReconnecting.value = false | |
| 402 | // Reconnect loop, if any, is done. | |
| 403 | reconnectJob?.cancel() | |
| 404 | reconnectJob = null | |
| 405 | } catch (e: Exception) { | |
| 406 | System.err.println("[EarhornCast] connect failed: ${e.message}") | |
| 407 | _session.value = null | |
| 408 | _isCasting.value = false | |
| 409 | // Leave _isReconnecting true if the user still wants this | |
| 410 | // device — we'll try again when discovery sees it next. | |
| 411 | // Also start the periodic retry loop so we self-heal even | |
| 412 | // without a fresh mDNS event. | |
| 413 | _isReconnecting.value = (desiredDeviceId == deviceId) | |
| 414 | if (_isReconnecting.value) startReconnectLoop() | |
| 415 | } | |
| 416 | } | |
| 417 | } | |
| 418 | ||
| 419 | /** Disconnect the current session if any. This is the explicit user-driven | |
| 420 | * teardown — it clears [desiredDeviceId] so the auto-reconnect path | |
| 421 | * doesn't bring the session back up after the user said "stop." */ | |
| 422 | fun disconnect() { | |
| 423 | // Clear immediately so a near-simultaneous discovery event doesn't | |
| 424 | // race us into reconnecting. | |
| 425 | desiredDeviceId = null | |
| 426 | _isReconnecting.value = false | |
| 427 | reconnectJob?.cancel() | |
| 428 | reconnectJob = null | |
| 429 | scope.launch { | |
| 430 | val s = _session.value ?: return@launch | |
| 431 | runCatching { s.backing.stopApp() } | |
| 432 | runCatching { s.backing.disconnect() } | |
| 433 | // Only if it is still THAT session: a pick made meanwhile (Stop, | |
| 434 | // then re-pick at once) may already have a new one, and nulling it | |
| 435 | // — or stopping the proxy its links point at — would break it. | |
| 436 | if (_session.compareAndSet(s, null)) { | |
| 437 | _isCasting.value = false | |
| 438 | _castingToGroup.value = false | |
| 439 | streamProxy.stop() | |
| 440 | } | |
| 441 | } | |
| 442 | } | |
| 443 | ||
| 444 | /** | |
| 445 | * Release the desktop's "this is my chosen cast device" marker without | |
| 446 | * tearing down the receiver-side session. Used when the user switches the | |
| 447 | * active renderer to local or to a UPnP remote: the cast device should | |
| 448 | * keep playing (distributed playback is the whole point), but the desktop | |
| 449 | * stops being its remote control — which means we also stop auto- | |
| 450 | * reconnecting if the device blips off the network. | |
| 451 | * | |
| 452 | * If the user re-picks the same cast device later, [selectDevice] is | |
| 453 | * called again and the session is re-established. The receiver-side | |
| 454 | * session that may still be running on the device is detected and reused. | |
| 455 | */ | |
| 456 | fun releaseControl() { | |
| 457 | desiredDeviceId = null | |
| 458 | _isReconnecting.value = false | |
| 459 | } | |
| 460 | ||
| 461 | /** | |
| 462 | * Wait for an active cast session to land, returning it once available. | |
| 463 | * Returns null if the timeout elapses (typically meaning the user picked a | |
| 464 | * device that isn't on the network or the connect handshake failed). | |
| 465 | * | |
| 466 | * This is the key fix for the "I picked cast and the music kept playing | |
| 467 | * on local" failure mode: transport operations called right after | |
| 468 | * [selectDevice] would otherwise race the async connect/launchApp work | |
| 469 | * and silently drop because `_session.value` was still null. Waiting | |
| 470 | * (briefly) guarantees we issue commands AGAINST the receiver instead of | |
| 471 | * into the void. | |
| 472 | */ | |
| 473 | private suspend fun ensureSession(): CastSession? { | |
| 474 | _session.value?.let { return it } | |
| 475 | return withTimeoutOrNull(SESSION_WAIT_TIMEOUT_MS) { | |
| 476 | _session.first { it != null } | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | /** Load a single track on the current Cast session. If the session targets | |
| 481 | * a speaker group, the URL is rewritten through the local stream proxy | |
| 482 | * so the group leader gets well-behaved HTTP responses. */ | |
| 483 | fun load(track: DidlItem.Track) { | |
| 484 | scope.launch { | |
| 485 | val s = ensureSession() ?: run { | |
| 486 | System.err.println("[EarhornCast] load: no session within timeout") | |
| 487 | return@launch | |
| 488 | } | |
| 489 | val cc = s.backing | |
| 490 | // The proxy listens on the address this computer uses to reach the | |
| 491 | // group (never every interface); when there is none it says so and | |
| 492 | // the group fetches from the server directly. | |
| 493 | val url = if (_castingToGroup.value) { | |
| 494 | streamProxy.proxyUrl(track.resourceUrl, cc.address) ?: track.resourceUrl | |
| 495 | } else { | |
| 496 | track.resourceUrl | |
| 497 | } | |
| 498 | try { | |
| 499 | cc.load( | |
| 500 | /* mediaTitle = */ track.title, | |
| 501 | /* thumb = */ track.albumArtUri, | |
| 502 | /* url = */ url, | |
| 503 | /* contentType = */ track.mimeType ?: "audio/mpeg", | |
| 504 | ) | |
| 505 | // Bootstrap the media-channel subscription. The chromecast-java-api-v2 | |
| 506 | // library only starts pushing spontaneous MEDIA_STATUS events to | |
| 507 | // registered listeners after getMediaStatus() has been called at | |
| 508 | // least once for the running app — calling it here ensures the | |
| 509 | // listener registered in CastRenderer actually receives events | |
| 510 | // for THIS load. Without this call, some receivers stay silent and | |
| 511 | // the UI shows a frozen play/pause + position bar. | |
| 512 | runCatching { cc.mediaStatus } | |
| 513 | .onFailure { t -> | |
| 514 | System.err.println( | |
| 515 | "[EarhornCast] post-load getMediaStatus failed " + | |
| 516 | "(media events may not fire): ${t.message}" | |
| 517 | ) | |
| 518 | } | |
| 519 | } catch (e: Exception) { | |
| 520 | System.err.println("[EarhornCast] load failed: ${e.message}") | |
| 521 | } | |
| 522 | } | |
| 523 | } | |
| 524 | ||
| 525 | fun play() { | |
| 526 | scope.launch { | |
| 527 | val s = ensureSession() ?: return@launch | |
| 528 | runCatching { s.backing.play() } | |
| 529 | .onFailure { t -> | |
| 530 | System.err.println("[EarhornCast] play failed: ${t.message}") | |
| 531 | } | |
| 532 | } | |
| 533 | } | |
| 534 | ||
| 535 | fun pause() { | |
| 536 | scope.launch { | |
| 537 | val s = ensureSession() ?: return@launch | |
| 538 | runCatching { s.backing.pause() } | |
| 539 | .onFailure { t -> | |
| 540 | System.err.println("[EarhornCast] pause failed: ${t.message}") | |
| 541 | } | |
| 542 | } | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * Soft-stop: pause and rewind to start. | |
| 547 | * | |
| 548 | * The earlier implementation called `cc.stopApp()` which fully kills the | |
| 549 | * receiver app on the device. On Chromecast Audio firmware, killing the | |
| 550 | * receiver also briefly drops the device's mDNS advertisement — our | |
| 551 | * discovery listener fires `chromeCastRemoved`, the device disappears | |
| 552 | * from the picker, and the user can't re-select it for the mDNS-TTL | |
| 553 | * window (often 30+ seconds). | |
| 554 | * | |
| 555 | * Pause + seek(0) achieves the user-visible "Stop" semantic (audio | |
| 556 | * silences, playhead returns to start, the session is in a state where | |
| 557 | * Play resumes the same track from 0) without taking the device offline. | |
| 558 | * The receiver stays alive in the same media session, and the device | |
| 559 | * stays visible in the picker. | |
| 560 | * | |
| 561 | * Explicit teardown (e.g. user picks "Stop and switch" in the renderer | |
| 562 | * confirmation dialog) still goes through [disconnect], which does kill | |
| 563 | * the receiver — that's the right semantic there. | |
| 564 | */ | |
| 565 | fun stop() { | |
| 566 | scope.launch { | |
| 567 | val s = ensureSession() ?: return@launch | |
| 568 | val backing = s.backing | |
| 569 | runCatching { backing.pause() } | |
| 570 | .onFailure { t -> System.err.println("[EarhornCast] stop/pause: ${t.message}") } | |
| 571 | // Breathing room: some Chromecast Audio firmware refuses two | |
| 572 | // media-channel commands issued back-to-back (the second sees a | |
| 573 | // stale mediaSessionId from before the first was processed). | |
| 574 | // ~120 ms is enough to clear that window in practice while still | |
| 575 | // feeling instant to the user. | |
| 576 | delay(STOP_INTER_COMMAND_DELAY_MS) | |
| 577 | runCatching { backing.seek(0.0) } | |
| 578 | .onFailure { t -> System.err.println("[EarhornCast] stop/seek: ${t.message}") } | |
| 579 | } | |
| 580 | } | |
| 581 | ||
| 582 | /** | |
| 583 | * Volume changes are debounced through a single-worker pattern. | |
| 584 | * | |
| 585 | * The volume slider in Compose can fire onValueChange dozens of times per | |
| 586 | * second during a drag. Each [su.litvak.chromecast.api.v2.ChromeCast.setVolume] | |
| 587 | * call is a network round-trip to the device — sending all of them is both | |
| 588 | * laggy (the device processes them serially) and noisy. Debouncing keeps | |
| 589 | * only the most recent target volume; the worker drains it on a rate- | |
| 590 | * limited cadence so the device hears a manageable rate of updates and | |
| 591 | * the final volume always matches the slider's final position. | |
| 592 | */ | |
| 593 | /** Latest requested level; CONFLATED keeps only the newest. One worker | |
| 594 | * lives as long as [scope], so a value sent just as the previous one | |
| 595 | * finished can't be lost (the old start-a-worker-if-idle check could miss | |
| 596 | * it, dropping the slider's final position — Team 3 P2-14). */ | |
| 597 | private val volumeRequests = Channel<Float>(Channel.CONFLATED) | |
| 598 | ||
| 599 | private val volumeWorker: Job = scope.launch { | |
| 600 | for (target in volumeRequests) { | |
| 601 | val s = _session.value | |
| 602 | if (s != null) { | |
| 603 | runCatching { s.backing.setVolume(target) } | |
| 604 | .onFailure { t -> | |
| 605 | System.err.println("[EarhornCast] setVolume failed: ${t.message}") | |
| 606 | } | |
| 607 | } | |
| 608 | // No session: the update is dropped. (We don't wait, unlike | |
| 609 | // ensureSession() in other ops: volume changes pile up so fast | |
| 610 | // during a drag that even a 100ms wait would back up.) | |
| 611 | delay(VOLUME_RATE_LIMIT_MS) | |
| 612 | } | |
| 613 | } | |
| 614 | ||
| 615 | fun setVolume(level: Float) { | |
| 616 | volumeRequests.trySend(level.coerceIn(0f, 1f)) | |
| 617 | } | |
| 618 | ||
| 619 | fun seek(seconds: Double) { | |
| 620 | scope.launch { | |
| 621 | val s = ensureSession() ?: return@launch | |
| 622 | runCatching { s.backing.seek(seconds) } | |
| 623 | .onFailure { t -> | |
| 624 | System.err.println("[EarhornCast] seek failed: ${t.message}") | |
| 625 | } | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | /** | |
| 630 | * Poll the receiver for its current MediaStatus. Used by [CastRenderer] as a | |
| 631 | * fallback to spontaneous events, which the chromecast-java-api-v2 library | |
| 632 | * doesn't always reliably deliver. Returns null if no session is active or | |
| 633 | * if the receiver had nothing to report. Runs on the IO dispatcher via | |
| 634 | * [scope]; result is delivered via the [onStatus] callback rather than | |
| 635 | * returned directly so the suspend-vs-non-suspend boundary is in one place. | |
| 636 | * | |
| 637 | * Failures are logged once at the WARN level rather than thrown — a single | |
| 638 | * dropped poll shouldn't crash the renderer; the next poll catches up. The | |
| 639 | * common transient failure ("No application is running in ChromeCast") just | |
| 640 | * means the receiver hasn't launched the app yet. | |
| 641 | */ | |
| 642 | fun pollMediaStatus(onStatus: (su.litvak.chromecast.api.v2.MediaStatus) -> Unit) { | |
| 643 | scope.launch { | |
| 644 | val s = _session.value ?: return@launch | |
| 645 | val status = runCatching { s.backing.mediaStatus } | |
| 646 | .onFailure { t -> | |
| 647 | // Don't spam — the "no application running" error is common | |
| 648 | // right after launchApp and right after stopApp. We only log | |
| 649 | // if the message looks like a real transport problem. | |
| 650 | val msg = t.message.orEmpty() | |
| 651 | if ("No application is running" !in msg && "invalid state" !in msg) { | |
| 652 | System.err.println("[EarhornCast] pollMediaStatus: ${t.message}") | |
| 653 | } | |
| 654 | } | |
| 655 | .getOrNull() ?: return@launch | |
| 656 | onStatus(status) | |
| 657 | } | |
| 658 | } | |
| 659 | ||
| 660 | /** | |
| 661 | * Release jmDNS discovery and synchronously tear down the active cast | |
| 662 | * session. Call once at app shutdown. | |
| 663 | * | |
| 664 | * Note: this method runs SYNCHRONOUSLY on the calling thread (not on the | |
| 665 | * integration's [scope]). Shutdown-path callers — including the JVM | |
| 666 | * shutdown hook — rely on this: if we launched the teardown asynchronously | |
| 667 | * the JVM could exit before the stopApp/disconnect packets actually go | |
| 668 | * out, leaving the receiver app running and audible on the user's speakers | |
| 669 | * with no way for this desktop to stop it. The synchronous path uses | |
| 670 | * bounded I/O (the library's `stopApp()` and `disconnect()` open and close | |
| 671 | * a TCP-TLS connection — a few hundred ms in the worst case). | |
| 672 | */ | |
| 673 | fun release() { | |
| 674 | runCatching { ChromeCasts.unregisterListener(discoveryListener) } | |
| 675 | runCatching { ChromeCasts.stopDiscovery() } | |
| 676 | // Clear flags first so any concurrent observer doesn't fight us during | |
| 677 | // the synchronous teardown below. | |
| 678 | desiredDeviceId = null | |
| 679 | _isReconnecting.value = false | |
| 680 | val s = _session.value | |
| 681 | if (s != null) { | |
| 682 | runCatching { s.backing.stopApp() } | |
| 683 | .onFailure { System.err.println("[EarhornCast] shutdown stopApp: ${it.message}") } | |
| 684 | runCatching { s.backing.disconnect() } | |
| 685 | .onFailure { System.err.println("[EarhornCast] shutdown disconnect: ${it.message}") } | |
| 686 | _session.value = null | |
| 687 | _isCasting.value = false | |
| 688 | _castingToGroup.value = false | |
| 689 | } | |
| 690 | streamProxy.stop() | |
| 691 | // Cancel the scope last so any in-flight launches (e.g. polls) wind | |
| 692 | // down. Don't bother joining — they'll be GC'd as the JVM exits. | |
| 693 | runCatching { scope.cancel() } | |
| 694 | } | |
| 695 | ||
| 696 | /** | |
| 697 | * Force a fresh round of cast device discovery without restarting the app. | |
| 698 | * | |
| 699 | * jmDNS is the discovery backend (via the chromecast-java-api-v2 library), | |
| 700 | * and it can get wedged under conditions that DON'T involve any code | |
| 701 | * change on our side: | |
| 702 | * | |
| 703 | * - Network interface coming up/down (Wi-Fi handoff, dock plug-in) | |
| 704 | * - VPN tunnel binding before mDNS multicast was joined on the LAN side, | |
| 705 | * so jmDNS ends up subscribed on the wrong interface | |
| 706 | * - The chromecast device itself rebooting and re-announcing while we | |
| 707 | * missed the SSDP packet | |
| 708 | * - Stale sockets left over from a previous JVM (gradle daemon) | |
| 709 | * | |
| 710 | * The symptom is always the same: ChromeCasts.get() returns an empty list | |
| 711 | * (or a stale subset) even though the devices are demonstrably alive | |
| 712 | * elsewhere on the network. Calling stopDiscovery + startDiscovery | |
| 713 | * teardown and rebuilds jmDNS's sockets, after which devices repopulate | |
| 714 | * within the usual 2-5 second mDNS window. | |
| 715 | * | |
| 716 | * Asynchronous because jmDNS needs a beat between stop and start for its | |
| 717 | * threads to wind down; doing it synchronously sometimes leaves the | |
| 718 | * library in a half-initialized state where the next discovery callback | |
| 719 | * fires against a closed socket. Listener subscribers (Settings, | |
| 720 | * renderer picker) automatically see the new device list via the | |
| 721 | * existing StateFlow plumbing — no caller cleanup needed. | |
| 722 | */ | |
| 723 | fun rescan() { | |
| 724 | scope.launch { | |
| 725 | System.err.println( | |
| 726 | "[EarhornCast] rescan: stopping discovery (current snapshot size = ${_devices.value.size})" | |
| 727 | ) | |
| 728 | runCatching { ChromeCasts.stopDiscovery() } | |
| 729 | .onFailure { System.err.println("[EarhornCast] rescan stopDiscovery: ${it.message}") } | |
| 730 | // jmDNS internals need a beat to fully shutter sockets before we | |
| 731 | // bring them back up. 300ms is empirically enough on Linux without | |
| 732 | // making the user wait visibly. | |
| 733 | delay(300) | |
| 734 | runCatching { ChromeCasts.startDiscovery() } | |
| 735 | .onFailure { System.err.println("[EarhornCast] rescan startDiscovery: ${it.message}") } | |
| 736 | // First device announcements should arrive within 1-2 seconds; | |
| 737 | // give jmDNS a generous window before we publish whatever it has | |
| 738 | // collected so the user sees a populated list on the first refresh. | |
| 739 | delay(2500) | |
| 740 | refreshDevices() | |
| 741 | System.err.println( | |
| 742 | "[EarhornCast] rescan complete: new snapshot size = ${_devices.value.size}" | |
| 743 | ) | |
| 744 | } | |
| 745 | } | |
| 746 | ||
| 747 | companion object { | |
| 748 | // Stock Google Cast default media receiver application id. Lets us send | |
| 749 | // audio URLs without registering a custom receiver in the Cast Developer | |
| 750 | // Console. This is also what Android's CastIntegration uses. | |
| 751 | private const val DEFAULT_MEDIA_RECEIVER_APP_ID = "CC1AD845" | |
| 752 | ||
| 753 | /** | |
| 754 | * How long [ensureSession] waits for a session to become non-null before | |
| 755 | * giving up. Long enough that a fresh connect + launchApp handshake | |
| 756 | * (which takes 200-800 ms on typical Cast Audio firmware) lands | |
| 757 | * comfortably; short enough that a truly absent device doesn't make | |
| 758 | * the user wait forever for a single transport op to time out. | |
| 759 | */ | |
| 760 | private const val SESSION_WAIT_TIMEOUT_MS = 5_000L | |
| 761 | ||
| 762 | /** | |
| 763 | * Minimum delay between consecutive setVolume calls hitting the | |
| 764 | * receiver. The Compose volume slider fires onValueChange dozens of | |
| 765 | * times per second during a drag; without rate-limiting the receiver | |
| 766 | * gets flooded, slider feels laggy, and final volume can land | |
| 767 | * incorrectly because of queue ordering. 80 ms gives ~12 updates/sec | |
| 768 | * — fast enough to feel responsive, slow enough that the device | |
| 769 | * keeps up with each one. | |
| 770 | */ | |
| 771 | private const val VOLUME_RATE_LIMIT_MS = 80L | |
| 772 | ||
| 773 | /** | |
| 774 | * Inter-command delay between the pause and seek issued by [stop]. | |
| 775 | * See the comment at the call site — exists to keep some Chromecast | |
| 776 | * Audio firmware from rejecting the second command for using a | |
| 777 | * mediaSessionId that's still being torn down by the first. | |
| 778 | */ | |
| 779 | private const val STOP_INTER_COMMAND_DELAY_MS = 120L | |
| 780 | ||
| 781 | /** | |
| 782 | * How often the reconnect loop retries [selectDevice] while we have a | |
| 783 | * pending desiredDeviceId but no session. 1.5 s gives a brief network | |
| 784 | * blip time to recover transparently — the user sees the | |
| 785 | * "Reconnecting…" banner for under two seconds and the controls | |
| 786 | * resume — without flooding jmDNS or the device with reconnect | |
| 787 | * attempts on a persistent outage. | |
| 788 | */ | |
| 789 | private const val RECONNECT_RETRY_INTERVAL_MS = 1_500L | |
| 790 | ||
| 791 | /** How long the ACTIVE reconnect loop runs before force-exiting the | |
| 792 | * suspended/reconnecting state (sender bug class §4.4). Matches the | |
| 793 | * cross-platform 15s suspended→ended timeout. Passive reconnect on | |
| 794 | * mDNS reappearance is unaffected — desiredDeviceId stays set. */ | |
| 795 | private const val RECONNECT_GIVE_UP_MS = 15_000L | |
| 796 | ||
| 797 | /** | |
| 798 | * Every this-many reconnect attempts (so every | |
| 799 | * RECONNECT_RETRY_INTERVAL_MS × RECONNECT_RESCAN_EVERY ≈ 4.5 s), | |
| 800 | * stop+restart jmDNS discovery to flush a possibly-stale local mDNS | |
| 801 | * cache. The cheap `ChromeCasts.get().firstOrNull { it.name == … }` | |
| 802 | * check by itself waits forever in the case where jmDNS evicted the | |
| 803 | * device entry (mDNS TTL expired without a re-announce, multicast | |
| 804 | * packet was dropped on a flaky LAN, a VPN tunnel coming up | |
| 805 | * shuffled route priorities). 3 attempts is small enough that the | |
| 806 | * user doesn't sit through ages of dead waiting before we try the | |
| 807 | * heavier remedy, and large enough that a clean recovery from a | |
| 808 | * blip happens via the cheap path without paying the rescan cost. | |
| 809 | */ | |
| 810 | private const val RECONNECT_RESCAN_EVERY = 3 | |
| 811 | } | |
| 812 | } | |
| 813 | ||
| 814 | // ─── data shapes ──────────────────────────────────────────────────────────── | |
| 815 | ||
| 816 | /** A discoverable Cast device. Mirrors the Android side's [CastDevice] | |
| 817 | * but additionally holds the underlying [ChromeCast] handle so the | |
| 818 | * integration can act on it without re-resolving by id. */ | |
| 819 | data class CastDevice( | |
| 820 | val id: String, | |
| 821 | val friendlyName: String, | |
| 822 | val isGroup: Boolean, | |
| 823 | val backing: ChromeCast, | |
| 824 | ) { | |
| 825 | /** IP address of the device's HTTP/cast endpoint. Pulled from the | |
| 826 | * backing [ChromeCast] for use by the observer-mode RawCastClient, | |
| 827 | * which needs to open a TLS socket directly to the device's :8009 | |
| 828 | * port without going through chromecast-java-api-v2. */ | |
| 829 | val host: String? get() = backing.address | |
| 830 | /** Cast control port — always 8009 on stock receivers but read from | |
| 831 | * the discovery record to be safe. The observer client falls back to | |
| 832 | * 8009 if this is unavailable for any reason. */ | |
| 833 | val port: Int get() = backing.port.takeIf { it > 0 } ?: 8009 | |
| 834 | } | |
| 835 | ||
| 836 | /** An active Cast session — i.e. we've launched the receiver app on a | |
| 837 | * device and the connection is healthy. */ | |
| 838 | data class CastSession( | |
| 839 | val deviceId: String, | |
| 840 | val friendlyName: String, | |
| 841 | val isGroup: Boolean, | |
| 842 | val backing: ChromeCast, | |
| 843 | ) | |
| 844 | ||
| 845 | /** Diagnostic surface for the picker UI. Same fields as the Android side, | |
| 846 | * minus the Play-Services-specific ones (which have no meaning on desktop). */ | |
| 847 | data class DiagnosticState( | |
| 848 | val rawRouteCount: Int = 0, | |
| 849 | val filteredDeviceCount: Int = 0, | |
| 850 | val tick: Int = 0, | |
| 851 | val rawRouteNames: List<String> = emptyList(), | |
| 852 | ) | |
| 853 | ||
| 854 | /* TODO(next session): wire this into the renderer pipeline. | |
| 855 | * | |
| 856 | * 1. Add a `CastRenderer : Renderer` that delegates load/play/pause/etc to | |
| 857 | * a [CastIntegration]. It would also coordinate with [VlcjLocalRenderer] | |
| 858 | * so local audio mutes the moment a session opens and resumes when the | |
| 859 | * session ends. The simplest model: a "primary" renderer that the rest | |
| 860 | * of the app talks to is a coordinator wrapping both, and switches its | |
| 861 | * delegate based on isCasting. | |
| 862 | * | |
| 863 | * 2. Surface the picker in StaveApp.kt — a Cast button in the toolbar that | |
| 864 | * opens a Compose Desktop port of CastPickerSheet.kt, showing the | |
| 865 | * [devices] StateFlow. | |
| 866 | * | |
| 867 | * 3. Track-end handling: chromecast-java-api-v2 exposes session events via | |
| 868 | * ChromeCastSpontaneousEventListener — we'll need to wire that up so | |
| 869 | * "remote track finished" advances our local queue index just like | |
| 870 | * VlcjLocalRenderer's MediaListPlayer nextItem event does. | |
| 871 | * | |
| 872 | * 4. Reconnect-after-network-blip — the Android version has retry logic. | |
| 873 | * chromecast-java-api-v2 may need similar; depends on observed behavior | |
| 874 | * with real hardware. | |
| 875 | */ |


