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

CastIntegration.kt

package dev.stave.cast

import android.content.Context
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.CastDevice as GmsCastDevice
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession as GmsCastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.media.MediaQueue
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.images.WebImage
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.RepeatMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Play flavor: Google Cast integration. Holds the CastContext, exposes session
 * lifecycle, and provides playback delegation methods that route to the remote
 * device's RemoteMediaClient when a session is active.
 *
 * Current integration is "broadcast from local" — local renderer continues running
 * the queue logic; every load() also pushes the track's URL to the remote. The
 * remote plays; caller is responsible for muting the local output so audio comes
 * from one place only. A proper CastRenderer+coordinator split is future work.
 *
 * Requires a CastOptionsProvider in the play-flavor manifest. Uses the Default
 * Media Receiver so no Cast Developer Console registration is needed.
 */
@Singleton
class CastIntegration @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    val available: Boolean = true

    /** Local HTTP proxy for Cast groups. Created lazily — only starts
     *  its ServerSocket when first used (i.e. when a group is selected).
     *  Not Hilt-injected because adding it to the constructor crashed
     *  the dependency graph. */
    private val streamProxy = CastStreamProxy(context)

    private val _devices = MutableStateFlow<List<CastDevice>>(emptyList())
    val devices: StateFlow<List<CastDevice>> = _devices.asStateFlow()

    // Diagnostic surface. Compose picker observes this to display WHAT the
    // framework is seeing — without logcat access, user-visible diagnostics
    // are the only way to distinguish "no devices on network" from "Cast
    // framework didn't initialize" from "MediaRouter reports routes but our
    // filter removes them all." Expected failure modes on some OEM Android TV
    // builds: CastContext returns non-null but Play Services never starts the
    // discovery providers; or Play Services is too old for the sender SDK;
    // or mDNS is blocked by the TV's own firewall/IGMP config.
    private val _diagnostic = MutableStateFlow(DiagnosticState())
    val diagnostic: StateFlow<DiagnosticState> = _diagnostic.asStateFlow()

    private val _session = MutableStateFlow<CastSession?>(null)
    val session: StateFlow<CastSession?> = _session.asStateFlow()

    /** True iff a Cast session is currently active. Convenience derived from
     *  [session]; callers can observe this to gate local audio output. */
    private val _isCasting = MutableStateFlow(false)
    val isCasting: StateFlow<Boolean> = _isCasting.asStateFlow()

    /** True when the current Cast target is a speaker group rather than
     *  a single device. Groups need STREAM_TYPE_LIVE (not BUFFERED) to
     *  prevent the group leader from making HTTP Range requests that
     *  MinimServer responds to with non-frame-aligned data — which
     *  produces broken/tortured audio on all group members. */
    private val _castingToGroup = MutableStateFlow(false)

    private val playServicesStatusCode: Int = runCatching {
        com.google.android.gms.common.GoogleApiAvailability
            .getInstance()
            .isGooglePlayServicesAvailable(context)
    }.getOrElse {
        android.util.Log.e("EarhornCast", "Play Services check failed", it)
        -1
    }.also { code ->
        android.util.Log.i("EarhornCast", "Play Services status code: $code")
    }

    // Cast framework initialization — three-step approach:
    //   1. Try the synchronous getSharedInstance(Context). This is the classic
    //      entry point but it can throw on Android TV OEM builds where the
    //      Cast framework's backing Play Services component isn't fully loaded
    //      at the time of the call (even when Play Services itself reports OK).
    //   2. Capture whatever error it threw into [castInitError] so the
    //      diagnostic panel can display the real reason instead of the
    //      generic "missing."
    //   3. If the sync call failed, fall back to getSharedInstance(Context, Executor)
    //      — the async variant that waits for the framework to be ready and
    //      succeeds in many cases where the sync call doesn't.
    @Volatile private var castContext: CastContext? = null
    @Volatile private var castInitError: String? = null

    init {
        val sync = runCatching { CastContext.getSharedInstance(context) }
        sync.onSuccess { ctx ->
            castContext = ctx
            android.util.Log.i("EarhornCast", "CastContext (sync) initialized: ${ctx != null}")
        }
        sync.onFailure { e ->
            castInitError = "${e.javaClass.simpleName}: ${e.message ?: "no message"}"
            android.util.Log.e("EarhornCast", "CastContext (sync) init failed", e)
            // Fallback: async Task variant on the app's main executor. The
            // framework loads the provider on a background thread and
            // notifies the listener when ready.
            runCatching {
                val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
                CastContext.getSharedInstance(context, executor).addOnCompleteListener { task ->
                    if (task.isSuccessful) {
                        castContext = task.result
                        castInitError = null
                        android.util.Log.i("EarhornCast", "CastContext (async) initialized")
                        // Refresh the diagnostic so the UI updates
                        refreshDevices()
                    } else {
                        val ex = task.exception
                        castInitError = "async: ${ex?.javaClass?.simpleName}: ${ex?.message ?: "no message"}"
                        android.util.Log.e("EarhornCast", "CastContext (async) init failed", ex)
                        refreshDevices()
                    }
                }
            }.onFailure { ex2 ->
                // Even the async call's preparation threw — record that.
                castInitError = "$castInitError; async setup: ${ex2.javaClass.simpleName}"
                android.util.Log.e("EarhornCast", "CastContext async setup threw", ex2)
            }
        }
    }

    // MediaRouter-based device discovery. Populates _devices whenever routes are
    // added/removed/changed. This is how the Compose picker UI gets its list,
    // sidestepping the AppCompat-theme requirements of MediaRouteChooserDialog.
    private val mediaRouter: androidx.mediarouter.media.MediaRouter? = runCatching {
        androidx.mediarouter.media.MediaRouter.getInstance(context)
    }.getOrNull()

    private val routeSelector: androidx.mediarouter.media.MediaRouteSelector =
        androidx.mediarouter.media.MediaRouteSelector.Builder()
            .addControlCategory(
                com.google.android.gms.cast.CastMediaControlIntent.categoryForCast(
                    com.google.android.gms.cast.CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
                )
            )
            .build()

    private val routerCallback = object : androidx.mediarouter.media.MediaRouter.Callback() {
        override fun onRouteAdded(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
            refreshDevices()
        }
        override fun onRouteRemoved(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
            refreshDevices()
        }
        override fun onRouteChanged(router: androidx.mediarouter.media.MediaRouter, route: androidx.mediarouter.media.MediaRouter.RouteInfo) {
            refreshDevices()
        }
    }

    private var refreshTick: Int = 0

    private fun playServicesLabel(code: Int): String = when (code) {
        0 -> "OK"
        1 -> "SERVICE_MISSING"
        2 -> "SERVICE_VERSION_UPDATE_REQUIRED"
        3 -> "SERVICE_DISABLED"
        9 -> "SERVICE_INVALID"
        18 -> "SERVICE_UPDATING"
        19 -> "SERVICE_MISSING_PERMISSION"
        -1 -> "CHECK_THREW"
        else -> "code=$code"
    }

    private fun refreshDevices() {
        val mr = mediaRouter
        refreshTick++
        if (mr == null) {
            _diagnostic.value = DiagnosticState(
                castContextOk = castContext != null,
                castInitError = castInitError,
                mediaRouterOk = false,
                playServices = playServicesLabel(playServicesStatusCode),
                rawRouteCount = 0,
                filteredDeviceCount = 0,
                tick = refreshTick,
                rawRouteNames = emptyList(),
            )
            return
        }
        val all = mr.routes
        val routes = all
            .filter { !it.isDefault && !it.isBluetooth }
            .map { r ->
                // RouteInfo.isGroup is unreliable on many Cast SDK /
                // MediaRouter combinations — it returns false for speaker
                // groups that the raw mDNS discovery correctly identifies
                // via the "Google Cast Group" model name. Fall back to
                // extracting the CastDevice from the route's extras bundle
                // and checking its model name when isGroup says false.
                val groupFromRoute = r.isGroup
                val groupFromModel = if (!groupFromRoute) {
                    runCatching {
                        val extras = r.extras
                        if (extras != null) {
                            val castDev = GmsCastDevice.getFromBundle(extras)
                            castDev?.modelName?.contains("Group", ignoreCase = true) == true
                        } else false
                    }.getOrDefault(false)
                } else false
                CastDevice(
                    id = r.id,
                    friendlyName = r.name,
                    isGroup = groupFromRoute || groupFromModel,
                )
            }
        _devices.value = routes
        _diagnostic.value = DiagnosticState(
            castContextOk = castContext != null,
            castInitError = castInitError,
            mediaRouterOk = true,
            playServices = playServicesLabel(playServicesStatusCode),
            rawRouteCount = all.size,
            filteredDeviceCount = routes.size,
            tick = refreshTick,
            rawRouteNames = all.take(6).map {
                buildString {
                    append(it.name ?: "(unnamed)")
                    if (it.isDefault) append(" [default]")
                    if (it.isBluetooth) append(" [bt]")
                    val modelGroup = runCatching {
                        it.extras?.let { b -> GmsCastDevice.getFromBundle(b) }
                            ?.modelName?.contains("Group", ignoreCase = true) == true
                    }.getOrDefault(false)
                    if (it.isGroup || modelGroup) append(" [group]")
                }
            },
        )
        android.util.Log.i("EarhornCast", "Routes updated: ${routes.size} devices: ${routes.joinToString { it.friendlyName }}")
    }

    /** Select a discovered route by its id. The session listener observes the
     *  resulting state change and updates isCasting. */
    private var connectRetryJob: kotlinx.coroutines.Job? = null
    /** Timeout for session-suspended state. If the session doesn't
     *  resume within 15s, we force-end it so the user can reconnect.
     *  Cancelled on resume/start. */
    private var suspendTimeoutJob: kotlinx.coroutines.Job? = null

    fun selectRoute(routeId: String) {
        val mr = mediaRouter ?: return
        connectRetryJob?.cancel()
        suspendTimeoutJob?.cancel()

        // If the app thinks it's still casting but the session is
        // actually dead (e.g. suspended timeout hasn't fired yet, or
        // the remote media client is gone), clean up before trying to
        // connect so the new session doesn't collide with stale state.
        if (_isCasting.value) {
            val currentSession = castContext?.sessionManager?.currentCastSession
            if (currentSession == null || !currentSession.isConnected) {
                android.util.Log.w("EarhornCast",
                    "selectRoute: stale casting state (session=${currentSession?.isConnected}), resetting")
                _isCasting.value = false
                _castingToGroup.value = false
                _castIsPlaying.value = false
                detachMediaClientCallbacks()
            }
        }

        refreshDevices()
        val route = resolveRoute(mr, routeId) ?: return
        android.util.Log.i("EarhornCast",
            "selectRoute: ${route.name} (id=${route.id} group=${route.isGroup})")
        _castingToGroup.value = route.isGroup
        mr.selectRoute(route)

        // After disconnect, the Chromecast device needs 2-3s to
        // restart its receiver app before accepting new connections.
        // Retry until the session establishes or we hit 4 attempts.
        connectRetryJob = discoveryScope.launch {
            repeat(4) { attempt ->
                kotlinx.coroutines.delay(1500)
                if (_isCasting.value) return@launch
                if (attempt == 0) {
                    // First retry — the initial attempt didn't connect
                    // immediately (device still restarting). Let the
                    // user know we're still trying.
                    android.widget.Toast.makeText(
                        context, "Reconnecting…", android.widget.Toast.LENGTH_SHORT
                    ).show()
                }
                android.util.Log.i("EarhornCast",
                    "selectRoute: retry ${attempt + 1} for ${route.name}")
                refreshDevices()
                val fresh = resolveRoute(mr, routeId) ?: return@launch
                mr.selectRoute(fresh)
            }
        }
    }

    /** Resolve a routeId to a MediaRouter.RouteInfo — tries exact ID
     *  match first, falls back to name match for stale IDs. */
    private fun resolveRoute(
        mr: androidx.mediarouter.media.MediaRouter,
        routeId: String,
    ): androidx.mediarouter.media.MediaRouter.RouteInfo? {
        // 1. Exact ID match — also verify it's Cast-capable. After
        //    disconnect, route IDs can rotate and the same ID might
        //    now belong to a group-member duplicate (matchesCast=false).
        var route = mr.routes.firstOrNull {
            it.id == routeId && it.matchesSelector(routeSelector)
        }
        if (route == null) {
            // 2. Stale ID — resolve by name. MUST filter for
            //    matchesSelector: devices like "HeißeBad" appear
            //    multiple times (as castable device AND as non-castable
            //    group member). Without this filter, firstOrNull can
            //    pick the group-member duplicate → silent cast failure.
            val device = _devices.value.firstOrNull { it.id == routeId }
            if (device != null) {
                route = mr.routes.firstOrNull {
                    it.name == device.friendlyName &&
                    it.matchesSelector(routeSelector) &&
                    !it.isDefault
                }
                if (route != null) {
                    android.util.Log.i("EarhornCast",
                        "resolveRoute: ID stale, resolved by name '${device.friendlyName}' → ${route.id}")
                }
            }
        }
        if (route == null) {
            android.util.Log.w("EarhornCast",
                "resolveRoute: no route for id=$routeId (${mr.routes.size} routes)")
        }
        return route
    }

    /** Current RemoteMediaClient — refreshed whenever session changes. Null when
     *  no session is active. */
    private val remoteMediaClient: RemoteMediaClient?
        get() = castContext?.sessionManager?.currentCastSession?.remoteMediaClient

    /** Force the Cast SDK to pull fresh state from the receiver.
     *
     *  Used by the PlaybackService wake-on-screen receiver after doze:
     *  the Cast sender connection in Google Play Services can be
     *  suspended/throttled while the phone is in deep doze, so the
     *  RemoteMediaClient.Callback (which feeds _castCurrentItemIndex,
     *  _castIsPlaying, etc) stops firing. requestStatus() sends a
     *  STATUS message to the receiver and the response re-triggers
     *  onStatusUpdated / onQueueStatusUpdated, which lets the StateFlow
     *  collectors in PlaybackService re-fan the notification listeners.
     *  No-op when no session is active or the client isn't ready. */
    fun requestStatusRefresh() {
        runCatching {
            remoteMediaClient?.requestStatus()
            android.util.Log.d("EarhornCast", "requestStatusRefresh: dispatched")
        }
    }

    // ── Cast playback state mirrors ─────────────────────────────────
    // Surfaces the remote device's actual play state and position so the
    // app's UI reflects what the Cast device is doing, not what the
    // muted/paused local controller is doing. Without these, the UI
    // shows Play (because local was paused at session start) while the
    // Cast device is actually playing — and the position label freezes
    // at whatever the local controller's last position was.
    private val _castIsPlaying = MutableStateFlow(false)
    val castIsPlaying: StateFlow<Boolean> = _castIsPlaying.asStateFlow()
    private val _castPositionMs = MutableStateFlow(0L)
    val castPositionMs: StateFlow<Long> = _castPositionMs.asStateFlow()
    private val _castDurationMs = MutableStateFlow(0L)
    val castDurationMs: StateFlow<Long> = _castDurationMs.asStateFlow()
    // Index of the receiver's currently-playing item within the queue we
    // sent via loadQueue. -1 when not casting or no queue is loaded. The
    // receiver advances this on its own at end-of-track (because we send
    // the full queue, not single tracks), and LocalRenderer observes it
    // to keep _state.queueIndex / _state.current in sync — otherwise the
    // sender UI stays on track 1 forever while the speaker plays track 5.
    private val _castCurrentItemIndex = MutableStateFlow(-1)
    val castCurrentItemIndex: StateFlow<Int> = _castCurrentItemIndex.asStateFlow()

    // RemoteMediaClient callback — fires on every state transition
    // (loaded, playing, paused, idle, buffering, ended). Pulls the
    // current play state and approximate stream position out and writes
    // them to the StateFlows above.
    private val mediaClientCallback = object : RemoteMediaClient.Callback() {
        override fun onStatusUpdated() {
            val client = remoteMediaClient ?: return
            _castIsPlaying.value = client.isPlaying
            _castPositionMs.value = client.approximateStreamPosition.coerceAtLeast(0L)
            val mediaInfo = client.mediaInfo
            _castDurationMs.value = mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
            _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
        }
        override fun onMetadataUpdated() {
            // Duration only becomes known after the receiver loads the
            // track — pick it up here too in case onStatusUpdated fires
            // before metadata is ready.
            val mediaInfo = remoteMediaClient?.mediaInfo
            _castDurationMs.value = mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
        }
        override fun onQueueStatusUpdated() {
            // Receiver-side queue change (most importantly: end-of-track
            // auto-advance). onStatusUpdated also fires on these, but the
            // queue-specific callback is the authoritative one and runs
            // synchronously when currentItemId changes server-side.
            val client = remoteMediaClient ?: return
            _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
        }
    }

    /** Look up which queue position the receiver is currently playing.
     *
     *  Earlier implementations used `MediaStatus.getQueueItems()` /
     *  `getQueueItem(i)` and iterated to find `currentItemId`. That API is
     *  deprecated for exactly the reason it broke us: `MediaStatus`
     *  carries only a WINDOWED CACHE of queue items (the items most
     *  recently fetched, not the full queue), and iterating it returns
     *  a WINDOW INDEX rather than an absolute queue position. With a
     *  5-track queue, while playing track 3 the cached window might
     *  be items [trackB, trackC, trackD] (positions 1, 2, 3) — finding
     *  currentItemId in that window returned i=1, and our mirror then
     *  pointed _state.queue[1] = trackB at the now-playing track 3.
     *  That's the "audio is track N but UI shows track N-1" symptom.
     *
     *  The correct API is [RemoteMediaClient.getMediaQueue], a separate
     *  [MediaQueue] object whose [MediaQueue.getItemIds] returns the
     *  FULL, ordered list of itemIds — automatically synchronized with
     *  the receiver, never windowed. Finding currentItemId in that array
     *  yields the absolute queue index, which is what LocalRenderer
     *  expects to use as an index into its _state.queue.
     *
     *  Returns -1 if no queue is loaded or the currentItemId can't be
     *  located, which signals "don't try to mirror" to observers. */
    private fun resolveCurrentItemIndex(client: RemoteMediaClient): Int {
        val status = client.mediaStatus ?: return -1
        val currentId = status.currentItemId
        if (currentId == MediaQueueItem.INVALID_ITEM_ID) return -1
        val queue = client.mediaQueue ?: return -1
        val itemIds = queue.itemIds ?: return -1
        if (itemIds.isEmpty()) return -1
        val idx = itemIds.indexOf(currentId)
        return if (idx >= 0) idx else -1
    }

    // MediaQueue change callback. The receiver's queue can change after
    // load (insertions, removals, reorderings, fetched-on-demand item
    // bodies). Each more-specific event also raises mediaQueueChanged,
    // so overriding just that one catches every case while avoiding
    // signature-mismatch risk on the parameterized variants. We need
    // this because the itemIds array can shift positions even when our
    // currentItemId stays the same — re-running resolveCurrentItemIndex
    // on any change keeps _castCurrentItemIndex pointing at the right
    // queue position.
    private val mediaQueueCallback = object : MediaQueue.Callback() {
        override fun mediaQueueChanged() {
            val client = remoteMediaClient ?: return
            _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
        }
    }

    // Sub-second position polling. The RemoteMediaClient only emits status
    // updates on transitions (play, pause, seek, end of track) — between
    // them, approximateStreamPosition advances on its own but nothing
    // triggers a flow emission. We poll at 500ms (matching the local
    // position tick) so the timeline scrubs smoothly during cast playback.
    private val progressListener = RemoteMediaClient.ProgressListener { progressMs, durationMs ->
        _castPositionMs.value = progressMs.coerceAtLeast(0L)
        if (durationMs > 0L) _castDurationMs.value = durationMs
    }

    private val sessionListener = object : SessionManagerListener<GmsCastSession> {
        override fun onSessionStarted(s: GmsCastSession, sessionId: String) {
            suspendTimeoutJob?.cancel()
            onSessionAttached(s)
        }
        override fun onSessionResumed(s: GmsCastSession, wasSuspended: Boolean) {
            suspendTimeoutJob?.cancel()
            // Mark this attach as a RESUME so LocalRenderer does not treat it
            // as a fresh cast and re-push its queue (review N.8, the maintainer's
            // rule 2026-09-02): on reconnection the phone sends nothing.
            _lastAttachWasResume = true
            onSessionAttached(s)
        }
        override fun onSessionEnded(s: GmsCastSession, error: Int) {
            detachMediaClientCallbacks()
            // Close the stream proxy: it has no business staying open once
            // nothing is casting (design decision 2026-09-02, I.32).
            runCatching { streamProxy.stop() }
            _session.value = null
            _isCasting.value = false
            _castingToGroup.value = false
            _castIsPlaying.value = false
            _castPositionMs.value = 0L
            _castDurationMs.value = 0L
            _castCurrentItemIndex.value = -1
        }
        override fun onSessionSuspended(s: GmsCastSession, reason: Int) {
            // Network blip or Cast app backgrounded — keep isCasting=true
            // so the local renderer doesn't un-mute. If the session
            // fully ends we'll transition via onSessionEnded.
            //
            // BUT: if the session stays suspended for too long (network
            // changed, Cast device went offline), the app gets stuck in
            // casting mode with no active session and no way to recover
            // short of force-quitting. Add a timeout — if the session
            // doesn't resume within 15s, treat it as ended so the user
            // can reconnect or start a new session.
            suspendTimeoutJob?.cancel()
            suspendTimeoutJob = discoveryScope.launch {
                kotlinx.coroutines.delay(15_000)
                android.util.Log.w("EarhornCast",
                    "Session suspended > 15s — treating as ended (reason=$reason)")
                onSessionEnded(s, reason)
            }
        }
        override fun onSessionStarting(s: GmsCastSession) = Unit
        override fun onSessionResuming(s: GmsCastSession, sessionId: String) = Unit
        override fun onSessionEnding(s: GmsCastSession) = Unit
        override fun onSessionStartFailed(s: GmsCastSession, error: Int) {
            _isCasting.value = false
            _castingToGroup.value = false
        }
        override fun onSessionResumeFailed(s: GmsCastSession, error: Int) {
            _isCasting.value = false
            _castingToGroup.value = false
        }
    }

    /** Single attachment point for both freshly-started sessions
     *  ([SessionManagerListener.onSessionStarted]) and reconnected ones
     *  ([SessionManagerListener.onSessionResumed]). Previously these two
     *  callbacks held duplicated bodies, which made it easy for the
     *  resume path to drift out of sync with start — e.g. an addition
     *  to the start side ([attachMediaClientCallbacks] priming) that
     *  silently missed the resume side would leave reconnected sessions
     *  responsive to play/pause but unable to track queue advances.
     *  All future "I'm now attached, sync state and start listening"
     *  work lands here so both paths get it. */
    private fun onSessionAttached(s: GmsCastSession) {
        _session.value = s.toDomain()
        _isCasting.value = true
        attachMediaClientCallbacks()
    }

    /** True when the most recent `isCasting → true` came from a session
     *  RESUME (reconnection), not a fresh start. Cleared by the reader. */
    @Volatile private var _lastAttachWasResume: Boolean = false
    fun consumeAttachWasResume(): Boolean {
        val v = _lastAttachWasResume
        _lastAttachWasResume = false
        return v
    }

    /** The contentId (stream URL) the receiver is currently playing, or null. */
    fun currentContentId(): String? =
        runCatching { remoteMediaClient?.mediaInfo?.contentId }.getOrNull()?.let { id ->
            // Group casts (and local files) go through the proxy's tokened
            // links; translate back to the track URL the phone knows, so the
            // resume check can recognise its own queue (review C.3).
            streamProxy.originalFor(id) ?: id
        }

    /** Leave the session WITHOUT stopping the receiver app. Used after a
     *  reconnection when the group turns out to be playing something that
     *  isn't ours (musicnow took it back): the phone stops pretending it is
     *  casting and does not touch what is playing (design decision
     *  2026-09-02). `endCurrentSession(true)` would stop the receiver. */
    fun detachWithoutStopping() {
        _userInitiatedDisconnect.value = false
        runCatching { castContext?.sessionManager?.endCurrentSession(false) }
    }

    private fun attachMediaClientCallbacks() {
        val client = remoteMediaClient ?: return
        runCatching {
            client.registerCallback(mediaClientCallback)
            // Also subscribe to the MediaQueue object — it's what tracks
            // the receiver's full itemIds list. Without this, the itemIds
            // array can change underneath us without _castCurrentItemIndex
            // being re-published, and the UI re-displays a stale track.
            client.mediaQueue?.registerCallback(mediaQueueCallback)
            // 500ms cadence — same as the local position tick. The framework
            // documents 1000ms minimum but in practice 500ms works and gives
            // smoother scrubbing when the timeline is visible. Still cheap;
            // it's all in-process arithmetic on a cached lastKnownPosition.
            client.addProgressListener(progressListener, 500L)
            // Prime the flows with the current state immediately rather than
            // waiting for the first callback fire — this matters when
            // resuming a session (onSessionResumed) where the receiver may
            // already be in a stable state and not emit a status update.
            _castIsPlaying.value = client.isPlaying
            _castPositionMs.value = client.approximateStreamPosition.coerceAtLeast(0L)
            _castDurationMs.value = client.mediaInfo?.streamDuration?.coerceAtLeast(0L) ?: 0L
            _castCurrentItemIndex.value = resolveCurrentItemIndex(client)
        }
    }

    private fun detachMediaClientCallbacks() {
        val client = remoteMediaClient ?: return
        runCatching {
            client.unregisterCallback(mediaClientCallback)
            client.mediaQueue?.unregisterCallback(mediaQueueCallback)
            client.removeProgressListener(progressListener)
        }
    }

    /** Cancel any media notification that isn't Earhorn's own
     *  (NOTIFICATION_ID 2002). The Cast SDK's Default Media Receiver
     *  creates its own notification card when a session starts — we
     *  suppress it here rather than in CastOptionsProvider because
     *  touching the provider's notification config can break Cast
     *  session establishment on some SDK versions. Scheduled with a
     *  short delay because the Cast notification appears async after
     *  onSessionStarted. */
    private fun cancelDuplicateNotifications() {
        android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
            runCatching {
                val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
                    as android.app.NotificationManager
                nm.activeNotifications.forEach { sbn ->
                    if (sbn.id != 2002 && sbn.packageName == context.packageName) {
                        nm.cancel(sbn.tag, sbn.id)
                    }
                }
            }
        }, 1500) // Cast notification appears ~1s after session start
    }

    init {
        castContext?.sessionManager?.addSessionManagerListener(
            sessionListener, GmsCastSession::class.java
        )
    }

    /** Send a track to the connected Cast device. No-op when no session is active.
     *
     *  Builds a MediaInfo from the track's resourceUrl + DIDL metadata. The
     *  Default Media Receiver handles standard formats (mp3, aac, ogg, wav) and
     *  most non-encrypted MP4 containers. FLAC + ALAC are not guaranteed —
     *  the receiver will error out for unsupported formats; that surfaces as
     *  a RemoteMediaClient error callback which the caller can observe via
     *  registerCallback. */
    fun loadTrack(track: DidlItem.Track, autoplay: Boolean = true) {
        val client = remoteMediaClient ?: return
        val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
            putString(MediaMetadata.KEY_TITLE, track.title)
            track.artist?.let { putString(MediaMetadata.KEY_ARTIST, it) }
            track.album?.let { putString(MediaMetadata.KEY_ALBUM_TITLE, it) }
            track.albumArtUri?.let {
                addImage(WebImage(android.net.Uri.parse(it)))
            }
        }
        // Content type: URL extension is the most reliable source —
        // UPnP servers frequently report wrong or exotic MIME types
        // (audio/x-flac, audio/vnd.wave, audio/mpeg3) that single
        // Cast speakers sniff-correct but group leaders trust
        // literally, producing broken audio. Fall through to the
        // server-reported MIME (normalized) only when the URL has no
        // recognizable extension.
        val contentType = inferMimeFromUrl(track.resourceUrl)
            ?: normalizeCastMime(track.mimeType)
        val isGroup = _castingToGroup.value
        // Determine the stream URL. Three cases:
        //   1. content:// URI (local file) → always proxy, Cast can't resolve content://
        //   2. http(s) + group target → proxy for Range-request normalization
        //   3. http(s) + single speaker → direct URL, speaker handles it
        val isLocalFile = track.resourceUrl.startsWith("content://")
        val streamUrl = when {
            isLocalFile -> streamProxy.localProxyUrl(track.resourceUrl, contentType)
            isGroup -> streamProxy.proxyUrl(track.resourceUrl)
            else -> track.resourceUrl
        }
        android.util.Log.i("EarhornCast",
            "loadTrack: '${track.title}' serverMime=${track.mimeType} " +
            "resolvedType=$contentType group=$isGroup local=$isLocalFile autoplay=$autoplay")
        val mediaInfo = MediaInfo.Builder(streamUrl)
            .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
            .setContentType(contentType)
            .setMetadata(metadata)
            .apply {
                track.duration?.inWholeMilliseconds?.let { setStreamDuration(it) }
            }
            .build()
        val request = MediaLoadRequestData.Builder()
            .setMediaInfo(mediaInfo)
            .setAutoplay(autoplay)
            .build()
        client.load(request)
    }

    /** Send a full queue to the receiver in one call so that next-track
     *  advancement happens receiver-side (without requiring the sender
     *  app to be in the foreground or even running). Maps each track to
     *  a [MediaQueueItem] using the same MIME inference and proxy
     *  routing as [loadTrack], then issues a single
     *  [RemoteMediaClient.queueLoad].
     *
     *  Without this, the receiver only knows about the currently
     *  playing track and stops at the end. Symptom: phone-side queue
     *  advances visually but Shield/CCA never starts the next track,
     *  user sees one song play then silence. */
    /** Build the receiver-side queue items for [tracks], with the same MIME
     *  inference and proxy routing as [loadTrack]. Shared by [loadQueue] and
     *  the in-place queue edits below. */
    private fun buildQueueItems(tracks: List<DidlItem.Track>): Array<MediaQueueItem> {
        val isGroup = _castingToGroup.value
        return tracks.map { track ->
            val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
                putString(MediaMetadata.KEY_TITLE, track.title)
                track.artist?.let { putString(MediaMetadata.KEY_ARTIST, it) }
                track.album?.let { putString(MediaMetadata.KEY_ALBUM_TITLE, it) }
                track.albumArtUri?.let {
                    addImage(WebImage(android.net.Uri.parse(it)))
                }
            }
            val contentType = inferMimeFromUrl(track.resourceUrl)
                ?: normalizeCastMime(track.mimeType)
            val isLocalFile = track.resourceUrl.startsWith("content://")
            val streamUrl = when {
                isLocalFile -> streamProxy.localProxyUrl(track.resourceUrl, contentType)
                isGroup -> streamProxy.proxyUrl(track.resourceUrl)
                else -> track.resourceUrl
            }
            val mediaInfo = MediaInfo.Builder(streamUrl)
                .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
                .setContentType(contentType)
                .setMetadata(metadata)
                .apply {
                    track.duration?.inWholeMilliseconds?.let { setStreamDuration(it) }
                }
                .build()
            // autoplay=true on each ITEM is what makes the receiver flow from
            // one track to the next at end-of-track. Whether the FIRST item
            // starts playing is decided by the load request, below.
            MediaQueueItem.Builder(mediaInfo)
                .setAutoplay(true)
                .build()
        }.toTypedArray()
    }

    /** Send a full queue to the receiver in one call so that next-track
     *  advancement happens receiver-side (without requiring the sender
     *  app to be in the foreground or even running).
     *
     *  [autoplay] is honoured (design decision 2026-09-02; review
     *  N.8/N.13): it used to be logged and ignored, so every queue load
     *  played — casting from a paused phone started audio, and every queue
     *  edit un-paused the speaker. The load now goes through
     *  MediaLoadRequestData with the queue in queueData, which carries the
     *  autoplay flag the bare queueLoad() call never did. */
    fun loadQueue(
        tracks: List<DidlItem.Track>,
        startIndex: Int = 0,
        autoplay: Boolean = true,
        repeat: RepeatMode = RepeatMode.OFF,
        shuffle: Boolean = false,
    ) {
        val client = remoteMediaClient ?: return
        if (tracks.isEmpty()) return
        val items = buildQueueItems(tracks)
        val safeStart = startIndex.coerceIn(0, items.lastIndex)
        val castRepeatMode = mapToCastRepeatMode(repeat, shuffle)
        android.util.Log.i("EarhornCast",
            "loadQueue: ${items.size} items, startIndex=$safeStart group=${_castingToGroup.value} autoplay=$autoplay repeat=$repeat shuffle=$shuffle castMode=$castRepeatMode")
        val queueData = com.google.android.gms.cast.MediaQueueData.Builder()
            .setItems(items.toList())
            .setStartIndex(safeStart)
            .setRepeatMode(castRepeatMode)
            .build()
        val request = MediaLoadRequestData.Builder()
            .setQueueData(queueData)
            .setAutoplay(autoplay)
            .build()
        client.load(request)
    }

    // ── In-place queue edits (review N.12/N.13, design decision 2026-09-02) ──
    // None of these reload the queue: nothing restarts, nothing un-pauses.

    /** Append [tracks] to the end of the receiver's queue. */
    fun queueAppend(tracks: List<DidlItem.Track>, fullQueue: List<DidlItem.Track> = tracks) {
        val client = remoteMediaClient ?: return
        if (tracks.isEmpty()) return
        // Maintainer, 2026-10-29: connected but nothing loaded on the receiver
        // (no media session) — there is no queue to append to, so
        // queueAppendItem did nothing. Load the phone's WHOLE queue so the
        // receiver's indices line up with the phone's, starting at the first
        // appended track (D4).
        if (client.mediaStatus == null) {
            // autoplay = false: queue edits never start or un-pause the
            // speaker (ruling 2026-09-02); enqueueAndPlay follows with play().
            loadQueue(
                fullQueue,
                startIndex = (fullQueue.size - tracks.size).coerceAtLeast(0),
                autoplay = false,
            )
            return
        }
        runCatching { client.queueAppendItem(buildQueueItems(tracks).first(), null) }
        if (tracks.size > 1) {
            runCatching {
                client.queueInsertItems(buildQueueItems(tracks.drop(1)), MediaQueueItem.INVALID_ITEM_ID, null)
            }
        }
    }

    /** Insert [tracks] immediately after the receiver's current item. */
    fun queueInsertAfterCurrent(tracks: List<DidlItem.Track>) {
        val client = remoteMediaClient ?: return
        if (tracks.isEmpty()) return
        val ids = client.mediaQueue?.itemIds ?: return
        val curIdx = resolveCurrentItemIndex(client)
        // Insert BEFORE the item that follows the current one; if there is
        // none, append (INVALID_ITEM_ID = end of queue).
        val beforeId = ids.getOrNull(curIdx + 1) ?: MediaQueueItem.INVALID_ITEM_ID
        runCatching { client.queueInsertItems(buildQueueItems(tracks), beforeId, null) }
    }

    /** Remove the item at [index] (sender-side queue position) from the receiver. */
    fun queueRemoveAt(index: Int) {
        val client = remoteMediaClient ?: return
        val itemId = client.mediaQueue?.itemIds?.getOrNull(index) ?: return
        runCatching { client.queueRemoveItem(itemId, null) }
    }

    /** Move the item at [from] to position [to] on the receiver. */
    fun queueMove(from: Int, to: Int) {
        val client = remoteMediaClient ?: return
        val ids = client.mediaQueue?.itemIds ?: return
        val itemId = ids.getOrNull(from) ?: return
        runCatching { client.queueMoveItemToNewIndex(itemId, to, null) }
    }

    /** Push a repeat/shuffle change to an in-flight cast session without
     *  reloading the queue. Called by [LocalRenderer.setShuffle] and
     *  [LocalRenderer.setRepeatMode] when a session is active — without
     *  this, the local UI flips the shuffle icon but the receiver keeps
     *  advancing the queue in its loaded order, so the next track plays
     *  sequentially regardless of what the button says. */
    fun setRepeatAndShuffle(repeat: RepeatMode, shuffle: Boolean) {
        val client = remoteMediaClient ?: return
        val castMode = mapToCastRepeatMode(repeat, shuffle)
        android.util.Log.i("EarhornCast",
            "setRepeatAndShuffle: repeat=$repeat shuffle=$shuffle castMode=$castMode")
        runCatching { client.queueSetRepeatMode(castMode, null) }
    }

    /** Combine Earhorn's two-axis (RepeatMode × Boolean shuffle) model
     *  down to Cast's single repeat-mode enum. The Cast API doesn't have
     *  shuffle-without-repeat; the closest expression of "play in random
     *  order" is REPEAT_ALL_AND_SHUFFLE, which also loops the queue.
     *  When user wants shuffle without repeat, we accept that compromise
     *  rather than the alternative (reordering the receiver-side queue
     *  manually on every toggle, which is a much bigger surface).
     *
     *  REPEAT_ONE takes precedence over shuffle — repeating a single
     *  track makes shuffle moot, and Media3's local player handles this
     *  combination the same way (shuffle is irrelevant when not
     *  advancing). */
    private fun mapToCastRepeatMode(repeat: RepeatMode, shuffle: Boolean): Int = when {
        repeat == RepeatMode.ONE -> MediaStatus.REPEAT_MODE_REPEAT_SINGLE
        shuffle -> MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE
        repeat == RepeatMode.ALL -> MediaStatus.REPEAT_MODE_REPEAT_ALL
        else -> MediaStatus.REPEAT_MODE_REPEAT_OFF
    }

    /** Infer MIME from the resource URL's file extension. More reliable
     *  than the UPnP server's reported protocolInfo MIME because servers
     *  frequently use vendor-prefixed or legacy subtypes that the Cast
     *  Default Media Receiver doesn't recognize. Returns null for
     *  unrecognized extensions so the caller can fall through to the
     *  server-reported MIME. */
    private fun inferMimeFromUrl(url: String?): String? {
        if (url.isNullOrBlank()) return null
        val path = url.substringBefore('?').substringBefore('#')
        val ext = path.substringAfterLast('.', "").lowercase()
        return when (ext) {
            "flac" -> "audio/flac"
            "mp3" -> "audio/mpeg"
            "m4a", "aac" -> "audio/mp4"
            "mp4" -> "audio/mp4"
            "ogg", "oga" -> "audio/ogg"
            "opus" -> "audio/ogg"
            "wav", "wave" -> "audio/wav"
            "wma" -> "audio/x-ms-wma"
            "webm" -> "audio/webm"
            "aif", "aiff" -> "audio/aiff"
            "dsf" -> "audio/dsf"
            "dff" -> "audio/dsd"
            else -> null
        }
    }

    /**
     * Map a DLNA-reported MIME to the canonical form Chromecast accepts.
     *
     * UPnP servers differ: MinimServer emits "audio/flac", BubbleUPnP emits
     * "audio/x-flac", Windows Media emits "audio/vnd.wave", older servers
     * emit "audio/mpeg3" — all for the same underlying format. The Cast
     * receiver rejects anything it doesn't recognize, and the x-/vnd./legacy
     * variants are exactly what it doesn't recognize.
     *
     * For unknown inputs, falls back to "audio/mpeg" (most compatible) rather
     * than a wildcard content type which the Cast SDK treats as unsupported.
     */
    private fun normalizeCastMime(mime: String?): String {
        if (mime.isNullOrBlank()) return "audio/mpeg"
        val base = mime.substringBefore(';').trim().lowercase()
        val subtype = base.substringAfter('/', missingDelimiterValue = base)
            .removePrefix("x-")
            .removePrefix("vnd.")
        return when (subtype) {
            "mpeg", "mpeg3", "mp3" -> "audio/mpeg"
            "mp4", "m4a", "aac" -> "audio/mp4"
            "flac", "flac+ogg" -> "audio/flac"
            "ogg", "vorbis", "ogg-vorbis" -> "audio/ogg"
            "opus" -> "audio/ogg"
            "wav", "wave", "x-wav" -> "audio/wav"
            "webm" -> "audio/webm"
            else -> "audio/$subtype"
        }
    }

    /** Remote playback controls. No-op when no session is active. */
    fun play() { remoteMediaClient?.play() }
    fun pause() { remoteMediaClient?.pause() }
    fun stop() { remoteMediaClient?.stop() }
    fun seekTo(positionMs: Long) {
        val client = remoteMediaClient ?: return
        client.seek(
            com.google.android.gms.cast.MediaSeekOptions.Builder()
                .setPosition(positionMs)
                .build()
        )
    }

    /** Advance to next item in the receiver's loaded queue. */
    fun queueNext() {
        remoteMediaClient?.queueNext(null)
    }

    /** Go back to previous item in the receiver's loaded queue. */
    fun queuePrev() {
        remoteMediaClient?.queuePrev(null)
    }

    /** Jump to a specific index in the receiver's loaded queue. */
    fun queueJumpTo(index: Int) {
        val client = remoteMediaClient ?: return
        // Use the full receiver-synchronized itemIds list (see
        // resolveCurrentItemIndex's comment for why MediaStatus.queueItems
        // is the wrong source here — it's a windowed cache that can shift
        // out from under us, causing this jump to land on a wrong track).
        val itemIds = client.mediaQueue?.itemIds ?: return
        val itemId = itemIds.getOrNull(index) ?: return
        client.queueJumpToItem(itemId, null)
    }

    /** Maintainer, 2026-10-29 (D4): [queueJumpTo] for an index that may not be
     *  in the receiver's queue yet (an append still in flight). Polls the
     *  receiver-synchronized itemIds every 150 ms, on the main thread
     *  (RemoteMediaClient's thread), for up to ~5 s; jumps once the index
     *  exists, gives up quietly otherwise. */
    suspend fun queueJumpToWhenReady(index: Int) {
        kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
            var waitedMs = 0L
            while (true) {
                val client = remoteMediaClient ?: return@withContext
                val itemIds = client.mediaQueue?.itemIds
                val itemId = itemIds?.getOrNull(index)
                if (itemId != null) {
                    client.queueJumpToItem(itemId, null)
                    return@withContext
                }
                if (waitedMs >= 5_000L) {
                    android.util.Log.w("EarhornCast", "queueJumpToWhenReady: index $index never appeared")
                    return@withContext
                }
                kotlinx.coroutines.delay(150L)
                waitedMs += 150L
            }
        }
    }

    fun setVolume(volume: Double) {
        runCatching { remoteMediaClient?.setStreamVolume(volume.coerceIn(0.0, 1.0)) }
    }

    /** Device-level volume (hardware volume of the Cast speaker).
     *  Used by hardware volume button interception in the Activity. */
    fun getDeviceVolume(): Double =
        castContext?.sessionManager?.currentCastSession?.volume ?: 1.0

    fun setDeviceVolume(volume: Double) {
        runCatching {
            castContext?.sessionManager?.currentCastSession?.volume =
                volume.coerceIn(0.0, 1.0)
        }
    }

    private var discoveryTickJob: kotlinx.coroutines.Job? = null
    private val discoveryScope = kotlinx.coroutines.CoroutineScope(
        kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main
    )

    fun startDiscovery() {
        val mr = mediaRouter ?: return
        android.util.Log.i("EarhornCast", "startDiscovery")
        // Broad selector so MediaRouter reports every remote-playback-capable
        // route, not only ones advertising Cast's Default Media Receiver app.
        // CATEGORY_REMOTE_PLAYBACK is the widest sensible filter for "device
        // that can play media we push to it." Combined with PERFORM_ACTIVE_SCAN
        // to force mDNS queries on entry rather than waiting for the passive
        // cache.
        val broad = androidx.mediarouter.media.MediaRouteSelector.Builder()
            .addControlCategory(androidx.mediarouter.media.MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
            .addControlCategory(
                com.google.android.gms.cast.CastMediaControlIntent.categoryForCast(
                    com.google.android.gms.cast.CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
                )
            )
            .build()
        mr.addCallback(
            broad,
            routerCallback,
            androidx.mediarouter.media.MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN,
        )
        refreshDevices()
        // Periodic re-check. Cast discovery via mDNS is inherently lossy and
        // can take 5-10 seconds to populate, and not every route-change event
        // fires reliably on every OEM's Android TV. Poll every second while
        // the picker is open so devices appear as they come online.
        discoveryTickJob?.cancel()
        discoveryTickJob = discoveryScope.launch {
            while (true) {
                kotlinx.coroutines.delay(1000)
                refreshDevices()
            }
        }
    }
    fun stopDiscovery() {
        val mr = mediaRouter ?: return
        discoveryTickJob?.cancel()
        discoveryTickJob = null
        mr.removeCallback(routerCallback)
    }
    fun connect(deviceId: String) = selectRoute(deviceId)
    fun disconnect() {
        _userInitiatedDisconnect.value = true
        // true = end session AND stop the receiver app. This also
        // clears MediaRouter's selected-route state internally, so
        // re-selecting the same route later is NOT a no-op.
        // The device needs 2-3s to restart — selectRoute's retry
        // loop handles that delay.
        castContext?.sessionManager?.endCurrentSession(true)
    }

    /** True iff the most recent session end was triggered by the user
     *  calling [disconnect]. Auto-cleared on the next session start. */
    private val _userInitiatedDisconnect = MutableStateFlow(false)
    val userInitiatedDisconnect: StateFlow<Boolean> = _userInitiatedDisconnect.asStateFlow()

    /** Called by consumers (LocalRenderer) after they've acted on a
     *  session-end event so the next external disconnect doesn't
     *  inherit a stale "user-initiated" flag. */
    fun consumeDisconnectIntent(): Boolean {
        val v = _userInitiatedDisconnect.value
        _userInitiatedDisconnect.value = false
        return v
    }

    private fun GmsCastSession.toDomain(): CastSession {
        val device: GmsCastDevice? = castDevice
        return CastSession(
            deviceId = device?.deviceId.orEmpty(),
            deviceName = device?.friendlyName.orEmpty()
        )
    }
}

