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

RawCastClient.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.cast.raw
2
3import kotlinx.coroutines.*
4import kotlinx.coroutines.flow.*
5import org.json.JSONObject
6import java.io.*
7import java.net.InetSocketAddress
8import javax.net.ssl.SSLContext
9import javax.net.ssl.SSLSocket
10import javax.net.ssl.TrustManager
11import javax.net.ssl.X509TrustManager
12
13/**
14 * Raw Cast protocol client for observing Chromecast sessions without the Cast SDK.
15 *
16 * Connects via TLS to port 8009 on any Chromecast/Chromecast Audio/group leader,
17 * discovers the active media session, and streams [MediaStatus] updates. Transport
18 * commands (play/pause/stop/seek/next/previous) are sent as JSON over the media
19 * namespace.
20 *
21 * This bypasses Google Play Services entirely — works on Shield TV, Fire TV, libre
22 * builds, or any device that can open a TLS socket.
23 *
24 * Protocol references:
25 * - github.com/thibauts/node-castv2
26 * - github.com/home-assistant-libs/pychromecast
27 * - chromium.googlesource.com/.../cast_channel.proto
28 */
29class RawCastClient(
30 val host: String,
31 private val port: Int = 8009,
32) {
33 companion object {
34 // Cast protocol namespaces
35 private const val NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection"
36 private const val NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat"
37 private const val NS_RECEIVER = "urn:x-cast:com.google.cast.receiver"
38 private const val NS_MEDIA = "urn:x-cast:com.google.cast.media"
39
40 private const val SENDER_ID = "sender-earhorn"
41 private const val RECEIVER_ID = "receiver-0"
42
43 private const val HEARTBEAT_INTERVAL_MS = 5_000L
44 private const val CONNECT_TIMEOUT_MS = 10_000
45 }
46
47 data class MediaStatus(
48 val title: String? = null,
49 val artist: String? = null,
50 val album: String? = null,
51 val albumArtUrl: String? = null,
52 val contentId: String? = null,
53 val duration: Double = 0.0,
54 val currentTime: Double = 0.0,
55 val playerState: String = "UNKNOWN", // PLAYING, PAUSED, IDLE, BUFFERING
56 val mediaSessionId: Int = 0,
57 /** Receiver's supportedMediaCommands bitmask (Cast constants:
58 * 1=PAUSE, 2=SEEK, 64=QUEUE_NEXT, 128=QUEUE_PREV, 256=SHUFFLE,
59 * 1024|2048=REPEAT). 0 = not yet reported. */
60 val supportedMediaCommands: Long = 0L,
61 /** BUFFERED or LIVE; "" until reported. */
62 val streamType: String = "",
63 /** The receiver's queue repeat mode: REPEAT_OFF, REPEAT_ALL,
64 * REPEAT_SINGLE, REPEAT_ALL_AND_SHUFFLE; "" until reported. Read so
65 * the Shuffle/Repeat controls can show and change the HOUSE's mode
66 * while observing (I.15, design decision 2026-09-02). */
67 val repeatMode: String = "",
68 val volume: Double = 1.0,
69 val isMuted: Boolean = false,
70 /** The receiver-side queue item ID of the currently playing item.
71 * Cross-referenced against [queueItems] to derive the queue index. */
72 val currentItemId: Int = 0,
73 /** Advances by one for every MEDIA_STATUS message the receiver sends,
74 * and for nothing else. RECEIVER_STATUS (device volume) copies this
75 * object with the same value. The observer keys "is this a new
76 * playback report?" on this number, not on object identity — a
77 * volume report used to count as a new status with an unchanged
78 * position and trip the freeze detector (review N.1, 2026-09-02). */
79 val statusSeq: Long = 0L,
80 )
81
82 /** One entry of the receiver-side queue, fetched via QUEUE_GET_ITEM_IDS /
83 * QUEUE_GET_ITEMS. This is the same mechanism Google's own MediaQueue
84 * class uses to populate sender-side queue views — the receiver is the
85 * source of truth and any connected sender/observer may read it. */
86 data class QueueItem(
87 val itemId: Int,
88 val contentId: String?,
89 val title: String?,
90 val artist: String?,
91 val album: String?,
92 val albumArtUrl: String?,
93 val durationSeconds: Double,
94 )
95
96 private val _mediaStatus = MutableStateFlow(MediaStatus())
97 val mediaStatus: StateFlow<MediaStatus> = _mediaStatus.asStateFlow()
98
99 /** The receiver's queue in playback order. Populated after connect once
100 * a media session is discovered; refreshed automatically on QUEUE_CHANGE
101 * broadcasts (fired when the sender edits the queue) and on media-session
102 * change (new load). Emits progressively as item batches arrive. */
103 private val _queueItems = MutableStateFlow<List<QueueItem>>(emptyList())
104 val queueItems: StateFlow<List<QueueItem>> = _queueItems.asStateFlow()
105
106 // Queue-fetch bookkeeping. Guarded by [queueLock]: the read loop
107 // mutates from its handlers, but disconnect()/clearQueueState() can
108 // arrive from the UI thread mid-iteration — an unsynchronized HashMap
109 // there is a ConcurrentModificationException crash.
110 private val queueLock = Any()
111 private var queueItemIdOrder: List<Int> = emptyList()
112 private val queueItemBuf = mutableMapOf<Int, QueueItem>()
113 private var queueRefetchJob: Job? = null
114
115 // Last-received timestamps — lets observers gate their periodic pulls
116 // on staleness instead of hammering the receiver on a fixed tick.
117 @Volatile private var lastMediaStatusAt = 0L
118 @Volatile private var lastReceiverStatusAt = 0L
119 fun mediaStatusAgeMs(): Long = System.currentTimeMillis() - lastMediaStatusAt
120 fun receiverStatusAgeMs(): Long = System.currentTimeMillis() - lastReceiverStatusAt
121
122 private val _connected = MutableStateFlow(false)
123 val connected: StateFlow<Boolean> = _connected.asStateFlow()
124
125 private var socket: SSLSocket? = null
126 private var output: OutputStream? = null
127 private var input: InputStream? = null
128 private var scope: CoroutineScope? = null
129 private var transportId: String? = null
130 private var requestId = 1
131
132 // ── Connect / Disconnect ─────────────────────────────────────
133
134 suspend fun connect() = withContext(Dispatchers.IO) {
135 disconnect()
136 val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO)
137 scope = sc
138
139 android.util.Log.i("EarhornCast", "connecting to $host:$port")
140 // TLS with permissive trust (Chromecast uses self-signed certs)
141 val sslContext = SSLContext.getInstance("TLS")
142 sslContext.init(null, arrayOf<TrustManager>(PermissiveTrustManager), null)
143 val factory = sslContext.socketFactory
144
145 val sock = factory.createSocket() as SSLSocket
146 sock.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
147 // The handshake had no timeout: a receiver that accepts and stalls
148 // pinned this thread until it let go (I.13). Bound it; a real
149 // handshake on the LAN completes in well under a second. Reset to
150 // no timeout afterwards — the read loop blocks on purpose.
151 sock.soTimeout = CONNECT_TIMEOUT_MS
152 sock.startHandshake()
153 sock.soTimeout = 0
154 socket = sock
155 output = BufferedOutputStream(sock.outputStream)
156 input = BufferedInputStream(sock.inputStream)
157 _connected.value = true
158 android.util.Log.i("EarhornCast", "TLS handshake OK — sending CONNECT + GET_STATUS")
159
160 // Start reader + heartbeat
161 sc.launch { readLoop() }
162 sc.launch { heartbeatLoop() }
163
164 // Connect to receiver platform
165 sendJson(RECEIVER_ID, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""")
166 // Get receiver status to discover active app/session
167 sendJson(RECEIVER_ID, NS_RECEIVER,
168 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""")
169 }
170
171 fun disconnect() {
172 if (_connected.value || socket != null) {
173 android.util.Log.i("EarhornCast", "disconnecting from $host:$port")
174 }
175 scope?.cancel()
176 scope = null
177 runCatching { socket?.close() }
178 socket = null
179 output = null
180 input = null
181 transportId = null
182 _connected.value = false
183 clearQueueState()
184 }
185
186 // ── Transport commands ───────────────────────────────────────
187
188 suspend fun play() = sendMediaCommand("PLAY")
189 suspend fun pause() = sendMediaCommand("PAUSE")
190 suspend fun stop() = sendMediaCommand("STOP")
191
192 suspend fun seek(positionSeconds: Double) {
193 val tid = transportId ?: return
194 val msid = _mediaStatus.value.mediaSessionId
195 if (msid == 0) return
196 sendJson(tid, NS_MEDIA,
197 """{"type":"SEEK","mediaSessionId":$msid,"currentTime":$positionSeconds,"requestId":${nextRequestId()}}""")
198 }
199
200 suspend fun next() {
201 val tid = transportId ?: return
202 val msid = _mediaStatus.value.mediaSessionId
203 if (msid == 0) return
204 sendJson(tid, NS_MEDIA,
205 """{"type":"QUEUE_NEXT","mediaSessionId":$msid,"requestId":${nextRequestId()}}""")
206 }
207
208 suspend fun previous() {
209 val tid = transportId ?: return
210 val msid = _mediaStatus.value.mediaSessionId
211 if (msid == 0) return
212 sendJson(tid, NS_MEDIA,
213 """{"type":"QUEUE_PREV","mediaSessionId":$msid,"requestId":${nextRequestId()}}""")
214 }
215
216 suspend fun setVolume(level: Double) {
217 sendJson(RECEIVER_ID, NS_RECEIVER,
218 """{"type":"SET_VOLUME","volume":{"level":$level},"requestId":${nextRequestId()}}""")
219 // Optimistic echo: reflect the target immediately so the local UI
220 // (and any localVol re-sync) never reads a stale level mid-gesture.
221 // The receiver's RECEIVER_STATUS broadcast confirms shortly after
222 // and corrects if the device clamped the value.
223 _mediaStatus.value = _mediaStatus.value.copy(volume = level.coerceIn(0.0, 1.0))
224 }
225
226 /** Re-request receiver status (device volume, running app). The receiver
227 * broadcasts RECEIVER_STATUS on every volume change from any sender or
228 * hardware control, but a periodic pull guards against missed
229 * broadcasts so multi-device observers stay in sync. */
230 suspend fun requestReceiverStatus() {
231 sendJson(RECEIVER_ID, NS_RECEIVER,
232 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""")
233 }
234
235 /** Re-request current media status from the Chromecast. Called periodically
236 * to catch any MEDIA_STATUS broadcasts that were missed. */
237 suspend fun requestMediaStatus() {
238 val tid = transportId ?: return
239 sendJson(tid, NS_MEDIA,
240 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""")
241 }
242
243 // ── Queue introspection ──────────────────────────────────────
244 // Two-step fetch, mirroring the Cast SDK's MediaQueue: first ask for
245 // the ordered list of item IDs, then fetch item metadata in batches.
246
247 /** Ask the receiver for the ordered queue item IDs. The response
248 * arrives as a QUEUE_ITEM_IDS message handled in [handleMessage],
249 * which then fires batched QUEUE_GET_ITEMS requests. */
250 suspend fun requestQueueItemIds() {
251 val tid = transportId ?: return
252 val msid = _mediaStatus.value.mediaSessionId
253 if (msid == 0) return
254 sendJson(tid, NS_MEDIA,
255 """{"type":"QUEUE_GET_ITEM_IDS","mediaSessionId":$msid,"requestId":${nextRequestId()}}""")
256 }
257
258 private suspend fun requestQueueItemsBatch(ids: List<Int>) {
259 if (ids.isEmpty()) return
260 val tid = transportId ?: return
261 val msid = _mediaStatus.value.mediaSessionId
262 if (msid == 0) return
263 sendJson(tid, NS_MEDIA,
264 """{"type":"QUEUE_GET_ITEMS","mediaSessionId":$msid,"itemIds":${ids.joinToString(",", "[", "]")},"requestId":${nextRequestId()}}""")
265 }
266
267 /** Jump playback to a specific queue item. QUEUE_UPDATE with a
268 * currentItemId changes position without altering queue contents,
269 * so the sender's local queue copy stays in sync (its index tracker
270 * follows via onQueueStatusUpdated). */
271 /** Set the receiver queue's repeat mode (Cast QUEUE_UPDATE.repeatMode). */
272 suspend fun setRepeatMode(mode: String) {
273 val tid = transportId ?: return
274 val msid = _mediaStatus.value.mediaSessionId
275 if (msid == 0) return
276 sendJson(tid, NS_MEDIA,
277 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"repeatMode":"$mode","requestId":${nextRequestId()}}""")
278 }
279
280 /** Shuffle the receiver queue (Cast QUEUE_UPDATE.shuffle). */
281 suspend fun setShuffle(on: Boolean) {
282 val tid = transportId ?: return
283 val msid = _mediaStatus.value.mediaSessionId
284 if (msid == 0) return
285 sendJson(tid, NS_MEDIA,
286 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"shuffle":$on,"requestId":${nextRequestId()}}""")
287 }
288
289 suspend fun queueJump(itemId: Int) {
290 val tid = transportId ?: return
291 val msid = _mediaStatus.value.mediaSessionId
292 if (msid == 0) return
293 sendJson(tid, NS_MEDIA,
294 """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"currentItemId":$itemId,"requestId":${nextRequestId()}}""")
295 }
296
297 private suspend fun sendMediaCommand(type: String) {
298 // Every mutating command is logged WITH ITS CALL SITE. If any screen
299 // ever kills a session, this line is the proof — and its absence at
300 // the moment a cast dies is proof Earhorn did not send the kill.
301 android.util.Log.i("EarhornCast",
302 "OUTBOUND MEDIA COMMAND: $type to $host", Throwable("call site"))
303 val tid = transportId ?: return
304 val msid = _mediaStatus.value.mediaSessionId
305 if (msid == 0) return
306 sendJson(tid, NS_MEDIA,
307 """{"type":"$type","mediaSessionId":$msid,"requestId":${nextRequestId()}}""")
308 }
309
310 // ── Message I/O ─────────────────────────────────────────────
311
312 private suspend fun sendJson(
313 destinationId: String,
314 namespace: String,
315 json: String,
316 ) = withContext(Dispatchers.IO) {
317 val msg = CastMessageCodec.encode(
318 sourceId = SENDER_ID,
319 destinationId = destinationId,
320 namespace = namespace,
321 payloadUtf8 = json,
322 )
323 val lenBuf = ByteArray(4)
324 lenBuf[0] = (msg.size shr 24 and 0xFF).toByte()
325 lenBuf[1] = (msg.size shr 16 and 0xFF).toByte()
326 lenBuf[2] = (msg.size shr 8 and 0xFF).toByte()
327 lenBuf[3] = (msg.size and 0xFF).toByte()
328 val out = output ?: return@withContext
329 // Every public command (play/pause/volume/queue/…) funnels through
330 // here, and callers launch them from viewModelScope / UI scopes.
331 // A write on a dead socket (CCA rebooted, network drop) used to
332 // throw IOException out of this suspend fun → unhandled coroutine
333 // exception → APP CRASH, landing the user back on the Browse
334 // start-destination. Commands are fire-and-forget over an
335 // unreliable transport: swallow the failure, flip connected=false
336 // so the observer's supervisor can start reconnecting.
337 try {
338 synchronized(out) {
339 out.write(lenBuf)
340 out.write(msg)
341 out.flush()
342 }
343 } catch (e: java.io.IOException) {
344 android.util.Log.w("EarhornCast",
345 "send failed (${e.message}) — marking connection lost")
346 _connected.value = false
347 }
348 }
349
350 private suspend fun readLoop() {
351 try {
352 val inp = input ?: return
353 val lenBuf = ByteArray(4)
354 while (currentCoroutineContext().isActive) {
355 // Read 4-byte length prefix
356 readFully(inp, lenBuf)
357 val len = (lenBuf[0].toInt() and 0xFF shl 24) or
358 (lenBuf[1].toInt() and 0xFF shl 16) or
359 (lenBuf[2].toInt() and 0xFF shl 8) or
360 (lenBuf[3].toInt() and 0xFF)
361 if (len <= 0 || len > 1_000_000) {
362 // The stream is desynchronised; skipping the prefix
363 // would parse payload bytes as the next length. Drop the
364 // connection and let the supervisor reconnect (I.14).
365 android.util.Log.w("EarhornCast", "bad length prefix $len — marking connection lost")
366 _connected.value = false
367 return
368 }
369
370 val msgBuf = ByteArray(len)
371 readFully(inp, msgBuf)
372
373 val decoded = CastMessageCodec.decode(msgBuf) ?: continue
374 handleMessage(decoded)
375 }
376 } catch (e: IOException) {
377 // Connection lost
378 _connected.value = false
379 } catch (t: Throwable) {
380 // Anything else — a payload the codec's guards let through, an
381 // unexpected type — used to kill this coroutine silently while
382 // the heartbeat kept the socket "connected": a mute socket of
383 // the screen's own making (review N.2, 2026-09-02). Treat it
384 // as a lost connection so the supervisor reconnects at once.
385 android.util.Log.w("EarhornCast", "read loop died (${t.javaClass.simpleName}: ${t.message}) — marking connection lost")
386 _connected.value = false
387 }
388 }
389
390 private fun readFully(inp: InputStream, buf: ByteArray) {
391 var offset = 0
392 while (offset < buf.size) {
393 val n = inp.read(buf, offset, buf.size - offset)
394 if (n < 0) throw IOException("End of stream")
395 offset += n
396 }
397 }
398
399 private suspend fun heartbeatLoop() {
400 while (currentCoroutineContext().isActive) {
401 delay(HEARTBEAT_INTERVAL_MS)
402 runCatching {
403 sendJson(RECEIVER_ID, NS_HEARTBEAT, """{"type":"PING"}""")
404 }
405 }
406 }
407
408 // ── Message handling ────────────────────────────────────────
409
410 private suspend fun handleMessage(msg: CastMessageCodec.DecodedMessage) {
411 val json = runCatching { JSONObject(msg.payloadUtf8) }.getOrNull()
412 ?: return
413 val type = json.optString("type", "")
414
415 // Diagnostic — skip heartbeat to keep logcat readable, log everything else
416 // with a truncated payload preview. Filter on tag "EarhornCast" in
417 // logcat to see the receiver's message flow during a session. Useful
418 // when a device gets stuck on "Waiting for…" — the logs will show
419 // whether MEDIA_STATUS is arriving at all, and what it contains.
420 // Release logging: message TYPE only. Full-payload wire logging was
421 // the diag build's instrument (retired 2026-08-29 after the leader
422 // investigation got its specimens); type lines keep session
423 // narratives readable without the volume.
424 if (msg.namespace != NS_HEARTBEAT && type != "MEDIA_STATUS" && type != "RECEIVER_STATUS") {
425 android.util.Log.i("EarhornCast",
426 "rx ns=${msg.namespace.removePrefix("urn:x-cast:com.google.cast.")} type=$type")
427 }
428 // One compact line per media status: the receiver's own claim of
429 // state and position, straight off the wire — the forced-read
430 // record for stale-position and zombie investigations. This line
431 // was wrongly cut in the 0.4.3 trim; its absence cost the group
432 // its screen-side instrument for the Classical KC incident.
433 if (type == "MEDIA_STATUS") {
434 val st = _mediaStatus.value
435 android.util.Log.i("EarhornCast",
436 "status: state=${st.playerState} pos=${st.currentTime}s session=${st.mediaSessionId}")
437 }
438
439 when (msg.namespace) {
440 NS_HEARTBEAT -> {
441 if (type == "PING") {
442 sendJson(msg.sourceId, NS_HEARTBEAT, """{"type":"PONG"}""")
443 }
444 }
445
446 NS_RECEIVER -> {
447 if (type == "RECEIVER_STATUS") {
448 handleReceiverStatus(json)
449 }
450 }
451
452 NS_MEDIA -> {
453 when (type) {
454 "MEDIA_STATUS" -> handleMediaStatus(json)
455 "QUEUE_ITEM_IDS" -> handleQueueItemIds(json)
456 "QUEUE_ITEMS" -> handleQueueItems(json)
457 // Broadcast by the receiver whenever any sender edits
458 // the queue (insert/remove/reorder/replace). Refetch
459 // rather than applying the delta — queues here are
460 // small and a full refetch is unconditionally correct.
461 "QUEUE_CHANGE" -> scheduleQueueRefetch()
462 }
463 }
464
465 NS_CONNECTION -> {
466 if (type == "CLOSE") {
467 // Session ended
468 android.util.Log.i("EarhornCast", "transport session CLOSE from ${msg.sourceId}")
469 transportId = null
470 _mediaStatus.value = MediaStatus(statusSeq = _mediaStatus.value.statusSeq + 1)
471 clearQueueState()
472 }
473 }
474 }
475 }
476
477 private suspend fun handleReceiverStatus(json: JSONObject) {
478 val status = json.optJSONObject("status") ?: return
479 // Stamp the age here, before the no-app early return: an idle
480 // device otherwise never refreshed it and was polled every 10 s
481 // instead of every 25 (review N.3, 2026-09-02).
482 lastReceiverStatusAt = System.currentTimeMillis()
483 val apps = status.optJSONArray("applications")
484 if (apps == null || apps.length() == 0) {
485 // No media app running on the device. If we were attached to a
486 // session, this is POSITIVE evidence it is dead — and it is the
487 // only evidence we get on the freeze-then-recover path: the
488 // leader freezes, the socket dies, the reconnect supervisor
489 // succeeds once the device recovers, and the recovered device
490 // reports no application. Logging-and-returning here left the
491 // last PLAYING MediaStatus in place forever: sessionLive stuck
492 // true, hand-back structurally unable to fire, screens frozen
493 // on a stale queue (observed on three devices at once,
494 // 2026-08-28). Clear exactly as the CLOSE handler does.
495 if (transportId != null || _mediaStatus.value.mediaSessionId != 0) {
496 android.util.Log.i("EarhornCast",
497 "RECEIVER_STATUS reports no running app while we were " +
498 "attached — treating as session death (was tid=$transportId)")
499 transportId = null
500 _mediaStatus.value = MediaStatus(statusSeq = _mediaStatus.value.statusSeq + 1)
501 clearQueueState()
502 } else {
503 android.util.Log.i("EarhornCast",
504 "RECEIVER_STATUS: no media app running (not attached — no-op)")
505 }
506 return
507 }
508
509 val app = apps.getJSONObject(0)
510 val tid = app.optString("transportId", "")
511 val appId = app.optString("appId", "")
512 val displayName = app.optString("displayName", "")
513 if (tid.isBlank()) {
514 android.util.Log.i("EarhornCast", "RECEIVER_STATUS app has no transportId — appId=$appId displayName=$displayName")
515 return
516 }
517
518 // Extract volume (device volume, not a playback report — statusSeq
519 // is deliberately left unchanged by this copy).
520 val vol = status.optJSONObject("volume")
521 if (vol != null) {
522 _mediaStatus.value = _mediaStatus.value.copy(
523 volume = vol.optDouble("level", 1.0),
524 isMuted = vol.optBoolean("muted", false),
525 )
526 }
527
528 if (tid != transportId) {
529 android.util.Log.i("EarhornCast",
530 "attaching to transport tid=$tid appId=$appId displayName=$displayName")
531 transportId = tid
532 // Connect to the app's transport and get media status
533 sendJson(tid, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""")
534 sendJson(tid, NS_MEDIA,
535 """{"type":"GET_STATUS","requestId":${nextRequestId()}}""")
536 }
537 }
538
539 private fun handleMediaStatus(json: JSONObject) {
540 val statusArray = json.optJSONArray("status") ?: return
541 if (statusArray.length() == 0) {
542 // Empty status = idle. Don't wipe the previously-seen track metadata —
543 // a tap of "stop" briefly returns an empty status array before the next
544 // track loads, and the user shouldn't see the album art / title vanish
545 // for the millisecond between broadcasts.
546 _mediaStatus.value = _mediaStatus.value.let {
547 it.copy(playerState = "IDLE", currentTime = 0.0, statusSeq = it.statusSeq + 1)
548 }
549 return
550 }
551
552 val previous = _mediaStatus.value
553 val s = statusArray.getJSONObject(0)
554
555 // Always-present transport fields. Fall back to previous when the receiver
556 // omits a field rather than to a hardcoded default — receivers do send
557 // partial updates and we don't want a missing field to overwrite a known
558 // value with garbage.
559 val playerState = s.optString("playerState", previous.playerState)
560 .ifBlank { previous.playerState }
561 val currentTime = s.optDouble("currentTime", previous.currentTime)
562 val mediaSessionId = s.optInt("mediaSessionId", previous.mediaSessionId)
563 .let { if (it == 0) previous.mediaSessionId else it }
564 val supportedMediaCommands =
565 s.optLong("supportedMediaCommands", previous.supportedMediaCommands)
566 val repeatMode = s.optString("repeatMode").ifBlank { null } ?: previous.repeatMode
567
568 // Volume can ride on a status update even when there's no media block
569 // (the receiver layer exposes its own volume separately from the media
570 // app). Pull it before the media-block check so PAUSE/PLAY/SEEK updates
571 // can still propagate volume changes.
572 // ── Volume: DEVICE volume only ────────────────────────────────
573 // The media-namespace status carries the per-media STREAM volume,
574 // which sits at 1.0 essentially always — it is NOT the speaker's
575 // volume. The real device volume arrives via RECEIVER_STATUS on
576 // the receiver namespace (captured in the RECEIVER_STATUS handler
577 // and broadcast by the receiver whenever ANY sender or hardware
578 // control changes it). Previously this line overwrote the device
579 // volume with the stream volume on every media push, so observers
580 // read "100%" regardless of the actual speaker level — a first
581 // volume-up tap then sent an absolute 100% and blasted the room.
582 val newVolume = previous.volume
583 val newMuted = previous.isMuted
584
585 // ── Queue-relevant fields ─────────────────────────────────────
586 lastMediaStatusAt = System.currentTimeMillis()
587 val currentItemId = s.optInt("currentItemId", previous.currentItemId)
588 // New media session = new load = new queue. Clear the stale queue
589 // and fetch the fresh one. Also covers the initial connect (previous
590 // sessionId 0 → real id).
591 if (mediaSessionId != previous.mediaSessionId && mediaSessionId != 0) {
592 clearQueueState()
593 scheduleQueueRefetch()
594 }
595 // Receivers piggyback the current (and sometimes preloaded) queue
596 // items on MEDIA_STATUS. Merge them opportunistically so the
597 // now-playing row has metadata even before the batched fetch lands.
598 s.optJSONArray("items")?.let { inline ->
599 synchronized(queueLock) {
600 var merged = false
601 for (i in 0 until inline.length()) {
602 val qi = parseQueueItem(inline.optJSONObject(i) ?: continue) ?: continue
603 queueItemBuf[qi.itemId] = qi
604 merged = true
605 }
606 if (merged) publishQueueSnapshotLocked()
607 }
608 }
609
610 val media = s.optJSONObject("media")
611 if (media == null) {
612 // Transport-state-only update — PAUSE, RESUME, SEEK, or a heartbeat-ish
613 // refresh. The receiver does NOT redeliver the media block on every
614 // status push. Preserve the track metadata we already had; only update
615 // the volatile transport fields. THIS is the fix for the symptom where
616 // the displayed album art / title flickered back to a stale local-
617 // renderer track between Cast broadcasts.
618 _mediaStatus.value = previous.copy(
619 playerState = playerState,
620 currentTime = currentTime,
621 mediaSessionId = mediaSessionId,
622 supportedMediaCommands = supportedMediaCommands,
623 repeatMode = repeatMode,
624 volume = newVolume,
625 isMuted = newMuted,
626 currentItemId = currentItemId,
627 statusSeq = previous.statusSeq + 1,
628 )
629 return
630 }
631
632 // A media block IS present. Re-derive every metadata field from it, but
633 // treat empty strings as "not provided" and keep the previous value —
634 // `JSONObject.optString` returns "" (not null) for missing keys, and we
635 // don't want a partial metadata payload to blank the artist or album.
636 val duration = media.optDouble("duration", previous.duration)
637 val streamType = media.optString("streamType").ifBlank { null } ?: previous.streamType
638 val contentId = media.optString("contentId").ifBlank { null } ?: previous.contentId
639
640 val metadata = media.optJSONObject("metadata")
641 val title = metadata?.optString("title")?.ifBlank { null } ?: previous.title
642 val artist = (metadata?.optString("artist")?.ifBlank { null }
643 ?: metadata?.optString("albumArtist")?.ifBlank { null })
644 ?: previous.artist
645 val album = metadata?.optString("albumName")?.ifBlank { null } ?: previous.album
646
647 val images = metadata?.optJSONArray("images")
648 val albumArtUrl = if (images != null && images.length() > 0) {
649 images.getJSONObject(0).optString("url").ifBlank { null } ?: previous.albumArtUrl
650 } else {
651 previous.albumArtUrl
652 }
653
654 _mediaStatus.value = MediaStatus(
655 title = title,
656 artist = artist,
657 album = album,
658 albumArtUrl = albumArtUrl,
659 contentId = contentId,
660 duration = duration,
661 currentTime = currentTime,
662 playerState = playerState,
663 mediaSessionId = mediaSessionId,
664 supportedMediaCommands = supportedMediaCommands,
665 streamType = streamType,
666 repeatMode = repeatMode,
667 volume = newVolume,
668 isMuted = newMuted,
669 currentItemId = currentItemId,
670 statusSeq = previous.statusSeq + 1,
671 )
672 }
673
674 // ── Queue message handlers ───────────────────────────────────
675
676 private suspend fun handleQueueItemIds(json: JSONObject) {
677 val arr = json.optJSONArray("itemIds") ?: return
678 val ids = (0 until arr.length()).map { arr.optInt(it) }.filter { it != 0 }
679 android.util.Log.i("EarhornCast", "QUEUE_ITEM_IDS: ${ids.size} ids")
680 // Mutate under the lock; compute what to fetch; then suspend-send
681 // OUTSIDE the lock (never suspend while holding a monitor).
682 val toFetch = synchronized(queueLock) {
683 queueItemIdOrder = ids
684 // Drop buffered items that fell out of the queue, keep the rest
685 // so the list doesn't flash empty during a refetch. Surviving
686 // itemIds are trustworthy: item IDs are unique per insert, so an
687 // edited/replaced entry always arrives under a NEW id — there is
688 // no need to re-fetch metadata we already hold. (The previous
689 // "cheap safety" re-fetch of buffered items doubled QUEUE_GET_ITEMS
690 // traffic on every refetch; with several observers attached that
691 // measurably loads a group leader that is also doing multiroom.)
692 queueItemBuf.keys.retainAll(ids.toSet())
693 publishQueueSnapshotLocked()
694 ids.filterNot { queueItemBuf.containsKey(it) }
695 }
696 // Fetch missing metadata in batches of 20 — the SDK's MediaQueue
697 // uses the same chunk size to keep individual frames small.
698 toFetch.chunked(20).forEach { requestQueueItemsBatch(it) }
699 }
700
701 private fun handleQueueItems(json: JSONObject) {
702 val arr = json.optJSONArray("items") ?: return
703 var changed = false
704 synchronized(queueLock) {
705 for (i in 0 until arr.length()) {
706 val qi = parseQueueItem(arr.optJSONObject(i) ?: continue) ?: continue
707 queueItemBuf[qi.itemId] = qi
708 changed = true
709 }
710 if (changed) {
711 android.util.Log.i("EarhornCast",
712 "QUEUE_ITEMS batch → buf ${queueItemBuf.size}/${queueItemIdOrder.size}")
713 publishQueueSnapshotLocked()
714 }
715 }
716 }
717
718 /** Parse one queue item object: `{itemId, media: {contentId, duration,
719 * metadata: {title, artist|albumArtist, albumName, images: [{url}]}}}`. */
720 private fun parseQueueItem(obj: JSONObject): QueueItem? {
721 val itemId = obj.optInt("itemId", 0)
722 if (itemId == 0) return null
723 val media = obj.optJSONObject("media")
724 val metadata = media?.optJSONObject("metadata")
725 val images = metadata?.optJSONArray("images")
726 val artUrl = if (images != null && images.length() > 0) {
727 images.optJSONObject(0)?.optString("url")?.ifBlank { null }
728 } else null
729 return QueueItem(
730 itemId = itemId,
731 contentId = media?.optString("contentId")?.ifBlank { null },
732 title = metadata?.optString("title")?.ifBlank { null },
733 artist = metadata?.optString("artist")?.ifBlank { null }
734 ?: metadata?.optString("albumArtist")?.ifBlank { null },
735 album = metadata?.optString("albumName")?.ifBlank { null },
736 albumArtUrl = artUrl,
737 durationSeconds = media?.optDouble("duration", 0.0) ?: 0.0,
738 )
739 }
740
741 /** Publish the ordered snapshot. Callers MUST hold [queueLock]. */
742 private fun publishQueueSnapshotLocked() {
743 if (queueItemIdOrder.isEmpty()) {
744 // No ID order yet (e.g. inline items merged before the first
745 // QUEUE_ITEM_IDS response) — publish buffered items by itemId
746 // order as a best-effort interim view.
747 _queueItems.value = queueItemBuf.values.sortedBy { it.itemId }
748 return
749 }
750 _queueItems.value = queueItemIdOrder.mapNotNull { queueItemBuf[it] }
751 }
752
753 /** Debounced full refetch — coalesces bursts of QUEUE_CHANGE broadcasts
754 * during bulk sender edits into a single round-trip. */
755 private fun scheduleQueueRefetch() {
756 val sc = scope ?: return
757 queueRefetchJob?.cancel()
758 queueRefetchJob = sc.launch {
759 delay(300)
760 runCatching { requestQueueItemIds() }
761 }
762 }
763
764 private fun clearQueueState() {
765 queueRefetchJob?.cancel()
766 queueRefetchJob = null
767 synchronized(queueLock) {
768 queueItemIdOrder = emptyList()
769 queueItemBuf.clear()
770 _queueItems.value = emptyList()
771 }
772 }
773
774 private fun nextRequestId(): Int = synchronized(this) { requestId++ }
775
776 // ── TLS trust (Chromecast uses self-signed certs) ───────────
777
778 private object PermissiveTrustManager : X509TrustManager {
779 override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
780 override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
781 override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = emptyArray()
782 }
783}
784
785/**
786 * Minimal hand-rolled protobuf codec for CastMessage.
787 *
788 * The CastMessage protobuf has exactly 7 fields, all simple types. Rather than
789 * pulling in the full protobuf-javalite dependency, we encode/decode the ~80 bytes
790 * of wire format directly. This keeps the client zero-dependency.
791 *
792 * Wire format reference:
793 * message CastMessage {
794 * required ProtocolVersion protocol_version = 1; // varint, always 0
795 * required string source_id = 2;
796 * required string destination_id = 3;
797 * required string namespace = 4;
798 * required PayloadType payload_type = 5; // varint, 0=STRING 1=BINARY
799 * optional string payload_utf8 = 6;
800 * optional bytes payload_binary = 7;
801 * }
802 */
803object CastMessageCodec {
804
805 data class DecodedMessage(
806 val sourceId: String,
807 val destinationId: String,
808 val namespace: String,
809 val payloadUtf8: String,
810 )
811
812 fun encode(
813 sourceId: String,
814 destinationId: String,
815 namespace: String,
816 payloadUtf8: String,
817 ): ByteArray {
818 val buf = ByteArrayOutputStream(256)
819 // field 1: protocol_version = 0 (CASTV2_1_0), wire type 0 (varint)
820 writeVarint(buf, (1 shl 3) or 0)
821 writeVarint(buf, 0)
822 // field 2: source_id
823 writeString(buf, 2, sourceId)
824 // field 3: destination_id
825 writeString(buf, 3, destinationId)
826 // field 4: namespace
827 writeString(buf, 4, namespace)
828 // field 5: payload_type = 0 (STRING)
829 writeVarint(buf, (5 shl 3) or 0)
830 writeVarint(buf, 0)
831 // field 6: payload_utf8
832 writeString(buf, 6, payloadUtf8)
833 return buf.toByteArray()
834 }
835
836 fun decode(data: ByteArray): DecodedMessage? {
837 var sourceId = ""
838 var destinationId = ""
839 var namespace = ""
840 var payloadUtf8 = ""
841
842 var pos = 0
843 while (pos < data.size) {
844 val (tag, newPos) = readVarint(data, pos)
845 pos = newPos
846 val fieldNumber = tag ushr 3
847 val wireType = tag and 0x07
848
849 when (wireType) {
850 0 -> { // varint — skip the value
851 val (_, p) = readVarint(data, pos)
852 pos = p
853 }
854 2 -> { // length-delimited
855 val (len, p) = readVarint(data, pos)
856 pos = p
857 if (pos + len > data.size) return null
858 val bytes = data.copyOfRange(pos, pos + len)
859 pos += len
860 when (fieldNumber) {
861 2 -> sourceId = String(bytes, Charsets.UTF_8)
862 3 -> destinationId = String(bytes, Charsets.UTF_8)
863 4 -> namespace = String(bytes, Charsets.UTF_8)
864 6 -> payloadUtf8 = String(bytes, Charsets.UTF_8)
865 // 7 = payload_binary, ignored
866 }
867 }
868 else -> return null // unexpected wire type
869 }
870 }
871
872 return DecodedMessage(sourceId, destinationId, namespace, payloadUtf8)
873 }
874
875 private fun writeString(buf: ByteArrayOutputStream, fieldNumber: Int, value: String) {
876 val bytes = value.toByteArray(Charsets.UTF_8)
877 writeVarint(buf, (fieldNumber shl 3) or 2)
878 writeVarint(buf, bytes.size)
879 buf.write(bytes)
880 }
881
882 private fun writeVarint(buf: ByteArrayOutputStream, value: Int) {
883 var v = value
884 while (v > 0x7F) {
885 buf.write((v and 0x7F) or 0x80)
886 v = v ushr 7
887 }
888 buf.write(v and 0x7F)
889 }
890
891 private fun readVarint(data: ByteArray, startPos: Int): Pair<Int, Int> {
892 var result = 0
893 var shift = 0
894 var pos = startPos
895 while (pos < data.size) {
896 val b = data[pos].toInt() and 0xFF
897 result = result or ((b and 0x7F) shl shift)
898 pos++
899 if (b and 0x80 == 0) break
900 shift += 7
901 }
902 return result to pos
903 }
904}