LocalRenderer.kt
1033 lines · 52.3 KB
Changed in: 0.5.0
| 1 | package dev.stave.playback |
| 2 | |
| 3 | import android.content.ComponentName |
| 4 | import android.content.Context |
| 5 | import androidx.core.content.ContextCompat |
| 6 | import androidx.media3.common.MediaItem |
| 7 | import androidx.media3.common.MediaMetadata |
| 8 | import androidx.media3.common.Player |
| 9 | import androidx.media3.session.MediaController |
| 10 | import androidx.media3.session.SessionCommand |
| 11 | import androidx.media3.session.SessionToken |
| 12 | import dagger.hilt.android.qualifiers.ApplicationContext |
| 13 | import dev.stave.data.ReplayGainMode |
| 14 | import dev.stave.data.SettingsStore |
| 15 | import dev.stave.data.toDomain |
| 16 | import dev.stave.data.toPersisted |
| 17 | import dev.stave.domain.model.DidlItem |
| 18 | import dev.stave.domain.renderer.PlaybackState |
| 19 | import dev.stave.domain.renderer.Renderer |
| 20 | import dev.stave.domain.renderer.RepeatMode |
| 21 | import kotlinx.coroutines.CompletableDeferred |
| 22 | import kotlinx.coroutines.CoroutineScope |
| 23 | import kotlinx.coroutines.Dispatchers |
| 24 | import kotlinx.coroutines.SupervisorJob |
| 25 | import kotlinx.coroutines.delay |
| 26 | import kotlinx.coroutines.flow.MutableStateFlow |
| 27 | import kotlinx.coroutines.flow.StateFlow |
| 28 | import kotlinx.coroutines.flow.asStateFlow |
| 29 | import kotlinx.coroutines.flow.combine |
| 30 | import kotlinx.coroutines.flow.distinctUntilChanged |
| 31 | import kotlinx.coroutines.flow.map |
| 32 | import kotlinx.coroutines.flow.update |
| 33 | import kotlinx.coroutines.launch |
| 34 | import kotlinx.coroutines.suspendCancellableCoroutine |
| 35 | import kotlinx.coroutines.withContext |
| 36 | import kotlinx.coroutines.withTimeoutOrNull |
| 37 | import javax.inject.Inject |
| 38 | import javax.inject.Singleton |
| 39 | import kotlin.coroutines.resume |
| 40 | import kotlin.coroutines.resumeWithException |
| 41 | import kotlin.math.pow |
| 42 | import kotlin.time.Duration |
| 43 | import kotlin.time.Duration.Companion.milliseconds |
| 44 | |
| 45 | @Singleton |
| 46 | class 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 | } |