data class CastDevice(
    val id: String,
    val friendlyName: String,
    val isGroup: Boolean,
)

data class CastSession(
    val deviceId: String,
    val deviceName: String,
)

/** Snapshot of what the Cast discovery plumbing is currently seeing. Consumed by
 *  the Cast picker UI to show the user what's happening when devices don't
 *  appear. Updated on every refresh tick (roughly once per second while the
 *  picker is open). */
data class DiagnosticState(
    /** True if CastContext.getSharedInstance() returned non-null. False implies
     *  the Cast framework didn't initialize — usually Play Services missing
     *  or too old on this Android TV. */
    val castContextOk: Boolean = false,
    /** When Cast init failed, the captured exception summary (class name +
     *  message). Shown in the diagnostic panel to reveal the actual reason
     *  behind a 'missing' status. Null when init succeeded OR when init has
     *  not yet been attempted. */
    val castInitError: String? = null,
    /** True if MediaRouter.getInstance() returned non-null. Should effectively
     *  always be true on any Android build. */
    val mediaRouterOk: Boolean = false,
    /** Human-readable summary of GoogleApiAvailability.isGooglePlayServicesAvailable().
     *  SUCCESS means Cast should work; anything else probably means it won't.
     *  SERVICE_MISSING is common on OEM Android TV builds that ship without
     *  the full Play Services. */
    val playServices: String = "unknown",
    /** Number of routes MediaRouter is reporting — including default and
     *  bluetooth that our filter removes. Zero means discovery hasn't
     *  found anything; any positive number at least means the pipeline works. */
    val rawRouteCount: Int = 0,
    /** Number of routes that survive filtering and become pickable devices. */
    val filteredDeviceCount: Int = 0,
    /** Tick counter — increments on every refresh. If it stops advancing, the
     *  discovery coroutine died. If it keeps advancing but rawRouteCount
     *  stays 0, MediaRouter isn't getting any mDNS responses. */
    val tick: Int = 0,
    /** First few route names (with flags) to help identify what MediaRouter
     *  is actually seeing — e.g. the TV's own speakers will often appear here. */
    val rawRouteNames: List<String> = emptyList(),
)