EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LocalRenderer.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.playback
2
3import android.content.ComponentName
4import android.content.Context
5import androidx.core.content.ContextCompat
6import androidx.media3.common.MediaItem
7import androidx.media3.common.MediaMetadata
8import androidx.media3.common.Player
9import androidx.media3.session.MediaController
10import androidx.media3.session.SessionCommand
11import androidx.media3.session.SessionToken
12import dagger.hilt.android.qualifiers.ApplicationContext
13import dev.stave.data.ReplayGainMode
14import dev.stave.data.SettingsStore
15import dev.stave.data.toDomain
16import dev.stave.data.toPersisted
17import dev.stave.domain.model.DidlItem
18import dev.stave.domain.renderer.PlaybackState
19import dev.stave.domain.renderer.Renderer
20import dev.stave.domain.renderer.RepeatMode
21import kotlinx.coroutines.CompletableDeferred
22import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.SupervisorJob
25import kotlinx.coroutines.delay
26import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.combine
30import kotlinx.coroutines.flow.distinctUntilChanged
31import kotlinx.coroutines.flow.map
32import kotlinx.coroutines.flow.update
33import kotlinx.coroutines.launch
34import kotlinx.coroutines.suspendCancellableCoroutine
35import kotlinx.coroutines.withContext
36import kotlinx.coroutines.withTimeoutOrNull
37import javax.inject.Inject
38import javax.inject.Singleton
39import kotlin.coroutines.resume
40import kotlin.coroutines.resumeWithException
41import kotlin.math.pow
42import kotlin.time.Duration
43import kotlin.time.Duration.Companion.milliseconds
44
45@Singleton
46class LocalRenderer @Inject constructor(
47 @ApplicationContext private val context: Context,
48 private val settings: SettingsStore,
49 private val queueStore: dev.stave.data.QueuePersistenceStore,
50 private val cast: dev.stave.cast.CastIntegration,
51) : Renderer {
52
53 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
54
55 private val _state = MutableStateFlow(PlaybackState())
56 override val state: StateFlow<PlaybackState> = _state.asStateFlow()
57
58 private var controller: MediaController? = null
59 private var queueTracks: List<DidlItem.Track> = emptyList()
60
61 /** Maintainer, 2026-10-29: completed once [controller] is connected, so
62 * [load] can wait for it instead of dropping the request while still
63 * publishing the new queue. */
64 private val controllerReady = CompletableDeferred<Unit>()
65
66 /** Maintainer, 2026-10-29: the local play intent (isPlaying || playWhenReady)
67 * as last seen while NOT casting. The cast-start handler used to read it
68 * from the controller after PlaybackService had already paused exo and
69 * switched its getters to the (not yet playing) cast state, so the
70 * speaker could start paused. Main thread only. */
71 private var localPlayIntent = false
72
73 private val playerListener = object : Player.Listener {
74 override fun onEvents(player: Player, events: Player.Events) {
75 // Maintainer, 2026-10-29: an outside controller (Android Auto's
76 // browse list → onAddMediaItems) can replace the player's items;
77 // re-adopt the live queue when it no longer matches ours (D5).
78 if (events.contains(Player.EVENT_TIMELINE_CHANGED)) adoptExternalQueueIfChanged(player)
79 // While casting, the local controller is paused at volume=0
80 // and its currentMediaItemIndex never advances — the receiver
81 // is the source of truth for queue position. Source idx from
82 // cast.castCurrentItemIndex in that case so the now-playing
83 // UI tracks the speaker, not the frozen local controller.
84 //
85 // When castCurrentItemIndex is -1 (cast queue not yet loaded,
86 // or transitioning), fall back to the last known local index
87 // so we don't snap back to 0 mid-session.
88 val casting = cast.isCasting.value
89 // Maintainer, 2026-10-29: remember the local intent while not casting
90 // (see localPlayIntent).
91 if (!casting) localPlayIntent = player.isPlaying || player.playWhenReady
92 val rawIdx = if (casting) {
93 val remoteIdx = cast.castCurrentItemIndex.value
94 if (remoteIdx >= 0) remoteIdx else _state.value.queueIndex.coerceAtLeast(0)
95 } else {
96 player.currentMediaItemIndex
97 }
98 val idx = rawIdx.coerceIn(0, (queueTracks.size - 1).coerceAtLeast(0))
99 _state.value = _state.value.copy(
100 current = queueTracks.getOrNull(idx),
101 queueIndex = idx,
102 isPlaying = if (casting) cast.castIsPlaying.value else player.isPlaying,
103 isBuffering = player.playbackState == Player.STATE_BUFFERING,
104 // A queue that plays to its end IS stopped: ExoPlayer sits in
105 // STATE_ENDED, isPlaying false, and nothing else set the
106 // flag — so a disarmed fixed screen that played an album
107 // never returned to kiosk (review C.2, 2026-09-02).
108 isStopped = if (!casting && player.playbackState == Player.STATE_ENDED) true
109 else _state.value.isStopped,
110 duration = if (casting) {
111 cast.castDurationMs.value.takeIf { it > 0 }?.milliseconds ?: Duration.ZERO
112 } else {
113 player.duration.takeIf { it > 0 }?.milliseconds ?: Duration.ZERO
114 },
115 position = if (casting) {
116 cast.castPositionMs.value.coerceAtLeast(0L).milliseconds
117 } else {
118 player.currentPosition.coerceAtLeast(0).milliseconds
119 },
120 volumePercent = if (casting) {
121 // When casting, the local player's volume is irrelevant —
122 // the Cast receiver has its own volume. Preserve whatever
123 // setVolume() last wrote into _state.volumePercent rather
124 // than overwriting it with the local player's level (which
125 // may be 0 or 1.0 depending on how the Cast stream proxy
126 // is configured). Without this guard, the playerListener
127 // fires after each setVolume() and snaps volumePercent
128 // back to the local value, making adjustVolume() read
129 // stale data on the next call.
130 _state.value.volumePercent
131 } else {
132 (player.volume * 100).toInt()
133 },
134 error = player.playerError?.localizedMessage,
135 // Mirror shuffle + repeat from the player so notification-
136 // driven toggles (which write directly to player.shuffleModeEnabled
137 // / player.repeatMode rather than calling renderer.setShuffle /
138 // setRepeatMode) propagate into the in-app UI's modifier row.
139 // Without this, tapping shuffle in the notification flips the
140 // queue order but the in-app shuffle icon stays unfilled.
141 shuffleEnabled = player.shuffleModeEnabled,
142 repeatMode = when (player.repeatMode) {
143 Player.REPEAT_MODE_ONE -> RepeatMode.ONE
144 Player.REPEAT_MODE_ALL -> RepeatMode.ALL
145 else -> RepeatMode.OFF
146 },
147 )
148 }
149 }
150
151 /** Maintainer, 2026-10-29 (D5): queueTracks is only updated by this class's
152 * own calls, so playback started by another controller (Auto) left the
153 * app showing the wrong track. When the controller's media ids no
154 * longer equal queueTracks' ids (same size, same order), rebuild
155 * queueTracks the way the cold-start adoption does: persisted Track by
156 * id, else toMinimalTrack. Our own mutations update queueTracks in the
157 * same main-thread block as the controller call, before onEvents runs,
158 * so they never trigger this. */
159 private fun adoptExternalQueueIfChanged(player: Player) {
160 val liveIds = (0 until player.mediaItemCount).map { player.getMediaItemAt(it).mediaId }
161 if (liveIds.isEmpty() || liveIds == queueTracks.map { it.id }) return
162 scope.launch {
163 val persistedById: Map<String, DidlItem.Track> = runCatching {
164 queueStore.load()?.tracks
165 ?.map { it.toDomain() }
166 ?.associateBy { it.id }
167 ?: emptyMap()
168 }.getOrDefault(emptyMap())
169 // Re-read after the suspend: the queue may have moved on (or been
170 // brought back in line by our own call) meanwhile.
171 val c = controller ?: return@launch
172 val items = (0 until c.mediaItemCount).map { c.getMediaItemAt(it) }
173 if (items.isEmpty() || items.map { it.mediaId } == queueTracks.map { it.id }) return@launch
174 val recovered = items.mapNotNull { persistedById[it.mediaId] ?: it.toMinimalTrack() }
175 if (recovered.isEmpty()) return@launch
176 queueTracks = recovered
177 val idx = c.currentMediaItemIndex.coerceIn(0, recovered.lastIndex)
178 _state.value = _state.value.copy(
179 current = recovered.getOrNull(idx),
180 queue = recovered,
181 queueIndex = idx,
182 )
183 android.util.Log.i("EarhornRenderer", "Adopted external queue change: ${recovered.size} items, idx=$idx")
184 }
185 }
186
187 init {
188 scope.launch {
189 try {
190 connectAndTickPosition()
191 } catch (t: Throwable) {
192 // Whatever goes wrong (service not available, permission, JUL crash),
193 // surface it in state rather than crashing the app. The UI handles a null controller.
194 _state.value = _state.value.copy(error = "Playback service unavailable: ${t.message}")
195 }
196 }
197 }
198
199 private suspend fun connectAndTickPosition() {
200 val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
201 val ctrl: MediaController = suspendCancellableCoroutine { cont ->
202 val future = MediaController.Builder(context, token).buildAsync()
203 future.addListener({
204 try {
205 if (cont.isActive) cont.resume(future.get())
206 } catch (t: Throwable) {
207 if (cont.isActive) cont.resumeWithException(t)
208 }
209 }, ContextCompat.getMainExecutor(context))
210 }
211 controller = ctrl
212 ctrl.addListener(playerListener)
213 // Maintainer, 2026-10-29: seed the pre-cast intent (onEvents may not fire
214 // before a cast starts) and release any load() waiting for us.
215 if (!cast.isCasting.value) localPlayIntent = ctrl.isPlaying || ctrl.playWhenReady
216 controllerReady.complete(Unit)
217
218 // Cold-start recovery: if the service is already alive with a playing
219 // or paused session (e.g. user tapped Play in the media notification
220 // before opening the app), adopt the live session's state rather than
221 // overwriting it with the persisted queue. Without this, the app
222 // would silently continue playing in the background while Browse
223 // rendered with no NowPlayingBar because queueTracks was empty.
224 val liveItemCount = ctrl.mediaItemCount
225 val playerIsLive = ctrl.playbackState != Player.STATE_IDLE && liveItemCount > 0
226 if (playerIsLive) {
227 // Try to read the persisted queue snapshot and use it to enrich
228 // the live MediaItems. Persisted DidlItem.Track has the full
229 // domain object (parentId, real albumArtUri, all the technical
230 // fields). MediaMetadata round-tripped through MediaController
231 // IPC drops or mangles extras and most non-display fields, so
232 // toMinimalTrack alone produces a degraded Track. Look each
233 // live MediaItem up by mediaId in the persisted snapshot; use
234 // the persisted Track when it matches, fall back to
235 // toMinimalTrack for any that don't (e.g. Auto-added tracks).
236 val persistedById: Map<String, DidlItem.Track> = runCatching {
237 queueStore.load()?.tracks
238 ?.map { it.toDomain() }
239 ?.associateBy { it.id }
240 ?: emptyMap()
241 }.getOrDefault(emptyMap())
242
243 val recovered = (0 until liveItemCount).mapNotNull { i ->
244 val mediaItem = runCatching { ctrl.getMediaItemAt(i) }.getOrNull()
245 ?: return@mapNotNull null
246 persistedById[mediaItem.mediaId]
247 ?: mediaItem.toMinimalTrack()
248 }
249 if (recovered.isNotEmpty()) {
250 queueTracks = recovered
251 val idx = ctrl.currentMediaItemIndex.coerceIn(0, recovered.lastIndex)
252 _state.value = _state.value.copy(
253 current = recovered.getOrNull(idx),
254 queue = recovered,
255 queueIndex = idx,
256 isPlaying = ctrl.isPlaying,
257 isStopped = false,
258 )
259 android.util.Log.i(
260 "EarhornRenderer",
261 "Adopted live session: ${recovered.size} items, idx=$idx, playing=${ctrl.isPlaying}"
262 )
263 }
264 } else {
265 // Queue restore. If the user exited mid-playback, bring back the queue at the
266 // same index + near the same position. We DO NOT auto-play — it's startling to
267 // open an app and have audio erupt. The user has to press Play to resume.
268 runCatching {
269 val saved = queueStore.load()
270 if (saved != null && saved.tracks.isNotEmpty()) {
271 val tracks = saved.tracks.map { it.toDomain() }
272 queueTracks = tracks
273 val mediaItems = tracks.map { it.toMediaItem() }
274 val startIndex = saved.currentIndex.coerceIn(0, tracks.lastIndex)
275 ctrl.setMediaItems(mediaItems, startIndex, saved.positionMs.coerceAtLeast(0L))
276 ctrl.prepare()
277 ctrl.playWhenReady = false
278 // Seed observable state so the NowPlaying bar shows the restored track
279 // before the player's first onEvents fires.
280 _state.value = _state.value.copy(
281 current = tracks.getOrNull(startIndex),
282 queue = tracks,
283 queueIndex = startIndex,
284 isStopped = false,
285 )
286 }
287 }
288 }
289
290 // ReplayGain observer. Combines the current track + RG settings; every time any
291 // of those change, recompute the volume multiplier and apply it. The multiplier
292 // is in [0, 1] — anything computed higher is clamped, because Media3's
293 // MediaController.setVolume contract requires that range and values outside it
294 // can throw IllegalArgumentException on some player backends.
295 //
296 // The whole collect is wrapped in try/catch: if anything throws during volume
297 // application (thread assertion violation, disconnected controller, clamp
298 // failure on some SDK level), we log and continue rather than crashing the
299 // coroutine scope and taking playback with it.
300 scope.launch {
301 combine(
302 _state.map { it.current }.distinctUntilChanged { a, b -> a?.id == b?.id },
303 settings.replayGainMode,
304 settings.replayGainPreAmpDb,
305 ) { track, mode, preAmp ->
306 Triple(track, mode, preAmp)
307 }.collect { (track, mode, preAmp) ->
308 runCatching {
309 // Local is muted while casting (the cast observer sets
310 // volume = 0 to cede audio to the sync'd remote group).
311 // Don't compete.
312 if (cast.isCasting.value) return@runCatching
313 val volume = computeReplayGainVolume(track, mode, preAmp)
314 controller?.volume = volume
315 }
316 }
317 }
318
319 // Cast-session observer. Mirrors local → remote when a session starts, and
320 // restores local playback when the session ends. This is the coordinator
321 // seam — nothing else in LocalRenderer branches on isCasting.
322 scope.launch {
323 var wasPlayingBeforeCast = false
324 var savedPositionMs = 0L
325 cast.isCasting.collect { casting ->
326 val c = controller ?: return@collect
327 if (casting && cast.consumeAttachWasResume()) {
328 // RECONNECTION, not a fresh cast (design decision
329 // 2026-09-02; review N.8). Ask the group what it is
330 // playing. If it is one of ours, carry on and let the
331 // index mirror below match the screen to it. If it is
332 // anything else — house music, nothing — stop pretending
333 // we are casting and wait for a person. Never send a
334 // queue on our own, under any answer.
335 cast.requestStatusRefresh()
336 delay(1_500)
337 val remote = cast.currentContentId()
338 val ours = remote != null && _state.value.queue.any { t ->
339 remote == t.resourceUrl || remote.contains(java.net.URLEncoder.encode(t.resourceUrl, "UTF-8"))
340 }
341 if (!ours) {
342 android.util.Log.i("EarhornCast",
343 "resumed session is not playing our queue (remote=$remote) — detaching without touching it")
344 cast.detachWithoutStopping()
345 } else {
346 android.util.Log.i("EarhornCast", "resumed session is playing our queue — carrying on")
347 }
348 return@collect
349 }
350 if (casting) {
351 // Session started. Save pre-cast state for eventual
352 // session end. PlaybackService manages ExoPlayer's
353 // volume (→0), audio focus (→disabled), and
354 // playWhenReady (→mirrors castIsPlaying) directly
355 // on the ExoPlayer instance — we DON'T touch the
356 // controller here to avoid conflicting writes.
357 // Maintainer, 2026-10-29: the intent recorded before the cast
358 // state switch, not the controller's (possibly already
359 // switched) values — see localPlayIntent.
360 wasPlayingBeforeCast = localPlayIntent
361 savedPositionMs = c.currentPosition.coerceAtLeast(0L)
362 val current = _state.value.current
363 if (current != null) {
364 // Push full queue with start index = current
365 // track so receiver-side next-track advancement
366 // works. Falls back to single-track load only
367 // if the queue is somehow empty (shouldn't
368 // happen, but defensive).
369 val queue = _state.value.queue
370 val idx = queue.indexOfFirst { it.id == current.id }
371 .takeIf { it >= 0 } ?: 0
372 if (queue.isNotEmpty()) {
373 cast.loadQueue(
374 queue,
375 idx,
376 autoplay = wasPlayingBeforeCast,
377 repeat = _state.value.repeatMode,
378 shuffle = _state.value.shuffleEnabled,
379 )
380 } else {
381 cast.loadTrack(current, autoplay = wasPlayingBeforeCast)
382 }
383 if (savedPositionMs > 0) cast.seekTo(savedPositionMs)
384 }
385 // Initialize volumePercent from the Cast session's
386 // actual volume so the first touch-volume adjustment
387 // starts from the correct baseline. Without this, the
388 // state still holds the local player's volume (which
389 // may be 0 after muting for Cast) and the first tap
390 // slams the Cast speaker to 5% or 0%.
391 val castVol = (cast.getDeviceVolume() * 100).toInt().coerceIn(0, 100)
392 _state.value = _state.value.copy(volumePercent = castVol)
393 } else {
394 // Session ended. PlaybackService restores volume
395 // and audio focus on ExoPlayer directly. We just
396 // consume the disconnect intent flag so it doesn't
397 // carry over to the next session.
398 cast.consumeDisconnectIntent()
399 }
400 }
401 }
402
403 // Cast → local mirror for the receiver's current queue position.
404 // While casting, the local controller is paused at volume=0 and its
405 // currentMediaItemIndex never advances — PlayerListener can't pick
406 // up track changes because no transitions fire on a stopped player.
407 // The receiver, however, is advancing the queue on its own (we sent
408 // the full queue via loadQueue), so its current-item-index is the
409 // authoritative source of "what track is the user actually hearing
410 // right now". Mirror that into _state.queueIndex / _state.current
411 // so the now-playing UI, queue highlight, and album art track the
412 // remote device instead of freezing on whichever song was playing
413 // when the cast session started.
414 //
415 // The earlier implementation of this observer pushed local→cast on
416 // every _state.current change (cast.loadTrack(current)) on the
417 // theory that whatever changed the local track should also change
418 // the remote one. That observer is no longer needed because
419 // next() / previous() / skipToQueueItem() now each call cast.queueNext
420 // / queuePrev / queueJumpTo explicitly — so the cast-side already
421 // knows. Keeping the old observer was actively harmful: it caused
422 // every receiver-initiated advance to bounce back as a loadTrack,
423 // which restarted the track the user was already happily hearing.
424 scope.launch {
425 cast.castCurrentItemIndex
426 .collect { remoteIdx ->
427 if (!cast.isCasting.value) return@collect
428 if (remoteIdx < 0) return@collect
429 val queue = _state.value.queue
430 val track = queue.getOrNull(remoteIdx) ?: return@collect
431 if (_state.value.queueIndex == remoteIdx &&
432 _state.value.current?.id == track.id) return@collect
433 _state.value = _state.value.copy(
434 queueIndex = remoteIdx,
435 current = track,
436 )
437 }
438 }
439
440 // Cast play-state mirror. While casting, the local controller is
441 // muted (volume=0) and the Player.Listener (which keys off local
442 // events) doesn't fire — we'd be stuck at whatever local state was
443 // when the session began. Watch the cast flows directly and push
444 // their values into _state so the play/pause icon, scrub bar, and
445 // duration label all reflect the remote device's actual state.
446 // Combined into one observer so we re-publish on any of the three
447 // changing.
448 //
449 // Also mirrors cast.castIsPlaying onto the local controller's
450 // playWhenReady. The local controller drives the system
451 // MediaSession's notification — when cast is playing, local must
452 // also report "playing" so the notification shows Pause icon (and
453 // vice-versa). Volume stays at 0 so this produces no actual audio.
454 scope.launch {
455 data class CastSnapshot(
456 val casting: Boolean,
457 val playing: Boolean,
458 val posMs: Long,
459 val durMs: Long,
460 )
461 combine(
462 cast.isCasting,
463 cast.castIsPlaying,
464 cast.castPositionMs,
465 cast.castDurationMs,
466 ) { casting, playing, posMs, durMs ->
467 CastSnapshot(casting, playing, posMs, durMs)
468 }.collect { snap ->
469 if (!snap.casting) return@collect
470 _state.update { s ->
471 s.copy(
472 isPlaying = snap.playing,
473 position = snap.posMs.coerceAtLeast(0L).milliseconds,
474 duration = snap.durMs.takeIf { it > 0L }
475 ?.milliseconds ?: s.duration,
476 )
477 }
478 // No mirroring back onto local.playWhenReady — local is
479 // paused for the duration of the cast session. The system
480 // notification will show Play (because session reports
481 // not-playing) even when the cast device is actually
482 // playing — accepted trade-off; the alternative (keeping
483 // local "playing" silently) was preventing cast from
484 // establishing playback at all.
485 }
486 }
487
488 var persistTick = 0
489 while (true) {
490 // design decision 2026-09-02 (I.39): this loop used to run
491 // five times a second for the life of the process on every
492 // device — a MediaController IPC each tick — including wall
493 // screens that never play locally. Now it idles at one check a
494 // second while nothing is playing anywhere.
495 val casting = cast.isCasting.value
496 val playingNow = (casting && cast.castIsPlaying.value) || (!casting && ctrl.isPlaying)
497 if (!playingNow) {
498 delay(1_000)
499 continue
500 }
501 // Position tick. When local is the audio source, read from the
502 // local controller. When casting, the cast play-state observer
503 // above already mirrors position into _state, so this path
504 // skips the local read to avoid clobbering it with stale local
505 // values.
506 if (!casting) {
507 val pos = ctrl.currentPosition.coerceAtLeast(0).milliseconds
508 if (_state.value.position != pos) {
509 _state.value = _state.value.copy(position = pos)
510 }
511 }
512 // Persist queue snapshot every ~5 seconds during playback so the restore
513 // position stays fresh. Counter-gated to avoid a DataStore write every
514 // 200ms (which would be wasteful and add disk load to the audio path).
515 persistTick++
516 if (persistTick >= 25) {
517 persistQueue()
518 persistTick = 0
519 }
520 // 200ms tick — fast enough for responsive lyrics highlighting
521 // (synced lyrics lines can be as short as 1s) while still light
522 // on CPU. Was 500ms which left visible lag on the lyric highlight.
523 delay(200)
524 }
525 }
526
527 /** Derive the Player.volume multiplier for the current track given RG settings.
528 *
529 * Reads REPLAYGAIN_TRACK_GAIN or REPLAYGAIN_ALBUM_GAIN from track.extras, adds
530 * the pre-amp, converts dB to linear via 10^(dB/20). Pre-amp still applies even
531 * when the track has no RG tag — that's a user preference independent of RG
532 * availability. When mode is OFF, returns 1.0 (unity).
533 *
534 * Clamped to [0, 1] because Media3's MediaController.setVolume spec is [0,1] and
535 * some underlying players throw IllegalArgumentException on out-of-range values.
536 * Values above 1 from positive preamp + low attenuation simply saturate at 1.
537 * NaN/infinity defensively mapped to 1 in case pow overflows. */
538 private fun computeReplayGainVolume(
539 track: DidlItem.Track?,
540 mode: ReplayGainMode,
541 preAmpDb: Float,
542 ): Float {
543 if (mode == ReplayGainMode.OFF || track == null) return 1f
544 val key = when (mode) {
545 ReplayGainMode.TRACK -> "REPLAYGAIN_TRACK_GAIN"
546 ReplayGainMode.ALBUM -> "REPLAYGAIN_ALBUM_GAIN"
547 ReplayGainMode.OFF -> return 1f
548 }
549 val rgGainRaw = track.extras.entries
550 .firstOrNull { it.key.equals(key, ignoreCase = true) }
551 ?.value
552 // ALBUM fallback to TRACK when album tag is absent — common because many
553 // tagging tools only write per-track RG.
554 ?: track.extras.entries
555 .firstOrNull { it.key.equals("REPLAYGAIN_TRACK_GAIN", ignoreCase = true) }
556 ?.value
557 val rgGainDb = rgGainRaw?.let { parseRgGain(it) } ?: 0f
558 val totalDb = rgGainDb + preAmpDb
559 val linear = 10.0.pow(totalDb / 20.0).toFloat()
560 // Guard against NaN / Infinity / out-of-range. Media3 [0,1] spec is strict.
561 return when {
562 linear.isNaN() || linear.isInfinite() -> 1f
563 linear < 0f -> 0f
564 linear > 1f -> 1f
565 else -> linear
566 }
567 }
568
569 /** Parse a ReplayGain tag string. Accepts formats like "-7.22 dB", "-7.22dB",
570 * "-7.22", "+3.5 dB". Returns 0f for unparseable values rather than throwing. */
571 private fun parseRgGain(raw: String): Float {
572 val match = Regex("""[+-]?\d+(?:\.\d+)?""").find(raw.trim())
573 return match?.value?.toFloatOrNull() ?: 0f
574 }
575
576 /** Snapshot the current queue + index + position to persistent storage. Called
577 * after every queue mutation (load/enqueue/playNext/skipTo/remove/move/clear)
578 * and periodically during playback so the resume position is fresh within a
579 * few seconds of the last save.
580 *
581 * Reads controller state on main thread; fire-and-forget write to DataStore. */
582 private fun persistQueue() {
583 scope.launch {
584 runCatching {
585 val tracks = queueTracks
586 if (tracks.isEmpty()) {
587 queueStore.clear()
588 return@launch
589 }
590 val c = controller
591 // While casting the local controller is paused and stale; the
592 // receiver's index/position are the truth (review N.15,
593 // design decision 2026-09-02).
594 val casting = cast.isCasting.value
595 val idx = if (casting) _state.value.queueIndex.coerceAtLeast(0)
596 else c?.currentMediaItemIndex?.coerceAtLeast(0) ?: 0
597 val pos = if (casting) cast.castPositionMs.value.coerceAtLeast(0L)
598 else c?.currentPosition?.coerceAtLeast(0L) ?: 0L
599 queueStore.save(
600 dev.stave.data.PersistedQueue(
601 tracks = tracks.map { it.toPersisted() },
602 currentIndex = idx,
603 positionMs = pos,
604 )
605 )
606 }
607 }
608 }
609
610 override suspend fun load(tracks: List<DidlItem.Track>, startIndex: Int, autoplay: Boolean) {
611 // Maintainer, 2026-10-29: a play before the MediaController connected did
612 // nothing yet still published the new queue. Wait (bounded) for the
613 // connection; if it still isn't there, drop the load without touching
614 // the queue or _state.
615 if (controller == null) withTimeoutOrNull(CONTROLLER_WAIT_MS) { controllerReady.await() }
616 if (controller == null) {
617 android.util.Log.w("EarhornRenderer", "load: MediaController not connected — ignored")
618 return
619 }
620 queueTracks = tracks
621 val mediaItems = tracks.map { it.toMediaItem() }
622 withContext(Dispatchers.Main) {
623 val c = controller ?: return@withContext
624 c.setMediaItems(mediaItems, startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0)), 0)
625 c.prepare()
626 // autoplay=false (UPnP setAVTransportURI path) stays in Stopped until
627 // the control point sends Play. Default true preserves the user-click
628 // semantics of every other call site.
629 c.playWhenReady = autoplay
630 }
631 // isStopped tracks the "no media at all" / "media but stopped" axis. autoplay=false
632 // keeps it true since the transport will report STOPPED to UPnP control points
633 // until Play arrives.
634 _state.value = _state.value.copy(queue = tracks, isStopped = !autoplay)
635 // If casting, push the FULL queue to the remote so next-track
636 // advancement happens receiver-side. Loading just the start
637 // track was the cause of "Shield won't advance to next track":
638 // without queue context the receiver finishes one item and
639 // stops. The phone-side controller still owns the queue index
640 // for state reporting; the receiver owns playback continuity.
641 if (cast.isCasting.value) {
642 cast.loadQueue(
643 tracks,
644 startIndex.coerceIn(0, tracks.lastIndex.coerceAtLeast(0)),
645 repeat = _state.value.repeatMode,
646 shuffle = _state.value.shuffleEnabled,
647 )
648 }
649 persistQueue()
650 }
651
652 override suspend fun enqueue(tracks: List<DidlItem.Track>) {
653 if (tracks.isEmpty()) return
654 withContext(Dispatchers.Main) {
655 val c = controller ?: return@withContext
656 val mediaItems = tracks.map { it.toMediaItem() }
657 if (c.mediaItemCount == 0) {
658 queueTracks = tracks
659 c.setMediaItems(mediaItems, 0, 0)
660 c.prepare()
661 } else {
662 queueTracks = queueTracks + tracks
663 c.addMediaItems(mediaItems)
664 }
665 }
666 _state.value = _state.value.copy(queue = queueTracks)
667 // design decision 2026-09-02 (review N.12): tracks added during
668 // a cast used to exist on the phone only — the comment here claimed
669 // a later push that nothing performed, so the receiver stopped at
670 // the end of the list it was given. Append them to the receiver's
671 // queue in place; nothing playing is interrupted.
672 // Maintainer, 2026-10-29: pass the whole queue so an empty receiver is
673 // loaded in step with the phone (D4).
674 if (cast.isCasting.value) cast.queueAppend(tracks, fullQueue = queueTracks)
675 persistQueue()
676 }
677
678 override suspend fun playNext(tracks: List<DidlItem.Track>) {
679 if (tracks.isEmpty()) return
680 var autoStarted = false
681 withContext(Dispatchers.Main) {
682 val c = controller ?: return@withContext
683 val mediaItems = tracks.map { it.toMediaItem() }
684 if (c.mediaItemCount == 0) {
685 queueTracks = tracks
686 c.setMediaItems(mediaItems, 0, 0)
687 c.prepare()
688 c.playWhenReady = true
689 autoStarted = true
690 } else {
691 val current = c.currentMediaItemIndex
692 val insertAt = if (current < 0) c.mediaItemCount
693 else (current + 1).coerceAtMost(c.mediaItemCount)
694 // Maintainer, 2026-10-29: insertAt comes from the controller;
695 // coerce it to our own list so a desync can't throw
696 // IndexOutOfBounds (D5).
697 queueTracks = queueTracks.toMutableList().apply { addAll(insertAt.coerceAtMost(size), tracks) }
698 c.addMediaItems(insertAt, mediaItems)
699 }
700 }
701 _state.value = if (autoStarted) _state.value.copy(queue = queueTracks, isStopped = false)
702 else _state.value.copy(queue = queueTracks)
703 // Same ruling as enqueue (N.12): insert on the receiver right after
704 // its current item, without reloading anything.
705 if (cast.isCasting.value) cast.queueInsertAfterCurrent(tracks)
706 persistQueue()
707 }
708
709 override suspend fun skipToQueueItem(index: Int) = onMain {
710 val c = controller ?: return@onMain
711 if (index in 0 until c.mediaItemCount) {
712 c.seekTo(index, 0)
713 c.playWhenReady = true
714 _state.value = _state.value.copy(isStopped = false)
715 if (cast.isCasting.value) {
716 // Maintainer, 2026-10-29: enqueueAndPlay appends then jumps at
717 // once; the receiver's queue doesn't list the appended item
718 // yet, so queueJumpTo silently did nothing. Wait (bounded)
719 // for the index to exist, off this call (D4).
720 scope.launch { cast.queueJumpToWhenReady(index) }
721 }
722 persistQueue()
723 }
724 }
725
726 override suspend fun removeFromQueue(index: Int) = onMain {
727 val c = controller ?: return@onMain
728 if (index !in 0 until c.mediaItemCount) return@onMain
729 c.removeMediaItem(index)
730 queueTracks = queueTracks.toMutableList().apply { if (index < size) removeAt(index) }
731 _state.value = _state.value.copy(queue = queueTracks)
732 // design decision 2026-09-02 (review N.13): the old full reload
733 // restarted the current track from 0:00 and — because loadQueue
734 // ignored autoplay — un-paused a paused speaker. Remove the one
735 // item on the receiver instead.
736 if (cast.isCasting.value) cast.queueRemoveAt(index)
737 persistQueue()
738 }
739
740 override suspend fun moveInQueue(from: Int, to: Int) = onMain {
741 val c = controller ?: return@onMain
742 val count = c.mediaItemCount
743 if (from !in 0 until count || to !in 0 until count || from == to) return@onMain
744 c.moveMediaItem(from, to)
745 queueTracks = queueTracks.toMutableList().apply {
746 val item = removeAt(from)
747 add(to, item)
748 }
749 _state.value = _state.value.copy(queue = queueTracks)
750 // Same ruling (N.13): reorder on the receiver in place.
751 if (cast.isCasting.value) cast.queueMove(from, to)
752 persistQueue()
753 }
754
755 override suspend fun clearQueue() = onMain {
756 val c = controller ?: return@onMain
757 c.stop()
758 c.clearMediaItems()
759 queueTracks = emptyList()
760 _state.value = _state.value.copy(
761 queue = emptyList(), current = null, queueIndex = -1, isStopped = true,
762 )
763 // Persist the emptiness. Without this the periodic snapshot from the
764 // last playback survived, and every relaunch restored the queue the
765 // user had just cleared. persistQueue() clears the store when the
766 // queue is empty.
767 persistQueue()
768 // Stop the Cast receiver too — clearing the queue means stopping.
769 if (cast.isCasting.value) {
770 cast.stop()
771 }
772 }
773
774 /** Re-send the full local queue to the Cast receiver after any queue
775 * mutation (enqueue, playNext, remove, move, clear).
776 *
777 * The pragmatic approach: reload the full queue on the receiver,
778 * preserving the current play position. Individual QUEUE_INSERT /
779 * QUEUE_REMOVE / QUEUE_REORDER commands exist in the Cast SDK but
780 * require tracking item IDs across the sender-receiver boundary and
781 * handling partial failures. A full reload is simpler and more
782 * reliable — the brief re-buffer on the receiver (if any) is
783 * acceptable for queue-edit operations which are inherently user-
784 * initiated and infrequent. */
785 private fun syncQueueToCast() {
786 if (!cast.isCasting.value) return
787 val queue = queueTracks
788 if (queue.isEmpty()) return
789 val currentIdx = _state.value.queueIndex.coerceIn(0, queue.lastIndex)
790 cast.loadQueue(
791 queue,
792 currentIdx,
793 autoplay = _state.value.isPlaying,
794 repeat = _state.value.repeatMode,
795 shuffle = _state.value.shuffleEnabled,
796 )
797 // Restore position within the current track so the reload doesn't
798 // restart from 0:00. The seek fires after loadQueue's async load
799 // completes on the receiver, which is fast enough that the user
800 // doesn't hear a gap.
801 val posMs = _state.value.position.inWholeMilliseconds
802 if (posMs > 0) cast.seekTo(posMs)
803 }
804
805 override suspend fun play() = onMain {
806 if (cast.isCasting.value) {
807 cast.play()
808 } else {
809 controller?.play()
810 }
811 _state.value = _state.value.copy(isStopped = false)
812 }
813 override suspend fun pause() = onMain {
814 if (cast.isCasting.value) cast.pause() else controller?.pause()
815 }
816 override suspend fun stop() = onMain {
817 if (cast.isCasting.value) {
818 val castIdx = cast.castCurrentItemIndex.value
819 if (castIdx >= 0) controller?.seekToDefaultPosition(castIdx)
820 cast.stop()
821 cast.disconnect()
822 _state.value = _state.value.copy(position = Duration.ZERO)
823 } else {
824 controller?.pause()
825 controller?.seekTo(0)
826 }
827 _state.value = _state.value.copy(isStopped = true)
828 }
829 override suspend fun seekTo(position: Duration) = onMain {
830 if (cast.isCasting.value) {
831 cast.seekTo(position.inWholeMilliseconds)
832 } else {
833 controller?.seekTo(position.inWholeMilliseconds)
834 }
835 }
836 override suspend fun next() = onMain {
837 // Always advance the local controller — it owns the queue index,
838 // and the player listener uses that index to update _state.current
839 // (which drives album art, title, artist labels). Then, when
840 // casting, push the new current track to the remote device so it
841 // actually plays. Without the cast.loadTrack call, the album art
842 // moves on screen but the cast device keeps playing the old track.
843 controller?.seekToNext()
844 if (cast.isCasting.value) {
845 cast.queueNext()
846 }
847 }
848 override suspend fun previous() = onMain {
849 // Same fork as next() — advance local for queueIndex/state.current,
850 // then load the new track on the cast device.
851 controller?.seekToPrevious()
852 if (cast.isCasting.value) {
853 cast.queuePrev()
854 }
855 }
856 override suspend fun setVolume(volumePercent: Int) = onMain {
857 val clamped = volumePercent.coerceIn(0, 100)
858 if (cast.isCasting.value) {
859 // DEVICE volume, not stream volume. setVolume() maps to the
860 // SDK's setStreamVolume — a per-media multiplier that isn't
861 // what the user means by "volume" and often has no audible
862 // effect on a speaker group. setDeviceVolume is what the
863 // hardware volume keys adjust; the album-art taps must match.
864 cast.setDeviceVolume(clamped / 100.0)
865 } else {
866 controller?.volume = (clamped / 100f)
867 }
868 // Immediately update the state so that the next adjustVolume()
869 // call reads the value we just set, not the stale local-player
870 // volume from the PlayerListener (which is ~0 when casting and
871 // only updates asynchronously via onEvents). Without this, rapid
872 // taps on the album art all read volumePercent=0 from state,
873 // add ±5, and slam the Cast volume to 5% or 0% every time.
874 _state.value = _state.value.copy(volumePercent = clamped)
875 }
876
877 override suspend fun setRepeatMode(mode: RepeatMode) = onMain {
878 // controller.repeatMode = ... is silently dropped by Media3 because
879 // ForwardingPlayer.getAvailableCommands() hides COMMAND_SET_REPEAT_MODE
880 // (kept hidden to keep the system notification clean). Route through
881 // a SessionCommand instead — see CMD_SET_REPEAT_MODE comment.
882 val exoMode = when (mode) {
883 RepeatMode.OFF -> Player.REPEAT_MODE_OFF
884 RepeatMode.ONE -> Player.REPEAT_MODE_ONE
885 RepeatMode.ALL -> Player.REPEAT_MODE_ALL
886 }
887 controller?.sendCustomCommand(
888 SessionCommand(PlaybackService.CMD_SET_REPEAT_MODE, android.os.Bundle.EMPTY),
889 android.os.Bundle().apply { putInt(PlaybackService.ARG_REPEAT_MODE, exoMode) },
890 )
891 _state.update { it.copy(repeatMode = mode) }
892 // Push the receiver-side queue mode too — without this, the
893 // receiver finishes its current track and advances per its
894 // loaded repeat setting (REPEAT_OFF from queueLoad), ignoring
895 // the local toggle.
896 if (cast.isCasting.value) {
897 cast.setRepeatAndShuffle(mode, _state.value.shuffleEnabled)
898 }
899 }
900
901 override suspend fun setShuffle(enabled: Boolean) = onMain {
902 // Same reason as setRepeatMode: COMMAND_SET_SHUFFLE_MODE is hidden
903 // from the available commands, so MediaController.setShuffleModeEnabled
904 // is a no-op. We use a custom command to reach the player.
905 // Media3 Player handles the save/restore of the original ordering
906 // internally — when we flip shuffle off, the queue re-linearises
907 // to the order we originally set via setMediaItems(). We don't
908 // need to track the pre-shuffle snapshot.
909 controller?.sendCustomCommand(
910 SessionCommand(PlaybackService.CMD_SET_SHUFFLE, android.os.Bundle.EMPTY),
911 android.os.Bundle().apply { putBoolean(PlaybackService.ARG_SHUFFLE_ENABLED, enabled) },
912 )
913 _state.update { it.copy(shuffleEnabled = enabled) }
914 // Push to receiver. Cast doesn't have shuffle-without-repeat,
915 // so when shuffle=on+repeat=off the receiver maps to
916 // REPEAT_ALL_AND_SHUFFLE — accepting that the receiver will
917 // loop in random order rather than stop at end of queue. See
918 // CastIntegration.mapToCastRepeatMode for the full mapping.
919 if (cast.isCasting.value) {
920 cast.setRepeatAndShuffle(_state.value.repeatMode, enabled)
921 }
922 }
923
924 private suspend inline fun onMain(crossinline block: () -> Unit) =
925 withContext(Dispatchers.Main) { block() }
926
927 private fun DidlItem.Track.toMediaItem(): MediaItem {
928 // parentId stashed in extras so toMinimalTrack can recover it during
929 // cold-start adoption. MediaMetadata's first-class fields don't include
930 // a parent-container slot — the extras Bundle is the only round-trip
931 // surface that survives Media3's queue persistence. Without this,
932 // tracks reconstructed from a live session have parentId="" and any
933 // "open this track's album" navigation lands on the server's root.
934 val extras = android.os.Bundle().apply {
935 putString(EXTRA_PARENT_ID, parentId)
936 }
937 // Artwork URI: real one if present, otherwise the bundled Earhorn badge
938 // resource. The launcher widget's artwork slot reads MediaMetadata.artworkUri
939 // through Media3's standard BitmapLoader pipeline, which handles
940 // android.resource:// URIs natively — no BitmapLoader subclass needed.
941 // Effect: the launcher's now-playing widget shows the badge instead of
942 // the launcher's generic music-note silhouette for tracks with no
943 // embedded art.
944 val artUri: android.net.Uri = albumArtUri?.let(android.net.Uri::parse)
945 ?: defaultArtworkUri
946 val meta = MediaMetadata.Builder()
947 .setTitle(title)
948 .setArtist(artist ?: albumArtist)
949 .setAlbumArtist(albumArtist)
950 .setAlbumTitle(album)
951 .setComposer(composer)
952 .setTrackNumber(trackNumber)
953 .setDiscNumber(discNumber)
954 .setArtworkUri(artUri)
955 .setExtras(extras)
956 .build()
957 return MediaItem.Builder()
958 .setMediaId(id)
959 .setUri(resourceUrl)
960 .setMediaMetadata(meta)
961 .build()
962 }
963
964 /** android.resource:// URI for the bundled Earhorn badge. Built once per
965 * LocalRenderer instance — the package name doesn't change at runtime
966 * and the resource ID is compile-time stable. */
967 private val defaultArtworkUri: android.net.Uri by lazy {
968 android.net.Uri.parse(
969 "android.resource://${context.packageName}/${dev.stave.R.drawable.earhorn_badge}"
970 )
971 }
972
973 /** Rebuild a DidlItem.Track from a live MediaItem, populating only fields
974 * MediaMetadata carries. Used during cold-start recovery when the app
975 * connects to a session it didn't originate — we have no persisted
976 * DidlItem to match against, so we synthesize a minimal one. Technical
977 * fields (mimeType, sampleFrequency, bitsPerSample, channels, bitrate,
978 * size) are null because they aren't round-tripped through MediaMetadata;
979 * the NowPlaying metadata row renders each nullable field conditionally
980 * so missing values just show fewer bullet-separated items. */
981 private fun MediaItem.toMinimalTrack(): DidlItem.Track? {
982 val meta = mediaMetadata
983 val url = localConfiguration?.uri?.toString() ?: return null
984 // parentId travels in MediaMetadata extras — see [toMediaItem]. Empty
985 // string is the legitimate "no parent" value for tracks that came in
986 // without one (truly minimal recovery from a session that didn't pack
987 // parentId, or from before this field was preserved). Detail screen
988 // already handles empty/invalid container IDs with a graceful error
989 // via the BROWSE_TIMEOUT_MS path in ContentDirectoryClient.
990 val parentId = meta.extras?.getString(EXTRA_PARENT_ID).orEmpty()
991 // Filter out the synthetic badge URI that toMediaItem injects when a
992 // track has no real art. Tracks that genuinely have no artwork should
993 // round-trip with albumArtUri=null so the in-app AlbumArt composable
994 // renders its brown-background-with-badge fallback (different from
995 // the bare badge that the launcher widget receives via artworkUri).
996 val artUri = meta.artworkUri?.toString()
997 val realArtUri = if (artUri == defaultArtworkUri.toString()) null else artUri
998 return DidlItem.Track(
999 id = mediaId.ifBlank { url },
1000 parentId = parentId,
1001 title = meta.title?.toString() ?: "Unknown",
1002 artist = meta.artist?.toString(),
1003 albumArtist = meta.albumArtist?.toString(),
1004 album = meta.albumTitle?.toString(),
1005 composer = meta.composer?.toString(),
1006 genre = null,
1007 date = null,
1008 trackNumber = meta.trackNumber,
1009 discNumber = meta.discNumber,
1010 duration = null,
1011 albumArtUri = realArtUri,
1012 resourceUrl = url,
1013 mimeType = null,
1014 sampleFrequency = null,
1015 bitsPerSample = null,
1016 channels = null,
1017 bitrate = null,
1018 size = null,
1019 )
1020 }
1021
1022 companion object {
1023 /** MediaMetadata.extras key that carries [DidlItem.Track.parentId]
1024 * through the Media3 controller's queue. Without this, tracks
1025 * reconstructed via [toMinimalTrack] during cold-start adoption have
1026 * no parentId and any "open this track's album" navigation lands on
1027 * the server's root browse instead of the actual album. */
1028 private const val EXTRA_PARENT_ID = "earhorn.parentId"
1029
1030 /** Maintainer, 2026-10-29: how long [load] waits for the controller. */
1031 private const val CONTROLLER_WAIT_MS = 5_000L
1032 }
1033}