CastRenderer.kt
Each line, with the release that last changed it.
| 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 | } |


