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(),
)