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

CastIntegration.kt

1139 lines · 56.0 KB

Changed in: 0.5.0

1package dev.stave.cast
2
3import android.content.Context
4import com.google.android.gms.cast.MediaInfo
5import com.google.android.gms.cast.MediaLoadRequestData
6import com.google.android.gms.cast.MediaMetadata
7import com.google.android.gms.cast.MediaQueueItem
8import com.google.android.gms.cast.MediaStatus
9import com.google.android.gms.cast.CastDevice as GmsCastDevice
10import com.google.android.gms.cast.framework.CastContext
11import com.google.android.gms.cast.framework.CastSession as GmsCastSession
12import com.google.android.gms.cast.framework.SessionManagerListener
13import com.google.android.gms.cast.framework.media.MediaQueue
14import com.google.android.gms.cast.framework.media.RemoteMediaClient
15import com.google.android.gms.common.images.WebImage
16import dagger.hilt.android.qualifiers.ApplicationContext
17import dev.stave.domain.model.DidlItem
18import dev.stave.domain.renderer.RepeatMode
19import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.StateFlow
21import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.launch
23import javax.inject.Inject
24import javax.inject.Singleton
25
26/**
27 * Play flavor: Google Cast integration. Holds the CastContext, exposes session
28 * lifecycle, and provides playback delegation methods that route to the remote
29 * device's RemoteMediaClient when a session is active.
30 *
31 * Current integration is "broadcast from local" — local renderer continues running
32 * the queue logic; every load() also pushes the track's URL to the remote. The
33 * remote plays; caller is responsible for muting the local output so audio comes
34 * from one place only. A proper CastRenderer+coordinator split is future work.
35 *
36 * Requires a CastOptionsProvider in the play-flavor manifest. Uses the Default
37 * Media Receiver so no Cast Developer Console registration is needed.
38 */
39@Singleton
40class CastIntegration @Inject constructor(
41 @ApplicationContext private val context: Context,
42) {
43 val available: Boolean = true
44
45 /** Local HTTP proxy for Cast groups. Created lazily — only starts
46 * its ServerSocket when first used (i.e. when a group is selected).
47 * Not Hilt-injected because adding it to the constructor crashed
48 * the dependency graph. */
49 private val streamProxy = CastStreamProxy(context)
50
51 private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
52 val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()
53
54 // Diagnostic surface. Compose picker observes this to display WHAT the
55 // framework is seeing — without logcat access, user-visible diagnostics
56 // are the only way to distinguish "no devices on network" from "Cast
57 // framework didn't initialize" from "MediaRouter reports routes but our
58 // filter removes them all." Expected failure modes on some OEM Android TV
59 // builds: CastContext returns non-null but Play Services never starts the
60 // discovery providers; or Play Services is too old for the sender SDK;
61 // or mDNS is blocked by the TV's own firewall/IGMP config.
62 private val _diagnostic = MutableStateFlow(DiagnosticState())
63 val diagnostic: StateFlow<DiagnosticState> = _diagnostic.asStateFlow()
64
65 private val _session = MutableStateFlow<CastSession?>(null)
66 val session: StateFlow<CastSession?> = _session.asStateFlow()
67
68 /** True iff a Cast session is currently active. Convenience derived from
69 * [session]; callers can observe this to gate local audio output. */
70 private val _isCasting = MutableStateFlow(false)
71 val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow()
72
73 /** True when the current Cast target is a speaker group rather than
74 * a single device. Groups need STREAM_TYPE_LIVE (not BUFFERED) to
75 * prevent the group leader from making HTTP Range requests that
76 * MinimServer responds to with non-frame-aligned data — which
77 * produces broken/tortured audio on all group members. */
78 private val _castingToGroup = MutableStateFlow(false)
79
80 private val playServicesStatusCode: Int = runCatching {
81 com.google.android.gms.common.GoogleApiAvailability
82 .getInstance()
83 .isGooglePlayServicesAvailable(context)
84 }.getOrElse {
85 android.util.Log.e("EarhornCast", "Play Services check failed", it)
86 -1
87 }.also { code ->
88 android.util.Log.i("EarhornCast", "Play Services status code: $code")
89 }
90
91 // Cast framework initialization — three-step approach:
92 // 1. Try the synchronous getSharedInstance(Context). This is the classic
93 // entry point but it can throw on Android TV OEM builds where the
94 // Cast framework's backing Play Services component isn't fully loaded
95 // at the time of the call (even when Play Services itself reports OK).
96 // 2. Capture whatever error it threw into [castInitError] so the
97 // diagnostic panel can display the real reason instead of the
98 // generic "missing."
99 // 3. If the sync call failed, fall back to getSharedInstance(Context, Executor)
100 // — the async variant that waits for the framework to be ready and
101 // succeeds in many cases where the sync call doesn't.
102 @Volatile private var castContext: CastContext? = null
103 @Volatile private var castInitError: String? = null
104
105 init {
106 val sync = runCatching { CastContext.getSharedInstance(context) }
107 sync.onSuccess { ctx ->
108 castContext = ctx
109 android.util.Log.i("EarhornCast", "CastContext (sync) initialized: ${ctx != null}")
110 }
111 sync.onFailure { e ->
112 castInitError = "${e.javaClass.simpleName}: ${e.message ?: "no message"}"
113 android.util.Log.e("EarhornCast", "CastContext (sync) init failed", e)
114 // Fallback: async Task variant on the app's main executor. The
115 // framework loads the provider on a background thread and
116 // notifies the listener when ready.
117 runCatching {
118 val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
119 CastContext.getSharedInstance(context, executor).addOnCompleteListener { task ->
120 if (task.isSuccessful) {
121 castContext = task.result
122 castInitError = null
123 android.util.Log.i("EarhornCast", "CastContext (async) initialized")
124 // Refresh the diagnostic so the UI updates
125 refreshDevices()
126 } else {
127 val ex = task.exception
128 castInitError = "async: ${ex?.javaClass?.simpleName}: ${ex?.message ?: "no message"}"
129 android.util.Log.e("EarhornCast", "CastContext (async) init failed", ex)
130 refreshDevices()
131 }
132 }
133 }.onFailure { ex2 ->
134 // Even the async call's preparation threw — record that.
135 castInitError = "$castInitError; async setup: ${ex2.javaClass.simpleName}"
136 android.util.Log.e("EarhornCast", "CastContext async setup threw", ex2)
137 }
138 }
139 }
140
141 // MediaRouter-based device discovery. Populates _devices whenever routes are
142 // added/removed/changed. This is how the Compose picker UI gets its list,
143 // sidestepping the AppCompat-theme requirements of MediaRouteChooserDialog.
144 private val mediaRouter: androidx.mediarouter.media.MediaRouter? = runCatching {
145 androidx.mediarouter.media.MediaRouter.getInstance(context)
146 }.getOrNull()
147
148 private val routeSelector: androidx.mediarouter.media.MediaRouteSelector =
149 androidx.mediarouter.media.MediaRouteSelector.Builder()
150 .addControlCategory(
151 com.google.android.gms.cast.CastMediaControlIntent.categoryForCast(
152 com.google.android.gms.cast.CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
153 )
154 )
155 .build()
156
157 private val routerCallback = object : androidx.mediarouter.media.MediaRouter.Callback() {
158 override fun onRouteAdded(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
159 refreshDevices()
160 }
161 override fun onRouteRemoved(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
162 refreshDevices()
163 }
164 override fun onRouteChanged(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
165 refreshDevices()
166 }
167 }
168
169 private var refreshTick: Int = 0
170
171 private fun playServicesLabel(code: Int): String = when (code) {
172 0 -> "OK"
173 1 -> "SERVICE_MISSING"
174 2 -> "SERVICE_VERSION_UPDATE_REQUIRED"
175 3 -> "SERVICE_DISABLED"
176 9 -> "SERVICE_INVALID"
177 18 -> "SERVICE_UPDATING"
178 19 -> "SERVICE_MISSING_PERMISSION"
179 -1 -> "CHECK_THREW"
180 else -> "code=$code"
181 }
182
183 private fun refreshDevices() {
184 val mr = mediaRouter
185 refreshTick++
186 if (mr == null) {
187 _diagnostic.value = DiagnosticState(
188 castContextOk = castContext != null,
189 castInitError = castInitError,
190 mediaRouterOk = false,
191 playServices = playServicesLabel(playServicesStatusCode),
192 rawRouteCount = 0,
193 filteredDeviceCount = 0,
194 tick = refreshTick,
195 rawRouteNames = emptyList(),
196 )
197 return
198 }
199 val all = mr.routes
200 val routes = all
201 .filter { !it.isDefault && !it.isBluetooth }
202 .map { r ->
203 // RouteInfo.isGroup is unreliable on many Cast SDK /
204 // MediaRouter combinations — it returns false for speaker
205 // groups that the raw mDNS discovery correctly identifies
206 // via the "Google Cast Group" model name. Fall back to
207 // extracting the CastDevice from the route's extras bundle
208 // and checking its model name when isGroup says false.
209 val groupFromRoute = r.isGroup
210 val groupFromModel = if (!groupFromRoute) {
211 runCatching {
212 val extras = r.extras
213 if (extras != null) {
214 val castDev = GmsCastDevice.getFromBundle(extras)
215 castDev?.modelName?.contains("Group", ignoreCase = true) == true
216 } else false
217 }.getOrDefault(false)
218 } else false
219 CastDevice(
220 id = r.id,
221 friendlyName = r.name,
222 isGroup = groupFromRoute || groupFromModel,
223 )
224 }
225 _devices.value = routes
226 _diagnostic.value = DiagnosticState(
227 castContextOk = castContext != null,
228 castInitError = castInitError,
229 mediaRouterOk = true,
230 playServices = playServicesLabel(playServicesStatusCode),
231 rawRouteCount = all.size,
232 filteredDeviceCount = routes.size,
233 tick = refreshTick,
234 rawRouteNames = all.take(6).map {
235 buildString {
236 append(it.name ?: "(unnamed)")
237 if (it.isDefault) append(" [default]")
238 if (it.isBluetooth) append(" [bt]")
239 val modelGroup = runCatching {
240 it.extras?.let { b -> GmsCastDevice.getFromBundle(b) }
241 ?.modelName?.contains("Group", ignoreCase = true) == true
242 }.getOrDefault(false)
243 if (it.isGroup || modelGroup) append(" [group]")
244 }
245 },
246 )
247 android.util.Log.i("EarhornCast", "Routes updated: ${routes.size} devices: ${routes.joinToString { it.friendlyName }}")
248 }
249
250 /** Select a discovered route by its id. The session listener observes the
251 * resulting state change and updates isCasting. */
252 private var connectRetryJob: kotlinx.coroutines.Job? = null
253 /** Timeout for session-suspended state. If the session doesn't
254 * resume within 15s, we force-end it so the user can reconnect.
255 * Cancelled on resume/start. */
256 private var suspendTimeoutJob: kotlinx.coroutines.Job? = null
257
258 fun selectRoute(routeId: String) {
259 val mr = mediaRouter ?: return
260 connectRetryJob?.cancel()
261 suspendTimeoutJob?.cancel()
262
263 // If the app thinks it's still casting but the session is
264 // actually dead (e.g. suspended timeout hasn't fired yet, or
265 // the remote media client is gone), clean up before trying to
266 // connect so the new session doesn't collide with stale state.
267 if (_isCasting.value) {
268 val currentSession = castContext?.sessionManager?.currentCastSession
269 if (currentSession == null || !currentSession.isConnected) {
270 android.util.Log.w("EarhornCast",
271 "selectRoute: stale casting state (session=${currentSession?.isConnected}), resetting")
272 _isCasting.value = false
273 _castingToGroup.value = false
274 _castIsPlaying.value = false
275 detachMediaClientCallbacks()
276 }
277 }
278
279 refreshDevices()
280 val route = resolveRoute(mr, routeId) ?: return
281 android.util.Log.i("EarhornCast",
282 "selectRoute: ${route.name} (id=${route.id} group=${route.isGroup})")
283 _castingToGroup.value = route.isGroup
284 mr.selectRoute(route)
285
286 // After disconnect, the Chromecast device needs 2-3s to
287 // restart its receiver app before accepting new connections.
288 // Retry until the session establishes or we hit 4 attempts.
289 connectRetryJob = discoveryScope.launch {
290 repeat(4) { attempt ->
291 kotlinx.coroutines.delay(1500)
292 if (_isCasting.value) return@launch
293 if (attempt == 0) {
294 // First retry — the initial attempt didn't connect
295 // immediately (device still restarting). Let the
296 // user know we're still trying.
297 android.widget.Toast.makeText(
298 context, "Reconnecting…", android.widget.Toast.LENGTH_SHORT
299 ).show()
300 }
301 android.util.Log.i("EarhornCast",
302 "selectRoute: retry ${attempt + 1} for ${route.name}")
303 refreshDevices()
304 val fresh = resolveRoute(mr, routeId) ?: return@launch
305 mr.selectRoute(fresh)
306 }
307 }
308 }
309
310 /** Resolve a routeId to a MediaRouter.RouteInfo — tries exact ID
311 * match first, falls back to name match for stale IDs. */
312 private fun resolveRoute(
313 mr: androidx.mediarouter.media.MediaRouter,
314 routeId: String,
315 ): androidx.mediarouter.media.MediaRouter.RouteInfo? {
316 // 1. Exact ID match — also verify it's Cast-capable. After
317 // disconnect, route IDs can rotate and the same ID might
318 // now belong to a group-member duplicate (matchesCast=false).
319 var route = mr.routes.firstOrNull {
320 it.id == routeId && it.matchesSelector(routeSelector)
321 }
322 if (route == null) {
323 // 2. Stale ID — resolve by name. MUST filter for
324 // matchesSelector: devices like "HeißeBad" appear
325 // multiple times (as castable device AND as non-castable
326 // group member). Without this filter, firstOrNull can
327 // pick the group-member duplicate → silent cast failure.
328 val device = _devices.value.firstOrNull { it.id == routeId }
329 if (device != null) {
330 route = mr.routes.firstOrNull {
331 it.name == device.friendlyName &&
332 it.matchesSelector(routeSelector) &&
333 !it.isDefault
334 }
335 if (route != null) {
336 android.util.Log.i("EarhornCast",
337 "resolveRoute: ID stale, resolved by name '${device.friendlyName}' → ${route.id}")
338 }
339 }
340 }
341 if (route == null) {
342 android.util.Log.w("EarhornCast",
343 "resolveRoute: no route for id=$routeId (${mr.routes.size} routes)")
344 }
345 return route
346 }
347
348 /** Current RemoteMediaClient — refreshed whenever session changes. Null when
349 * no session is active. */
350 private val remoteMediaClient: RemoteMediaClient?
351 get() = castContext?.sessionManager?.currentCastSession?.remoteMediaClient
352
353 /** Force the Cast SDK to pull fresh state from the receiver.
354 *
355 * Used by the PlaybackService wake-on-screen receiver after doze:
356 * the Cast sender connection in Google Play Services can be
357 * suspended/throttled while the phone is in deep doze, so the
358 * RemoteMediaClient.Callback (which feeds _castCurrentItemIndex,
359 * _castIsPlaying, etc) stops firing. requestStatus() sends a
360 * STATUS message to the receiver and the response re-triggers
361 * onStatusUpdated / onQueueStatusUpdated, which lets the StateFlow
362 * collectors in PlaybackService re-fan the notification listeners.
363 * No-op when no session is active or the client isn't ready. */
364 fun requestStatusRefresh() {
365 runCatching {
366 remoteMediaClient?.requestStatus()
367 android.util.Log.d("EarhornCast", "requestStatusRefresh: dispatched")
368 }
369 }
370
371 // ── Cast playback state mirrors ─────────────────────────────────
372 // Surfaces the remote device's actual play state and position so the
373 // app's UI reflects what the Cast device is doing, not what the
374 // muted/paused local controller is doing. Without these, the UI
375 // shows Play (because local was paused at session start) while the
376 // Cast device is actually playing — and the position label freezes
377 // at whatever the local controller's last position was.
378 private val _castIsPlaying = MutableStateFlow(false)
379 val castIsPlaying: StateFlow<Boolean> = _castIsPlaying.asStateFlow()
380 private val _castPositionMs = MutableStateFlow(0L)
381 val castPositionMs: StateFlow<Long> = _castPositionMs.asStateFlow()
382 private val _castDurationMs = MutableStateFlow(0L)
383 val castDurationMs: StateFlow<Long> = _castDurationMs.asStateFlow()
384 // Index of the receiver's currently-playing item within the queue we
385 // sent via loadQueue. -1 when not casting or no queue is loaded. The
386 // receiver advances this on its own at end-of-track (because we send
387 // the full queue, not single tracks), and LocalRenderer observes it
388 // to keep _state.queueIndex / _state.current in sync — otherwise the
389 // sender UI stays on track 1 forever while the speaker plays track 5.
390 private val _castCurrentItemIndex = MutableStateFlow(-1)
391 val castCurrentItemIndex: StateFlow<Int> = _castCurrentItemIndex.asStateFlow()
392
393 // RemoteMediaClient callback — fires on every state transition
394 // (loaded, playing, paused, idle, buffering, ended). Pulls the
395 // current play state and approximate stream position out and writes
396 // them to the StateFlows above.
397 private val mediaClientCallback = object : RemoteMediaClient.Callback() {
398 override fun onStatusUpdated() {
399 val client = remoteMediaClient ?: return
400 _castIsPlaying.value = client.isPlaying
401 _castPositionMs.value = client.approximateStreamPosition.coerceAtLeast(0L)
402 val mediaInfo = client.mediaInfo
403 _castDurationMs.value = mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
404 _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
405 }
406 override fun onMetadataUpdated() {
407 // Duration only becomes known after the receiver loads the
408 // track — pick it up here too in case onStatusUpdated fires
409 // before metadata is ready.
410 val mediaInfo = remoteMediaClient?.mediaInfo
411 _castDurationMs.value = mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
412 }
413 override fun onQueueStatusUpdated() {
414 // Receiver-side queue change (most importantly: end-of-track
415 // auto-advance). onStatusUpdated also fires on these, but the
416 // queue-specific callback is the authoritative one and runs
417 // synchronously when currentItemId changes server-side.
418 val client = remoteMediaClient ?: return
419 _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
420 }
421 }
422
423 /** Look up which queue position the receiver is currently playing.
424 *
425 * Earlier implementations used `MediaStatus.getQueueItems()` /
426 * `getQueueItem(i)` and iterated to find `currentItemId`. That API is
427 * deprecated for exactly the reason it broke us: `MediaStatus`
428 * carries only a WINDOWED CACHE of queue items (the items most
429 * recently fetched, not the full queue), and iterating it returns
430 * a WINDOW INDEX rather than an absolute queue position. With a
431 * 5-track queue, while playing track 3 the cached window might
432 * be items [trackB, trackC, trackD] (positions 1, 2, 3) — finding
433 * currentItemId in that window returned i=1, and our mirror then
434 * pointed _state.queue[1] = trackB at the now-playing track 3.
435 * That's the "audio is track N but UI shows track N-1" symptom.
436 *
437 * The correct API is [RemoteMediaClient.getMediaQueue], a separate
438 * [MediaQueue] object whose [MediaQueue.getItemIds] returns the
439 * FULL, ordered list of itemIds — automatically synchronized with
440 * the receiver, never windowed. Finding currentItemId in that array
441 * yields the absolute queue index, which is what LocalRenderer
442 * expects to use as an index into its _state.queue.
443 *
444 * Returns -1 if no queue is loaded or the currentItemId can't be
445 * located, which signals "don't try to mirror" to observers. */
446 private fun resolveCurrentItemIndex(client: RemoteMediaClient): Int {
447 val status = client.mediaStatus ?: return -1
448 val currentId = status.currentItemId
449 if (currentId == MediaQueueItem.INVALID_ITEM_ID) return -1
450 val queue = client.mediaQueue ?: return -1
451 val itemIds = queue.itemIds ?: return -1
452 if (itemIds.isEmpty()) return -1
453 val idx = itemIds.indexOf(currentId)
454 return if (idx >= 0) idx else -1
455 }
456
457 // MediaQueue change callback. The receiver's queue can change after
458 // load (insertions, removals, reorderings, fetched-on-demand item
459 // bodies). Each more-specific event also raises mediaQueueChanged,
460 // so overriding just that one catches every case while avoiding
461 // signature-mismatch risk on the parameterized variants. We need
462 // this because the itemIds array can shift positions even when our
463 // currentItemId stays the same — re-running resolveCurrentItemIndex
464 // on any change keeps _castCurrentItemIndex pointing at the right
465 // queue position.
466 private val mediaQueueCallback = object : MediaQueue.Callback() {
467 override fun mediaQueueChanged() {
468 val client = remoteMediaClient ?: return
469 _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
470 }
471 }
472
473 // Sub-second position polling. The RemoteMediaClient only emits status
474 // updates on transitions (play, pause, seek, end of track) — between
475 // them, approximateStreamPosition advances on its own but nothing
476 // triggers a flow emission. We poll at 500ms (matching the local
477 // position tick) so the timeline scrubs smoothly during cast playback.
478 private val progressListener = RemoteMediaClient.ProgressListener { progressMs, durationMs ->
479 _castPositionMs.value = progressMs.coerceAtLeast(0L)
480 if (durationMs > 0L) _castDurationMs.value = durationMs
481 }
482
483 private val sessionListener = object : SessionManagerListener<GmsCastSession> {
484 override fun onSessionStarted(s: GmsCastSession, sessionId: String) {
485 suspendTimeoutJob?.cancel()
486 onSessionAttached(s)
487 }
488 override fun onSessionResumed(s: GmsCastSession, wasSuspended: Boolean) {
489 suspendTimeoutJob?.cancel()
490 // Mark this attach as a RESUME so LocalRenderer does not treat it
491 // as a fresh cast and re-push its queue (review N.8, the maintainer's
492 // rule 2026-09-02): on reconnection the phone sends nothing.
493 _lastAttachWasResume = true
494 onSessionAttached(s)
495 }
496 override fun onSessionEnded(s: GmsCastSession, error: Int) {
497 detachMediaClientCallbacks()
498 // Close the stream proxy: it has no business staying open once
499 // nothing is casting (design decision 2026-09-02, I.32).
500 runCatching { streamProxy.stop() }
501 _session.value = null
502 _isCasting.value = false
503 _castingToGroup.value = false
504 _castIsPlaying.value = false
505 _castPositionMs.value = 0L
506 _castDurationMs.value = 0L
507 _castCurrentItemIndex.value = -1
508 }
509 override fun onSessionSuspended(s: GmsCastSession, reason: Int) {
510 // Network blip or Cast app backgrounded — keep isCasting=true
511 // so the local renderer doesn't un-mute. If the session
512 // fully ends we'll transition via onSessionEnded.
513 //
514 // BUT: if the session stays suspended for too long (network
515 // changed, Cast device went offline), the app gets stuck in
516 // casting mode with no active session and no way to recover
517 // short of force-quitting. Add a timeout — if the session
518 // doesn't resume within 15s, treat it as ended so the user
519 // can reconnect or start a new session.
520 suspendTimeoutJob?.cancel()
521 suspendTimeoutJob = discoveryScope.launch {
522 kotlinx.coroutines.delay(15_000)
523 android.util.Log.w("EarhornCast",
524 "Session suspended > 15s — treating as ended (reason=$reason)")
525 onSessionEnded(s, reason)
526 }
527 }
528 override fun onSessionStarting(s: GmsCastSession) = Unit
529 override fun onSessionResuming(s: GmsCastSession, sessionId: String) = Unit
530 override fun onSessionEnding(s: GmsCastSession) = Unit
531 override fun onSessionStartFailed(s: GmsCastSession, error: Int) {
532 _isCasting.value = false
533 _castingToGroup.value = false
534 }
535 override fun onSessionResumeFailed(s: GmsCastSession, error: Int) {
536 _isCasting.value = false
537 _castingToGroup.value = false
538 }
539 }
540
541 /** Single attachment point for both freshly-started sessions
542 * ([SessionManagerListener.onSessionStarted]) and reconnected ones
543 * ([SessionManagerListener.onSessionResumed]). Previously these two
544 * callbacks held duplicated bodies, which made it easy for the
545 * resume path to drift out of sync with start — e.g. an addition
546 * to the start side ([attachMediaClientCallbacks] priming) that
547 * silently missed the resume side would leave reconnected sessions
548 * responsive to play/pause but unable to track queue advances.
549 * All future "I'm now attached, sync state and start listening"
550 * work lands here so both paths get it. */
551 private fun onSessionAttached(s: GmsCastSession) {
552 _session.value = s.toDomain()
553 _isCasting.value = true
554 attachMediaClientCallbacks()
555 }
556
557 /** True when the most recent `isCasting → true` came from a session
558 * RESUME (reconnection), not a fresh start. Cleared by the reader. */
559 @Volatile private var _lastAttachWasResume: Boolean = false
560 fun consumeAttachWasResume(): Boolean {
561 val v = _lastAttachWasResume
562 _lastAttachWasResume = false
563 return v
564 }
565
566 /** The contentId (stream URL) the receiver is currently playing, or null. */
567 fun currentContentId(): String? =
568 runCatching { remoteMediaClient?.mediaInfo?.contentId }.getOrNull()?.let { id ->
569 // Group casts (and local files) go through the proxy's tokened
570 // links; translate back to the track URL the phone knows, so the
571 // resume check can recognise its own queue (review C.3).
572 streamProxy.originalFor(id) ?: id
573 }
574
575 /** Leave the session WITHOUT stopping the receiver app. Used after a
576 * reconnection when the group turns out to be playing something that
577 * isn't ours (musicnow took it back): the phone stops pretending it is
578 * casting and does not touch what is playing (design decision
579 * 2026-09-02). `endCurrentSession(true)` would stop the receiver. */
580 fun detachWithoutStopping() {
581 _userInitiatedDisconnect.value = false
582 runCatching { castContext?.sessionManager?.endCurrentSession(false) }
583 }
584
585 private fun attachMediaClientCallbacks() {
586 val client = remoteMediaClient ?: return
587 runCatching {
588 client.registerCallback(mediaClientCallback)
589 // Also subscribe to the MediaQueue object — it's what tracks
590 // the receiver's full itemIds list. Without this, the itemIds
591 // array can change underneath us without _castCurrentItemIndex
592 // being re-published, and the UI re-displays a stale track.
593 client.mediaQueue?.registerCallback(mediaQueueCallback)
594 // 500ms cadence — same as the local position tick. The framework
595 // documents 1000ms minimum but in practice 500ms works and gives
596 // smoother scrubbing when the timeline is visible. Still cheap;
597 // it's all in-process arithmetic on a cached lastKnownPosition.
598 client.addProgressListener(progressListener, 500L)
599 // Prime the flows with the current state immediately rather than
600 // waiting for the first callback fire — this matters when
601 // resuming a session (onSessionResumed) where the receiver may
602 // already be in a stable state and not emit a status update.
603 _castIsPlaying.value = client.isPlaying
604 _castPositionMs.value = client.approximateStreamPosition.coerceAtLeast(0L)
605 _castDurationMs.value = client.mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
606 _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
607 }
608 }
609
610 private fun detachMediaClientCallbacks() {
611 val client = remoteMediaClient ?: return
612 runCatching {
613 client.unregisterCallback(mediaClientCallback)
614 client.mediaQueue?.unregisterCallback(mediaQueueCallback)
615 client.removeProgressListener(progressListener)
616 }
617 }
618
619 /** Cancel any media notification that isn't Earhorn's own
620 * (NOTIFICATION_ID 2002). The Cast SDK's Default Media Receiver
621 * creates its own notification card when a session starts — we
622 * suppress it here rather than in CastOptionsProvider because
623 * touching the provider's notification config can break Cast
624 * session establishment on some SDK versions. Scheduled with a
625 * short delay because the Cast notification appears async after
626 * onSessionStarted. */
627 private fun cancelDuplicateNotifications() {
628 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
629 runCatching {
630 val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
631 as android.app.NotificationManager
632 nm.activeNotifications.forEach { sbn ->
633 if (sbn.id != 2002 && sbn.packageName == context.packageName) {
634 nm.cancel(sbn.tag, sbn.id)
635 }
636 }
637 }
638 }, 1500) // Cast notification appears ~1s after session start
639 }
640
641 init {
642 castContext?.sessionManager?.addSessionManagerListener(
643 sessionListener, GmsCastSession::class.java
644 )
645 }
646
647 /** Send a track to the connected Cast device. No-op when no session is active.
648 *
649 * Builds a MediaInfo from the track's resourceUrl + DIDL metadata. The
650 * Default Media Receiver handles standard formats (mp3, aac, ogg, wav) and
651 * most non-encrypted MP4 containers. FLAC + ALAC are not guaranteed —
652 * the receiver will error out for unsupported formats; that surfaces as
653 * a RemoteMediaClient error callback which the caller can observe via
654 * registerCallback. */
655 fun loadTrack(track: DidlItem.Track, autoplay: Boolean = true) {
656 val client = remoteMediaClient ?: return
657 val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
658 putString(MediaMetadata.KEY_TITLE, track.title)
659 track.artist?.let { putString(MediaMetadata.KEY_ARTIST, it) }
660 track.album?.let { putString(MediaMetadata.KEY_ALBUM_TITLE, it) }
661 track.albumArtUri?.let {
662 addImage(WebImage(android.net.Uri.parse(it)))
663 }
664 }
665 // Content type: URL extension is the most reliable source —
666 // UPnP servers frequently report wrong or exotic MIME types
667 // (audio/x-flac, audio/vnd.wave, audio/mpeg3) that single
668 // Cast speakers sniff-correct but group leaders trust
669 // literally, producing broken audio. Fall through to the
670 // server-reported MIME (normalized) only when the URL has no
671 // recognizable extension.
672 val contentType = inferMimeFromUrl(track.resourceUrl)
673 ?: normalizeCastMime(track.mimeType)
674 val isGroup = _castingToGroup.value
675 // Determine the stream URL. Three cases:
676 // 1. content:// URI (local file) → always proxy, Cast can't resolve content://
677 // 2. http(s) + group target → proxy for Range-request normalization
678 // 3. http(s) + single speaker → direct URL, speaker handles it
679 val isLocalFile = track.resourceUrl.startsWith("content://")
680 val streamUrl = when {
681 isLocalFile -> streamProxy.localProxyUrl(track.resourceUrl, contentType)
682 isGroup -> streamProxy.proxyUrl(track.resourceUrl)
683 else -> track.resourceUrl
684 }
685 android.util.Log.i("EarhornCast",
686 "loadTrack: '${track.title}' serverMime=${track.mimeType} " +
687 "resolvedType=$contentType group=$isGroup local=$isLocalFile autoplay=$autoplay")
688 val mediaInfo = MediaInfo.Builder(streamUrl)
689 .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
690 .setContentType(contentType)
691 .setMetadata(metadata)
692 .apply {
693 track.duration?.inWholeMilliseconds?.let { setStreamDuration(it) }
694 }
695 .build()
696 val request = MediaLoadRequestData.Builder()
697 .setMediaInfo(mediaInfo)
698 .setAutoplay(autoplay)
699 .build()
700 client.load(request)
701 }
702
703 /** Send a full queue to the receiver in one call so that next-track
704 * advancement happens receiver-side (without requiring the sender
705 * app to be in the foreground or even running). Maps each track to
706 * a [MediaQueueItem] using the same MIME inference and proxy
707 * routing as [loadTrack], then issues a single
708 * [RemoteMediaClient.queueLoad].
709 *
710 * Without this, the receiver only knows about the currently
711 * playing track and stops at the end. Symptom: phone-side queue
712 * advances visually but Shield/CCA never starts the next track,
713 * user sees one song play then silence. */
714 /** Build the receiver-side queue items for [tracks], with the same MIME
715 * inference and proxy routing as [loadTrack]. Shared by [loadQueue] and
716 * the in-place queue edits below. */
717 private fun buildQueueItems(tracks: List<DidlItem.Track>): Array<MediaQueueItem> {
718 val isGroup = _castingToGroup.value
719 return tracks.map { track ->
720 val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
721 putString(MediaMetadata.KEY_TITLE, track.title)
722 track.artist?.let { putString(MediaMetadata.KEY_ARTIST, it) }
723 track.album?.let { putString(MediaMetadata.KEY_ALBUM_TITLE, it) }
724 track.albumArtUri?.let {
725 addImage(WebImage(android.net.Uri.parse(it)))
726 }
727 }
728 val contentType = inferMimeFromUrl(track.resourceUrl)
729 ?: normalizeCastMime(track.mimeType)
730 val isLocalFile = track.resourceUrl.startsWith("content://")
731 val streamUrl = when {
732 isLocalFile -> streamProxy.localProxyUrl(track.resourceUrl, contentType)
733 isGroup -> streamProxy.proxyUrl(track.resourceUrl)
734 else -> track.resourceUrl
735 }
736 val mediaInfo = MediaInfo.Builder(streamUrl)
737 .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
738 .setContentType(contentType)
739 .setMetadata(metadata)
740 .apply {
741 track.duration?.inWholeMilliseconds?.let { setStreamDuration(it) }
742 }
743 .build()
744 // autoplay=true on each ITEM is what makes the receiver flow from
745 // one track to the next at end-of-track. Whether the FIRST item
746 // starts playing is decided by the load request, below.
747 MediaQueueItem.Builder(mediaInfo)
748 .setAutoplay(true)
749 .build()
750 }.toTypedArray()
751 }
752
753 /** Send a full queue to the receiver in one call so that next-track
754 * advancement happens receiver-side (without requiring the sender
755 * app to be in the foreground or even running).
756 *
757 * [autoplay] is honoured (design decision 2026-09-02; review
758 * N.8/N.13): it used to be logged and ignored, so every queue load
759 * played — casting from a paused phone started audio, and every queue
760 * edit un-paused the speaker. The load now goes through
761 * MediaLoadRequestData with the queue in queueData, which carries the
762 * autoplay flag the bare queueLoad() call never did. */
763 fun loadQueue(
764 tracks: List<DidlItem.Track>,
765 startIndex: Int = 0,
766 autoplay: Boolean = true,
767 repeat: RepeatMode = RepeatMode.OFF,
768 shuffle: Boolean = false,
769 ) {
770 val client = remoteMediaClient ?: return
771 if (tracks.isEmpty()) return
772 val items = buildQueueItems(tracks)
773 val safeStart = startIndex.coerceIn(0, items.lastIndex)
774 val castRepeatMode = mapToCastRepeatMode(repeat, shuffle)
775 android.util.Log.i("EarhornCast",
776 "loadQueue: ${items.size} items, startIndex=$safeStart group=${_castingToGroup.value} autoplay=$autoplay repeat=$repeat shuffle=$shuffle castMode=$castRepeatMode")
777 val queueData = com.google.android.gms.cast.MediaQueueData.Builder()
778 .setItems(items.toList())
779 .setStartIndex(safeStart)
780 .setRepeatMode(castRepeatMode)
781 .build()
782 val request = MediaLoadRequestData.Builder()
783 .setQueueData(queueData)
784 .setAutoplay(autoplay)
785 .build()
786 client.load(request)
787 }
788
789 // ── In-place queue edits (review N.12/N.13, design decision 2026-09-02) ──
790 // None of these reload the queue: nothing restarts, nothing un-pauses.
791
792 /** Append [tracks] to the end of the receiver's queue. */
793 fun queueAppend(tracks: List<DidlItem.Track>, fullQueue: List<DidlItem.Track> = tracks) {
794 val client = remoteMediaClient ?: return
795 if (tracks.isEmpty()) return
796 // Maintainer, 2026-10-29: connected but nothing loaded on the receiver
797 // (no media session) — there is no queue to append to, so
798 // queueAppendItem did nothing. Load the phone's WHOLE queue so the
799 // receiver's indices line up with the phone's, starting at the first
800 // appended track (D4).
801 if (client.mediaStatus == null) {
802 // autoplay = false: queue edits never start or un-pause the
803 // speaker (ruling 2026-09-02); enqueueAndPlay follows with play().
804 loadQueue(
805 fullQueue,
806 startIndex = (fullQueue.size - tracks.size).coerceAtLeast(0),
807 autoplay = false,
808 )
809 return
810 }
811 runCatching { client.queueAppendItem(buildQueueItems(tracks).first(), null) }
812 if (tracks.size > 1) {
813 runCatching {
814 client.queueInsertItems(buildQueueItems(tracks.drop(1)), MediaQueueItem.INVALID_ITEM_ID, null)
815 }
816 }
817 }
818
819 /** Insert [tracks] immediately after the receiver's current item. */
820 fun queueInsertAfterCurrent(tracks: List<DidlItem.Track>) {
821 val client = remoteMediaClient ?: return
822 if (tracks.isEmpty()) return
823 val ids = client.mediaQueue?.itemIds ?: return
824 val curIdx = resolveCurrentItemIndex(client)
825 // Insert BEFORE the item that follows the current one; if there is
826 // none, append (INVALID_ITEM_ID = end of queue).
827 val beforeId = ids.getOrNull(curIdx + 1) ?: MediaQueueItem.INVALID_ITEM_ID
828 runCatching { client.queueInsertItems(buildQueueItems(tracks), beforeId, null) }
829 }
830
831 /** Remove the item at [index] (sender-side queue position) from the receiver. */
832 fun queueRemoveAt(index: Int) {
833 val client = remoteMediaClient ?: return
834 val itemId = client.mediaQueue?.itemIds?.getOrNull(index) ?: return
835 runCatching { client.queueRemoveItem(itemId, null) }
836 }
837
838 /** Move the item at [from] to position [to] on the receiver. */
839 fun queueMove(from: Int, to: Int) {
840 val client = remoteMediaClient ?: return
841 val ids = client.mediaQueue?.itemIds ?: return
842 val itemId = ids.getOrNull(from) ?: return
843 runCatching { client.queueMoveItemToNewIndex(itemId, to, null) }
844 }
845
846 /** Push a repeat/shuffle change to an in-flight cast session without
847 * reloading the queue. Called by [LocalRenderer.setShuffle] and
848 * [LocalRenderer.setRepeatMode] when a session is active — without
849 * this, the local UI flips the shuffle icon but the receiver keeps
850 * advancing the queue in its loaded order, so the next track plays
851 * sequentially regardless of what the button says. */
852 fun setRepeatAndShuffle(repeat: RepeatMode, shuffle: Boolean) {
853 val client = remoteMediaClient ?: return
854 val castMode = mapToCastRepeatMode(repeat, shuffle)
855 android.util.Log.i("EarhornCast",
856 "setRepeatAndShuffle: repeat=$repeat shuffle=$shuffle castMode=$castMode")
857 runCatching { client.queueSetRepeatMode(castMode, null) }
858 }
859
860 /** Combine Earhorn's two-axis (RepeatMode × Boolean shuffle) model
861 * down to Cast's single repeat-mode enum. The Cast API doesn't have
862 * shuffle-without-repeat; the closest expression of "play in random
863 * order" is REPEAT_ALL_AND_SHUFFLE, which also loops the queue.
864 * When user wants shuffle without repeat, we accept that compromise
865 * rather than the alternative (reordering the receiver-side queue
866 * manually on every toggle, which is a much bigger surface).
867 *
868 * REPEAT_ONE takes precedence over shuffle — repeating a single
869 * track makes shuffle moot, and Media3's local player handles this
870 * combination the same way (shuffle is irrelevant when not
871 * advancing). */
872 private fun mapToCastRepeatMode(repeat: RepeatMode, shuffle: Boolean): Int = when {
873 repeat == RepeatMode.ONE -> MediaStatus.REPEAT_MODE_REPEAT_SINGLE
874 shuffle -> MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE
875 repeat == RepeatMode.ALL -> MediaStatus.REPEAT_MODE_REPEAT_ALL
876 else -> MediaStatus.REPEAT_MODE_REPEAT_OFF
877 }
878
879 /** Infer MIME from the resource URL's file extension. More reliable
880 * than the UPnP server's reported protocolInfo MIME because servers
881 * frequently use vendor-prefixed or legacy subtypes that the Cast
882 * Default Media Receiver doesn't recognize. Returns null for
883 * unrecognized extensions so the caller can fall through to the
884 * server-reported MIME. */
885 private fun inferMimeFromUrl(url: String?): String? {
886 if (url.isNullOrBlank()) return null
887 val path = url.substringBefore('?').substringBefore('#')
888 val ext = path.substringAfterLast('.', "").lowercase()
889 return when (ext) {
890 "flac" -> "audio/flac"
891 "mp3" -> "audio/mpeg"
892 "m4a", "aac" -> "audio/mp4"
893 "mp4" -> "audio/mp4"
894 "ogg", "oga" -> "audio/ogg"
895 "opus" -> "audio/ogg"
896 "wav", "wave" -> "audio/wav"
897 "wma" -> "audio/x-ms-wma"
898 "webm" -> "audio/webm"
899 "aif", "aiff" -> "audio/aiff"
900 "dsf" -> "audio/dsf"
901 "dff" -> "audio/dsd"
902 else -> null
903 }
904 }
905
906 /**
907 * Map a DLNA-reported MIME to the canonical form Chromecast accepts.
908 *
909 * UPnP servers differ: MinimServer emits "audio/flac", BubbleUPnP emits
910 * "audio/x-flac", Windows Media emits "audio/vnd.wave", older servers
911 * emit "audio/mpeg3" — all for the same underlying format. The Cast
912 * receiver rejects anything it doesn't recognize, and the x-/vnd./legacy
913 * variants are exactly what it doesn't recognize.
914 *
915 * For unknown inputs, falls back to "audio/mpeg" (most compatible) rather
916 * than a wildcard content type which the Cast SDK treats as unsupported.
917 */
918 private fun normalizeCastMime(mime: String?): String {
919 if (mime.isNullOrBlank()) return "audio/mpeg"
920 val base = mime.substringBefore(';').trim().lowercase()
921 val subtype = base.substringAfter('/', missingDelimiterValue = base)
922 .removePrefix("x-")
923 .removePrefix("vnd.")
924 return when (subtype) {
925 "mpeg", "mpeg3", "mp3" -> "audio/mpeg"
926 "mp4", "m4a", "aac" -> "audio/mp4"
927 "flac", "flac+ogg" -> "audio/flac"
928 "ogg", "vorbis", "ogg-vorbis" -> "audio/ogg"
929 "opus" -> "audio/ogg"
930 "wav", "wave", "x-wav" -> "audio/wav"
931 "webm" -> "audio/webm"
932 else -> "audio/$subtype"
933 }
934 }
935
936 /** Remote playback controls. No-op when no session is active. */
937 fun play() { remoteMediaClient?.play() }
938 fun pause() { remoteMediaClient?.pause() }
939 fun stop() { remoteMediaClient?.stop() }
940 fun seekTo(positionMs: Long) {
941 val client = remoteMediaClient ?: return
942 client.seek(
943 com.google.android.gms.cast.MediaSeekOptions.Builder()
944 .setPosition(positionMs)
945 .build()
946 )
947 }
948
949 /** Advance to next item in the receiver's loaded queue. */
950 fun queueNext() {
951 remoteMediaClient?.queueNext(null)
952 }
953
954 /** Go back to previous item in the receiver's loaded queue. */
955 fun queuePrev() {
956 remoteMediaClient?.queuePrev(null)
957 }
958
959 /** Jump to a specific index in the receiver's loaded queue. */
960 fun queueJumpTo(index: Int) {
961 val client = remoteMediaClient ?: return
962 // Use the full receiver-synchronized itemIds list (see
963 // resolveCurrentItemIndex's comment for why MediaStatus.queueItems
964 // is the wrong source here — it's a windowed cache that can shift
965 // out from under us, causing this jump to land on a wrong track).
966 val itemIds = client.mediaQueue?.itemIds ?: return
967 val itemId = itemIds.getOrNull(index) ?: return
968 client.queueJumpToItem(itemId, null)
969 }
970
971 /** Maintainer, 2026-10-29 (D4): [queueJumpTo] for an index that may not be
972 * in the receiver's queue yet (an append still in flight). Polls the
973 * receiver-synchronized itemIds every 150 ms, on the main thread
974 * (RemoteMediaClient's thread), for up to ~5 s; jumps once the index
975 * exists, gives up quietly otherwise. */
976 suspend fun queueJumpToWhenReady(index: Int) {
977 kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
978 var waitedMs = 0L
979 while (true) {
980 val client = remoteMediaClient ?: return@withContext
981 val itemIds = client.mediaQueue?.itemIds
982 val itemId = itemIds?.getOrNull(index)
983 if (itemId != null) {
984 client.queueJumpToItem(itemId, null)
985 return@withContext
986 }
987 if (waitedMs >= 5_000L) {
988 android.util.Log.w("EarhornCast", "queueJumpToWhenReady: index $index never appeared")
989 return@withContext
990 }
991 kotlinx.coroutines.delay(150L)
992 waitedMs += 150L
993 }
994 }
995 }
996
997 fun setVolume(volume: Double) {
998 runCatching { remoteMediaClient?.setStreamVolume(volume.coerceIn(0.0, 1.0)) }
999 }
1000
1001 /** Device-level volume (hardware volume of the Cast speaker).
1002 * Used by hardware volume button interception in the Activity. */
1003 fun getDeviceVolume(): Double =
1004 castContext?.sessionManager?.currentCastSession?.volume ?: 1.0
1005
1006 fun setDeviceVolume(volume: Double) {
1007 runCatching {
1008 castContext?.sessionManager?.currentCastSession?.volume =
1009 volume.coerceIn(0.0, 1.0)
1010 }
1011 }
1012
1013 private var discoveryTickJob: kotlinx.coroutines.Job? = null
1014 private val discoveryScope = kotlinx.coroutines.CoroutineScope(
1015 kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main
1016 )
1017
1018 fun startDiscovery() {
1019 val mr = mediaRouter ?: return
1020 android.util.Log.i("EarhornCast", "startDiscovery")
1021 // Broad selector so MediaRouter reports every remote-playback-capable
1022 // route, not only ones advertising Cast's Default Media Receiver app.
1023 // CATEGORY_REMOTE_PLAYBACK is the widest sensible filter for "device
1024 // that can play media we push to it." Combined with PERFORM_ACTIVE_SCAN
1025 // to force mDNS queries on entry rather than waiting for the passive
1026 // cache.
1027 val broad = androidx.mediarouter.media.MediaRouteSelector.Builder()
1028 .addControlCategory(androidx.mediarouter.media.MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
1029 .addControlCategory(
1030 com.google.android.gms.cast.CastMediaControlIntent.categoryForCast(
1031 com.google.android.gms.cast.CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
1032 )
1033 )
1034 .build()
1035 mr.addCallback(
1036 broad,
1037 routerCallback,
1038 androidx.mediarouter.media.MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN,
1039 )
1040 refreshDevices()
1041 // Periodic re-check. Cast discovery via mDNS is inherently lossy and
1042 // can take 5-10 seconds to populate, and not every route-change event
1043 // fires reliably on every OEM's Android TV. Poll every second while
1044 // the picker is open so devices appear as they come online.
1045 discoveryTickJob?.cancel()
1046 discoveryTickJob = discoveryScope.launch {
1047 while (true) {
1048 kotlinx.coroutines.delay(1000)
1049 refreshDevices()
1050 }
1051 }
1052 }
1053 fun stopDiscovery() {
1054 val mr = mediaRouter ?: return
1055 discoveryTickJob?.cancel()
1056 discoveryTickJob = null
1057 mr.removeCallback(routerCallback)
1058 }
1059 fun connect(deviceId: String) = selectRoute(deviceId)
1060 fun disconnect() {
1061 _userInitiatedDisconnect.value = true
1062 // true = end session AND stop the receiver app. This also
1063 // clears MediaRouter's selected-route state internally, so
1064 // re-selecting the same route later is NOT a no-op.
1065 // The device needs 2-3s to restart — selectRoute's retry
1066 // loop handles that delay.
1067 castContext?.sessionManager?.endCurrentSession(true)
1068 }
1069
1070 /** True iff the most recent session end was triggered by the user
1071 * calling [disconnect]. Auto-cleared on the next session start. */
1072 private val _userInitiatedDisconnect = MutableStateFlow(false)
1073 val userInitiatedDisconnect: StateFlow<Boolean> = _userInitiatedDisconnect.asStateFlow()
1074
1075 /** Called by consumers (LocalRenderer) after they've acted on a
1076 * session-end event so the next external disconnect doesn't
1077 * inherit a stale "user-initiated" flag. */
1078 fun consumeDisconnectIntent(): Boolean {
1079 val v = _userInitiatedDisconnect.value
1080 _userInitiatedDisconnect.value = false
1081 return v
1082 }
1083
1084 private fun GmsCastSession.toDomain(): CastSession {
1085 val device: GmsCastDevice? = castDevice
1086 return CastSession(
1087 deviceId = device?.deviceId.orEmpty(),
1088 deviceName = device?.friendlyName.orEmpty()
1089 )
1090 }
1091}
1092
1093data class CastDevice(
1094 val id: String,
1095 val friendlyName: String,
1096 val isGroup: Boolean,
1097)
1098
1099data class CastSession(
1100 val deviceId: String,
1101 val deviceName: String,
1102)
1103
1104/** Snapshot of what the Cast discovery plumbing is currently seeing. Consumed by
1105 * the Cast picker UI to show the user what's happening when devices don't
1106 * appear. Updated on every refresh tick (roughly once per second while the
1107 * picker is open). */
1108data class DiagnosticState(
1109 /** True if CastContext.getSharedInstance() returned non-null. False implies
1110 * the Cast framework didn't initialize — usually Play Services missing
1111 * or too old on this Android TV. */
1112 val castContextOk: Boolean = false,
1113 /** When Cast init failed, the captured exception summary (class name +
1114 * message). Shown in the diagnostic panel to reveal the actual reason
1115 * behind a 'missing' status. Null when init succeeded OR when init has
1116 * not yet been attempted. */
1117 val castInitError: String? = null,
1118 /** True if MediaRouter.getInstance() returned non-null. Should effectively
1119 * always be true on any Android build. */
1120 val mediaRouterOk: Boolean = false,
1121 /** Human-readable summary of GoogleApiAvailability.isGooglePlayServicesAvailable().
1122 * SUCCESS means Cast should work; anything else probably means it won't.
1123 * SERVICE_MISSING is common on OEM Android TV builds that ship without
1124 * the full Play Services. */
1125 val playServices: String = "unknown",
1126 /** Number of routes MediaRouter is reporting — including default and
1127 * bluetooth that our filter removes. Zero means discovery hasn't
1128 * found anything; any positive number at least means the pipeline works. */
1129 val rawRouteCount: Int = 0,
1130 /** Number of routes that survive filtering and become pickable devices. */
1131 val filteredDeviceCount: Int = 0,
1132 /** Tick counter — increments on every refresh. If it stops advancing, the
1133 * discovery coroutine died. If it keeps advancing but rawRouteCount
1134 * stays 0, MediaRouter isn't getting any mDNS responses. */
1135 val tick: Int = 0,
1136 /** First few route names (with flags) to help identify what MediaRouter
1137 * is actually seeing — e.g. the TV's own speakers will often appear here. */
1138 val rawRouteNames: List<String> = emptyList(),
1139)