package dev.stave.cast
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.PlaybackState
import dev.stave.domain.renderer.Renderer
import dev.stave.domain.renderer.RepeatMode
import dev.stave.domain.renderer.ScopedRenderer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener
import su.litvak.chromecast.api.v2.MediaStatus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* A [Renderer] implementation backed by a Chromecast device (or speaker group)
* via the existing [CastIntegration]. The rest of the app treats this exactly
* like the local libvlc renderer or a remote UPnP renderer — load tracks, hit
* play, the audio comes out wherever the user picked.
*
* **Where this fits.** [RendererCoordinator] selects between three renderer
* kinds based on the user's `activeRendererUdn` setting:
* - `null` → local libvlc, audio out the laptop speakers
* - UPnP UDN → [dev.stave.upnp.renderer.RemoteUpnpRenderer] driving a
* remote AVTransport device
* - `cast:<deviceId>` → this class, driving a Chromecast or speaker group
*
* **Why not just reuse CastIntegration directly.** The app's playback layer
* speaks the [Renderer] surface (suspend functions, a [StateFlow] of
* [PlaybackState], a queue model). [CastIntegration] is a thinner wrapper that
* speaks the chromecast-java-api-v2 surface — fire-and-forget methods, no
* unified state, no queue. This adapter bridges the two.
*
* **Queue ownership.** chromecast-java-api-v2 does have a queue concept
* (`loadQueue`), but it's underspecified across receiver firmware versions and
* historically flaky for speaker groups. We keep our own list-of-tracks in
* memory and use single-track loads, advancing on the `IdleReason.FINISHED`
* event that the receiver sends when a track ends naturally. This matches the
* shape [dev.stave.upnp.renderer.RemoteUpnpRenderer] uses for the same reason
* (UPnP AVTransport has no protocol-level queue either).
*
* **State translation.** A [ChromeCastSpontaneousEventListener] subscribes to
* the active [su.litvak.chromecast.api.v2.ChromeCast] and turns each incoming
* [ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS] into a
* [PlaybackState] update. Position isn't evented — we poll it on a 500 ms
* ticker while playing, same as the UPnP remote does.
*
* **Lifecycle.** Construct with the device id, call [attach] to register the
* spontaneous-event listener and start the position ticker. Call [detach] when
* switching away from this renderer (the coordinator does this). [detach]
* unregisters the listener but does NOT disconnect the Cast session — the user
* picked another renderer, they didn't say "stop casting." If they want to end
* the session, the Cast UI surface should call [CastIntegration.disconnect]
* directly.
*/
class CastRenderer(
private val cast: CastIntegration,
/** The id used to identify the target device. Matches the id returned by
* [CastIntegration.devices] (which is the mDNS address). Used during
* [attach] to select the device on the underlying integration and as part
* of the `cast:<id>` UDN convention used in the renderer coordinator. */
val targetDeviceId: String,
/** Seed for [_state.volumePercent] before the first authoritative reading
* from the device arrives via [syncVolumeFromDevice]. Without this the
* default PlaybackState() value of 100 would be exposed to the UI during
* the window between construction and first sync (typically ~250-1000ms
* depending on receiver app boot), causing the meter to flash 100% before
* snapping to the device's actual level — visually identical to a
* volume blast even though we never sent a 100% command. The
* coordinator passes the outgoing renderer's last-known volume so the
* meter starts at a sensible value during the sync window. */
initialVolumePercent: Int = 100,
) : ScopedRenderer {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _state = MutableStateFlow(PlaybackState(volumePercent = initialVolumePercent))
override val state: StateFlow<PlaybackState> = _state.asStateFlow()
/** Local queue. Cast's own queueLoad is not used — see class kdoc.
* Read and written only on [queueCtx] (Team 3 P2-2): user actions and the
* track-end advance used to run on different IO threads, and a track
* ending while a row was removed could throw out of `queue.toList()`. */
private val queue: MutableList<DidlItem.Track> = mutableListOf()
/** One thread at a time for [queue] / [queueIndex]. FIFO. */
private val queueCtx = Dispatchers.IO.limitedParallelism(1)
/** mediaSessionId of the receiver's media we last advanced away from.
* The receiver's own FINISHED broadcast and a poll already in flight can
* both report the same track end; advancing on each skipped a track
* (Team 3 P2-3). Every LOAD gets a new mediaSessionId. */
@Volatile private var lastAdvancedMediaSessionId: Long = -1L
/** Index into [queue] of the currently-loaded track. -1 when nothing loaded. */
private var queueIndex: Int = -1
/** Last MediaStatus's IdleReason. Used to disambiguate "track ended naturally
* → advance" vs "user pressed stop → stay stopped". */
private var lastIdleReason: MediaStatus.IdleReason? = null
/**
* Timestamps of the most recent transport commands. Used to suppress
* contradictory state updates that arrive shortly after.
*
* The problem: after the user issues Stop or Pause, we optimistically
* set `_state.isPlaying = false` immediately. But the status poller
* (running every 500 ms while we think we're playing) often has a
* `getMediaStatus()` in flight already, OR the receiver hasn't yet
* processed the pause/stop command by the time the next poll happens.
* Either way, the poll returns `PLAYING`, [onMediaStatus] overwrites
* our `false` with `true`, and the UI flickers — the user sees the
* play icon return and assumes the Stop button didn't work. They press
* it again, second time looks like it succeeded (because by then the
* receiver actually has processed the original command).
*
* The fix: ignore poll-reported `PLAYING` within a short window after
* Stop/Pause, and ignore poll-reported `PAUSED`/`IDLE` within a short
* window after Play. The receiver acknowledges within ~500 ms in
* practice, so [TRANSPORT_SUPPRESS_MS] of 1500 ms covers it generously
* without trapping the UI in a stale state if the command genuinely
* failed (a real failure will show up after the window passes).
*/
@Volatile private var lastPauseLikeCommandMs: Long = 0L
@Volatile private var lastPlayLikeCommandMs: Long = 0L
/** Timestamp of the most recent user-initiated [stop] call. Tracked
* separately from [lastPauseLikeCommandMs] because Stop and Pause have
* fundamentally different intent: Pause says "I might resume in a
* moment", while Stop says "I'm done listening, don't keep going."
*
* Used by the auto-advance fallback path in [onMediaStatus]: when the
* receiver reports IDLE with a NULL `idleReason` (some Chromecast
* firmwares and the chromecast-java-api-v2 library leave this unset
* for HTTP audio streams), we have to guess whether IDLE means
* "track ended naturally → advance" or "user pressed Stop → stay
* put". Within [USER_STOP_SUPPRESS_MS] of a user Stop, we choose the
* conservative answer; outside it, we advance. */
@Volatile private var lastUserStopMs: Long = 0L
/** Ticker that polls position while playing. Null when not active. */
private var positionPollerJob: Job? = null
/**
* Latches true the first time the user manually sets volume via [setVolume].
* Once set, the attach-time volume-sync loop stops overwriting the slider
* with the device's reported level.
*
* Why: the sync loop retries with backoff for several seconds because a
* Chromecast (especially a CCA group leader) is slow to report a volume
* after the receiver app launches. If the user adjusts the slider during
* that window, a late sync attempt would read the device's level — which
* for a group leader is often the group master (1.0 / 100%), NOT the
* per-member level the user just set — and clobber the user's value a few
* seconds after they set it. The user's explicit input is authoritative;
* once they've touched the slider, we never auto-overwrite it. */
@Volatile private var volumeUserOverride: Boolean = false
private var attached: Boolean = false
private val spontaneousListener = ChromeCastSpontaneousEventListener { event ->
if (!attached) return@ChromeCastSpontaneousEventListener
when (event.type) {
ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS -> {
val status = runCatching {
event.getData(MediaStatus::class.java)
}.getOrNull() ?: return@ChromeCastSpontaneousEventListener
onMediaStatus(status)
}
ChromeCastSpontaneousEvent.SpontaneousEventType.CLOSE -> {
// The library fires CLOSE whenever its TCP-TLS keepalive to
// the receiver times out. That happens for two very different
// reasons:
// 1. The receiver app legitimately shut down (user pressed
// Home on the TV, sleep timer fired on a speaker)
// 2. Transient network blip — receiver is still alive and,
// crucially, STILL PLAYING THE STREAM autonomously
//
// We can't distinguish the two from the CLOSE event alone.
// The user-visible symptom of confusing them was: music kept
// pouring out of the speaker, but our UI flipped to Stopped,
// the play button replaced pause, and the progress bar froze
// a frame before going stale. The desktop and the speaker
// told the user contradictory stories.
//
// The right answer is to NOT mutate playback state on CLOSE.
// The state flow simply freezes at its last-known values
// (the poller stops running once cast.session is null, so
// nothing else overwrites it either). The cast banner above
// the transport already shows "Reconnecting to X…" off
// CastIntegration.isReconnecting, so the user has the
// context. If the receiver really did shut down, the next
// observation (or the user pressing Stop) will reconcile.
System.err.println(
"[EarhornCast/Renderer] CLOSE event — freezing state, " +
"reconnect loop will handle resumption"
)
}
else -> { /* STATUS, APPEVENT, UNKNOWN — not interesting for transport */ }
}
}
/**
* Open the session against the target device and subscribe to its events.
* Idempotent. Returns immediately; session establishment happens asynchronously
* inside [CastIntegration] and the [state] flow updates when it completes.
*/
fun attach() {
if (attached) return
attached = true
// Make sure the integration is connected to the device we're targeting.
// selectDevice() is a no-op when already on this device, and idempotent
// for the chromecast-java-api-v2 connect() call underneath.
cast.selectDevice(targetDeviceId)
// Start polling for media status immediately. This is the primary source
// of truth for UI state — see startStatusPoller() for the rationale.
// (We *also* register the spontaneous-event listener below, but treat it
// as a low-latency optimization rather than a requirement.)
startStatusPoller()
// Wait for the session to land, then register the spontaneous event
// listener on the underlying ChromeCast handle. The session may not
// exist yet at the moment attach() returns, so we observe the session
// flow until it does.
scope.launch {
cast.session.collect { sess ->
if (sess != null && sess.deviceId == targetDeviceId) {
// A session that (re)appears after a dropout: the receiver
// app was relaunched and holds nothing. Ask it; if it has
// no media, the next press reloads the current track. We
// never load on the reconnect itself (design decision).
runCatching { sess.backing.mediaStatus }
.onSuccess { st -> if (st?.media == null) hasMediaOnReceiver = false }
.onFailure { hasMediaOnReceiver = false }
runCatching {
sess.backing.registerListener(spontaneousListener)
}.onFailure { t ->
System.err.println(
"[EarhornCast/Renderer] registerListener failed: " +
"${t.javaClass.simpleName}: ${t.message}"
)
}
// Sync the slider to the device's ACTUAL volume. Without
// this the slider defaults to whatever PlaybackState's
// initial volumePercent is (typically 100), so the user
// sees "100%" while the device is actually at 30% or
// wherever it was left. First touch of the slider then
// snaps to 100% and audibly blasts.
//
// The first sync attempt usually fires before the
// receiver app has fully launched, so cc.status returns
// null or a stale value. Retry on a short backoff until
// we get a real reading or we've tried 8 times. The
// backoff caps at 2s so a genuinely slow boot still
// converges in under ~10s, and an instant boot
// converges on the first try.
launch {
var delay = INITIAL_VOLUME_SYNC_DELAY_MS
repeat(VOLUME_SYNC_MAX_ATTEMPTS) {
// Stop if the user has taken manual control of the
// slider — their value wins over any device read.
// Without this, a late sync attempt clobbers a
// just-set volume (CCA group leaders report the
// group master, not the level the user set).
if (volumeUserOverride) return@launch
if (syncVolumeFromDevice(sess.backing)) {
return@launch
}
delay(delay)
delay = (delay * 3 / 2).coerceAtMost(VOLUME_SYNC_MAX_DELAY_MS)
}
System.err.println(
"[EarhornCast/Renderer] volume sync exhausted retries — " +
"slider may not match receiver until user touches it"
)
}
// Burst of fast getMediaStatus calls on attach. The regular
// poller runs at 1.5s while paused, which is too slow for
// the reattach case: on cold-start / detach+reattach, the
// local `_state` starts empty (current = null) and we need
// a MEDIA_STATUS *with a media block* to populate it. The
// receiver only includes the media block when the status
// request lands during an active media session — most
// polls in steady state are transport-only. Hammering
// getMediaStatus() five times at 400ms gives us up to
// five shots inside the first 2 seconds, and the receiver
// typically returns the full block on at least one of
// those even when subsequent steady-state polls won't.
//
// Caps at 5 because past that, if the receiver still
// hasn't given us metadata, it's not because we asked too
// few times — it's because no media session is actually
// active to describe.
launch {
repeat(METADATA_BOOTSTRAP_ATTEMPTS) { attempt ->
// Stop hammering once we've got something — most
// of the time the first or second call succeeds
// and the remaining calls would be redundant.
if (_state.value.current != null) return@launch
delay(METADATA_BOOTSTRAP_INTERVAL_MS)
runCatching { sess.backing.mediaStatus }
.onSuccess { st -> if (st != null) onMediaStatus(st) }
.onFailure { t ->
System.err.println(
"[EarhornCast/Renderer] metadata bootstrap " +
"attempt ${attempt + 1} failed: ${t.message}"
)
}
}
}
}
}
}
}
/**
* Pull the device's current volume out of its receiver status and mirror
* it into [_state]. Returns true on success (a non-null level was read),
* false if the device didn't report one (receiver still launching, network
* blip, or a TV that doesn't expose volume).
*
* Best-effort within an attempt: if the status read throws, we silently
* leave the existing volumePercent untouched rather than fabricating a
* value. The caller retries.
*/
private fun syncVolumeFromDevice(cc: ChromeCast): Boolean {
// Never overwrite a volume the user explicitly set. The caller's loop
// also checks this, but guarding here protects any future caller too.
if (volumeUserOverride) return true // report "done" so the loop stops
return runCatching {
val level = cc.status?.volume?.level ?: return@runCatching false
// Skia/JVM idiom: convert Number-typed level (libraries vary
// on Float vs Double) to a 0..100 Int via toDouble() to avoid
// a precision mismatch with the slider's Int contract.
val pct = (level.toDouble() * 100).toInt().coerceIn(0, 100)
_state.update { it.copy(volumePercent = pct) }
true
}.onFailure { t ->
System.err.println(
"[EarhornCast/Renderer] initial volume sync failed: ${t.message}"
)
}.getOrDefault(false)
}
/**
* Unregister the event listener and stop polling. Does NOT disconnect the
* Cast session — leaving the device playing is the whole point of "switch
* to another renderer." But it DOES release the desktop's "this is my
* chosen cast device" marker on the integration ([CastIntegration.releaseControl]),
* so the auto-reconnect path won't bring us back as the controller if the
* device blips off the network while we're not the active renderer.
* Idempotent.
*/
override fun detach() {
if (!attached) return
attached = false
runCatching {
cast.session.value?.backing?.unregisterListener(spontaneousListener)
}
positionPollerJob?.cancel()
positionPollerJob = null
cast.releaseControl()
// Fresh attach next time should re-load on first play() rather than
// assume the receiver still has our last media; sessions don't
// necessarily survive detach.
hasMediaOnReceiver = false
scope.cancel()
}
// ---- Spontaneous event handling -----------------------------------------
private fun onMediaStatus(status: MediaStatus) {
// Translate PlayerState → our flags. CASCADE: BUFFERING and LOADING both
// map to isBuffering=true; IDLE means stopped or about to advance.
val playerState = status.playerState
val idleReason = status.idleReason
lastIdleReason = idleReason
val rawIsPlaying = playerState == MediaStatus.PlayerState.PLAYING
val isBuffering = playerState == MediaStatus.PlayerState.BUFFERING ||
playerState == MediaStatus.PlayerState.LOADING
val isPaused = playerState == MediaStatus.PlayerState.PAUSED
val rawIsStopped = playerState == MediaStatus.PlayerState.IDLE && !isBuffering
// Apply the transport-command suppression window. If we recently told
// the receiver to pause/stop, ignore a status that still says PLAYING
// (receiver hasn't processed our command yet). Symmetrically, if we
// recently told it to play, ignore a status that says PAUSED/STOPPED.
// See the kdoc on lastPauseLikeCommandMs for the full rationale.
val now = System.currentTimeMillis()
val withinPauseSuppress = now - lastPauseLikeCommandMs < TRANSPORT_SUPPRESS_MS
val withinPlaySuppress = now - lastPlayLikeCommandMs < TRANSPORT_SUPPRESS_MS
val isPlaying = when {
rawIsPlaying && withinPauseSuppress -> false // we just stopped/paused
!rawIsPlaying && withinPlaySuppress && !isBuffering -> true // we just played
else -> rawIsPlaying
}
val isStopped = rawIsStopped && !withinPlaySuppress
_state.update {
it.copy(
isPlaying = isPlaying,
isBuffering = isBuffering,
isStopped = isStopped && !isPaused,
position = (status.currentTime * 1000).toLong().milliseconds,
duration = (status.media?.duration?.times(1000))?.toLong()?.milliseconds
?: it.duration,
error = null,
)
}
// Receiver-driven metadata fallback.
//
// Sender-mode CastRenderer historically ignored `media.metadata` on
// every MEDIA_STATUS — the design assumed "I drove the LOAD, so my
// local queue is the source of truth for what's playing." That's
// wrong for at least three real cases:
// 1. Cold-start auto-reattach. Settings persisted "cast:Foo".
// We reconnect on launch with an empty local queue. The
// receiver still has the previously-driven media loaded (the
// Cast session survives app restart). state.current stays
// null → "Nothing playing" gramophone glow despite audio.
// 2. Phone takes over. The user is also driving the same
// receiver from their phone. Receiver swaps to phone's track.
// Our local queue still points at our (now-irrelevant) last
// LOAD. State.current is stale.
// 3. clearQueue() while observing audible playback. The receiver
// didn't actually stop (user only cleared OUR queue), or
// another sender restarts it. Our state.current = null but
// audio plays on.
//
// The fix: when MEDIA_STATUS carries a media.metadata block, try to
// synthesize a track from it. Prefer the existing state.current when
// it has the SAME title (i.e., the receiver is echoing back the
// metadata we set on our own LOAD — keep the richer local-queue
// Track with full DIDL fields). Otherwise (state.current is null,
// OR the title differs because the receiver is playing something we
// didn't load) replace it with the receiver-synthesized Track.
//
// The brown-flash protection comes for free: MEDIA_STATUS broadcasts
// without a media block (PAUSE, SEEK, position-only refreshes) make
// [extractTrackFromMediaStatus] return null, the outer `if` skips,
// and state.current is preserved across the gap.
val receiverTrack = extractTrackFromMediaStatus(status)
if (receiverTrack != null) {
_state.update { state ->
val keep = state.current
when {
keep == null -> state.copy(current = receiverTrack)
keep.title.equals(receiverTrack.title, ignoreCase = true) ->
state // same track, keep the richer local version
else -> state.copy(current = receiverTrack)
}
}
}
// Auto-advance on natural track end.
//
// Primary detection: IDLE + IdleReason.FINISHED — reliable when the
// receiver firmware sets it. INTERRUPTED / CANCELLED / COMPLETED /
// ERROR mean stop here, never advance.
//
// Fallback: IDLE + null idleReason. The chromecast-java-api-v2
// library (and some Chromecast Audio firmwares) sometimes leave
// idleReason unset entirely when an HTTP audio stream ends — the
// status arrives as bare IDLE. Without a fallback we'd sit there
// forever after the first track. To avoid mis-firing on the
// user's own Stop command (which also produces IDLE), we require
// BOTH suppress windows to have passed:
//
// - transport suppress: covers transient IDLE during track-load
// handshake right after we issued play/load
// - user-stop suppress: covers IDLE from the user's own Stop
//
// Past both, bare IDLE means "track ended, please go to the next
// one." The asymmetry — we err on the side of NOT advancing if
// there's any doubt — is deliberate: a missed advance is mildly
// annoying, a false advance (playback resumes when the user wanted
// quiet) is a serious failure.
val withinUserStopSuppress = now - lastUserStopMs < USER_STOP_SUPPRESS_MS
// design decision 2026-09-04 (as on Android): advance ONLY when our
// own track reached its end. An IDLE with no reason is somebody's
// stop, not a finish.
val isTrackEnd = playerState == MediaStatus.PlayerState.IDLE &&
idleReason == MediaStatus.IdleReason.FINISHED
if (isTrackEnd && status.mediaSessionId != lastAdvancedMediaSessionId) {
lastAdvancedMediaSessionId = status.mediaSessionId
scope.launch(queueCtx) { advanceOnFinish() }
}
}
private suspend fun advanceOnFinish() {
val next = nextQueueIndex() ?: run {
// End of queue — respect the repeat mode the user set.
val mode = _state.value.repeatMode
if (mode == RepeatMode.ALL && queue.isNotEmpty()) {
queueIndex = 0
loadCurrent(autoplay = true)
} else {
_state.update { it.copy(isPlaying = false, isStopped = true) }
}
return
}
queueIndex = next
loadCurrent(autoplay = true)
}
private fun nextQueueIndex(): Int? {
if (queue.isEmpty()) return null
val nextIx = queueIndex + 1
return if (nextIx in queue.indices) nextIx else null
}
private fun startStatusPoller() {
// Cancel any prior poller — defensive in case attach() is called twice.
positionPollerJob?.cancel()
positionPollerJob = scope.launch {
// We poll cast.getMediaStatus() on a continuous cadence while attached.
// Two reasons we can't rely purely on the spontaneous-event listener:
// 1. The chromecast-java-api-v2 library only starts pushing
// MEDIA_STATUS events after the first getMediaStatus() call has
// bootstrapped the media-channel subscription. Polling
// guarantees state updates regardless.
// 2. Position isn't pushed at all — it has to be polled.
//
// Cadence is adaptive: tight (POSITION_POLL_INTERVAL_MS, 500 ms)
// while playing so the seek bar moves smoothly, looser
// (STATUS_POLL_INTERVAL_MS, 1500 ms) when paused/stopped to avoid
// hammering the device for no gain.
while (true) {
val s = cast.session.value
if (s != null && s.deviceId == targetDeviceId) {
runCatching {
s.backing.mediaStatus
}.onSuccess { status ->
if (status != null) onMediaStatus(status)
}.onFailure { t ->
// "No application running" is expected before launchApp
// completes — don't spam.
val msg = t.message.orEmpty()
if ("No application is running" !in msg &&
"invalid state" !in msg
) {
System.err.println(
"[EarhornCast/Renderer] mediaStatus poll error: ${t.message}"
)
}
}
}
val nextInterval = if (_state.value.isPlaying)
POSITION_POLL_INTERVAL_MS else STATUS_POLL_INTERVAL_MS
delay(nextInterval)
}
}
}
// ---- Renderer interface --------------------------------------------------
override suspend fun load(
tracks: List<DidlItem.Track>,
startIndex: Int,
autoplay: Boolean,
) = withContext(queueCtx) {
if (tracks.isEmpty()) return@withContext
queue.clear()
queue.addAll(tracks)
queueIndex = startIndex.coerceIn(0, queue.lastIndex)
publishQueue()
loadCurrent(autoplay = autoplay)
}
override suspend fun enqueue(tracks: List<DidlItem.Track>) = withContext(queueCtx) {
if (tracks.isEmpty()) return@withContext
val wasEmpty = queue.isEmpty()
queue.addAll(tracks)
publishQueue()
// Mirror VlcjLocalRenderer: enqueue into empty queue stages tracks but
// doesn't auto-play. The user said "queue", not "play."
if (wasEmpty) {
queueIndex = 0
// Don't loadCurrent — let the next play() invocation pick it up.
}
}
override suspend fun playNext(tracks: List<DidlItem.Track>) = withContext(queueCtx) {
if (tracks.isEmpty()) return@withContext
if (queue.isEmpty()) {
// playNext into empty queue mirrors load(autoplay=true).
queue.addAll(tracks)
queueIndex = 0
publishQueue()
loadCurrent(autoplay = true)
} else {
val insertAt = (queueIndex + 1).coerceAtMost(queue.size)
queue.addAll(insertAt, tracks)
publishQueue()
}
}
override suspend fun skipToQueueItem(index: Int) = withContext(queueCtx) {
if (index !in queue.indices) return@withContext
queueIndex = index
loadCurrent(autoplay = true)
}
override suspend fun removeFromQueue(index: Int) = withContext(queueCtx) {
if (index !in queue.indices) return@withContext
val wasCurrent = index == queueIndex
queue.removeAt(index)
when {
queue.isEmpty() -> {
queueIndex = -1
cast.stop()
_state.update { it.copy(isPlaying = false, isStopped = true, current = null) }
}
wasCurrent -> {
// Currently-playing track removed — advance into whatever's now
// at the same index, falling back to 0 if we removed the tail.
queueIndex = queueIndex.coerceAtMost(queue.lastIndex)
loadCurrent(autoplay = _state.value.isPlaying)
}
index < queueIndex -> {
queueIndex -= 1
}
}
publishQueue()
}
override suspend fun moveInQueue(from: Int, to: Int) = withContext(queueCtx) {
if (from !in queue.indices || to !in queue.indices || from == to) return@withContext
val track = queue.removeAt(from)
queue.add(to, track)
// Track queueIndex through the move so we don't lose the playhead.
queueIndex = when {
queueIndex == from -> to
from < queueIndex && to >= queueIndex -> queueIndex - 1
from > queueIndex && to <= queueIndex -> queueIndex + 1
else -> queueIndex
}
publishQueue()
}
override suspend fun clearQueue() = withContext(queueCtx) {
queue.clear()
queueIndex = -1
cast.stop()
hasMediaOnReceiver = false
_state.update {
it.copy(
isPlaying = false,
isStopped = true,
current = null,
queue = emptyList(),
queueIndex = -1,
position = Duration.ZERO,
)
}
}
/**
* Have we loaded media onto the receiver in this session yet?
*
* When the user attaches to a Cast device while a local renderer was
* driving the queue, the queue model migrates onto this renderer (we
* end up with `queue`/`queueIndex` populated) but the Cast RECEIVER
* has nothing — no [load] has been issued yet. If the user then hits
* Play, the naïve `cast.play()` SOAP-equivalent call goes out, the
* receiver sees "play what?" and silently does nothing, leaving the
* desktop UI showing a play attempt that never resolves. The fix is
* to track whether the receiver has been primed with the current
* track and have [play] fall back to [loadCurrent] when it hasn't.
*
* Reset on [detach] and on [stop], because both end the session's
* relationship with the receiver's current media. Set in
* [loadCurrent] right after a successful library load call.
*/
@Volatile private var hasMediaOnReceiver: Boolean = false
override suspend fun play() = withContext(queueCtx) {
lastPlayLikeCommandMs = System.currentTimeMillis()
when {
// Empty-current + non-empty queue → freshly attached after
// migration, no track loaded yet. Start at the top.
queueIndex !in queue.indices && queue.isNotEmpty() -> {
queueIndex = 0
loadCurrent(autoplay = true)
}
// Valid current but receiver hasn't been primed yet → either
// a fresh attach onto a populated queue (the case the user
// hit: "play button does not work when connected to a
// chromecast, songs must be clicked on in the queue to start
// playing") OR a post-stop resume (stop clears the receiver's
// media so the next Play has nothing to act on). Load +
// autoplay rather than send Play to an empty receiver.
queueIndex in queue.indices && !hasMediaOnReceiver -> {
loadCurrent(autoplay = true)
}
// Normal resume-from-pause case — receiver has the current
// media loaded, just unpause.
else -> cast.play()
}
}
override suspend fun pause() {
lastPauseLikeCommandMs = System.currentTimeMillis()
cast.pause()
// Optimistic: mirror the pause locally so the UI flips immediately,
// even before the receiver acknowledges. onMediaStatus's suppression
// window keeps a stale PLAYING from clobbering this.
_state.update { it.copy(isPlaying = false) }
}
override suspend fun stop() {
lastPauseLikeCommandMs = System.currentTimeMillis()
lastUserStopMs = System.currentTimeMillis()
// design decision 2026-09-04: Stop lets go of the speaker, as on
// the phone — the group can use it again.
cast.disconnect()
// Reset position to zero alongside the playing/stopped flags so the
// progress bar visibly returns to the start of the track. Without
// this the slider stayed wherever the track happened to be when the
// user pressed Stop, which looked like a frozen UI — the user
// reported "stop is not resetting playbar to zero". Also clear
// hasMediaOnReceiver: the receiver's media handle is invalidated by
// our soft-stop (pause + seek 0 + clear-next), so the next Play
// needs to re-load rather than send Play to a now-empty receiver.
hasMediaOnReceiver = false
_state.update {
it.copy(
isPlaying = false,
isStopped = true,
position = Duration.ZERO,
)
}
}
override suspend fun seekTo(position: Duration) {
cast.seek(position.inWholeMilliseconds.toDouble() / 1000.0)
}
override suspend fun next() = withContext(queueCtx) {
val next = nextQueueIndex() ?: return@withContext
queueIndex = next
loadCurrent(autoplay = true)
}
override suspend fun previous() = withContext(queueCtx) {
if (queue.isEmpty()) return@withContext
val prev = (queueIndex - 1).coerceAtLeast(0)
queueIndex = prev
loadCurrent(autoplay = true)
}
override suspend fun setVolume(volumePercent: Int) {
// Mark that the user has taken manual control. The attach-time sync
// loop checks this and stops overwriting the slider with device reads.
volumeUserOverride = true
cast.setVolume((volumePercent.coerceIn(0, 100).toFloat()) / 100f)
_state.update { it.copy(volumePercent = volumePercent.coerceIn(0, 100)) }
}
override suspend fun setRepeatMode(mode: RepeatMode) {
// Local-only; we implement repeat logic in advanceOnFinish() against
// our own queue rather than trying to drive the receiver's RepeatMode.
// The receiver's RepeatMode applies to its own queue (which we don't use).
_state.update { it.copy(repeatMode = mode) }
}
override suspend fun setShuffle(enabled: Boolean) = withContext(queueCtx) {
// Mirror VlcjLocalRenderer: enabling saves the current order, disabling
// restores it. We don't shuffle aggressively into the future — only the
// tracks AFTER queueIndex get shuffled so the currently-playing track
// doesn't change underneath the user.
if (enabled == _state.value.shuffleEnabled) return@withContext
if (enabled) {
preShuffleSnapshot = queue.toList()
if (queue.size > queueIndex + 1) {
val head = queue.subList(0, queueIndex + 1).toList()
val tail = queue.subList(queueIndex + 1, queue.size).shuffled()
queue.clear()
queue.addAll(head)
queue.addAll(tail)
}
} else {
preShuffleSnapshot?.let {
queue.clear()
queue.addAll(it)
preShuffleSnapshot = null
// Re-resolve queueIndex against the restored order.
val currentUrl = _state.value.current?.resourceUrl
if (currentUrl != null) {
val ix = queue.indexOfFirst { it.resourceUrl == currentUrl }
if (ix >= 0) queueIndex = ix
}
}
}
_state.update { it.copy(shuffleEnabled = enabled) }
publishQueue()
}
private var preShuffleSnapshot: List<DidlItem.Track>? = null
// ---- Helpers -------------------------------------------------------------
/**
* Load the track at [queueIndex] onto the active Cast session.
* No-op if the queue is empty or no session is active.
*/
private fun loadCurrent(autoplay: Boolean) {
val track = queue.getOrNull(queueIndex) ?: return
cast.load(track)
// Receiver now has media of its own (or will momentarily — load() is
// launched async inside CastIntegration). Subsequent play() calls
// can use cast.play() directly instead of needing to loadCurrent
// first; cleared again on stop() and detach().
hasMediaOnReceiver = true
if (!autoplay) {
// CastIntegration.load() autoplays by default. Pause immediately
// after to satisfy autoplay=false semantics. There's a brief moment
// where the receiver buffers and starts playing before we pause —
// not ideal, but the user-visible effect is acceptable for the
// single use of autoplay=false (UPnP SetAVTransportURI followed by
// an explicit Play).
scope.launch {
delay(BRIEF_DELAY_BEFORE_PAUSE_MS)
cast.pause()
}
}
_state.update {
it.copy(
current = track,
queueIndex = queueIndex,
queue = queue.toList(),
// Fresh track starts at zero — without this the slider would
// briefly carry over the old track's position until the first
// post-load poll lands.
position = Duration.ZERO,
)
}
}
private fun publishQueue() {
_state.update {
it.copy(
queue = queue.toList(),
queueIndex = queueIndex,
current = queue.getOrNull(queueIndex),
)
}
}
/**
* Synthesize a minimal [DidlItem.Track] from the receiver's reported
* media metadata. Returns null when there's no media block, no metadata,
* or no title — those signal a transport-only update (PAUSE / SEEK /
* position refresh) where we don't want to fabricate a Track at all.
*
* The chromecast-java-api-v2 library exposes `Media.metadata` as a
* `Map<String, Object>` populated from the receiver's JSON metadata
* payload. The standard Cast metadata keys for MUSIC_TRACK are `title`,
* `artist`, `albumName`, `albumArtist`, `images` (a list of maps with
* `url`), `trackNumber`, `discNumber`, `releaseDate`. For receivers that
* use the GENERIC metadata type (custom apps, some casual media
* receivers) the artist often arrives under `subtitle` instead — we
* walk the alternatives so common shapes all resolve.
*
* The returned Track carries [DidlItem.Track.resourceUrl] = the
* receiver's contentId so downstream consumers (the queue UI, MPRIS,
* lyrics lookup) have a stable identity. The track's `id` is the same
* string — observer-derived tracks have no DIDL provenance to draw
* from.
*/
@Suppress("UNCHECKED_CAST")
private fun extractTrackFromMediaStatus(status: MediaStatus): DidlItem.Track? {
val media = status.media ?: return null
val meta = media.metadata as? Map<String, Any?> ?: run {
// Diagnostic: media block present but metadata field is null or not
// a String-keyed map. Some receivers send the metadata under a
// non-standard wrapper; if you see this line on a session where the
// gramophone glow persists, paste the rx ns=media payload from the
// raw socket logs and we'll teach the parser about the new shape.
System.err.println(
"[EarhornCast/Renderer] MEDIA_STATUS media block has no usable " +
"metadata map (url=${media.url} contentType=${media.contentType})"
)
return null
}
if (meta.isEmpty()) {
System.err.println(
"[EarhornCast/Renderer] MEDIA_STATUS metadata map is empty " +
"(url=${media.url}). Receiver sent media block but no fields."
)
return null
}
val title = (meta["title"] as? String)?.takeIf { it.isNotBlank() }
?: run {
// Title is the one field we won't fabricate around. Most other
// metadata callers can render a track with just title + art,
// but no title means a placeholder that says "Nothing playing"
// is more honest than one that says "Unknown title."
System.err.println(
"[EarhornCast/Renderer] MEDIA_STATUS metadata has no title; " +
"keys present: ${meta.keys.joinToString()}"
)
return null
}
val artist = (meta["artist"] as? String)?.takeIf { it.isNotBlank() }
?: (meta["subtitle"] as? String)?.takeIf { it.isNotBlank() }
?: (meta["albumArtist"] as? String)?.takeIf { it.isNotBlank() }
val album = (meta["albumName"] as? String)?.takeIf { it.isNotBlank() }
?: (meta["albumTitle"] as? String)?.takeIf { it.isNotBlank() }
val images = meta["images"] as? List<*>
val artUrl = (images?.firstOrNull() as? Map<*, *>)
?.get("url") as? String
val contentId = media.url ?: ""
val duration = media.duration
?.let { (it * 1000).toLong().milliseconds }
?: Duration.ZERO
// Diagnostic: log the SUCCESSFUL extraction once per track change. If
// the symptom persists after this code lands, this is the line that
// tells us whether the receiver IS sending metadata that we ARE
// parsing correctly (and the bug is somewhere else, e.g. the UI not
// re-binding) or whether we're never getting here at all.
System.err.println(
"[EarhornCast/Renderer] extracted track from MEDIA_STATUS: " +
"title=$title artist=$artist album=$album art=${artUrl != null}"
)
return DidlItem.Track(
id = contentId,
parentId = "",
title = title,
artist = artist,
albumArtist = artist,
album = album,
composer = null,
genre = null,
date = (meta["releaseDate"] as? String)
?: (meta["originalAirDate"] as? String),
trackNumber = (meta["trackNumber"] as? Number)?.toInt(),
discNumber = (meta["discNumber"] as? Number)?.toInt(),
duration = duration,
albumArtUri = artUrl,
resourceUrl = contentId,
mimeType = media.contentType,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)
}
companion object {
/** How often we poll position from the receiver while playing.
* 500ms keeps the seek bar smooth without flooding the device. */
private const val POSITION_POLL_INTERVAL_MS = 1_000L
/** How often we poll for general media status when NOT playing — a
* looser cadence since neither the playhead nor the user expectations
* need sub-second resolution while paused/stopped. The poller can't
* go silent entirely though: the receiver may be in BUFFERING and
* about to transition to PLAYING, and we want the UI to react. */
private const val STATUS_POLL_INTERVAL_MS = 10_000L
/** Brief delay after `cast.load()` before issuing pause() for the
* autoplay=false case. Receiver needs a moment to enter PLAYING
* state for the pause to take effect. */
private const val BRIEF_DELAY_BEFORE_PAUSE_MS = 250L
/** How long after a transport command (play/pause/stop) we ignore
* contradictory playerState reports from the receiver. The
* receiver typically processes commands within a few hundred ms;
* 1500 ms is generous enough to cover slow Wi-Fi or busy speaker
* groups while still letting genuine failures surface promptly. */
private const val TRANSPORT_SUPPRESS_MS = 1500L
/** Used by the auto-advance fallback path in [onMediaStatus]: when
* the receiver reports IDLE with a NULL [MediaStatus.IdleReason]
* (which some firmwares do for HTTP audio streams instead of
* setting FINISHED), we treat that as "track ended naturally and
* should auto-advance" — UNLESS the user pressed Stop within this
* window, in which case IDLE-without-reason was probably the
* receiver acknowledging that Stop and we leave well enough
* alone. 2000 ms covers the round-trip latency for a Stop command
* plus a poll cycle, and is longer than [TRANSPORT_SUPPRESS_MS]
* because the asymmetry matters: a false advance (we resume
* playback when the user wanted silence) is a much worse failure
* than a missed advance (the track just doesn't go to the next
* one for a couple seconds). */
private const val USER_STOP_SUPPRESS_MS = 2000L
/** Initial delay between attempts in the post-attach volume sync
* retry loop. Grows by 1.5× each retry, capping at
* VOLUME_SYNC_MAX_DELAY_MS. */
private const val INITIAL_VOLUME_SYNC_DELAY_MS = 250L
/** Ceiling on the exponential backoff used for volume sync retries.
* Keeps the worst-case wait between attempts bounded so a slow-
* booting receiver still converges in seconds, not minutes. */
private const val VOLUME_SYNC_MAX_DELAY_MS = 2_000L
/** Maximum number of times we retry volume sync after attach. With
* the 250→2000ms backoff above this gives the receiver up to ~10
* seconds of total wallclock to start reporting its volume level
* before we give up and leave the slider showing whatever its
* default is. */
private const val VOLUME_SYNC_MAX_ATTEMPTS = 8
/** Interval between rapid getMediaStatus calls issued right after
* attach to bootstrap the metadata into [_state.current]. 400ms is
* short enough that the user sees the gramophone-glow replaced
* within ~half a second on a happy-path reattach, and long enough
* that we don't flood the receiver with overlapping requests in
* pathological cases. */
private const val METADATA_BOOTSTRAP_INTERVAL_MS = 400L
/** Number of metadata-bootstrap calls issued after attach (or until
* we've populated state.current, whichever comes first). 5 × 400ms
* = up to 2s of focused effort; past that, if the receiver hasn't
* given us a media block, there isn't an active session to
* describe and the regular poller takes over. */
private const val METADATA_BOOTSTRAP_ATTEMPTS = 5
}
}