package dev.stave.playback
import android.content.ComponentName
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionToken
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.stave.data.ReplayGainMode
import dev.stave.data.SettingsStore
import dev.stave.data.toDomain
import dev.stave.data.toPersisted
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 kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.pow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class LocalRenderer @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsStore,
private val queueStore: dev.stave.data.QueuePersistenceStore,
private val cast: dev.stave.cast.CastIntegration,
) : Renderer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _state = MutableStateFlow(PlaybackState())
override val state: StateFlow<PlaybackState> = _state.asStateFlow()
private var controller: MediaController? = null
private var queueTracks: List<DidlItem.Track> = emptyList()
/** Maintainer, 2026-10-29: completed once [controller] is connected, so
* [load] can wait for it instead of dropping the request while still
* publishing the new queue. */
private val controllerReady = CompletableDeferred<Unit>()
/** Maintainer, 2026-10-29: the local play intent (isPlaying || playWhenReady)
* as last seen while NOT casting. The cast-start handler used to read it
* from the controller after PlaybackService had already paused exo and
* switched its getters to the (not yet playing) cast state, so the
* speaker could start paused. Main thread only. */
private var localPlayIntent = false
private val playerListener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
// Maintainer, 2026-10-29: an outside controller (Android Auto's
// browse list → onAddMediaItems) can replace the player's items;
// re-adopt the live queue when it no longer matches ours (D5).
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) adoptExternalQueueIfChanged(player)
// While casting, the local controller is paused at volume=0
// and its currentMediaItemIndex never advances — the receiver
// is the source of truth for queue position. Source idx from
// cast.castCurrentItemIndex in that case so the now-playing
// UI tracks the speaker, not the frozen local controller.
//
// When castCurrentItemIndex is -1 (cast queue not yet loaded,
// or transitioning), fall back to the last known local index
// so we don't snap back to 0 mid-session.
val casting = cast.isCasting.value
// Maintainer, 2026-10-29: remember the local intent while not casting
// (see localPlayIntent).
if (!casting) localPlayIntent = player.isPlaying || player.playWhenReady
val rawIdx = if (casting) {
val remoteIdx = cast.castCurrentItemIndex.value
if (remoteIdx >= 0) remoteIdx else _state.value.queueIndex.coerceAtLeast(0)
} else {
player.currentMediaItemIndex
}
val idx = rawIdx.coerceIn(0, (queueTracks.size - 1).coerceAtLeast(0))
_state.value = _state.value.copy(
current = queueTracks.getOrNull(idx),
queueIndex = idx,
isPlaying = if (casting) cast.castIsPlaying.value else player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
// A queue that plays to its end IS stopped: ExoPlayer sits in
// STATE_ENDED, isPlaying false, and nothing else set the
// flag — so a disarmed fixed screen that played an album
// never returned to kiosk (review C.2, 2026-09-02).
isStopped = if (!casting && player.playbackState == Player.STATE_ENDED) true
else _state.value.isStopped,
duration = if (casting) {
cast.castDurationMs.value.takeIf { it > 0 }?.milliseconds ?: Duration.ZERO
} else {
player.duration.takeIf { it > 0 }?.milliseconds ?: Duration.ZERO
},
position = if (casting) {
cast.castPositionMs.value.coerceAtLeast(0L).milliseconds
} else {
player.currentPosition.coerceAtLeast(0).milliseconds
},
volumePercent = if (casting) {
// When casting, the local player's volume is irrelevant —
// the Cast receiver has its own volume. Preserve whatever
// setVolume() last wrote into _state.volumePercent rather
// than overwriting it with the local player's level (which
// may be 0 or 1.0 depending on how the Cast stream proxy
// is configured). Without this guard, the playerListener
// fires after each setVolume() and snaps volumePercent
// back to the local value, making adjustVolume() read
// stale data on the next call.
_state.value.volumePercent
} else {
(player.volume * 100).toInt()
},
error = player.playerError?.localizedMessage,
// Mirror shuffle + repeat from the player so notification-
// driven toggles (which write directly to player.shuffleModeEnabled
// / player.repeatMode rather than calling renderer.setShuffle /
// setRepeatMode) propagate into the in-app UI's modifier row.
// Without this, tapping shuffle in the notification flips the
// queue order but the in-app shuffle icon stays unfilled.
shuffleEnabled = player.shuffleModeEnabled,
repeatMode = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
else -> RepeatMode.OFF
},
)
}
}
/** Maintainer, 2026-10-29 (D5): queueTracks is only updated by this class's
* own calls, so playback started by another controller (Auto) left the
* app showing the wrong track. When the controller's media ids no
* longer equal queueTracks' ids (same size, same order), rebuild
* queueTracks the way the cold-start adoption does: persisted Track by
* id, else toMinimalTrack. Our own mutations update queueTracks in the
* same main-thread block as the controller call, before onEvents runs,
* so they never trigger this. */
private fun adoptExternalQueueIfChanged(player: Player) {
val liveIds = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).mediaId }
if (liveIds.isEmpty() || liveIds == queueTracks.map { it.id }) return
scope.launch {
val persistedById: Map<String, DidlItem.Track> = runCatching {
queueStore.load()?.tracks
?.map { it.toDomain() }
?.associateBy { it.id }
?: emptyMap()
}.getOrDefault(emptyMap())
// Re-read after the suspend: the queue may have moved on (or been
// brought back in line by our own call) meanwhile.
val c = controller ?: return@launch
val items = (0 until c.mediaItemCount).map { c.getMediaItemAt(it) }
if (items.isEmpty() || items.map { it.mediaId } == queueTracks.map { it.id }) return@launch
val recovered = items.mapNotNull { persistedById[it.mediaId] ?: it.toMinimalTrack() }
if (recovered.isEmpty()) return@launch
queueTracks = recovered
val idx = c.currentMediaItemIndex.coerceIn(0, recovered.lastIndex)
_state.value = _state.value.copy(
current = recovered.getOrNull(idx),
queue = recovered,
queueIndex = idx,
)
android.util.Log.i("EarhornRenderer", "Adopted external queue change: ${recovered.size} items, idx=$idx")
}
}
init {
scope.launch {
try {
connectAndTickPosition()
} catch (t: Throwable) {
// Whatever goes wrong (service not available, permission, JUL crash),
// surface it in state rather than crashing the app. The UI handles a null controller.
_state.value = _state.value.copy(error = "Playback service unavailable: ${t.message}")
}
}
}
private suspend fun connectAndTickPosition() {
val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val ctrl: MediaController = suspendCancellableCoroutine { cont ->
val future = MediaController.Builder(context, token).buildAsync()
future.addListener({
try {
if (cont.isActive) cont.resume(future.get())
} catch (t: Throwable) {
if (cont.isActive) cont.resumeWithException(t)
}
}, ContextCompat.getMainExecutor(context))
}
controller = ctrl
ctrl.addListener(playerListener)
// Maintainer, 2026-10-29: seed the pre-cast intent (onEvents may not fire
// before a cast starts) and release any load() waiting for us.
if (!cast.isCasting.value) localPlayIntent = ctrl.isPlaying || ctrl.playWhenReady
controllerReady.complete(Unit)
// Cold-start recovery: if the service is already alive with a playing
// or paused session (e.g. user tapped Play in the media notification
// before opening the app), adopt the live session's state rather than
// overwriting it with the persisted queue. Without this, the app
// would silently continue playing in the background while Browse
// rendered with no NowPlayingBar because queueTracks was empty.
val liveItemCount = ctrl.mediaItemCount
val playerIsLive = ctrl.playbackState != Player.STATE_IDLE && liveItemCount > 0
if (playerIsLive) {
// Try to read the persisted queue snapshot and use it to enrich
// the live MediaItems. Persisted DidlItem.Track has the full
// domain object (parentId, real albumArtUri, all the technical
// fields). MediaMetadata round-tripped through MediaController
// IPC drops or mangles extras and most non-display fields, so
// toMinimalTrack alone produces a degraded Track. Look each
// live MediaItem up by mediaId in the persisted snapshot; use
// the persisted Track when it matches, fall back to
// toMinimalTrack for any that don't (e.g. Auto-added tracks).
val persistedById: Map<String, DidlItem.Track> = runCatching {
queueStore.load()?.tracks
?.map { it.toDomain() }
?.associateBy { it.id }
?: emptyMap()
}.getOrDefault(emptyMap())
val recovered = (0 until liveItemCount).mapNotNull { i ->
val mediaItem = runCatching { ctrl.getMediaItemAt(i) }.getOrNull()
?: return@mapNotNull null
persistedById[mediaItem.mediaId]
?: mediaItem.toMinimalTrack()
}
if (recovered.isNotEmpty()) {
queueTracks = recovered
val idx = ctrl.currentMediaItemIndex.coerceIn(0, recovered.lastIndex)
_state.value = _state.value.copy(
current = recovered.getOrNull(idx),
queue = recovered,
queueIndex = idx,
isPlaying = ctrl.isPlaying,
isStopped = false,
)
android.util.Log.i(
"EarhornRenderer",
"Adopted live session: ${recovered.size} items, idx=$idx, playing=${ctrl.isPlaying}"
)
}
} else {
// Queue restore. If the user exited mid-playback, bring back the queue at the
// same index + near the same position. We DO NOT auto-play — it's startling to
// open an app and have audio erupt. The user has to press Play to resume.
runCatching {
val saved = queueStore.load()
if (saved != null && saved.tracks.isNotEmpty()) {
val tracks = saved.tracks.map { it.toDomain() }
queueTracks = tracks
val mediaItems = tracks.map { it.toMediaItem() }
val startIndex = saved.currentIndex.coerceIn(0, tracks.lastIndex)
ctrl.setMediaItems(mediaItems, startIndex, saved.positionMs.coerceAtLeast(0L))
ctrl.prepare()
ctrl.playWhenReady = false
// Seed observable state so the NowPlaying bar shows the restored track
// before the player's first onEvents fires.
_state.value = _state.value.copy(
current = tracks.getOrNull(startIndex),
queue = tracks,
queueIndex = startIndex,
isStopped = false,
)
}
}
}
// ReplayGain observer. Combines the current track + RG settings; every time any
// of those change, recompute the volume multiplier and apply it. The multiplier
// is in [0, 1] — anything computed higher is clamped, because Media3's
// MediaController.setVolume contract requires that range and values outside it
// can throw IllegalArgumentException on some player backends.
//
// The whole collect is wrapped in try/catch: if anything throws during volume
// application (thread assertion violation, disconnected controller, clamp
// failure on some SDK level), we log and continue rather than crashing the
// coroutine scope and taking playback with it.
scope.launch {
combine(
_state.map { it.current }.distinctUntilChanged { a, b -> a?.id == b?.id },
settings.replayGainMode,
settings.replayGainPreAmpDb,
) { track, mode, preAmp ->
Triple(track, mode, preAmp)
}.collect { (track, mode, preAmp) ->
runCatching {
// Local is muted while casting (the cast observer sets
// volume = 0 to cede audio to the sync'd remote group).
// Don't compete.
if (cast.isCasting.value) return@runCatching
val volume = computeReplayGainVolume(track, mode, preAmp)
controller?.volume = volume
}
}
}
// Cast-session observer. Mirrors local → remote when a session starts, and
// restores local playback when the session ends. This is the coordinator
// seam — nothing else in LocalRenderer branches on isCasting.
scope.launch {
var wasPlayingBeforeCast = false
var savedPositionMs = 0L
cast.isCasting.collect { casting ->
val c = controller ?: return@collect
if (casting && cast.consumeAttachWasResume()) {
// RECONNECTION, not a fresh cast (design decision
// 2026-09-02; review N.8). Ask the group what it is
// playing. If it is one of ours, carry on and let the
// index mirror below match the screen to it. If it is
// anything else — house music, nothing — stop pretending
// we are casting and wait for a person. Never send a
// queue on our own, under any answer.
cast.requestStatusRefresh()
delay(1_500)
val remote = cast.currentContentId()
val ours = remote != null && _state.value.queue.any { t ->
remote == t.resourceUrl || remote.contains(java.net.URLEncoder.encode(t.resourceUrl, "UTF-8"))
}
if (!ours) {
android.util.Log.i("EarhornCast",
"resumed session is not playing our queue (remote=$remote) — detaching without touching it")
cast.detachWithoutStopping()
} else {
android.util.Log.i("EarhornCast", "resumed session is playing our queue — carrying on")
}
return@collect
}
if (casting) {
// Session started. Save pre-cast state for eventual
// session end. PlaybackService manages ExoPlayer's
// volume (→0), audio focus (→disabled), and
// playWhenReady (→mirrors castIsPlaying) directly
// on the ExoPlayer instance — we DON'T touch the
// controller here to avoid conflicting writes.
// Maintainer, 2026-10-29: the intent recorded before the cast
// state switch, not the controller's (possibly already
// switched) values — see localPlayIntent.
wasPlayingBeforeCast = localPlayIntent
savedPositionMs = c.currentPosition.coerceAtLeast(0L)
val current = _state.value.current
if (current != null) {
// Push full queue with start index = current
// track so receiver-side next-track advancement
// works. Falls back to single-track load only
// if the queue is somehow empty (shouldn't
// happen, but defensive).
val queue = _state.value.queue
val idx = queue.indexOfFirst { it.id == current.id }
.takeIf { it >= 0 } ?: 0
if (queue.isNotEmpty()) {
cast.loadQueue(
queue,
idx,
autoplay = wasPlayingBeforeCast,
repeat = _state.value.repeatMode,
shuffle = _state.value.shuffleEnabled,
)
} else {
cast.loadTrack(current, autoplay = wasPlayingBeforeCast)
}
if (savedPositionMs > 0) cast.seekTo(savedPositionMs)
}
// Initialize volumePercent from the Cast session's
// actual volume so the first touch-volume adjustment
// starts from the correct baseline. Without this, the
// state still holds the local player's volume (which
// may be 0 after muting for Cast) and the first tap
// slams the Cast speaker to 5% or 0%.
val castVol = (cast.getDeviceVolume() * 100).toInt().coerceIn(0, 100)
_state.value = _state.value.copy(volumePercent = castVol)
} else {
// Session ended. PlaybackService restores volume
// and audio focus on ExoPlayer directly. We just
// consume the disconnect intent flag so it doesn't
// carry over to the next session.
cast.consumeDisconnectIntent()
}
}
}
// Cast → local mirror for the receiver's current queue position.
// While casting, the local controller is paused at volume=0 and its
// currentMediaItemIndex never advances — PlayerListener can't pick
// up track changes because no transitions fire on a stopped player.
// The receiver, however, is advancing the queue on its own (we sent
// the full queue via loadQueue), so its current-item-index is the
// authoritative source of "what track is the user actually hearing
// right now". Mirror that into _state.queueIndex / _state.current
// so the now-playing UI, queue highlight, and album art track the
// remote device instead of freezing on whichever song was playing
// when the cast session started.
//
// The earlier implementation of this observer pushed local→cast on
// every _state.current change (cast.loadTrack(current)) on the
// theory that whatever changed the local track should also change
// the remote one. That observer is no longer needed because
// next() / previous() / skipToQueueItem() now each call cast.queueNext
// / queuePrev / queueJumpTo explicitly — so the cast-side already
// knows. Keeping the old observer was actively harmful: it caused
// every receiver-initiated advance to bounce back as a loadTrack,
// which restarted the track the user was already happily hearing.
scope.launch {
cast.castCurrentItemIndex
.collect { remoteIdx ->
if (!cast.isCasting.value) return@collect
if (remoteIdx < 0) return@collect
val queue = _state.value.queue
val track = queue.getOrNull(remoteIdx) ?: return@collect
if (_state.value.queueIndex == remoteIdx &&
_state.value.current?.id == track.id) return@collect
_state.value = _state.value.copy(
queueIndex = remoteIdx,
current = track,
)
}
}
// Cast play-state mirror. While casting, the local controller is
// muted (volume=0) and the Player.Listener (which keys off local
// events) doesn't fire — we'd be stuck at whatever local state was
// when the session began. Watch the cast flows directly and push
// their values into _state so the play/pause icon, scrub bar, and
// duration label all reflect the remote device's actual state.
// Combined into one observer so we re-publish on any of the three
// changing.
//
// Also mirrors cast.castIsPlaying onto the local controller's
// playWhenReady. The local controller drives the system
// MediaSession's notification — when cast is playing, local must
// also report "playing" so the notification shows Pause icon (and
// vice-versa). Volume stays at 0 so this produces no actual audio.
scope.launch {
data class CastSnapshot(
val casting: Boolean,
val playing: Boolean,
val posMs: Long,
val durMs: Long,
)
combine(
cast.isCasting,
cast.castIsPlaying,
cast.castPositionMs,
cast.castDurationMs,
) { casting, playing, posMs, durMs ->
CastSnapshot(casting, playing, posMs, durMs)
}.collect { snap ->
if (!snap.casting) return@collect
_state.update { s ->
s.copy(
isPlaying = snap.playing,
position = snap.posMs.coerceAtLeast(0L).milliseconds,
duration = snap.durMs.takeIf { it > 0L }
?.milliseconds ?: s.duration,
)
}
// No mirroring back onto local.playWhenReady — local is
// paused for the duration of the cast session. The system
// notification will show Play (because session reports
// not-playing) even when the cast device is actually
// playing — accepted trade-off; the alternative (keeping
// local "playing" silently) was preventing cast from
// establishing playback at all.
}
}
var persistTick = 0
while (true) {
// design decision 2026-09-02 (I.39): this loop used to run
// five times a second for the life of the process on every
// device — a MediaController IPC each tick — including wall
// screens that never play locally. Now it idles at one check a
// second while nothing is playing anywhere.
val casting = cast.isCasting.value
val playingNow = (casting && cast.castIsPlaying.value) || (!casting && ctrl.isPlaying)
if (!playingNow) {
delay(1_000)
continue
}
// Position tick. When local is the audio source, read from the
// local controller. When casting, the cast play-state observer
// above already mirrors position into _state, so this path
// skips the local read to avoid clobbering it with stale local
// values.
if (!casting) {
val pos = ctrl.currentPosition.coerceAtLeast(0).milliseconds
if (_state.value.position != pos) {
_state.value = _state.value.copy(position = pos)
}
}
// Persist queue snapshot every ~5 seconds during playback so the restore
// position stays fresh. Counter-gated to avoid a DataStore write every
// 200ms (which would be wasteful and add disk load to the audio path).
persistTick++
if (persistTick >= 25) {
persistQueue()
persistTick = 0
}
// 200ms tick — fast enough for responsive lyrics highlighting
// (synced lyrics lines can be as short as 1s) while still light
// on CPU. Was 500ms which left visible lag on the lyric highlight.
delay(200)
}
}
/** Derive the Player.volume multiplier for the current track given RG settings.
*
* Reads REPLAYGAIN_TRACK_GAIN or REPLAYGAIN_ALBUM_GAIN from track.extras, adds
* the pre-amp, converts dB to linear via 10^(dB/20). Pre-amp still applies even
* when the track has no RG tag — that's a user preference independent of RG
* availability. When mode is OFF, returns 1.0 (unity).
*
* Clamped to [0, 1] because Media3's MediaController.setVolume spec is [0,1] and
* some underlying players throw IllegalArgumentException on out-of-range values.
* Values above 1 from positive preamp + low attenuation simply saturate at 1.
* NaN/infinity defensively mapped to 1 in case pow overflows. */
private fun computeReplayGainVolume(
track: DidlItem.Track?,
mode: ReplayGainMode,
preAmpDb: Float,
): Float {
if (mode == ReplayGainMode.OFF || track == null) return 1f
val key = when (mode) {
ReplayGainMode.TRACK -> "REPLAYGAIN_TRACK_GAIN"
ReplayGainMode.ALBUM -> "REPLAYGAIN_ALBUM_GAIN"
ReplayGainMode.OFF -> return 1f
}
val rgGainRaw = track.extras.entries
.firstOrNull { it.key.equals(key, ignoreCase = true) }
?.value
// ALBUM fallback to TRACK when album tag is absent — common because many
// tagging tools only write per-track RG.
?: track.extras.entries
.firstOrNull { it.key.equals("REPLAYGAIN_TRACK_GAIN", ignoreCase = true) }
?.value
val rgGainDb = rgGainRaw?.let { parseRgGain(it) } ?: 0f
val totalDb = rgGainDb + preAmpDb
val linear = 10.0.pow(totalDb / 20.0).toFloat()
// Guard against NaN / Infinity / out-of-range. Media3 [0,1] spec is strict.
return when {
linear.isNaN() || linear.isInfinite() -> 1f
linear < 0f -> 0f
linear > 1f -> 1f
else -> linear
}
}
/** Parse a ReplayGain tag string. Accepts formats like "-7.22 dB", "-7.22dB",
* "-7.22", "+3.5 dB". Returns 0f for unparseable values rather than throwing. */
private fun parseRgGain(raw: String): Float {
val match = Regex("""[+-]?\d+(?:\.\d+)?""").find(raw.trim())
return match?.value?.toFloatOrNull() ?: 0f
}
/** Snapshot the current queue + index + position to persistent storage. Called
* after every queue mutation (load/enqueue/playNext/skipTo/remove/move/clear)
* and periodically during playback so the resume position is fresh within a
* few seconds of the last save.
*
* Reads controller state on main thread; fire-and-forget write to DataStore. */
private fun persistQueue() {
scope.launch {
runCatching {
val tracks = queueTracks
if (tracks.isEmpty()) {
queueStore.clear()
return@launch
}
val c = controller
// While casting the local controller is paused and stale; the
// receiver's index/position are the truth (review N.15,
// design decision 2026-09-02).
val casting = cast.isCasting.value
val idx = if (casting) _state.value.queueIndex.coerceAtLeast(0)
else c?.currentMediaItemIndex?.coerceAtLeast(0) ?: 0
val pos = if (casting) cast.castPositionMs.value.coerceAtLeast(0L)
else c?.currentPosition?.coerceAtLeast(0L) ?: 0L
queueStore.save(
dev.stave.data.PersistedQueue(
tracks = tracks.map { it.toPersisted() },
currentIndex = idx,
positionMs = pos,
)
)
}
}
}
override suspend fun load(tracks: List<DidlItem.Track>, startIndex: Int, autoplay: Boolean) {
// Maintainer, 2026-10-29: a play before the MediaController connected did
// nothing yet still published the new queue. Wait (bounded) for the
// connection; if it still isn't there, drop the load without touching
// the queue or _state.
if (controller == null) withTimeoutOrNull(CONTROLLER_WAIT_MS) { controllerReady.await() }
if (controller == null) {
android.util.Log.w("EarhornRenderer", "load: MediaController not connected — ignored")
return
}
queueTracks = tracks
val mediaItems = tracks.map { it.toMediaItem() }
withContext(Dispatchers.Main) {
val c = controller ?: return@withContext
c.setMediaItems(mediaItems, startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0)), 0)
c.prepare()
// autoplay=false (UPnP setAVTransportURI path) stays in Stopped until
// the control point sends Play. Default true preserves the user-click
// semantics of every other call site.
c.playWhenReady = autoplay
}
// isStopped tracks the "no media at all" / "media but stopped" axis. autoplay=false
// keeps it true since the transport will report STOPPED to UPnP control points
// until Play arrives.
_state.value = _state.value.copy(queue = tracks, isStopped = !autoplay)
// If casting, push the FULL queue to the remote so next-track
// advancement happens receiver-side. Loading just the start
// track was the cause of "Shield won't advance to next track":
// without queue context the receiver finishes one item and
// stops. The phone-side controller still owns the queue index
// for state reporting; the receiver owns playback continuity.
if (cast.isCasting.value) {
cast.loadQueue(
tracks,
startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0)),
repeat = _state.value.repeatMode,
shuffle = _state.value.shuffleEnabled,
)
}
persistQueue()
}
override suspend fun enqueue(tracks: List<DidlItem.Track>) {
if (tracks.isEmpty()) return
withContext(Dispatchers.Main) {
val c = controller ?: return@withContext
val mediaItems = tracks.map { it.toMediaItem() }
if (c.mediaItemCount == 0) {
queueTracks = tracks
c.setMediaItems(mediaItems, 0, 0)
c.prepare()
} else {
queueTracks = queueTracks + tracks
c.addMediaItems(mediaItems)
}
}
_state.value = _state.value.copy(queue = queueTracks)
// design decision 2026-09-02 (review N.12): tracks added during
// a cast used to exist on the phone only — the comment here claimed
// a later push that nothing performed, so the receiver stopped at
// the end of the list it was given. Append them to the receiver's
// queue in place; nothing playing is interrupted.
// Maintainer, 2026-10-29: pass the whole queue so an empty receiver is
// loaded in step with the phone (D4).
if (cast.isCasting.value) cast.queueAppend(tracks, fullQueue = queueTracks)
persistQueue()
}
override suspend fun playNext(tracks: List<DidlItem.Track>) {
if (tracks.isEmpty()) return
var autoStarted = false
withContext(Dispatchers.Main) {
val c = controller ?: return@withContext
val mediaItems = tracks.map { it.toMediaItem() }
if (c.mediaItemCount == 0) {
queueTracks = tracks
c.setMediaItems(mediaItems, 0, 0)
c.prepare()
c.playWhenReady = true
autoStarted = true
} else {
val current = c.currentMediaItemIndex
val insertAt = if (current < 0) c.mediaItemCount
else (current + 1).coerceAtMost(c.mediaItemCount)
// Maintainer, 2026-10-29: insertAt comes from the controller;
// coerce it to our own list so a desync can't throw
// IndexOutOfBounds (D5).
queueTracks = queueTracks.toMutableList().apply { addAll(insertAt.coerceAtMost(size), tracks) }
c.addMediaItems(insertAt, mediaItems)
}
}
_state.value = if (autoStarted) _state.value.copy(queue = queueTracks, isStopped = false)
else _state.value.copy(queue = queueTracks)
// Same ruling as enqueue (N.12): insert on the receiver right after
// its current item, without reloading anything.
if (cast.isCasting.value) cast.queueInsertAfterCurrent(tracks)
persistQueue()
}
override suspend fun skipToQueueItem(index: Int) = onMain {
val c = controller ?: return@onMain
if (index in 0 until c.mediaItemCount) {
c.seekTo(index, 0)
c.playWhenReady = true
_state.value = _state.value.copy(isStopped = false)
if (cast.isCasting.value) {
// Maintainer, 2026-10-29: enqueueAndPlay appends then jumps at
// once; the receiver's queue doesn't list the appended item
// yet, so queueJumpTo silently did nothing. Wait (bounded)
// for the index to exist, off this call (D4).
scope.launch { cast.queueJumpToWhenReady(index) }
}
persistQueue()
}
}
override suspend fun removeFromQueue(index: Int) = onMain {
val c = controller ?: return@onMain
if (index !in 0 until c.mediaItemCount) return@onMain
c.removeMediaItem(index)
queueTracks = queueTracks.toMutableList().apply { if (index < size) removeAt(index) }
_state.value = _state.value.copy(queue = queueTracks)
// design decision 2026-09-02 (review N.13): the old full reload
// restarted the current track from 0:00 and — because loadQueue
// ignored autoplay — un-paused a paused speaker. Remove the one
// item on the receiver instead.
if (cast.isCasting.value) cast.queueRemoveAt(index)
persistQueue()
}
override suspend fun moveInQueue(from: Int, to: Int) = onMain {
val c = controller ?: return@onMain
val count = c.mediaItemCount
if (from !in 0 until count || to !in 0 until count || from == to) return@onMain
c.moveMediaItem(from, to)
queueTracks = queueTracks.toMutableList().apply {
val item = removeAt(from)
add(to, item)
}
_state.value = _state.value.copy(queue = queueTracks)
// Same ruling (N.13): reorder on the receiver in place.
if (cast.isCasting.value) cast.queueMove(from, to)
persistQueue()
}
override suspend fun clearQueue() = onMain {
val c = controller ?: return@onMain
c.stop()
c.clearMediaItems()
queueTracks = emptyList()
_state.value = _state.value.copy(
queue = emptyList(), current = null, queueIndex = -1, isStopped = true,
)
// Persist the emptiness. Without this the periodic snapshot from the
// last playback survived, and every relaunch restored the queue the
// user had just cleared. persistQueue() clears the store when the
// queue is empty.
persistQueue()
// Stop the Cast receiver too — clearing the queue means stopping.
if (cast.isCasting.value) {
cast.stop()
}
}
/** Re-send the full local queue to the Cast receiver after any queue
* mutation (enqueue, playNext, remove, move, clear).
*
* The pragmatic approach: reload the full queue on the receiver,
* preserving the current play position. Individual QUEUE_INSERT /
* QUEUE_REMOVE / QUEUE_REORDER commands exist in the Cast SDK but
* require tracking item IDs across the sender-receiver boundary and
* handling partial failures. A full reload is simpler and more
* reliable — the brief re-buffer on the receiver (if any) is
* acceptable for queue-edit operations which are inherently user-
* initiated and infrequent. */
private fun syncQueueToCast() {
if (!cast.isCasting.value) return
val queue = queueTracks
if (queue.isEmpty()) return
val currentIdx = _state.value.queueIndex.coerceIn(0, queue.lastIndex)
cast.loadQueue(
queue,
currentIdx,
autoplay = _state.value.isPlaying,
repeat = _state.value.repeatMode,
shuffle = _state.value.shuffleEnabled,
)
// Restore position within the current track so the reload doesn't
// restart from 0:00. The seek fires after loadQueue's async load
// completes on the receiver, which is fast enough that the user
// doesn't hear a gap.
val posMs = _state.value.position.inWholeMilliseconds
if (posMs > 0) cast.seekTo(posMs)
}
override suspend fun play() = onMain {
if (cast.isCasting.value) {
cast.play()
} else {
controller?.play()
}
_state.value = _state.value.copy(isStopped = false)
}
override suspend fun pause() = onMain {
if (cast.isCasting.value) cast.pause() else controller?.pause()
}
override suspend fun stop() = onMain {
if (cast.isCasting.value) {
val castIdx = cast.castCurrentItemIndex.value
if (castIdx >= 0) controller?.seekToDefaultPosition(castIdx)
cast.stop()
cast.disconnect()
_state.value = _state.value.copy(position = Duration.ZERO)
} else {
controller?.pause()
controller?.seekTo(0)
}
_state.value = _state.value.copy(isStopped = true)
}
override suspend fun seekTo(position: Duration) = onMain {
if (cast.isCasting.value) {
cast.seekTo(position.inWholeMilliseconds)
} else {
controller?.seekTo(position.inWholeMilliseconds)
}
}
override suspend fun next() = onMain {
// Always advance the local controller — it owns the queue index,
// and the player listener uses that index to update _state.current
// (which drives album art, title, artist labels). Then, when
// casting, push the new current track to the remote device so it
// actually plays. Without the cast.loadTrack call, the album art
// moves on screen but the cast device keeps playing the old track.
controller?.seekToNext()
if (cast.isCasting.value) {
cast.queueNext()
}
}
override suspend fun previous() = onMain {
// Same fork as next() — advance local for queueIndex/state.current,
// then load the new track on the cast device.
controller?.seekToPrevious()
if (cast.isCasting.value) {
cast.queuePrev()
}
}
override suspend fun setVolume(volumePercent: Int) = onMain {
val clamped = volumePercent.coerceIn(0, 100)
if (cast.isCasting.value) {
// DEVICE volume, not stream volume. setVolume() maps to the
// SDK's setStreamVolume — a per-media multiplier that isn't
// what the user means by "volume" and often has no audible
// effect on a speaker group. setDeviceVolume is what the
// hardware volume keys adjust; the album-art taps must match.
cast.setDeviceVolume(clamped / 100.0)
} else {
controller?.volume = (clamped / 100f)
}
// Immediately update the state so that the next adjustVolume()
// call reads the value we just set, not the stale local-player
// volume from the PlayerListener (which is ~0 when casting and
// only updates asynchronously via onEvents). Without this, rapid
// taps on the album art all read volumePercent=0 from state,
// add ±5, and slam the Cast volume to 5% or 0% every time.
_state.value = _state.value.copy(volumePercent = clamped)
}
override suspend fun setRepeatMode(mode: RepeatMode) = onMain {
// controller.repeatMode = ... is silently dropped by Media3 because
// ForwardingPlayer.getAvailableCommands() hides COMMAND_SET_REPEAT_MODE
// (kept hidden to keep the system notification clean). Route through
// a SessionCommand instead — see CMD_SET_REPEAT_MODE comment.
val exoMode = when (mode) {
RepeatMode.OFF -> Player.REPEAT_MODE_OFF
RepeatMode.ONE -> Player.REPEAT_MODE_ONE
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
}
controller?.sendCustomCommand(
SessionCommand(PlaybackService.CMD_SET_REPEAT_MODE, android.os.Bundle.EMPTY),
android.os.Bundle().apply { putInt(PlaybackService.ARG_REPEAT_MODE, exoMode) },
)
_state.update { it.copy(repeatMode = mode) }
// Push the receiver-side queue mode too — without this, the
// receiver finishes its current track and advances per its
// loaded repeat setting (REPEAT_OFF from queueLoad), ignoring
// the local toggle.
if (cast.isCasting.value) {
cast.setRepeatAndShuffle(mode, _state.value.shuffleEnabled)
}
}
override suspend fun setShuffle(enabled: Boolean) = onMain {
// Same reason as setRepeatMode: COMMAND_SET_SHUFFLE_MODE is hidden
// from the available commands, so MediaController.setShuffleModeEnabled
// is a no-op. We use a custom command to reach the player.
// Media3 Player handles the save/restore of the original ordering
// internally — when we flip shuffle off, the queue re-linearises
// to the order we originally set via setMediaItems(). We don't
// need to track the pre-shuffle snapshot.
controller?.sendCustomCommand(
SessionCommand(PlaybackService.CMD_SET_SHUFFLE, android.os.Bundle.EMPTY),
android.os.Bundle().apply { putBoolean(PlaybackService.ARG_SHUFFLE_ENABLED, enabled) },
)
_state.update { it.copy(shuffleEnabled = enabled) }
// Push to receiver. Cast doesn't have shuffle-without-repeat,
// so when shuffle=on+repeat=off the receiver maps to
// REPEAT_ALL_AND_SHUFFLE — accepting that the receiver will
// loop in random order rather than stop at end of queue. See
// CastIntegration.mapToCastRepeatMode for the full mapping.
if (cast.isCasting.value) {
cast.setRepeatAndShuffle(_state.value.repeatMode, enabled)
}
}
private suspend inline fun onMain(crossinline block: () -> Unit) =
withContext(Dispatchers.Main) { block() }
private fun DidlItem.Track.toMediaItem(): MediaItem {
// parentId stashed in extras so toMinimalTrack can recover it during
// cold-start adoption. MediaMetadata's first-class fields don't include
// a parent-container slot — the extras Bundle is the only round-trip
// surface that survives Media3's queue persistence. Without this,
// tracks reconstructed from a live session have parentId="" and any
// "open this track's album" navigation lands on the server's root.
val extras = android.os.Bundle().apply {
putString(EXTRA_PARENT_ID, parentId)
}
// Artwork URI: real one if present, otherwise the bundled Earhorn badge
// resource. The launcher widget's artwork slot reads MediaMetadata.artworkUri
// through Media3's standard BitmapLoader pipeline, which handles
// android.resource:// URIs natively — no BitmapLoader subclass needed.
// Effect: the launcher's now-playing widget shows the badge instead of
// the launcher's generic music-note silhouette for tracks with no
// embedded art.
val artUri: android.net.Uri = albumArtUri?.let(android.net.Uri::parse)
?: defaultArtworkUri
val meta = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist ?: albumArtist)
.setAlbumArtist(albumArtist)
.setAlbumTitle(album)
.setComposer(composer)
.setTrackNumber(trackNumber)
.setDiscNumber(discNumber)
.setArtworkUri(artUri)
.setExtras(extras)
.build()
return MediaItem.Builder()
.setMediaId(id)
.setUri(resourceUrl)
.setMediaMetadata(meta)
.build()
}
/** android.resource:// URI for the bundled Earhorn badge. Built once per
* LocalRenderer instance — the package name doesn't change at runtime
* and the resource ID is compile-time stable. */
private val defaultArtworkUri: android.net.Uri by lazy {
android.net.Uri.parse(
"android.resource://${context.packageName}/${dev.stave.R.drawable.earhorn_badge}"
)
}
/** Rebuild a DidlItem.Track from a live MediaItem, populating only fields
* MediaMetadata carries. Used during cold-start recovery when the app
* connects to a session it didn't originate — we have no persisted
* DidlItem to match against, so we synthesize a minimal one. Technical
* fields (mimeType, sampleFrequency, bitsPerSample, channels, bitrate,
* size) are null because they aren't round-tripped through MediaMetadata;
* the NowPlaying metadata row renders each nullable field conditionally
* so missing values just show fewer bullet-separated items. */
private fun MediaItem.toMinimalTrack(): DidlItem.Track? {
val meta = mediaMetadata
val url = localConfiguration?.uri?.toString() ?: return null
// parentId travels in MediaMetadata extras — see [toMediaItem]. Empty
// string is the legitimate "no parent" value for tracks that came in
// without one (truly minimal recovery from a session that didn't pack
// parentId, or from before this field was preserved). Detail screen
// already handles empty/invalid container IDs with a graceful error
// via the BROWSE_TIMEOUT_MS path in ContentDirectoryClient.
val parentId = meta.extras?.getString(EXTRA_PARENT_ID).orEmpty()
// Filter out the synthetic badge URI that toMediaItem injects when a
// track has no real art. Tracks that genuinely have no artwork should
// round-trip with albumArtUri=null so the in-app AlbumArt composable
// renders its brown-background-with-badge fallback (different from
// the bare badge that the launcher widget receives via artworkUri).
val artUri = meta.artworkUri?.toString()
val realArtUri = if (artUri == defaultArtworkUri.toString()) null else artUri
return DidlItem.Track(
id = mediaId.ifBlank { url },
parentId = parentId,
title = meta.title?.toString() ?: "Unknown",
artist = meta.artist?.toString(),
albumArtist = meta.albumArtist?.toString(),
album = meta.albumTitle?.toString(),
composer = meta.composer?.toString(),
genre = null,
date = null,
trackNumber = meta.trackNumber,
discNumber = meta.discNumber,
duration = null,
albumArtUri = realArtUri,
resourceUrl = url,
mimeType = null,
sampleFrequency = null,
bitsPerSample = null,
channels = null,
bitrate = null,
size = null,
)
}
companion object {
/** MediaMetadata.extras key that carries [DidlItem.Track.parentId]
* through the Media3 controller's queue. Without this, tracks
* reconstructed via [toMinimalTrack] during cold-start adoption have
* no parentId and any "open this track's album" navigation lands on
* the server's root browse instead of the actual album. */
private const val EXTRA_PARENT_ID = "earhorn.parentId"
/** Maintainer, 2026-10-29: how long [load] waits for the controller. */
private const val CONTROLLER_WAIT_MS = 5_000L
}
}