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


