CastRenderer.kt
1044 lines · 52.1 KB
Changed in: 0.5.0
| 1 | package dev.stave.cast |
| 2 | |
| 3 | import dev.stave.domain.model.DidlItem |
| 4 | import dev.stave.domain.renderer.PlaybackState |
| 5 | import dev.stave.domain.renderer.Renderer |
| 6 | import dev.stave.domain.renderer.RepeatMode |
| 7 | import dev.stave.domain.renderer.ScopedRenderer |
| 8 | import kotlinx.coroutines.CoroutineScope |
| 9 | import kotlinx.coroutines.Dispatchers |
| 10 | import kotlinx.coroutines.Job |
| 11 | import kotlinx.coroutines.SupervisorJob |
| 12 | import kotlinx.coroutines.cancel |
| 13 | import kotlinx.coroutines.delay |
| 14 | import kotlinx.coroutines.flow.MutableStateFlow |
| 15 | import kotlinx.coroutines.flow.StateFlow |
| 16 | import kotlinx.coroutines.flow.asStateFlow |
| 17 | import kotlinx.coroutines.flow.update |
| 18 | import kotlinx.coroutines.launch |
| 19 | import kotlinx.coroutines.withContext |
| 20 | import su.litvak.chromecast.api.v2.ChromeCast |
| 21 | import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent |
| 22 | import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener |
| 23 | import su.litvak.chromecast.api.v2.MediaStatus |
| 24 | import kotlin.time.Duration |
| 25 | import kotlin.time.Duration.Companion.milliseconds |
| 26 | |
| 27 | /** |
| 28 | * A [Renderer] implementation backed by a Chromecast device (or speaker group) |
| 29 | * via the existing [CastIntegration]. The rest of the app treats this exactly |
| 30 | * like the local libvlc renderer or a remote UPnP renderer — load tracks, hit |
| 31 | * play, the audio comes out wherever the user picked. |
| 32 | * |
| 33 | * **Where this fits.** [RendererCoordinator] selects between three renderer |
| 34 | * kinds based on the user's `activeRendererUdn` setting: |
| 35 | * - `null` → local libvlc, audio out the laptop speakers |
| 36 | * - UPnP UDN → [dev.stave.upnp.renderer.RemoteUpnpRenderer] driving a |
| 37 | * remote AVTransport device |
| 38 | * - `cast:<deviceId>` → this class, driving a Chromecast or speaker group |
| 39 | * |
| 40 | * **Why not just reuse CastIntegration directly.** The app's playback layer |
| 41 | * speaks the [Renderer] surface (suspend functions, a [StateFlow] of |
| 42 | * [PlaybackState], a queue model). [CastIntegration] is a thinner wrapper that |
| 43 | * speaks the chromecast-java-api-v2 surface — fire-and-forget methods, no |
| 44 | * unified state, no queue. This adapter bridges the two. |
| 45 | * |
| 46 | * **Queue ownership.** chromecast-java-api-v2 does have a queue concept |
| 47 | * (`loadQueue`), but it's underspecified across receiver firmware versions and |
| 48 | * historically flaky for speaker groups. We keep our own list-of-tracks in |
| 49 | * memory and use single-track loads, advancing on the `IdleReason.FINISHED` |
| 50 | * event that the receiver sends when a track ends naturally. This matches the |
| 51 | * shape [dev.stave.upnp.renderer.RemoteUpnpRenderer] uses for the same reason |
| 52 | * (UPnP AVTransport has no protocol-level queue either). |
| 53 | * |
| 54 | * **State translation.** A [ChromeCastSpontaneousEventListener] subscribes to |
| 55 | * the active [su.litvak.chromecast.api.v2.ChromeCast] and turns each incoming |
| 56 | * [ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS] into a |
| 57 | * [PlaybackState] update. Position isn't evented — we poll it on a 500 ms |
| 58 | * ticker while playing, same as the UPnP remote does. |
| 59 | * |
| 60 | * **Lifecycle.** Construct with the device id, call [attach] to register the |
| 61 | * spontaneous-event listener and start the position ticker. Call [detach] when |
| 62 | * switching away from this renderer (the coordinator does this). [detach] |
| 63 | * unregisters the listener but does NOT disconnect the Cast session — the user |
| 64 | * picked another renderer, they didn't say "stop casting." If they want to end |
| 65 | * the session, the Cast UI surface should call [CastIntegration.disconnect] |
| 66 | * directly. |
| 67 | */ |
| 68 | class CastRenderer( |
| 69 | private val cast: CastIntegration, |
| 70 | /** The id used to identify the target device. Matches the id returned by |
| 71 | * [CastIntegration.devices] (which is the mDNS address). Used during |
| 72 | * [attach] to select the device on the underlying integration and as part |
| 73 | * of the `cast:<id>` UDN convention used in the renderer coordinator. */ |
| 74 | val targetDeviceId: String, |
| 75 | /** Seed for [_state.volumePercent] before the first authoritative reading |
| 76 | * from the device arrives via [syncVolumeFromDevice]. Without this the |
| 77 | * default PlaybackState() value of 100 would be exposed to the UI during |
| 78 | * the window between construction and first sync (typically ~250-1000ms |
| 79 | * depending on receiver app boot), causing the meter to flash 100% before |
| 80 | * snapping to the device's actual level — visually identical to a |
| 81 | * volume blast even though we never sent a 100% command. The |
| 82 | * coordinator passes the outgoing renderer's last-known volume so the |
| 83 | * meter starts at a sensible value during the sync window. */ |
| 84 | initialVolumePercent: Int = 100, |
| 85 | ) : ScopedRenderer { |
| 86 | |
| 87 | private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
| 88 | |
| 89 | private val _state = MutableStateFlow(PlaybackState(volumePercent = initialVolumePercent)) |
| 90 | override val state: StateFlow<PlaybackState> = _state.asStateFlow() |
| 91 | |
| 92 | /** Local queue. Cast's own queueLoad is not used — see class kdoc. |
| 93 | * Read and written only on [queueCtx] (Team 3 P2-2): user actions and the |
| 94 | * track-end advance used to run on different IO threads, and a track |
| 95 | * ending while a row was removed could throw out of `queue.toList()`. */ |
| 96 | private val queue: MutableList<DidlItem.Track> = mutableListOf() |
| 97 | |
| 98 | /** One thread at a time for [queue] / [queueIndex]. FIFO. */ |
| 99 | private val queueCtx = Dispatchers.IO.limitedParallelism(1) |
| 100 | |
| 101 | /** mediaSessionId of the receiver's media we last advanced away from. |
| 102 | * The receiver's own FINISHED broadcast and a poll already in flight can |
| 103 | * both report the same track end; advancing on each skipped a track |
| 104 | * (Team 3 P2-3). Every LOAD gets a new mediaSessionId. */ |
| 105 | @Volatile private var lastAdvancedMediaSessionId: Long = -1L |
| 106 | |
| 107 | /** Index into [queue] of the currently-loaded track. -1 when nothing loaded. */ |
| 108 | private var queueIndex: Int = -1 |
| 109 | |
| 110 | /** Last MediaStatus's IdleReason. Used to disambiguate "track ended naturally |
| 111 | * → advance" vs "user pressed stop → stay stopped". */ |
| 112 | private var lastIdleReason: MediaStatus.IdleReason? = null |
| 113 | |
| 114 | /** |
| 115 | * Timestamps of the most recent transport commands. Used to suppress |
| 116 | * contradictory state updates that arrive shortly after. |
| 117 | * |
| 118 | * The problem: after the user issues Stop or Pause, we optimistically |
| 119 | * set `_state.isPlaying = false` immediately. But the status poller |
| 120 | * (running every 500 ms while we think we're playing) often has a |
| 121 | * `getMediaStatus()` in flight already, OR the receiver hasn't yet |
| 122 | * processed the pause/stop command by the time the next poll happens. |
| 123 | * Either way, the poll returns `PLAYING`, [onMediaStatus] overwrites |
| 124 | * our `false` with `true`, and the UI flickers — the user sees the |
| 125 | * play icon return and assumes the Stop button didn't work. They press |
| 126 | * it again, second time looks like it succeeded (because by then the |
| 127 | * receiver actually has processed the original command). |
| 128 | * |
| 129 | * The fix: ignore poll-reported `PLAYING` within a short window after |
| 130 | * Stop/Pause, and ignore poll-reported `PAUSED`/`IDLE` within a short |
| 131 | * window after Play. The receiver acknowledges within ~500 ms in |
| 132 | * practice, so [TRANSPORT_SUPPRESS_MS] of 1500 ms covers it generously |
| 133 | * without trapping the UI in a stale state if the command genuinely |
| 134 | * failed (a real failure will show up after the window passes). |
| 135 | */ |
| 136 | @Volatile private var lastPauseLikeCommandMs: Long = 0L |
| 137 | @Volatile private var lastPlayLikeCommandMs: Long = 0L |
| 138 | |
| 139 | /** Timestamp of the most recent user-initiated [stop] call. Tracked |
| 140 | * separately from [lastPauseLikeCommandMs] because Stop and Pause have |
| 141 | * fundamentally different intent: Pause says "I might resume in a |
| 142 | * moment", while Stop says "I'm done listening, don't keep going." |
| 143 | * |
| 144 | * Used by the auto-advance fallback path in [onMediaStatus]: when the |
| 145 | * receiver reports IDLE with a NULL `idleReason` (some Chromecast |
| 146 | * firmwares and the chromecast-java-api-v2 library leave this unset |
| 147 | * for HTTP audio streams), we have to guess whether IDLE means |
| 148 | * "track ended naturally → advance" or "user pressed Stop → stay |
| 149 | * put". Within [USER_STOP_SUPPRESS_MS] of a user Stop, we choose the |
| 150 | * conservative answer; outside it, we advance. */ |
| 151 | @Volatile private var lastUserStopMs: Long = 0L |
| 152 | |
| 153 | /** Ticker that polls position while playing. Null when not active. */ |
| 154 | private var positionPollerJob: Job? = null |
| 155 | |
| 156 | /** |
| 157 | * Latches true the first time the user manually sets volume via [setVolume]. |
| 158 | * Once set, the attach-time volume-sync loop stops overwriting the slider |
| 159 | * with the device's reported level. |
| 160 | * |
| 161 | * Why: the sync loop retries with backoff for several seconds because a |
| 162 | * Chromecast (especially a CCA group leader) is slow to report a volume |
| 163 | * after the receiver app launches. If the user adjusts the slider during |
| 164 | * that window, a late sync attempt would read the device's level — which |
| 165 | * for a group leader is often the group master (1.0 / 100%), NOT the |
| 166 | * per-member level the user just set — and clobber the user's value a few |
| 167 | * seconds after they set it. The user's explicit input is authoritative; |
| 168 | * once they've touched the slider, we never auto-overwrite it. */ |
| 169 | @Volatile private var volumeUserOverride: Boolean = false |
| 170 | |
| 171 | private var attached: Boolean = false |
| 172 | |
| 173 | private val spontaneousListener = ChromeCastSpontaneousEventListener { event -> |
| 174 | if (!attached) return@ChromeCastSpontaneousEventListener |
| 175 | when (event.type) { |
| 176 | ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS -> { |
| 177 | val status = runCatching { |
| 178 | event.getData(MediaStatus::class.java) |
| 179 | }.getOrNull() ?: return@ChromeCastSpontaneousEventListener |
| 180 | onMediaStatus(status) |
| 181 | } |
| 182 | ChromeCastSpontaneousEvent.SpontaneousEventType.CLOSE -> { |
| 183 | // The library fires CLOSE whenever its TCP-TLS keepalive to |
| 184 | // the receiver times out. That happens for two very different |
| 185 | // reasons: |
| 186 | // 1. The receiver app legitimately shut down (user pressed |
| 187 | // Home on the TV, sleep timer fired on a speaker) |
| 188 | // 2. Transient network blip — receiver is still alive and, |
| 189 | // crucially, STILL PLAYING THE STREAM autonomously |
| 190 | // |
| 191 | // We can't distinguish the two from the CLOSE event alone. |
| 192 | // The user-visible symptom of confusing them was: music kept |
| 193 | // pouring out of the speaker, but our UI flipped to Stopped, |
| 194 | // the play button replaced pause, and the progress bar froze |
| 195 | // a frame before going stale. The desktop and the speaker |
| 196 | // told the user contradictory stories. |
| 197 | // |
| 198 | // The right answer is to NOT mutate playback state on CLOSE. |
| 199 | // The state flow simply freezes at its last-known values |
| 200 | // (the poller stops running once cast.session is null, so |
| 201 | // nothing else overwrites it either). The cast banner above |
| 202 | // the transport already shows "Reconnecting to X…" off |
| 203 | // CastIntegration.isReconnecting, so the user has the |
| 204 | // context. If the receiver really did shut down, the next |
| 205 | // observation (or the user pressing Stop) will reconcile. |
| 206 | System.err.println( |
| 207 | "[EarhornCast/Renderer] CLOSE event — freezing state, " + |
| 208 | "reconnect loop will handle resumption" |
| 209 | ) |
| 210 | } |
| 211 | else -> { /* STATUS, APPEVENT, UNKNOWN — not interesting for transport */ } |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Open the session against the target device and subscribe to its events. |
| 217 | * Idempotent. Returns immediately; session establishment happens asynchronously |
| 218 | * inside [CastIntegration] and the [state] flow updates when it completes. |
| 219 | */ |
| 220 | fun attach() { |
| 221 | if (attached) return |
| 222 | attached = true |
| 223 | |
| 224 | // Make sure the integration is connected to the device we're targeting. |
| 225 | // selectDevice() is a no-op when already on this device, and idempotent |
| 226 | // for the chromecast-java-api-v2 connect() call underneath. |
| 227 | cast.selectDevice(targetDeviceId) |
| 228 | |
| 229 | // Start polling for media status immediately. This is the primary source |
| 230 | // of truth for UI state — see startStatusPoller() for the rationale. |
| 231 | // (We *also* register the spontaneous-event listener below, but treat it |
| 232 | // as a low-latency optimization rather than a requirement.) |
| 233 | startStatusPoller() |
| 234 | |
| 235 | // Wait for the session to land, then register the spontaneous event |
| 236 | // listener on the underlying ChromeCast handle. The session may not |
| 237 | // exist yet at the moment attach() returns, so we observe the session |
| 238 | // flow until it does. |
| 239 | scope.launch { |
| 240 | cast.session.collect { sess -> |
| 241 | if (sess != null && sess.deviceId == targetDeviceId) { |
| 242 | // A session that (re)appears after a dropout: the receiver |
| 243 | // app was relaunched and holds nothing. Ask it; if it has |
| 244 | // no media, the next press reloads the current track. We |
| 245 | // never load on the reconnect itself (design decision). |
| 246 | runCatching { sess.backing.mediaStatus } |
| 247 | .onSuccess { st -> if (st?.media == null) hasMediaOnReceiver = false } |
| 248 | .onFailure { hasMediaOnReceiver = false } |
| 249 | runCatching { |
| 250 | sess.backing.registerListener(spontaneousListener) |
| 251 | }.onFailure { t -> |
| 252 | System.err.println( |
| 253 | "[EarhornCast/Renderer] registerListener failed: " + |
| 254 | "${t.javaClass.simpleName}: ${t.message}" |
| 255 | ) |
| 256 | } |
| 257 | // Sync the slider to the device's ACTUAL volume. Without |
| 258 | // this the slider defaults to whatever PlaybackState's |
| 259 | // initial volumePercent is (typically 100), so the user |
| 260 | // sees "100%" while the device is actually at 30% or |
| 261 | // wherever it was left. First touch of the slider then |
| 262 | // snaps to 100% and audibly blasts. |
| 263 | // |
| 264 | // The first sync attempt usually fires before the |
| 265 | // receiver app has fully launched, so cc.status returns |
| 266 | // null or a stale value. Retry on a short backoff until |
| 267 | // we get a real reading or we've tried 8 times. The |
| 268 | // backoff caps at 2s so a genuinely slow boot still |
| 269 | // converges in under ~10s, and an instant boot |
| 270 | // converges on the first try. |
| 271 | launch { |
| 272 | var delay = INITIAL_VOLUME_SYNC_DELAY_MS |
| 273 | repeat(VOLUME_SYNC_MAX_ATTEMPTS) { |
| 274 | // Stop if the user has taken manual control of the |
| 275 | // slider — their value wins over any device read. |
| 276 | // Without this, a late sync attempt clobbers a |
| 277 | // just-set volume (CCA group leaders report the |
| 278 | // group master, not the level the user set). |
| 279 | if (volumeUserOverride) return@launch |
| 280 | if (syncVolumeFromDevice(sess.backing)) { |
| 281 | return@launch |
| 282 | } |
| 283 | delay(delay) |
| 284 | delay = (delay * 3 / 2).coerceAtMost(VOLUME_SYNC_MAX_DELAY_MS) |
| 285 | } |
| 286 | System.err.println( |
| 287 | "[EarhornCast/Renderer] volume sync exhausted retries — " + |
| 288 | "slider may not match receiver until user touches it" |
| 289 | ) |
| 290 | } |
| 291 | |
| 292 | // Burst of fast getMediaStatus calls on attach. The regular |
| 293 | // poller runs at 1.5s while paused, which is too slow for |
| 294 | // the reattach case: on cold-start / detach+reattach, the |
| 295 | // local `_state` starts empty (current = null) and we need |
| 296 | // a MEDIA_STATUS *with a media block* to populate it. The |
| 297 | // receiver only includes the media block when the status |
| 298 | // request lands during an active media session — most |
| 299 | // polls in steady state are transport-only. Hammering |
| 300 | // getMediaStatus() five times at 400ms gives us up to |
| 301 | // five shots inside the first 2 seconds, and the receiver |
| 302 | // typically returns the full block on at least one of |
| 303 | // those even when subsequent steady-state polls won't. |
| 304 | // |
| 305 | // Caps at 5 because past that, if the receiver still |
| 306 | // hasn't given us metadata, it's not because we asked too |
| 307 | // few times — it's because no media session is actually |
| 308 | // active to describe. |
| 309 | launch { |
| 310 | repeat(METADATA_BOOTSTRAP_ATTEMPTS) { attempt -> |
| 311 | // Stop hammering once we've got something — most |
| 312 | // of the time the first or second call succeeds |
| 313 | // and the remaining calls would be redundant. |
| 314 | if (_state.value.current != null) return@launch |
| 315 | delay(METADATA_BOOTSTRAP_INTERVAL_MS) |
| 316 | runCatching { sess.backing.mediaStatus } |
| 317 | .onSuccess { st -> if (st != null) onMediaStatus(st) } |
| 318 | .onFailure { t -> |
| 319 | System.err.println( |
| 320 | "[EarhornCast/Renderer] metadata bootstrap " + |
| 321 | "attempt ${attempt + 1} failed: ${t.message}" |
| 322 | ) |
| 323 | } |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Pull the device's current volume out of its receiver status and mirror |
| 333 | * it into [_state]. Returns true on success (a non-null level was read), |
| 334 | * false if the device didn't report one (receiver still launching, network |
| 335 | * blip, or a TV that doesn't expose volume). |
| 336 | * |
| 337 | * Best-effort within an attempt: if the status read throws, we silently |
| 338 | * leave the existing volumePercent untouched rather than fabricating a |
| 339 | * value. The caller retries. |
| 340 | */ |
| 341 | private fun syncVolumeFromDevice(cc: ChromeCast): Boolean { |
| 342 | // Never overwrite a volume the user explicitly set. The caller's loop |
| 343 | // also checks this, but guarding here protects any future caller too. |
| 344 | if (volumeUserOverride) return true // report "done" so the loop stops |
| 345 | return runCatching { |
| 346 | val level = cc.status?.volume?.level ?: return@runCatching false |
| 347 | // Skia/JVM idiom: convert Number-typed level (libraries vary |
| 348 | // on Float vs Double) to a 0..100 Int via toDouble() to avoid |
| 349 | // a precision mismatch with the slider's Int contract. |
| 350 | val pct = (level.toDouble() * 100).toInt().coerceIn(0, 100) |
| 351 | _state.update { it.copy(volumePercent = pct) } |
| 352 | true |
| 353 | }.onFailure { t -> |
| 354 | System.err.println( |
| 355 | "[EarhornCast/Renderer] initial volume sync failed: ${t.message}" |
| 356 | ) |
| 357 | }.getOrDefault(false) |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Unregister the event listener and stop polling. Does NOT disconnect the |
| 362 | * Cast session — leaving the device playing is the whole point of "switch |
| 363 | * to another renderer." But it DOES release the desktop's "this is my |
| 364 | * chosen cast device" marker on the integration ([CastIntegration.releaseControl]), |
| 365 | * so the auto-reconnect path won't bring us back as the controller if the |
| 366 | * device blips off the network while we're not the active renderer. |
| 367 | * Idempotent. |
| 368 | */ |
| 369 | override fun detach() { |
| 370 | if (!attached) return |
| 371 | attached = false |
| 372 | runCatching { |
| 373 | cast.session.value?.backing?.unregisterListener(spontaneousListener) |
| 374 | } |
| 375 | positionPollerJob?.cancel() |
| 376 | positionPollerJob = null |
| 377 | cast.releaseControl() |
| 378 | // Fresh attach next time should re-load on first play() rather than |
| 379 | // assume the receiver still has our last media; sessions don't |
| 380 | // necessarily survive detach. |
| 381 | hasMediaOnReceiver = false |
| 382 | scope.cancel() |
| 383 | } |
| 384 | |
| 385 | // ---- Spontaneous event handling ----------------------------------------- |
| 386 | |
| 387 | private fun onMediaStatus(status: MediaStatus) { |
| 388 | // Translate PlayerState → our flags. CASCADE: BUFFERING and LOADING both |
| 389 | // map to isBuffering=true; IDLE means stopped or about to advance. |
| 390 | val playerState = status.playerState |
| 391 | val idleReason = status.idleReason |
| 392 | lastIdleReason = idleReason |
| 393 | |
| 394 | val rawIsPlaying = playerState == MediaStatus.PlayerState.PLAYING |
| 395 | val isBuffering = playerState == MediaStatus.PlayerState.BUFFERING || |
| 396 | playerState == MediaStatus.PlayerState.LOADING |
| 397 | val isPaused = playerState == MediaStatus.PlayerState.PAUSED |
| 398 | val rawIsStopped = playerState == MediaStatus.PlayerState.IDLE && !isBuffering |
| 399 | |
| 400 | // Apply the transport-command suppression window. If we recently told |
| 401 | // the receiver to pause/stop, ignore a status that still says PLAYING |
| 402 | // (receiver hasn't processed our command yet). Symmetrically, if we |
| 403 | // recently told it to play, ignore a status that says PAUSED/STOPPED. |
| 404 | // See the kdoc on lastPauseLikeCommandMs for the full rationale. |
| 405 | val now = System.currentTimeMillis() |
| 406 | val withinPauseSuppress = now - lastPauseLikeCommandMs < TRANSPORT_SUPPRESS_MS |
| 407 | val withinPlaySuppress = now - lastPlayLikeCommandMs < TRANSPORT_SUPPRESS_MS |
| 408 | val isPlaying = when { |
| 409 | rawIsPlaying && withinPauseSuppress -> false // we just stopped/paused |
| 410 | !rawIsPlaying && withinPlaySuppress && !isBuffering -> true // we just played |
| 411 | else -> rawIsPlaying |
| 412 | } |
| 413 | val isStopped = rawIsStopped && !withinPlaySuppress |
| 414 | |
| 415 | _state.update { |
| 416 | it.copy( |
| 417 | isPlaying = isPlaying, |
| 418 | isBuffering = isBuffering, |
| 419 | isStopped = isStopped && !isPaused, |
| 420 | position = (status.currentTime * 1000).toLong().milliseconds, |
| 421 | duration = (status.media?.duration?.times(1000))?.toLong()?.milliseconds |
| 422 | ?: it.duration, |
| 423 | error = null, |
| 424 | ) |
| 425 | } |
| 426 | |
| 427 | // Receiver-driven metadata fallback. |
| 428 | // |
| 429 | // Sender-mode CastRenderer historically ignored `media.metadata` on |
| 430 | // every MEDIA_STATUS — the design assumed "I drove the LOAD, so my |
| 431 | // local queue is the source of truth for what's playing." That's |
| 432 | // wrong for at least three real cases: |
| 433 | // 1. Cold-start auto-reattach. Settings persisted "cast:Foo". |
| 434 | // We reconnect on launch with an empty local queue. The |
| 435 | // receiver still has the previously-driven media loaded (the |
| 436 | // Cast session survives app restart). state.current stays |
| 437 | // null → "Nothing playing" gramophone glow despite audio. |
| 438 | // 2. Phone takes over. The user is also driving the same |
| 439 | // receiver from their phone. Receiver swaps to phone's track. |
| 440 | // Our local queue still points at our (now-irrelevant) last |
| 441 | // LOAD. State.current is stale. |
| 442 | // 3. clearQueue() while observing audible playback. The receiver |
| 443 | // didn't actually stop (user only cleared OUR queue), or |
| 444 | // another sender restarts it. Our state.current = null but |
| 445 | // audio plays on. |
| 446 | // |
| 447 | // The fix: when MEDIA_STATUS carries a media.metadata block, try to |
| 448 | // synthesize a track from it. Prefer the existing state.current when |
| 449 | // it has the SAME title (i.e., the receiver is echoing back the |
| 450 | // metadata we set on our own LOAD — keep the richer local-queue |
| 451 | // Track with full DIDL fields). Otherwise (state.current is null, |
| 452 | // OR the title differs because the receiver is playing something we |
| 453 | // didn't load) replace it with the receiver-synthesized Track. |
| 454 | // |
| 455 | // The brown-flash protection comes for free: MEDIA_STATUS broadcasts |
| 456 | // without a media block (PAUSE, SEEK, position-only refreshes) make |
| 457 | // [extractTrackFromMediaStatus] return null, the outer `if` skips, |
| 458 | // and state.current is preserved across the gap. |
| 459 | val receiverTrack = extractTrackFromMediaStatus(status) |
| 460 | if (receiverTrack != null) { |
| 461 | _state.update { state -> |
| 462 | val keep = state.current |
| 463 | when { |
| 464 | keep == null -> state.copy(current = receiverTrack) |
| 465 | keep.title.equals(receiverTrack.title, ignoreCase = true) -> |
| 466 | state // same track, keep the richer local version |
| 467 | else -> state.copy(current = receiverTrack) |
| 468 | } |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // Auto-advance on natural track end. |
| 473 | // |
| 474 | // Primary detection: IDLE + IdleReason.FINISHED — reliable when the |
| 475 | // receiver firmware sets it. INTERRUPTED / CANCELLED / COMPLETED / |
| 476 | // ERROR mean stop here, never advance. |
| 477 | // |
| 478 | // Fallback: IDLE + null idleReason. The chromecast-java-api-v2 |
| 479 | // library (and some Chromecast Audio firmwares) sometimes leave |
| 480 | // idleReason unset entirely when an HTTP audio stream ends — the |
| 481 | // status arrives as bare IDLE. Without a fallback we'd sit there |
| 482 | // forever after the first track. To avoid mis-firing on the |
| 483 | // user's own Stop command (which also produces IDLE), we require |
| 484 | // BOTH suppress windows to have passed: |
| 485 | // |
| 486 | // - transport suppress: covers transient IDLE during track-load |
| 487 | // handshake right after we issued play/load |
| 488 | // - user-stop suppress: covers IDLE from the user's own Stop |
| 489 | // |
| 490 | // Past both, bare IDLE means "track ended, please go to the next |
| 491 | // one." The asymmetry — we err on the side of NOT advancing if |
| 492 | // there's any doubt — is deliberate: a missed advance is mildly |
| 493 | // annoying, a false advance (playback resumes when the user wanted |
| 494 | // quiet) is a serious failure. |
| 495 | val withinUserStopSuppress = now - lastUserStopMs < USER_STOP_SUPPRESS_MS |
| 496 | // design decision 2026-09-04 (as on Android): advance ONLY when our |
| 497 | // own track reached its end. An IDLE with no reason is somebody's |
| 498 | // stop, not a finish. |
| 499 | val isTrackEnd = playerState == MediaStatus.PlayerState.IDLE && |
| 500 | idleReason == MediaStatus.IdleReason.FINISHED |
| 501 | if (isTrackEnd && status.mediaSessionId != lastAdvancedMediaSessionId) { |
| 502 | lastAdvancedMediaSessionId = status.mediaSessionId |
| 503 | scope.launch(queueCtx) { advanceOnFinish() } |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | private suspend fun advanceOnFinish() { |
| 508 | val next = nextQueueIndex() ?: run { |
| 509 | // End of queue — respect the repeat mode the user set. |
| 510 | val mode = _state.value.repeatMode |
| 511 | if (mode == RepeatMode.ALL && queue.isNotEmpty()) { |
| 512 | queueIndex = 0 |
| 513 | loadCurrent(autoplay = true) |
| 514 | } else { |
| 515 | _state.update { it.copy(isPlaying = false, isStopped = true) } |
| 516 | } |
| 517 | return |
| 518 | } |
| 519 | queueIndex = next |
| 520 | loadCurrent(autoplay = true) |
| 521 | } |
| 522 | |
| 523 | private fun nextQueueIndex(): Int? { |
| 524 | if (queue.isEmpty()) return null |
| 525 | val nextIx = queueIndex + 1 |
| 526 | return if (nextIx in queue.indices) nextIx else null |
| 527 | } |
| 528 | |
| 529 | private fun startStatusPoller() { |
| 530 | // Cancel any prior poller — defensive in case attach() is called twice. |
| 531 | positionPollerJob?.cancel() |
| 532 | positionPollerJob = scope.launch { |
| 533 | // We poll cast.getMediaStatus() on a continuous cadence while attached. |
| 534 | // Two reasons we can't rely purely on the spontaneous-event listener: |
| 535 | // 1. The chromecast-java-api-v2 library only starts pushing |
| 536 | // MEDIA_STATUS events after the first getMediaStatus() call has |
| 537 | // bootstrapped the media-channel subscription. Polling |
| 538 | // guarantees state updates regardless. |
| 539 | // 2. Position isn't pushed at all — it has to be polled. |
| 540 | // |
| 541 | // Cadence is adaptive: tight (POSITION_POLL_INTERVAL_MS, 500 ms) |
| 542 | // while playing so the seek bar moves smoothly, looser |
| 543 | // (STATUS_POLL_INTERVAL_MS, 1500 ms) when paused/stopped to avoid |
| 544 | // hammering the device for no gain. |
| 545 | while (true) { |
| 546 | val s = cast.session.value |
| 547 | if (s != null && s.deviceId == targetDeviceId) { |
| 548 | runCatching { |
| 549 | s.backing.mediaStatus |
| 550 | }.onSuccess { status -> |
| 551 | if (status != null) onMediaStatus(status) |
| 552 | }.onFailure { t -> |
| 553 | // "No application running" is expected before launchApp |
| 554 | // completes — don't spam. |
| 555 | val msg = t.message.orEmpty() |
| 556 | if ("No application is running" !in msg && |
| 557 | "invalid state" !in msg |
| 558 | ) { |
| 559 | System.err.println( |
| 560 | "[EarhornCast/Renderer] mediaStatus poll error: ${t.message}" |
| 561 | ) |
| 562 | } |
| 563 | } |
| 564 | } |
| 565 | val nextInterval = if (_state.value.isPlaying) |
| 566 | POSITION_POLL_INTERVAL_MS else STATUS_POLL_INTERVAL_MS |
| 567 | delay(nextInterval) |
| 568 | } |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | // ---- Renderer interface -------------------------------------------------- |
| 573 | |
| 574 | override suspend fun load( |
| 575 | tracks: List<DidlItem.Track>, |
| 576 | startIndex: Int, |
| 577 | autoplay: Boolean, |
| 578 | ) = withContext(queueCtx) { |
| 579 | if (tracks.isEmpty()) return@withContext |
| 580 | queue.clear() |
| 581 | queue.addAll(tracks) |
| 582 | queueIndex = startIndex.coerceIn(0, queue.lastIndex) |
| 583 | publishQueue() |
| 584 | loadCurrent(autoplay = autoplay) |
| 585 | } |
| 586 | |
| 587 | override suspend fun enqueue(tracks: List<DidlItem.Track>) = withContext(queueCtx) { |
| 588 | if (tracks.isEmpty()) return@withContext |
| 589 | val wasEmpty = queue.isEmpty() |
| 590 | queue.addAll(tracks) |
| 591 | publishQueue() |
| 592 | // Mirror VlcjLocalRenderer: enqueue into empty queue stages tracks but |
| 593 | // doesn't auto-play. The user said "queue", not "play." |
| 594 | if (wasEmpty) { |
| 595 | queueIndex = 0 |
| 596 | // Don't loadCurrent — let the next play() invocation pick it up. |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | override suspend fun playNext(tracks: List<DidlItem.Track>) = withContext(queueCtx) { |
| 601 | if (tracks.isEmpty()) return@withContext |
| 602 | if (queue.isEmpty()) { |
| 603 | // playNext into empty queue mirrors load(autoplay=true). |
| 604 | queue.addAll(tracks) |
| 605 | queueIndex = 0 |
| 606 | publishQueue() |
| 607 | loadCurrent(autoplay = true) |
| 608 | } else { |
| 609 | val insertAt = (queueIndex + 1).coerceAtMost(queue.size) |
| 610 | queue.addAll(insertAt, tracks) |
| 611 | publishQueue() |
| 612 | } |
| 613 | } |
| 614 | |
| 615 | override suspend fun skipToQueueItem(index: Int) = withContext(queueCtx) { |
| 616 | if (index !in queue.indices) return@withContext |
| 617 | queueIndex = index |
| 618 | loadCurrent(autoplay = true) |
| 619 | } |
| 620 | |
| 621 | override suspend fun removeFromQueue(index: Int) = withContext(queueCtx) { |
| 622 | if (index !in queue.indices) return@withContext |
| 623 | val wasCurrent = index == queueIndex |
| 624 | queue.removeAt(index) |
| 625 | when { |
| 626 | queue.isEmpty() -> { |
| 627 | queueIndex = -1 |
| 628 | cast.stop() |
| 629 | _state.update { it.copy(isPlaying = false, isStopped = true, current = null) } |
| 630 | } |
| 631 | wasCurrent -> { |
| 632 | // Currently-playing track removed — advance into whatever's now |
| 633 | // at the same index, falling back to 0 if we removed the tail. |
| 634 | queueIndex = queueIndex.coerceAtMost(queue.lastIndex) |
| 635 | loadCurrent(autoplay = _state.value.isPlaying) |
| 636 | } |
| 637 | index < queueIndex -> { |
| 638 | queueIndex -= 1 |
| 639 | } |
| 640 | } |
| 641 | publishQueue() |
| 642 | } |
| 643 | |
| 644 | override suspend fun moveInQueue(from: Int, to: Int) = withContext(queueCtx) { |
| 645 | if (from !in queue.indices || to !in queue.indices || from == to) return@withContext |
| 646 | val track = queue.removeAt(from) |
| 647 | queue.add(to, track) |
| 648 | // Track queueIndex through the move so we don't lose the playhead. |
| 649 | queueIndex = when { |
| 650 | queueIndex == from -> to |
| 651 | from < queueIndex && to >= queueIndex -> queueIndex - 1 |
| 652 | from > queueIndex && to <= queueIndex -> queueIndex + 1 |
| 653 | else -> queueIndex |
| 654 | } |
| 655 | publishQueue() |
| 656 | } |
| 657 | |
| 658 | override suspend fun clearQueue() = withContext(queueCtx) { |
| 659 | queue.clear() |
| 660 | queueIndex = -1 |
| 661 | cast.stop() |
| 662 | hasMediaOnReceiver = false |
| 663 | _state.update { |
| 664 | it.copy( |
| 665 | isPlaying = false, |
| 666 | isStopped = true, |
| 667 | current = null, |
| 668 | queue = emptyList(), |
| 669 | queueIndex = -1, |
| 670 | position = Duration.ZERO, |
| 671 | ) |
| 672 | } |
| 673 | } |
| 674 | |
| 675 | /** |
| 676 | * Have we loaded media onto the receiver in this session yet? |
| 677 | * |
| 678 | * When the user attaches to a Cast device while a local renderer was |
| 679 | * driving the queue, the queue model migrates onto this renderer (we |
| 680 | * end up with `queue`/`queueIndex` populated) but the Cast RECEIVER |
| 681 | * has nothing — no [load] has been issued yet. If the user then hits |
| 682 | * Play, the naïve `cast.play()` SOAP-equivalent call goes out, the |
| 683 | * receiver sees "play what?" and silently does nothing, leaving the |
| 684 | * desktop UI showing a play attempt that never resolves. The fix is |
| 685 | * to track whether the receiver has been primed with the current |
| 686 | * track and have [play] fall back to [loadCurrent] when it hasn't. |
| 687 | * |
| 688 | * Reset on [detach] and on [stop], because both end the session's |
| 689 | * relationship with the receiver's current media. Set in |
| 690 | * [loadCurrent] right after a successful library load call. |
| 691 | */ |
| 692 | @Volatile private var hasMediaOnReceiver: Boolean = false |
| 693 | |
| 694 | override suspend fun play() = withContext(queueCtx) { |
| 695 | lastPlayLikeCommandMs = System.currentTimeMillis() |
| 696 | when { |
| 697 | // Empty-current + non-empty queue → freshly attached after |
| 698 | // migration, no track loaded yet. Start at the top. |
| 699 | queueIndex !in queue.indices && queue.isNotEmpty() -> { |
| 700 | queueIndex = 0 |
| 701 | loadCurrent(autoplay = true) |
| 702 | } |
| 703 | // Valid current but receiver hasn't been primed yet → either |
| 704 | // a fresh attach onto a populated queue (the case the user |
| 705 | // hit: "play button does not work when connected to a |
| 706 | // chromecast, songs must be clicked on in the queue to start |
| 707 | // playing") OR a post-stop resume (stop clears the receiver's |
| 708 | // media so the next Play has nothing to act on). Load + |
| 709 | // autoplay rather than send Play to an empty receiver. |
| 710 | queueIndex in queue.indices && !hasMediaOnReceiver -> { |
| 711 | loadCurrent(autoplay = true) |
| 712 | } |
| 713 | // Normal resume-from-pause case — receiver has the current |
| 714 | // media loaded, just unpause. |
| 715 | else -> cast.play() |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | override suspend fun pause() { |
| 720 | lastPauseLikeCommandMs = System.currentTimeMillis() |
| 721 | cast.pause() |
| 722 | // Optimistic: mirror the pause locally so the UI flips immediately, |
| 723 | // even before the receiver acknowledges. onMediaStatus's suppression |
| 724 | // window keeps a stale PLAYING from clobbering this. |
| 725 | _state.update { it.copy(isPlaying = false) } |
| 726 | } |
| 727 | |
| 728 | override suspend fun stop() { |
| 729 | lastPauseLikeCommandMs = System.currentTimeMillis() |
| 730 | lastUserStopMs = System.currentTimeMillis() |
| 731 | // design decision 2026-09-04: Stop lets go of the speaker, as on |
| 732 | // the phone — the group can use it again. |
| 733 | cast.disconnect() |
| 734 | // Reset position to zero alongside the playing/stopped flags so the |
| 735 | // progress bar visibly returns to the start of the track. Without |
| 736 | // this the slider stayed wherever the track happened to be when the |
| 737 | // user pressed Stop, which looked like a frozen UI — the user |
| 738 | // reported "stop is not resetting playbar to zero". Also clear |
| 739 | // hasMediaOnReceiver: the receiver's media handle is invalidated by |
| 740 | // our soft-stop (pause + seek 0 + clear-next), so the next Play |
| 741 | // needs to re-load rather than send Play to a now-empty receiver. |
| 742 | hasMediaOnReceiver = false |
| 743 | _state.update { |
| 744 | it.copy( |
| 745 | isPlaying = false, |
| 746 | isStopped = true, |
| 747 | position = Duration.ZERO, |
| 748 | ) |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | override suspend fun seekTo(position: Duration) { |
| 753 | cast.seek(position.inWholeMilliseconds.toDouble() / 1000.0) |
| 754 | } |
| 755 | |
| 756 | override suspend fun next() = withContext(queueCtx) { |
| 757 | val next = nextQueueIndex() ?: return@withContext |
| 758 | queueIndex = next |
| 759 | loadCurrent(autoplay = true) |
| 760 | } |
| 761 | |
| 762 | override suspend fun previous() = withContext(queueCtx) { |
| 763 | if (queue.isEmpty()) return@withContext |
| 764 | val prev = (queueIndex - 1).coerceAtLeast(0) |
| 765 | queueIndex = prev |
| 766 | loadCurrent(autoplay = true) |
| 767 | } |
| 768 | |
| 769 | override suspend fun setVolume(volumePercent: Int) { |
| 770 | // Mark that the user has taken manual control. The attach-time sync |
| 771 | // loop checks this and stops overwriting the slider with device reads. |
| 772 | volumeUserOverride = true |
| 773 | cast.setVolume((volumePercent.coerceIn(0, 100).toFloat()) / 100f) |
| 774 | _state.update { it.copy(volumePercent = volumePercent.coerceIn(0, 100)) } |
| 775 | } |
| 776 | |
| 777 | override suspend fun setRepeatMode(mode: RepeatMode) { |
| 778 | // Local-only; we implement repeat logic in advanceOnFinish() against |
| 779 | // our own queue rather than trying to drive the receiver's RepeatMode. |
| 780 | // The receiver's RepeatMode applies to its own queue (which we don't use). |
| 781 | _state.update { it.copy(repeatMode = mode) } |
| 782 | } |
| 783 | |
| 784 | override suspend fun setShuffle(enabled: Boolean) = withContext(queueCtx) { |
| 785 | // Mirror VlcjLocalRenderer: enabling saves the current order, disabling |
| 786 | // restores it. We don't shuffle aggressively into the future — only the |
| 787 | // tracks AFTER queueIndex get shuffled so the currently-playing track |
| 788 | // doesn't change underneath the user. |
| 789 | if (enabled == _state.value.shuffleEnabled) return@withContext |
| 790 | if (enabled) { |
| 791 | preShuffleSnapshot = queue.toList() |
| 792 | if (queue.size > queueIndex + 1) { |
| 793 | val head = queue.subList(0, queueIndex + 1).toList() |
| 794 | val tail = queue.subList(queueIndex + 1, queue.size).shuffled() |
| 795 | queue.clear() |
| 796 | queue.addAll(head) |
| 797 | queue.addAll(tail) |
| 798 | } |
| 799 | } else { |
| 800 | preShuffleSnapshot?.let { |
| 801 | queue.clear() |
| 802 | queue.addAll(it) |
| 803 | preShuffleSnapshot = null |
| 804 | // Re-resolve queueIndex against the restored order. |
| 805 | val currentUrl = _state.value.current?.resourceUrl |
| 806 | if (currentUrl != null) { |
| 807 | val ix = queue.indexOfFirst { it.resourceUrl == currentUrl } |
| 808 | if (ix >= 0) queueIndex = ix |
| 809 | } |
| 810 | } |
| 811 | } |
| 812 | _state.update { it.copy(shuffleEnabled = enabled) } |
| 813 | publishQueue() |
| 814 | } |
| 815 | |
| 816 | private var preShuffleSnapshot: List<DidlItem.Track>? = null |
| 817 | |
| 818 | // ---- Helpers ------------------------------------------------------------- |
| 819 | |
| 820 | /** |
| 821 | * Load the track at [queueIndex] onto the active Cast session. |
| 822 | * No-op if the queue is empty or no session is active. |
| 823 | */ |
| 824 | private fun loadCurrent(autoplay: Boolean) { |
| 825 | val track = queue.getOrNull(queueIndex) ?: return |
| 826 | cast.load(track) |
| 827 | // Receiver now has media of its own (or will momentarily — load() is |
| 828 | // launched async inside CastIntegration). Subsequent play() calls |
| 829 | // can use cast.play() directly instead of needing to loadCurrent |
| 830 | // first; cleared again on stop() and detach(). |
| 831 | hasMediaOnReceiver = true |
| 832 | if (!autoplay) { |
| 833 | // CastIntegration.load() autoplays by default. Pause immediately |
| 834 | // after to satisfy autoplay=false semantics. There's a brief moment |
| 835 | // where the receiver buffers and starts playing before we pause — |
| 836 | // not ideal, but the user-visible effect is acceptable for the |
| 837 | // single use of autoplay=false (UPnP SetAVTransportURI followed by |
| 838 | // an explicit Play). |
| 839 | scope.launch { |
| 840 | delay(BRIEF_DELAY_BEFORE_PAUSE_MS) |
| 841 | cast.pause() |
| 842 | } |
| 843 | } |
| 844 | _state.update { |
| 845 | it.copy( |
| 846 | current = track, |
| 847 | queueIndex = queueIndex, |
| 848 | queue = queue.toList(), |
| 849 | // Fresh track starts at zero — without this the slider would |
| 850 | // briefly carry over the old track's position until the first |
| 851 | // post-load poll lands. |
| 852 | position = Duration.ZERO, |
| 853 | ) |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | private fun publishQueue() { |
| 858 | _state.update { |
| 859 | it.copy( |
| 860 | queue = queue.toList(), |
| 861 | queueIndex = queueIndex, |
| 862 | current = queue.getOrNull(queueIndex), |
| 863 | ) |
| 864 | } |
| 865 | } |
| 866 | |
| 867 | /** |
| 868 | * Synthesize a minimal [DidlItem.Track] from the receiver's reported |
| 869 | * media metadata. Returns null when there's no media block, no metadata, |
| 870 | * or no title — those signal a transport-only update (PAUSE / SEEK / |
| 871 | * position refresh) where we don't want to fabricate a Track at all. |
| 872 | * |
| 873 | * The chromecast-java-api-v2 library exposes `Media.metadata` as a |
| 874 | * `Map<String, Object>` populated from the receiver's JSON metadata |
| 875 | * payload. The standard Cast metadata keys for MUSIC_TRACK are `title`, |
| 876 | * `artist`, `albumName`, `albumArtist`, `images` (a list of maps with |
| 877 | * `url`), `trackNumber`, `discNumber`, `releaseDate`. For receivers that |
| 878 | * use the GENERIC metadata type (custom apps, some casual media |
| 879 | * receivers) the artist often arrives under `subtitle` instead — we |
| 880 | * walk the alternatives so common shapes all resolve. |
| 881 | * |
| 882 | * The returned Track carries [DidlItem.Track.resourceUrl] = the |
| 883 | * receiver's contentId so downstream consumers (the queue UI, MPRIS, |
| 884 | * lyrics lookup) have a stable identity. The track's `id` is the same |
| 885 | * string — observer-derived tracks have no DIDL provenance to draw |
| 886 | * from. |
| 887 | */ |
| 888 | @Suppress("UNCHECKED_CAST") |
| 889 | private fun extractTrackFromMediaStatus(status: MediaStatus): DidlItem.Track? { |
| 890 | val media = status.media ?: return null |
| 891 | val meta = media.metadata as? Map<String, Any?> ?: run { |
| 892 | // Diagnostic: media block present but metadata field is null or not |
| 893 | // a String-keyed map. Some receivers send the metadata under a |
| 894 | // non-standard wrapper; if you see this line on a session where the |
| 895 | // gramophone glow persists, paste the rx ns=media payload from the |
| 896 | // raw socket logs and we'll teach the parser about the new shape. |
| 897 | System.err.println( |
| 898 | "[EarhornCast/Renderer] MEDIA_STATUS media block has no usable " + |
| 899 | "metadata map (url=${media.url} contentType=${media.contentType})" |
| 900 | ) |
| 901 | return null |
| 902 | } |
| 903 | if (meta.isEmpty()) { |
| 904 | System.err.println( |
| 905 | "[EarhornCast/Renderer] MEDIA_STATUS metadata map is empty " + |
| 906 | "(url=${media.url}). Receiver sent media block but no fields." |
| 907 | ) |
| 908 | return null |
| 909 | } |
| 910 | |
| 911 | val title = (meta["title"] as? String)?.takeIf { it.isNotBlank() } |
| 912 | ?: run { |
| 913 | // Title is the one field we won't fabricate around. Most other |
| 914 | // metadata callers can render a track with just title + art, |
| 915 | // but no title means a placeholder that says "Nothing playing" |
| 916 | // is more honest than one that says "Unknown title." |
| 917 | System.err.println( |
| 918 | "[EarhornCast/Renderer] MEDIA_STATUS metadata has no title; " + |
| 919 | "keys present: ${meta.keys.joinToString()}" |
| 920 | ) |
| 921 | return null |
| 922 | } |
| 923 | val artist = (meta["artist"] as? String)?.takeIf { it.isNotBlank() } |
| 924 | ?: (meta["subtitle"] as? String)?.takeIf { it.isNotBlank() } |
| 925 | ?: (meta["albumArtist"] as? String)?.takeIf { it.isNotBlank() } |
| 926 | val album = (meta["albumName"] as? String)?.takeIf { it.isNotBlank() } |
| 927 | ?: (meta["albumTitle"] as? String)?.takeIf { it.isNotBlank() } |
| 928 | |
| 929 | val images = meta["images"] as? List<*> |
| 930 | val artUrl = (images?.firstOrNull() as? Map<*, *>) |
| 931 | ?.get("url") as? String |
| 932 | |
| 933 | val contentId = media.url ?: "" |
| 934 | val duration = media.duration |
| 935 | ?.let { (it * 1000).toLong().milliseconds } |
| 936 | ?: Duration.ZERO |
| 937 | |
| 938 | // Diagnostic: log the SUCCESSFUL extraction once per track change. If |
| 939 | // the symptom persists after this code lands, this is the line that |
| 940 | // tells us whether the receiver IS sending metadata that we ARE |
| 941 | // parsing correctly (and the bug is somewhere else, e.g. the UI not |
| 942 | // re-binding) or whether we're never getting here at all. |
| 943 | System.err.println( |
| 944 | "[EarhornCast/Renderer] extracted track from MEDIA_STATUS: " + |
| 945 | "title=$title artist=$artist album=$album art=${artUrl != null}" |
| 946 | ) |
| 947 | |
| 948 | return DidlItem.Track( |
| 949 | id = contentId, |
| 950 | parentId = "", |
| 951 | title = title, |
| 952 | artist = artist, |
| 953 | albumArtist = artist, |
| 954 | album = album, |
| 955 | composer = null, |
| 956 | genre = null, |
| 957 | date = (meta["releaseDate"] as? String) |
| 958 | ?: (meta["originalAirDate"] as? String), |
| 959 | trackNumber = (meta["trackNumber"] as? Number)?.toInt(), |
| 960 | discNumber = (meta["discNumber"] as? Number)?.toInt(), |
| 961 | duration = duration, |
| 962 | albumArtUri = artUrl, |
| 963 | resourceUrl = contentId, |
| 964 | mimeType = media.contentType, |
| 965 | sampleFrequency = null, |
| 966 | bitsPerSample = null, |
| 967 | channels = null, |
| 968 | bitrate = null, |
| 969 | size = null, |
| 970 | ) |
| 971 | } |
| 972 | |
| 973 | companion object { |
| 974 | /** How often we poll position from the receiver while playing. |
| 975 | * 500ms keeps the seek bar smooth without flooding the device. */ |
| 976 | private const val POSITION_POLL_INTERVAL_MS = 1_000L |
| 977 | |
| 978 | /** How often we poll for general media status when NOT playing — a |
| 979 | * looser cadence since neither the playhead nor the user expectations |
| 980 | * need sub-second resolution while paused/stopped. The poller can't |
| 981 | * go silent entirely though: the receiver may be in BUFFERING and |
| 982 | * about to transition to PLAYING, and we want the UI to react. */ |
| 983 | private const val STATUS_POLL_INTERVAL_MS = 10_000L |
| 984 | |
| 985 | /** Brief delay after `cast.load()` before issuing pause() for the |
| 986 | * autoplay=false case. Receiver needs a moment to enter PLAYING |
| 987 | * state for the pause to take effect. */ |
| 988 | private const val BRIEF_DELAY_BEFORE_PAUSE_MS = 250L |
| 989 | |
| 990 | /** How long after a transport command (play/pause/stop) we ignore |
| 991 | * contradictory playerState reports from the receiver. The |
| 992 | * receiver typically processes commands within a few hundred ms; |
| 993 | * 1500 ms is generous enough to cover slow Wi-Fi or busy speaker |
| 994 | * groups while still letting genuine failures surface promptly. */ |
| 995 | private const val TRANSPORT_SUPPRESS_MS = 1500L |
| 996 | |
| 997 | /** Used by the auto-advance fallback path in [onMediaStatus]: when |
| 998 | * the receiver reports IDLE with a NULL [MediaStatus.IdleReason] |
| 999 | * (which some firmwares do for HTTP audio streams instead of |
| 1000 | * setting FINISHED), we treat that as "track ended naturally and |
| 1001 | * should auto-advance" — UNLESS the user pressed Stop within this |
| 1002 | * window, in which case IDLE-without-reason was probably the |
| 1003 | * receiver acknowledging that Stop and we leave well enough |
| 1004 | * alone. 2000 ms covers the round-trip latency for a Stop command |
| 1005 | * plus a poll cycle, and is longer than [TRANSPORT_SUPPRESS_MS] |
| 1006 | * because the asymmetry matters: a false advance (we resume |
| 1007 | * playback when the user wanted silence) is a much worse failure |
| 1008 | * than a missed advance (the track just doesn't go to the next |
| 1009 | * one for a couple seconds). */ |
| 1010 | private const val USER_STOP_SUPPRESS_MS = 2000L |
| 1011 | |
| 1012 | /** Initial delay between attempts in the post-attach volume sync |
| 1013 | * retry loop. Grows by 1.5× each retry, capping at |
| 1014 | * VOLUME_SYNC_MAX_DELAY_MS. */ |
| 1015 | private const val INITIAL_VOLUME_SYNC_DELAY_MS = 250L |
| 1016 | |
| 1017 | /** Ceiling on the exponential backoff used for volume sync retries. |
| 1018 | * Keeps the worst-case wait between attempts bounded so a slow- |
| 1019 | * booting receiver still converges in seconds, not minutes. */ |
| 1020 | private const val VOLUME_SYNC_MAX_DELAY_MS = 2_000L |
| 1021 | |
| 1022 | /** Maximum number of times we retry volume sync after attach. With |
| 1023 | * the 250→2000ms backoff above this gives the receiver up to ~10 |
| 1024 | * seconds of total wallclock to start reporting its volume level |
| 1025 | * before we give up and leave the slider showing whatever its |
| 1026 | * default is. */ |
| 1027 | private const val VOLUME_SYNC_MAX_ATTEMPTS = 8 |
| 1028 | |
| 1029 | /** Interval between rapid getMediaStatus calls issued right after |
| 1030 | * attach to bootstrap the metadata into [_state.current]. 400ms is |
| 1031 | * short enough that the user sees the gramophone-glow replaced |
| 1032 | * within ~half a second on a happy-path reattach, and long enough |
| 1033 | * that we don't flood the receiver with overlapping requests in |
| 1034 | * pathological cases. */ |
| 1035 | private const val METADATA_BOOTSTRAP_INTERVAL_MS = 400L |
| 1036 | |
| 1037 | /** Number of metadata-bootstrap calls issued after attach (or until |
| 1038 | * we've populated state.current, whichever comes first). 5 × 400ms |
| 1039 | * = up to 2s of focused effort; past that, if the receiver hasn't |
| 1040 | * given us a media block, there isn't an active session to |
| 1041 | * describe and the regular poller takes over. */ |
| 1042 | private const val METADATA_BOOTSTRAP_ATTEMPTS = 5 |
| 1043 | } |
| 1044 | } |


