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>(emptyList()) val devices: StateFlow> = _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 = _diagnostic.asStateFlow() private val _session = MutableStateFlow(null) val session: StateFlow = _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 = _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 = _castIsPlaying.asStateFlow() private val _castPositionMs = MutableStateFlow(0L) val castPositionMs: StateFlow = _castPositionMs.asStateFlow() private val _castDurationMs = MutableStateFlow(0L) val castDurationMs: StateFlow = _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 = _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 { 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): Array { 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, 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, fullQueue: List = 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) { 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 = _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 = emptyList(), )