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

RawCastClient.kt

1076 lines · 49.1 KB

Changed in: 0.5.0

1package dev.stave.cast.raw
2
3import kotlinx.coroutines.CoroutineScope
4import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.SupervisorJob
6import kotlinx.coroutines.cancel
7import kotlinx.coroutines.currentCoroutineContext
8import kotlinx.coroutines.delay
9import kotlinx.coroutines.flow.MutableSharedFlow
10import kotlinx.coroutines.flow.MutableStateFlow
11import kotlinx.coroutines.flow.SharedFlow
12import kotlinx.coroutines.flow.StateFlow
13import kotlinx.coroutines.flow.asSharedFlow
14import kotlinx.coroutines.flow.asStateFlow
15import kotlinx.coroutines.isActive
16import kotlinx.coroutines.launch
17import kotlinx.coroutines.withContext
18import org.json.JSONObject
19import java.io.BufferedInputStream
20import java.io.BufferedOutputStream
21import java.io.ByteArrayOutputStream
22import java.io.IOException
23import java.io.InputStream
24import java.io.OutputStream
25import java.net.InetSocketAddress
26import javax.net.ssl.SSLContext
27import javax.net.ssl.SSLSocket
28import javax.net.ssl.TrustManager
29import javax.net.ssl.X509TrustManager
30
31/**
32 * Raw Cast protocol client for observing Chromecast sessions without the Cast SDK.
33 *
34 * Connects via TLS to port 8009 on any Chromecast/Chromecast Audio/group leader,
35 * discovers the active media session, and streams [MediaStatus] updates. Transport
36 * commands (play/pause/stop/seek/next/previous) are sent as JSON over the media
37 * namespace.
38 *
39 * This bypasses Google Play Services entirely and works on libre builds, the
40 * GNU/Linux desktop, or anywhere with a JDK and a TLS-capable socket. Ported
41 * from the Android TV implementation: the protocol code is identical, only the
42 * logging surface differs (`android.util.Log` → stderr in the
43 * `[EarhornCast/Raw]` channel matching the existing `CastIntegration` style).
44 *
45 * Protocol references:
46 * - github.com/thibauts/node-castv2
47 * - github.com/home-assistant-libs/pychromecast
48 * - chromium.googlesource.com/.../cast_channel.proto
49 */
50class RawCastClient(
51 private val host: String,
52 private val port: Int = 8009,
53) {
54 companion object {
55 // Cast protocol namespaces
56 private const val NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection"
57 private const val NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat"
58 private const val NS_RECEIVER = "urn:x-cast:com.google.cast.receiver"
59 private const val NS_MEDIA = "urn:x-cast:com.google.cast.media"
60
61 private const val SENDER_ID = "sender-earhorn"
62 private const val RECEIVER_ID = "receiver-0"
63
64 private const val HEARTBEAT_INTERVAL_MS = 5_000L
65
66 /** No bytes from the receiver for this long = the socket is dead.
67 * Receivers answer every PING with a PONG (and send their own PINGs),
68 * so a live connection never goes quiet this long. Without it a
69 * speaker that lost power or Wi-Fi (no FIN) kept `connected` true
70 * until the kernel gave up on TCP retransmits, about 15 minutes, and
71 * the observer's reconnect never ran. Same budget as pychromecast
72 * (10 s ping + 10 s pong wait). */
73 private const val RX_TIMEOUT_MS = 20_000L
74 private const val CONNECT_TIMEOUT_MS = 10_000
75
76 /** How long after a local [setVolume] incoming RECEIVER_STATUS volume
77 * readings are ignored. Long enough to outlive any status response
78 * that was already on the wire when the user clicked; short enough
79 * that genuine external changes (another sender's slider) surface
80 * within a poll cycle. */
81 private const val VOLUME_ECHO_SUPPRESS_MS = 1_500L
82
83 /** Diagnostic tag prefix. Matches `[EarhornCast]` from the sender-side
84 * integration so all cast-related output sits in one mental bucket. */
85 private const val TAG = "[EarhornCast/Raw]"
86 }
87
88 data class MediaStatus(
89 val title: String? = null,
90 val artist: String? = null,
91 val album: String? = null,
92 val albumArtUrl: String? = null,
93 val contentId: String? = null,
94 val duration: Double = 0.0,
95 val currentTime: Double = 0.0,
96 val playerState: String = "UNKNOWN", // PLAYING, PAUSED, IDLE, BUFFERING
97 val mediaSessionId: Int = 0,
98 val volume: Double = 1.0,
99 val isMuted: Boolean = false,
100 /** False until we have an authoritative DEVICE volume — from a
101 * RECEIVER_STATUS `volume` block, or latched by our own [setVolume].
102 * (MEDIA_STATUS never contributes: its `volume` is the media STREAM
103 * volume, a different knob — see [handleMediaStatus].) UI
104 * surfaces use this to distinguish "we don't know what the device
105 * is at" (do not display the slider as if it's the truth, do not
106 * apply optimistic updates) from "device reported 100%."
107 *
108 * The default volume of 1.0 above is a placeholder used until the
109 * first authoritative value arrives; without this flag the slider
110 * would sit at 100% on attach and a user click anywhere below
111 * that — say at 30% on a device actually at 5% — would jump the
112 * audible level by 6x in a single command, feeling like a
113 * "volume blast" even though the new value is correct. */
114 val volumeKnown: Boolean = false,
115 /** The receiver's authoritative "which queue item is playing" pointer —
116 * the `currentItemId` top-level field of MEDIA_STATUS. Cross-referenced
117 * against the QUEUE_ITEM_IDS order to derive the queue index. NEVER
118 * count the index locally; this value cannot drift from what's audible.
119 * Null until the receiver first reports it (or when the session has no
120 * receiver-side queue at all — e.g. a single-item load). Sticky across
121 * transport-only pushes that omit it, same as the metadata fields. */
122 val currentItemId: Int? = null,
123 /** Advances by one for every MEDIA_STATUS the receiver sends, and for
124 * nothing else. RECEIVER_STATUS (device volume) copies keep it. The
125 * observer keys "is this a new playback report?" on this number
126 * (ported from Android, 2026-09-04: a volume report used to count as
127 * "same position twice" and hold the bar). */
128 val statusSeq: Long = 0L,
129 /** Receiver queue repeat mode: REPEAT_OFF, REPEAT_ALL, REPEAT_SINGLE,
130 * REPEAT_ALL_AND_SHUFFLE; "" until reported. */
131 val repeatMode: String = "",
132 )
133
134 // ── Receiver-queue introspection (follower queue) ────────────
135 //
136 // The receiver is the source of truth for its queue and answers
137 // introspection requests from any connected peer — the same mechanism
138 // Google's own MediaQueue sender class uses. We expose the wire traffic
139 // as a stream of [QueueEvent]s; RawCastObserver owns the assembled
140 // queue state (ordering, item buffer, batching, debouncing).
141
142 /** One queue entry as it appears in a QUEUE_ITEMS response (or the `items`
143 * array some receivers piggyback on MEDIA_STATUS pushes). Blank strings
144 * from lax JSON parsers are normalised to null before this is built. */
145 data class QueueItemLite(
146 val itemId: Int,
147 val contentId: String?,
148 val title: String?,
149 val artist: String?,
150 val album: String?,
151 val albumArtUrl: String?,
152 val durationSeconds: Double?,
153 )
154
155 sealed interface QueueEvent {
156 /** QUEUE_ITEM_IDS response: the receiver's full ordered id list. */
157 data class ItemIds(val itemIds: List<Int>) : QueueEvent
158
159 /** QUEUE_ITEMS response batch — or items piggybacked on a MEDIA_STATUS
160 * push ([fromStatusPush] = true). Piggybacked payloads carry only the
161 * current (and sometimes preloaded) item; merge them opportunistically
162 * so the now-playing row has metadata before the batched fetch lands,
163 * but never treat them as a complete queue. */
164 data class Items(
165 val items: List<QueueItemLite>,
166 val fromStatusPush: Boolean = false,
167 ) : QueueEvent
168
169 /** QUEUE_CHANGE broadcast — the receiver announcing that *some* sender
170 * edited the queue. [changeType] is ITEMS_CHANGED|INSERT|REMOVE|UPDATE.
171 * The correct reaction is a (debounced) refetch of the id list. */
172 data class Change(val changeType: String, val itemIds: List<Int>) : QueueEvent
173
174 /** mediaSessionId transitioned to a new non-zero value — a new load,
175 * which means a new receiver-side queue. Everything buffered for the
176 * old session is invalid. */
177 data class SessionChanged(val mediaSessionId: Int) : QueueEvent
178 }
179
180 private val _queueEvents = MutableSharedFlow<QueueEvent>(
181 // Queue traffic is bursty (batched QUEUE_ITEMS during a refetch) and
182 // every event is recoverable by a fresh refetch, so a bounded buffer
183 // with drop-oldest is the right failure mode — never suspend the read
184 // loop on a slow collector.
185 extraBufferCapacity = 256,
186 onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
187 )
188 /** Wire-level queue traffic. Collected by [RawCastObserver]'s queue manager. */
189 val queueEvents: SharedFlow<QueueEvent> = _queueEvents.asSharedFlow()
190
191 private val _mediaStatus = MutableStateFlow(MediaStatus())
192 val mediaStatus: StateFlow<MediaStatus> = _mediaStatus.asStateFlow()
193
194 private val _connected = MutableStateFlow(false)
195 val connected: StateFlow<Boolean> = _connected.asStateFlow()
196
197 // Written by connect()/disconnect() and the reader thread, read by command
198 // callers on other threads, hence @Volatile.
199 @Volatile private var socket: SSLSocket? = null
200 @Volatile private var output: OutputStream? = null
201 @Volatile private var input: InputStream? = null
202 @Volatile private var scope: CoroutineScope? = null
203 @Volatile private var transportId: String? = null
204 /** Wall-clock of the last frame read from the socket (any namespace). */
205 @Volatile private var lastRxAt: Long = 0L
206 private var requestId = 1
207 /** Wall-clock of the last RECEIVER_STATUS, idle or not (ported from Android). */
208 @Volatile var lastReceiverStatusAt: Long = 0L
209 private set
210 /** Log every non-heartbeat message with a payload preview. Off by default:
211 * one line per status, every few seconds, forever, on every desktop. */
212 @Volatile var verboseWireLog: Boolean = false
213
214 /** Wallclock of the most recent local [setVolume]. RECEIVER_STATUS volume
215 * ingest is suppressed for [VOLUME_ECHO_SUPPRESS_MS] after this so a
216 * status that was already in flight when the user clicked (carrying the
217 * OLD device level) can't briefly bounce the slider back before the
218 * post-set broadcast confirms the new value. Written on the caller's
219 * coroutine, read on the socket-reader coroutine — hence @Volatile. */
220 @Volatile
221 private var localVolumeSetAtMs = 0L
222
223 // ── Connect / Disconnect ─────────────────────────────────────
224
225 suspend fun connect() = withContext(Dispatchers.IO) {
226 disconnect()
227 val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO)
228 scope = sc
229
230 System.err.println("$TAG connecting to $host:$port")
231 // TLS with permissive trust (Chromecast uses self-signed certs)
232 val sslContext = SSLContext.getInstance("TLS")
233 sslContext.init(null, arrayOf<TrustManager>(PermissiveTrustManager), null)
234 val factory = sslContext.socketFactory
235
236 val sock = factory.createSocket() as SSLSocket
237 // Published before connecting so a disconnect() from another thread
238 // (the user stops following mid-connect) closes it and aborts the
239 // blocking connect/handshake instead of waiting for the timeouts.
240 socket = sock
241 try {
242 sock.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
243 // The handshake had no timeout: a receiver that accepts and stalls
244 // pinned this thread until it let go. Reset after — the read loop
245 // blocks on purpose.
246 sock.soTimeout = CONNECT_TIMEOUT_MS
247 sock.startHandshake()
248 sock.soTimeout = 0
249 output = BufferedOutputStream(sock.outputStream)
250 input = BufferedInputStream(sock.inputStream)
251 } catch (t: Throwable) {
252 // A timeout or TLS failure used to leave the socket's fd open,
253 // once per reconnect attempt.
254 runCatching { sock.close() }
255 if (socket === sock) socket = null
256 sc.cancel()
257 if (scope === sc) scope = null
258 throw t
259 }
260 lastRxAt = System.currentTimeMillis()
261 _connected.value = true
262 System.err.println("$TAG TLS handshake OK — sending CONNECT + GET_STATUS")
263
264 // Start reader + heartbeat
265 sc.launch { readLoop(sock) }
266 sc.launch { heartbeatLoop(sock) }
267
268 // Connect to receiver platform
269 sendJson(RECEIVER_ID, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""")
270 // Get receiver status to discover active app/session
271 sendJson(
272 RECEIVER_ID, NS_RECEIVER,
273 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""",
274 )
275 }
276
277 fun disconnect() {
278 if (_connected.value || socket != null) {
279 System.err.println("$TAG disconnecting from $host:$port")
280 }
281 scope?.cancel()
282 scope = null
283 runCatching { socket?.close() }
284 socket = null
285 output = null
286 input = null
287 transportId = null
288 _connected.value = false
289 }
290
291 // ── Transport commands ───────────────────────────────────────
292
293 suspend fun play() = sendMediaCommand("PLAY")
294 suspend fun pause() = sendMediaCommand("PAUSE")
295 suspend fun stop() = sendMediaCommand("STOP")
296
297 suspend fun seek(positionSeconds: Double) {
298 val tid = transportId ?: return
299 val msid = _mediaStatus.value.mediaSessionId
300 if (msid == 0) return
301 sendJson(
302 tid, NS_MEDIA,
303 """{"type":"SEEK","mediaSessionId":$msid,"currentTime":$positionSeconds,"requestId":${nextRequestId()}}""",
304 )
305 }
306
307 suspend fun next() {
308 val tid = transportId ?: return
309 val msid = _mediaStatus.value.mediaSessionId
310 if (msid == 0) return
311 sendJson(
312 tid, NS_MEDIA,
313 """{"type":"QUEUE_NEXT","mediaSessionId":$msid,"requestId":${nextRequestId()}}""",
314 )
315 }
316
317 suspend fun previous() {
318 val tid = transportId ?: return
319 val msid = _mediaStatus.value.mediaSessionId
320 if (msid == 0) return
321 sendJson(
322 tid, NS_MEDIA,
323 """{"type":"QUEUE_PREV","mediaSessionId":$msid,"requestId":${nextRequestId()}}""",
324 )
325 }
326
327 suspend fun setVolume(level: Double) {
328 // Optimistically reflect the new level in our own MediaStatus BEFORE
329 // sending. The observer bridges _mediaStatus → PlaybackState on every
330 // emission; MEDIA_STATUS payloads never contribute volume (their
331 // `volume` field is the media STREAM volume — see the note in
332 // [handleMediaStatus]), so this optimistic write is the baseline every
333 // subsequent media update preserves. An authoritative RECEIVER_STATUS
334 // still wins if the device reports something different — but only
335 // after [VOLUME_ECHO_SUPPRESS_MS], so a status already in flight with
336 // the OLD level can't bounce the slider before the post-set broadcast
337 // lands (see [handleReceiverStatus]).
338 val clamped = level.coerceIn(0.0, 1.0)
339 localVolumeSetAtMs = System.currentTimeMillis()
340 _mediaStatus.value = _mediaStatus.value.copy(volume = clamped, volumeKnown = true)
341 sendJson(
342 RECEIVER_ID, NS_RECEIVER,
343 """{"type":"SET_VOLUME","volume":{"level":$clamped},"requestId":${nextRequestId()}}""",
344 )
345 }
346
347 /** Re-request current media status from the Chromecast. Called periodically
348 * to catch any MEDIA_STATUS broadcasts that were missed. */
349 suspend fun requestMediaStatus() {
350 val tid = transportId ?: return
351 sendJson(
352 tid, NS_MEDIA,
353 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""",
354 )
355 }
356
357 /**
358 * Re-request the device's RECEIVER_STATUS. Unlike [requestMediaStatus], this
359 * goes to the receiver-platform namespace and reliably returns a payload
360 * with the `volume` sub-object — MEDIA_STATUS broadcasts often omit it
361 * after the initial connect because volume changes are infrequent. The
362 * observer uses this on attach (with backoff) to bootstrap an
363 * authoritative volume reading; without it the slider sits on the
364 * default 100% until something happens to make the receiver re-broadcast,
365 * which on a steady-state PLAYING session may never happen.
366 */
367 suspend fun requestReceiverStatus() {
368 sendJson(
369 RECEIVER_ID, NS_RECEIVER,
370 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""",
371 )
372 }
373
374 // ── Queue introspection commands ─────────────────────────────
375 //
376 // All three go to the app transportId (NOT receiver-0) on the media
377 // namespace, and all require a live mediaSessionId from MEDIA_STATUS —
378 // the guards below silently no-op until both are known, which matches
379 // how transport commands already behave.
380
381 /** Ask the receiver for its ordered queue-item id list. The response
382 * arrives asynchronously as a [QueueEvent.ItemIds]. */
383 suspend fun requestQueueItemIds() {
384 val tid = transportId ?: return
385 val msid = _mediaStatus.value.mediaSessionId
386 if (msid == 0) return
387 sendJson(
388 tid, NS_MEDIA,
389 """{"type":"QUEUE_GET_ITEM_IDS","mediaSessionId":$msid,"requestId":${nextRequestId()}}""",
390 )
391 }
392
393 /** Fetch metadata for a batch of queue item ids. Callers chunk to the
394 * shared batch size (20); the response arrives as [QueueEvent.Items]. */
395 suspend fun requestQueueItems(itemIds: List<Int>) {
396 if (itemIds.isEmpty()) return
397 val tid = transportId ?: return
398 val msid = _mediaStatus.value.mediaSessionId
399 if (msid == 0) return
400 val ids = itemIds.joinToString(",")
401 sendJson(
402 tid, NS_MEDIA,
403 """{"type":"QUEUE_GET_ITEMS","mediaSessionId":$msid,"itemIds":[$ids],"requestId":${nextRequestId()}}""",
404 )
405 }
406
407 /**
408 * Jump the receiver's playback position to [itemId] — position-only,
409 * queue contents untouched. This is the ONLY queue mutation a follower
410 * is allowed to send: the driving sender's local queue is authoritative
411 * and (on Android at least) it full-reloads the receiver queue on its
412 * own edits, so a follower REMOVE/REORDER would be silently clobbered
413 * and desync the sender in the interim. QUEUE_UPDATE with currentItemId
414 * is safe because the sender's index tracker follows the receiver via
415 * its own status callbacks.
416 */
417 /** Set the receiver queue's repeat mode (QUEUE_UPDATE.repeatMode). */
418 suspend fun setRepeatMode(mode: String) {
419 val tid = transportId ?: return
420 val msid = _mediaStatus.value.mediaSessionId
421 if (msid == 0) return
422 sendJson(
423 tid, NS_MEDIA,
424 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"repeatMode":"$mode","requestId":${nextRequestId()}}""",
425 )
426 }
427
428 /** Shuffle the receiver queue (QUEUE_UPDATE.shuffle). */
429 suspend fun setShuffle(on: Boolean) {
430 val tid = transportId ?: return
431 val msid = _mediaStatus.value.mediaSessionId
432 if (msid == 0) return
433 sendJson(
434 tid, NS_MEDIA,
435 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"shuffle":$on,"requestId":${nextRequestId()}}""",
436 )
437 }
438
439 suspend fun queueJumpTo(itemId: Int) {
440 val tid = transportId ?: return
441 val msid = _mediaStatus.value.mediaSessionId
442 if (msid == 0) return
443 sendJson(
444 tid, NS_MEDIA,
445 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"currentItemId":$itemId,"requestId":${nextRequestId()}}""",
446 )
447 }
448
449 private suspend fun sendMediaCommand(type: String) {
450 val tid = transportId ?: return
451 val msid = _mediaStatus.value.mediaSessionId
452 if (msid == 0) return
453 sendJson(
454 tid, NS_MEDIA,
455 """{"type":"$type","mediaSessionId":$msid,"requestId":${nextRequestId()}}""",
456 )
457 }
458
459 // ── Message I/O ─────────────────────────────────────────────
460
461 private suspend fun sendJson(
462 destinationId: String,
463 namespace: String,
464 json: String,
465 ) = withContext(Dispatchers.IO) {
466 val msg = CastMessageCodec.encode(
467 sourceId = SENDER_ID,
468 destinationId = destinationId,
469 namespace = namespace,
470 payloadUtf8 = json,
471 )
472 val lenBuf = ByteArray(4)
473 lenBuf[0] = (msg.size shr 24 and 0xFF).toByte()
474 lenBuf[1] = (msg.size shr 16 and 0xFF).toByte()
475 lenBuf[2] = (msg.size shr 8 and 0xFF).toByte()
476 lenBuf[3] = (msg.size and 0xFF).toByte()
477 val sock = socket
478 val out = output ?: return@withContext
479 // Ported from Android: a write on a dead socket (receiver rebooted,
480 // network drop) is the earliest sign the connection is gone, since
481 // the read loop may block forever on a half-open socket. Mark the
482 // connection lost so the observer's reconnect supervisor runs, and
483 // don't throw into the caller (commands are fire-and-forget).
484 try {
485 synchronized(out) {
486 out.write(lenBuf)
487 out.write(msg)
488 out.flush()
489 }
490 } catch (e: IOException) {
491 System.err.println("$TAG send failed (${e.message}) — marking connection lost")
492 markLost(sock)
493 }
494 }
495
496 /** Flag the connection lost, but only if [sock] is still the current
497 * socket: a reader or writer of a socket that a reconnect has already
498 * replaced must not knock the new connection's flag back to false. */
499 private fun markLost(sock: SSLSocket?) {
500 if (sock != null && socket === sock) _connected.value = false
501 }
502
503 private suspend fun readLoop(sock: SSLSocket) {
504 try {
505 val inp = input ?: return
506 val lenBuf = ByteArray(4)
507 while (currentCoroutineContext().isActive) {
508 // Read 4-byte length prefix
509 readFully(inp, lenBuf)
510 val len = (lenBuf[0].toInt() and 0xFF shl 24) or
511 (lenBuf[1].toInt() and 0xFF shl 16) or
512 (lenBuf[2].toInt() and 0xFF shl 8) or
513 (lenBuf[3].toInt() and 0xFF)
514 if (len <= 0 || len > 1_000_000) {
515 // Desynchronised stream: skipping the prefix would parse
516 // payload bytes as the next length. Drop the connection.
517 System.err.println("$TAG bad length prefix $len — marking connection lost")
518 markLost(sock)
519 return
520 }
521
522 val msgBuf = ByteArray(len)
523 readFully(inp, msgBuf)
524 lastRxAt = System.currentTimeMillis()
525
526 val decoded = CastMessageCodec.decode(msgBuf) ?: continue
527 handleMessage(decoded)
528 }
529 } catch (e: IOException) {
530 // Connection lost (or closed by disconnect(); markLost ignores
531 // a socket that has already been replaced).
532 markLost(sock)
533 } catch (e: kotlinx.coroutines.CancellationException) {
534 throw e
535 } catch (t: Throwable) {
536 // Anything else used to kill this coroutine silently while the
537 // heartbeat kept the socket "connected" — a mute socket.
538 System.err.println("$TAG read loop died (${t.javaClass.simpleName}: ${t.message}) — marking connection lost")
539 markLost(sock)
540 }
541 }
542
543 private fun readFully(inp: InputStream, buf: ByteArray) {
544 var offset = 0
545 while (offset < buf.size) {
546 val n = inp.read(buf, offset, buf.size - offset)
547 if (n < 0) throw IOException("End of stream")
548 offset += n
549 }
550 }
551
552 private suspend fun heartbeatLoop(sock: SSLSocket) {
553 while (currentCoroutineContext().isActive) {
554 delay(HEARTBEAT_INTERVAL_MS)
555 if (socket !== sock) return
556 // Rx watchdog: a socket that has gone silent past RX_TIMEOUT_MS is
557 // dead even though nothing has told us so (no FIN, read blocked).
558 // Closing it unblocks the read loop; marking it lost starts the
559 // observer's reconnect supervisor now rather than in ~15 minutes.
560 val quietMs = System.currentTimeMillis() - lastRxAt
561 if (quietMs > RX_TIMEOUT_MS) {
562 System.err.println("$TAG nothing received for ${quietMs / 1000}s — marking connection lost")
563 markLost(sock)
564 runCatching { sock.close() }
565 return
566 }
567 // sendJson handles IOException itself (marks the connection lost).
568 sendJson(RECEIVER_ID, NS_HEARTBEAT, """{"type":"PING"}""")
569 }
570 }
571
572 // ── Message handling ────────────────────────────────────────
573
574 private suspend fun handleMessage(msg: CastMessageCodec.DecodedMessage) {
575 val json = runCatching { JSONObject(msg.payloadUtf8) }.getOrNull()
576 ?: return
577 val type = json.optString("type", "")
578
579 // Diagnostic — skip heartbeat to keep output readable; log everything
580 // else with a truncated payload preview. Filter on "[EarhornCast/Raw]"
581 // in stderr to see the receiver's message flow during a session. Useful
582 // when a device gets stuck on "Waiting for…" — the output will show
583 // whether MEDIA_STATUS is arriving at all, and what it contains.
584 if (verboseWireLog && msg.namespace != NS_HEARTBEAT) {
585 val preview = msg.payloadUtf8.take(280) +
586 if (msg.payloadUtf8.length > 280) "…" else ""
587 System.err.println(
588 "$TAG rx ns=${msg.namespace.removePrefix("urn:x-cast:com.google.cast.")} " +
589 "type=$type payload=$preview",
590 )
591 }
592
593 when (msg.namespace) {
594 NS_HEARTBEAT -> {
595 if (type == "PING") {
596 sendJson(msg.sourceId, NS_HEARTBEAT, """{"type":"PONG"}""")
597 }
598 }
599
600 NS_RECEIVER -> {
601 if (type == "RECEIVER_STATUS") {
602 handleReceiverStatus(json)
603 }
604 }
605
606 NS_MEDIA -> {
607 when (type) {
608 "MEDIA_STATUS" -> handleMediaStatus(json)
609
610 "QUEUE_ITEM_IDS" -> {
611 val arr = json.optJSONArray("itemIds")
612 val ids = if (arr == null) emptyList() else {
613 (0 until arr.length()).mapNotNull { i ->
614 arr.optInt(i, -1).takeIf { it > 0 }
615 }
616 }
617 _queueEvents.tryEmit(QueueEvent.ItemIds(ids))
618 }
619
620 "QUEUE_ITEMS" -> {
621 val arr = json.optJSONArray("items")
622 if (arr != null) {
623 val items = (0 until arr.length()).mapNotNull { i ->
624 parseQueueItem(arr.optJSONObject(i))
625 }
626 if (items.isNotEmpty()) {
627 _queueEvents.tryEmit(QueueEvent.Items(items))
628 }
629 }
630 }
631
632 "QUEUE_CHANGE" -> {
633 val changeType = json.optString("changeType", "ITEMS_CHANGED")
634 val arr = json.optJSONArray("itemIds")
635 val ids = if (arr == null) emptyList() else {
636 (0 until arr.length()).mapNotNull { i ->
637 arr.optInt(i, -1).takeIf { it > 0 }
638 }
639 }
640 _queueEvents.tryEmit(QueueEvent.Change(changeType, ids))
641 }
642 }
643 }
644
645 NS_CONNECTION -> {
646 if (type == "CLOSE") {
647 // Session ended
648 System.err.println("$TAG transport session CLOSE from ${msg.sourceId}")
649 transportId = null
650 // Keep the DEVICE volume reading: it belongs to the
651 // receiver, not the session that just ended. Resetting it
652 // locked the follower's volume slider (volumeKnown=false)
653 // until the next RECEIVER_STATUS with a volume block.
654 val prev = _mediaStatus.value
655 _mediaStatus.value = MediaStatus(
656 volume = prev.volume,
657 isMuted = prev.isMuted,
658 volumeKnown = prev.volumeKnown,
659 statusSeq = prev.statusSeq + 1,
660 )
661 }
662 }
663 }
664 }
665
666 private suspend fun handleReceiverStatus(json: JSONObject) {
667 val status = json.optJSONObject("status") ?: return
668 lastReceiverStatusAt = System.currentTimeMillis()
669 val apps = status.optJSONArray("applications")
670 if (apps == null || apps.length() == 0) {
671 // No media app running. Now that the observer polls NS_RECEIVER
672 // every 3s (for device volume), this branch fires continuously on
673 // an idle device — so it logs and tears down only on the
674 // TRANSITION (we previously had a transport, now the app is
675 // gone), and stays silent while already idle. Clearing
676 // [transportId] mirrors the CLOSE handler: the old transport is
677 // dead, and nulling it turns subsequent media polls/commands
678 // into no-ops instead of writes to a defunct session. Track
679 // metadata stays sticky (art/title shouldn't vanish); only the
680 // transport state drops to IDLE, same as the empty-status-array
681 // branch of [handleMediaStatus].
682 // Also when transportId is already null but the last status still
683 // claims a live session: after a reconnect, connect() has cleared
684 // transportId, and a recovered device that reports no app is the
685 // only evidence the old session is gone (ported from Android's
686 // 2026-08-28 fix). Without this a stale PLAYING state stuck until
687 // the observer's 150 s silence watchdog.
688 val ms0 = _mediaStatus.value
689 if (transportId != null || (ms0.mediaSessionId != 0 && ms0.playerState != "IDLE")) {
690 System.err.println(
691 "$TAG RECEIVER_STATUS reports no running media app — session ended; detaching transport",
692 )
693 transportId = null
694 _mediaStatus.value = _mediaStatus.value.let {
695 it.copy(playerState = "IDLE", currentTime = 0.0, statusSeq = it.statusSeq + 1)
696 }
697 }
698 return
699 }
700
701 // Pick the app that actually owns the media namespace, not just apps[0].
702 // CCA groups in particular run TWO apps simultaneously when a session is
703 // active: the "Multizone Leader" (managing audio sync across followers,
704 // exposes its own namespaces but NOT media), and the sender's actual
705 // receiver app (Default Media Receiver, Spotify, etc — exposes
706 // urn:x-cast:com.google.cast.media). Attaching to the leader gives us a
707 // working channel that never delivers MEDIA_STATUS payloads, which is
708 // why the UI was sitting at "Nothing playing" while the user could hear
709 // music: transport state was syncing via the receiver's other channels,
710 // but media metadata only rides on the media namespace.
711 //
712 // Fallback to apps[0] if no app exposes the media namespace (e.g., the
713 // device is showing only a leader or a non-media app) — preserves the
714 // pre-fix behavior in the unlikely edge case rather than silently doing
715 // nothing.
716 val app = (0 until apps.length()).map { apps.getJSONObject(it) }
717 .firstOrNull { candidate ->
718 val ns = candidate.optJSONArray("namespaces") ?: return@firstOrNull false
719 (0 until ns.length()).any { i ->
720 ns.getJSONObject(i).optString("name") == NS_MEDIA
721 }
722 } ?: apps.getJSONObject(0)
723
724 val tid = app.optString("transportId", "")
725 val appId = app.optString("appId", "")
726 val displayName = app.optString("displayName", "")
727 if (tid.isBlank()) {
728 System.err.println(
729 "$TAG RECEIVER_STATUS app has no transportId — appId=$appId displayName=$displayName",
730 )
731 return
732 }
733
734 // Extract DEVICE volume. RECEIVER_STATUS is the one place the real
735 // device/group level lives (MEDIA_STATUS only ever carries the media
736 // STREAM volume — see [handleMediaStatus]). Skip the copy inside the
737 // short post-[setVolume] window: a GET_STATUS response that was
738 // already in flight when the user clicked still reports the OLD
739 // level, and ingesting it would bounce the slider back for a beat
740 // before the volume-change broadcast confirms the new value. After
741 // the window the receiver is authoritative again, which is what lets
742 // volume changes made by OTHER senders (the phone's slider) track.
743 val vol = status.optJSONObject("volume")
744 val sinceLocalSet = System.currentTimeMillis() - localVolumeSetAtMs
745 if (vol != null && sinceLocalSet > VOLUME_ECHO_SUPPRESS_MS) {
746 _mediaStatus.value = _mediaStatus.value.copy(
747 volume = vol.optDouble("level", 1.0),
748 isMuted = vol.optBoolean("muted", false),
749 volumeKnown = true,
750 )
751 }
752
753 if (tid != transportId) {
754 System.err.println(
755 "$TAG attaching to media-app transport tid=$tid appId=$appId displayName=$displayName",
756 )
757 transportId = tid
758 // Connect to the app's transport and get media status
759 sendJson(tid, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""")
760 sendJson(
761 tid, NS_MEDIA,
762 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""",
763 )
764 }
765 }
766
767 private fun handleMediaStatus(json: JSONObject) {
768 val statusArray = json.optJSONArray("status") ?: return
769 if (statusArray.length() == 0) {
770 // Empty status = idle. Don't wipe the previously-seen track metadata —
771 // a tap of "stop" briefly returns an empty status array before the next
772 // track loads, and the user shouldn't see the album art / title vanish
773 // for the millisecond between broadcasts.
774 _mediaStatus.value = _mediaStatus.value.let {
775 it.copy(playerState = "IDLE", currentTime = 0.0, statusSeq = it.statusSeq + 1)
776 }
777 return
778 }
779
780 val previous = _mediaStatus.value
781 val s = statusArray.getJSONObject(0)
782
783 // Always-present transport fields. Fall back to previous when the receiver
784 // omits a field rather than to a hardcoded default — receivers do send
785 // partial updates and we don't want a missing field to overwrite a known
786 // value with garbage.
787 val playerState = s.optString("playerState", previous.playerState)
788 .ifBlank { previous.playerState }
789 val currentTime = s.optDouble("currentTime", previous.currentTime)
790 val mediaSessionId = s.optInt("mediaSessionId", previous.mediaSessionId)
791 .let { if (it == 0) previous.mediaSessionId else it }
792
793 // New load = new receiver-side queue. Tell the queue manager so it can
794 // clear everything buffered for the old session and refetch. The very
795 // first MEDIA_STATUS after connect also lands here (previous id is 0),
796 // which doubles as the initial fetch trigger.
797 if (mediaSessionId != 0 && mediaSessionId != previous.mediaSessionId) {
798 _queueEvents.tryEmit(QueueEvent.SessionChanged(mediaSessionId))
799 }
800
801 // Receiver-authoritative queue pointer. Sticky across pushes that omit
802 // it (transport-only refreshes drop it along with the media block).
803 val currentItemId = s.optInt("currentItemId", -1).takeIf { it > 0 }
804 ?: previous.currentItemId
805
806 // Receivers piggyback an `items` array on some MEDIA_STATUS pushes
807 // containing the current (and sometimes preloaded) item. Merge those
808 // opportunistically so the now-playing row has metadata before the
809 // batched QUEUE_GET_ITEMS fetch lands.
810 s.optJSONArray("items")?.let { arr ->
811 val items = (0 until arr.length()).mapNotNull { i ->
812 parseQueueItem(arr.optJSONObject(i))
813 }
814 if (items.isNotEmpty()) {
815 _queueEvents.tryEmit(QueueEvent.Items(items, fromStatusPush = true))
816 }
817 }
818
819 // ── Volume: deliberately IGNORED here ────────────────────────────
820 // MEDIA_STATUS *does* sometimes carry a `volume` sub-object, but per
821 // the Cast protocol that is the media STREAM volume (the per-item
822 // attenuation a sender can apply on top of the device level), NOT the
823 // device/group volume the user hears and controls. The Default Media
824 // Receiver and CCA groups report the stream volume pegged at 1.0
825 // essentially always. An earlier revision ingested it as if it were
826 // the device volume, which produced the "set the slider to 40, watch
827 // it snap back to 100 a few seconds later while the audio stays at
828 // 40" bug: the user's SET_VOLUME (receiver namespace → device volume)
829 // worked, then the next 3s media poll delivered volume:{level:1.0}
830 // (stream volume) and clobbered the display.
831 //
832 // Device volume flows exclusively through RECEIVER_STATUS (see
833 // [handleReceiverStatus]) plus the optimistic latch in [setVolume].
834 // Both status constructions below carry `previous.*` forward
835 // untouched.
836
837 val media = s.optJSONObject("media")
838 if (media == null) {
839 // Transport-state-only update — PAUSE, RESUME, SEEK, or a heartbeat-ish
840 // refresh. The receiver does NOT redeliver the media block on every
841 // status push. Preserve the track metadata we already had; only update
842 // the volatile transport fields. THIS is the fix for the symptom where
843 // the displayed album art / title flickered back to a stale local-
844 // renderer track between Cast broadcasts.
845 _mediaStatus.value = previous.copy(
846 playerState = playerState,
847 currentTime = currentTime,
848 mediaSessionId = mediaSessionId,
849 currentItemId = currentItemId,
850 repeatMode = s.optString("repeatMode").ifBlank { null } ?: previous.repeatMode,
851 statusSeq = previous.statusSeq + 1,
852 )
853 return
854 }
855
856 // A media block IS present. Re-derive every metadata field from it, but
857 // treat empty strings as "not provided" and keep the previous value —
858 // `JSONObject.optString` returns "" (not null) for missing keys, and we
859 // don't want a partial metadata payload to blank the artist or album.
860 val duration = media.optDouble("duration", previous.duration)
861 val contentId = media.optString("contentId").ifBlank { null } ?: previous.contentId
862
863 val metadata = media.optJSONObject("metadata")
864 val title = metadata?.optString("title")?.ifBlank { null } ?: previous.title
865 val artist = (
866 metadata?.optString("artist")?.ifBlank { null }
867 ?: metadata?.optString("albumArtist")?.ifBlank { null }
868 )
869 ?: previous.artist
870 val album = metadata?.optString("albumName")?.ifBlank { null } ?: previous.album
871
872 val images = metadata?.optJSONArray("images")
873 // Keep the previous picture only while the SAME item is loaded (a
874 // partial status). A different item with no picture gets none — a
875 // radio station used to wear the last album's cover (decision,
876 // 2026-09-07).
877 val sameItem = contentId == previous.contentId
878 val albumArtUrl = if (images != null && images.length() > 0) {
879 images.getJSONObject(0).optString("url").ifBlank { null } ?: previous.albumArtUrl.takeIf { sameItem }
880 } else {
881 previous.albumArtUrl.takeIf { sameItem }
882 }
883
884 _mediaStatus.value = MediaStatus(
885 title = title,
886 artist = artist,
887 album = album,
888 albumArtUrl = albumArtUrl,
889 contentId = contentId,
890 duration = duration,
891 currentTime = currentTime,
892 playerState = playerState,
893 mediaSessionId = mediaSessionId,
894 volume = previous.volume,
895 isMuted = previous.isMuted,
896 volumeKnown = previous.volumeKnown,
897 currentItemId = currentItemId,
898 repeatMode = s.optString("repeatMode").ifBlank { null } ?: previous.repeatMode,
899 statusSeq = previous.statusSeq + 1,
900 )
901 }
902
903 /**
904 * Parse one queue item from a QUEUE_ITEMS response or a MEDIA_STATUS
905 * `items` piggyback. Per the cross-platform spec: `artist` sometimes
906 * arrives as `albumArtist` instead — check both; missing keys come back
907 * as empty strings from lax JSON parsers — treat blank as absent.
908 */
909 private fun parseQueueItem(o: JSONObject?): QueueItemLite? {
910 if (o == null) return null
911 val itemId = o.optInt("itemId", -1)
912 if (itemId <= 0) return null
913 val media = o.optJSONObject("media")
914 val metadata = media?.optJSONObject("metadata")
915 val images = metadata?.optJSONArray("images")
916 val duration = media?.optDouble("duration", Double.NaN)
917 ?.takeIf { it.isFinite() && it > 0.0 }
918 return QueueItemLite(
919 itemId = itemId,
920 contentId = media?.optString("contentId")?.ifBlank { null },
921 title = metadata?.optString("title")?.ifBlank { null },
922 artist = metadata?.optString("artist")?.ifBlank { null }
923 ?: metadata?.optString("albumArtist")?.ifBlank { null },
924 album = metadata?.optString("albumName")?.ifBlank { null },
925 albumArtUrl = if (images != null && images.length() > 0) {
926 images.optJSONObject(0)?.optString("url")?.ifBlank { null }
927 } else null,
928 durationSeconds = duration,
929 )
930 }
931
932 private fun nextRequestId(): Int = synchronized(this) { requestId++ }
933
934 // ── TLS trust (Chromecast uses self-signed certs) ───────────
935
936 private object PermissiveTrustManager : X509TrustManager {
937 override fun checkClientTrusted(
938 chain: Array<java.security.cert.X509Certificate>?,
939 authType: String?,
940 ) {
941 }
942
943 override fun checkServerTrusted(
944 chain: Array<java.security.cert.X509Certificate>?,
945 authType: String?,
946 ) {
947 }
948
949 override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> =
950 emptyArray()
951 }
952}
953
954/**
955 * Minimal hand-rolled protobuf codec for CastMessage.
956 *
957 * The CastMessage protobuf has exactly 7 fields, all simple types. Rather than
958 * pulling in the full protobuf-javalite dependency, we encode/decode the ~80 bytes
959 * of wire format directly. This keeps the client zero-dependency.
960 *
961 * Wire format reference:
962 * message CastMessage {
963 * required ProtocolVersion protocol_version = 1; // varint, always 0
964 * required string source_id = 2;
965 * required string destination_id = 3;
966 * required string namespace = 4;
967 * required PayloadType payload_type = 5; // varint, 0=STRING 1=BINARY
968 * optional string payload_utf8 = 6;
969 * optional bytes payload_binary = 7;
970 * }
971 */
972object CastMessageCodec {
973
974 data class DecodedMessage(
975 val sourceId: String,
976 val destinationId: String,
977 val namespace: String,
978 val payloadUtf8: String,
979 )
980
981 fun encode(
982 sourceId: String,
983 destinationId: String,
984 namespace: String,
985 payloadUtf8: String,
986 ): ByteArray {
987 val buf = ByteArrayOutputStream(256)
988 // field 1: protocol_version = 0 (CASTV2_1_0), wire type 0 (varint)
989 writeVarint(buf, (1 shl 3) or 0)
990 writeVarint(buf, 0)
991 // field 2: source_id
992 writeString(buf, 2, sourceId)
993 // field 3: destination_id
994 writeString(buf, 3, destinationId)
995 // field 4: namespace
996 writeString(buf, 4, namespace)
997 // field 5: payload_type = 0 (STRING)
998 writeVarint(buf, (5 shl 3) or 0)
999 writeVarint(buf, 0)
1000 // field 6: payload_utf8
1001 writeString(buf, 6, payloadUtf8)
1002 return buf.toByteArray()
1003 }
1004
1005 fun decode(data: ByteArray): DecodedMessage? {
1006 var sourceId = ""
1007 var destinationId = ""
1008 var namespace = ""
1009 var payloadUtf8 = ""
1010
1011 var pos = 0
1012 while (pos < data.size) {
1013 val (tag, newPos) = readVarint(data, pos)
1014 pos = newPos
1015 val fieldNumber = tag ushr 3
1016 val wireType = tag and 0x07
1017
1018 when (wireType) {
1019 0 -> { // varint — skip the value
1020 val (_, p) = readVarint(data, pos)
1021 pos = p
1022 }
1023 2 -> { // length-delimited
1024 val (len, p) = readVarint(data, pos)
1025 pos = p
1026 // A varint that overflows to a negative length passed the
1027 // old `pos + len > size` check and threw in copyOfRange,
1028 // which dropped the connection.
1029 if (len < 0 || len > data.size - pos) return null
1030 val bytes = data.copyOfRange(pos, pos + len)
1031 pos += len
1032 when (fieldNumber) {
1033 2 -> sourceId = String(bytes, Charsets.UTF_8)
1034 3 -> destinationId = String(bytes, Charsets.UTF_8)
1035 4 -> namespace = String(bytes, Charsets.UTF_8)
1036 6 -> payloadUtf8 = String(bytes, Charsets.UTF_8)
1037 // 7 = payload_binary, ignored
1038 }
1039 }
1040 else -> return null // unexpected wire type
1041 }
1042 }
1043
1044 return DecodedMessage(sourceId, destinationId, namespace, payloadUtf8)
1045 }
1046
1047 private fun writeString(buf: ByteArrayOutputStream, fieldNumber: Int, value: String) {
1048 val bytes = value.toByteArray(Charsets.UTF_8)
1049 writeVarint(buf, (fieldNumber shl 3) or 2)
1050 writeVarint(buf, bytes.size)
1051 buf.write(bytes)
1052 }
1053
1054 private fun writeVarint(buf: ByteArrayOutputStream, value: Int) {
1055 var v = value
1056 while (v > 0x7F) {
1057 buf.write((v and 0x7F) or 0x80)
1058 v = v ushr 7
1059 }
1060 buf.write(v and 0x7F)
1061 }
1062
1063 private fun readVarint(data: ByteArray, startPos: Int): Pair<Int, Int> {
1064 var result = 0
1065 var shift = 0
1066 var pos = startPos
1067 while (pos < data.size) {
1068 val b = data[pos].toInt() and 0xFF
1069 result = result or ((b and 0x7F) shl shift)
1070 pos++
1071 if (b and 0x80 == 0) break
1072 shift += 7
1073 }
1074 return result to pos
1075 }
1076}