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


