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

NowPlayingViewModel.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.nowplaying
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import dagger.hilt.android.lifecycle.HiltViewModel
6import dev.stave.cast.CastIntegration
7import dev.stave.cast.CastSession
8import dev.stave.domain.model.DidlItem
9import dev.stave.domain.renderer.PlaybackState
10import dev.stave.cast.raw.RawCastObserver
11import dev.stave.data.SettingsStore
12import dev.stave.domain.model.RemoteRenderer
13import dev.stave.domain.renderer.Renderer
14import dev.stave.upnp.UpnpServiceHolder
15import dev.stave.domain.renderer.RepeatMode
16import dev.stave.upnp.BrowseResult
17import dev.stave.upnp.ContentDirectoryClient
18import dev.stave.upnp.MediaCollector
19import dev.stave.upnp.renderer.RendererCoordinator
20import kotlinx.coroutines.ExperimentalCoroutinesApi
21import kotlinx.coroutines.Job
22import kotlinx.coroutines.channels.Channel
23import kotlinx.coroutines.delay
24import kotlinx.coroutines.flow.Flow
25import kotlinx.coroutines.flow.MutableSharedFlow
26import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.SharedFlow
28import kotlinx.coroutines.flow.SharingStarted
29import kotlinx.coroutines.flow.StateFlow
30import kotlinx.coroutines.flow.asSharedFlow
31import kotlinx.coroutines.flow.asStateFlow
32import kotlinx.coroutines.flow.combine
33import kotlinx.coroutines.flow.distinctUntilChanged
34import kotlinx.coroutines.flow.map
35import kotlinx.coroutines.flow.mapLatest
36import kotlinx.coroutines.flow.receiveAsFlow
37import kotlinx.coroutines.flow.stateIn
38import kotlinx.coroutines.launch
39import javax.inject.Inject
40import kotlin.time.Duration
41
42@HiltViewModel
43class NowPlayingViewModel @Inject constructor(
44 val playbackGate: dev.stave.playback.LocalPlaybackGate,
45 private val renderer: Renderer,
46 private val cd: ContentDirectoryClient,
47 private val collector: MediaCollector,
48 private val cast: CastIntegration,
49 private val settings: SettingsStore,
50 val rawCast: RawCastObserver,
51 val radioStatus: dev.stave.cast.radio.RadioStatusClient,
52 private val upnpHolder: UpnpServiceHolder,
53) : ViewModel() {
54
55 val state: StateFlow<PlaybackState> = renderer.state
56
57 // Maintainer, 2026-10-29: short user-facing lines (a play that loaded no
58 // tracks) for the screens to show; those plays used to fail silently.
59 private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 8)
60 val messages: SharedFlow<String> = _messages.asSharedFlow()
61
62 // ── Renderer output indicator ───────────────────────────────
63 val observedCaps = rawCast.observedCaps
64
65 /** True when [action] is permitted right now. Local playback: always.
66 * Observed session: per the receiver's own capability bits. The UI
67 * dims from the same source; this is the belt to that suspender —
68 * a d-pad or race-through press on a dead control becomes a logged
69 * no-op instead of a dead command on the wire. */
70 private fun obsAllows(picker: (dev.stave.cast.raw.RawCastObserver.ObservedCaps) -> Boolean): Boolean {
71 if (!rawCast.observing.value) return true
72 val caps = rawCast.observedCaps.value ?: return true
73 return picker(caps).also {
74 if (!it) android.util.Log.i("EarhornCast",
75 "transport action refused: receiver does not support it on this session")
76 }
77 }
78
79 val domEthaUrl: StateFlow<String?> = settings.domEthaUrl.stateIn(
80 scope = viewModelScope,
81 started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
82 initialValue = null,
83 )
84
85 val cornerButtonDomEtha: StateFlow<Boolean> = settings.cornerButtonDomEtha.stateIn(
86 scope = viewModelScope,
87 started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
88 initialValue = false,
89 )
90
91 val activeRendererUdn: StateFlow<String?> = settings.activeRendererUdn.stateIn(
92 scope = viewModelScope,
93 started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
94 initialValue = null,
95 )
96 val renderers: StateFlow<List<RemoteRenderer>> = upnpHolder.renderers
97
98 /** Maintainer, 2026-10-29: the remote renderer audio is ACTUALLY going to
99 * (null = this device). [activeRendererUdn] is only the saved choice;
100 * the coordinator stays local while that renderer is undiscovered, so
101 * "Playing on X" claims must read this instead. The injected [renderer]
102 * is the RendererCoordinator (RendererModule.provideRenderer); any other
103 * Renderer is local-only, hence the null fallback. */
104 val activeRemoteUdn: StateFlow<String?> =
105 (renderer as? RendererCoordinator)?.activeRemoteUdn
106 ?: MutableStateFlow<String?>(null).asStateFlow()
107
108 fun setActiveRenderer(udn: String?) {
109 viewModelScope.launch { settings.setActiveRendererUdn(udn) }
110 }
111
112 // ── Cast wiring (retained but disabled) ─────────────────────────
113 // Cast infrastructure stays injected and functional so re-enabling
114 // is a single UI change. The NowPlayingScreen no longer exposes the
115 // cast button; a sleep timer takes its slot instead.
116
117 val isCasting: StateFlow<Boolean> = cast.isCasting
118 val castIntegration: CastIntegration = cast
119 private val _showCastPicker = Channel<Unit>(Channel.BUFFERED)
120 val showCastPicker: Flow<Unit> = _showCastPicker.receiveAsFlow()
121
122 fun toggleCast() {
123 // Always open the picker, whether casting or not. This lets
124 // the user switch speakers mid-session without stopping.
125 // Disconnect is exclusively the stop button's job.
126 val ok = _showCastPicker.trySend(Unit).isSuccess
127 android.util.Log.i("EarhornCast", "toggleCast: showCastPicker emitted=$ok isCasting=${isCasting.value}")
128 }
129
130 // ── Sleep timer ─────────────────────────────────────────────────
131 // Countdown runs in viewModelScope. UI observes [sleepTimerMinutesLeft]:
132 // null = no timer, non-null = minutes remaining (updated every 60s).
133 // When the timer reaches zero, playback stops automatically.
134
135 private val _sleepTimerMinutesLeft = MutableStateFlow<Int?>(null)
136 val sleepTimerMinutesLeft: StateFlow<Int?> = _sleepTimerMinutesLeft.asStateFlow()
137
138 private var sleepTimerJob: Job? = null
139
140 fun startSleepTimer(minutes: Int) {
141 cancelSleepTimer()
142 _sleepTimerMinutesLeft.value = minutes
143 sleepTimerJob = viewModelScope.launch {
144 var remaining = minutes
145 while (remaining > 0) {
146 delay(60_000L) // 1 minute
147 remaining--
148 _sleepTimerMinutesLeft.value = remaining
149 }
150 // Timer expired — stop playback. Same routing as the Stop
151 // button: the observed cast session when observing, else the
152 // local renderer. (Audit 2026-09-02: this called renderer.stop()
153 // unconditionally, so on a screen following the group's music
154 // the timer counted down and did nothing.)
155 if (observing) rawCast.stop() else renderer.stop()
156 _sleepTimerMinutesLeft.value = null
157 }
158 }
159
160 fun cancelSleepTimer() {
161 sleepTimerJob?.cancel()
162 sleepTimerJob = null
163 _sleepTimerMinutesLeft.value = null
164 }
165
166 // Audit 2026-09-02: every starter below that begins audio now goes
167 // through the LocalPlaybackGate, as its own contract (and the NavHost
168 // comment) already claimed. They did not: Detail/More/Browse track
169 // taps, album Play, playlists and local random all reached
170 // renderer.load directly, so a fixed screen following the group started
171 // a second, invisible audio source under the observed display, and
172 // the only visible Stop then went to the group's group.
173 /** [onRan] fires after the action actually ran — immediately when not
174 * following, after the person confirms when following. Screens use it
175 * to navigate to Now Playing only then (design decision 2026-09-02). */
176 fun playTracks(tracks: List<DidlItem.Track>, startIndex: Int, onRan: (() -> Unit)? = null) {
177 playbackGate.request(onRan) { renderer.load(tracks, startIndex) }
178 }
179
180 fun enqueueTracks(tracks: List<DidlItem.Track>) {
181 // Queue-only: adds to a queue, starts nothing. Not gated (gate contract).
182 viewModelScope.launch { renderer.enqueue(tracks) }
183 }
184
185 fun playNextTracks(tracks: List<DidlItem.Track>) {
186 // Play Next into an EMPTY queue starts audio; onto a non-empty
187 // queue it only inserts. Gate the former only.
188 if (state.value.queue.isEmpty()) playbackGate.request { renderer.playNext(tracks) }
189 else viewModelScope.launch { renderer.playNext(tracks) }
190 }
191
192 // Container variants — collect all tracks under the container first, then apply the
193 // same action. Runs off-screen; deep containers (big genres) may take a few seconds.
194 // Mirrors BrowseViewModel's equivalents so the behaviour is identical regardless of
195 // where the container action is invoked from.
196
197 fun playContainer(udn: String, containerId: String, onRan: (() -> Unit)? = null) {
198 // Maintainer, 2026-10-29: nothing collected (MediaCollector returns an
199 // empty list on failure too) used to fail silently and still open Now
200 // Playing. Say so, and navigate (onRan) only after tracks loaded.
201 playbackGate.request {
202 val tracks = collector.collectTracks(udn, containerId)
203 if (tracks.isNotEmpty()) {
204 renderer.load(tracks, 0)
205 playbackGate.runOnRan(onRan)
206 } else {
207 _messages.tryEmit(MSG_NOTHING_TO_PLAY)
208 }
209 }
210 }
211
212 fun enqueueContainer(udn: String, containerId: String) {
213 viewModelScope.launch {
214 val tracks = collector.collectTracks(udn, containerId)
215 if (tracks.isNotEmpty()) renderer.enqueue(tracks)
216 else _messages.tryEmit(MSG_NOTHING_TO_PLAY) // Maintainer, 2026-10-29: was silent
217 }
218 }
219
220 fun playNextContainer(udn: String, containerId: String) {
221 val starter: suspend () -> Unit = {
222 val tracks = collector.collectTracks(udn, containerId)
223 if (tracks.isNotEmpty()) renderer.playNext(tracks)
224 else _messages.tryEmit(MSG_NOTHING_TO_PLAY) // Maintainer, 2026-10-29: was silent
225 }
226 if (state.value.queue.isEmpty()) playbackGate.request(action = starter)
227 else viewModelScope.launch { starter() }
228 }
229
230 /**
231 * Append [tracks] to the queue, then immediately jump playback to the first one.
232 * Non-destructive (anything already queued stays in place) but instantaneous — the user
233 * doesn't have to wait for the existing queue to drain. Complementary to [playNextTracks]
234 * (which inserts just-after-current but keeps current playing) and [playTracks] (which
235 * clobbers the queue entirely). Starts audio → gated.
236 */
237 fun enqueueAndPlay(tracks: List<DidlItem.Track>, onRan: (() -> Unit)? = null) {
238 if (tracks.isEmpty()) return
239 playbackGate.request(onRan) {
240 val startIndex = state.value.queue.size
241 renderer.enqueue(tracks)
242 renderer.skipToQueueItem(startIndex)
243 renderer.play()
244 }
245 }
246
247 /**
248 * Random tracks action — fetches [count] random tracks from the active server and
249 * starts playing them immediately. Matches BubbleUPnP's equivalent feature.
250 *
251 * On server failure or when no tracks come back it emits a line on [messages]
252 * and does not navigate (decision, 2026-10-29).
253 */
254 fun loadRandomTracks(udn: String, count: Int, onRan: (() -> Unit)? = null) {
255 // Maintainer, 2026-10-29: a failed/empty fetch was silent yet still
256 // opened Now Playing. Say so, and navigate (onRan) only after tracks
257 // actually loaded.
258 playbackGate.request {
259 when (val r = cd.randomTracks(udn, count)) {
260 is BrowseResult.Success -> {
261 val tracks = r.items.filterIsInstance<DidlItem.Track>()
262 if (tracks.isNotEmpty()) {
263 renderer.load(tracks, 0)
264 playbackGate.runOnRan(onRan)
265 } else {
266 _messages.tryEmit(MSG_NOTHING_TO_PLAY)
267 }
268 }
269 is BrowseResult.Failure -> {
270 _messages.tryEmit(MSG_LOAD_FAILED)
271 }
272 }
273 }
274 }
275
276 /**
277 * Genre-scoped random — fetches [count] random tracks matching [genreName]
278 * from the active server and starts playing them immediately. Used by the
279 * "Play 42 random tracks" long-press action on genre containers.
280 *
281 * The genre exclusion list in Settings does NOT apply here — the user
282 * explicitly chose this genre, so filtering it out would be contradictory.
283 */
284 fun loadRandomTracksByGenre(udn: String, genreName: String, count: Int = 42) {
285 playbackGate.request {
286 // Maintainer, 2026-10-29: failed/empty fetches were silent; say so.
287 when (val r = cd.randomTracksByGenre(udn, genreName, count)) {
288 is BrowseResult.Success -> {
289 val tracks = r.items.filterIsInstance<DidlItem.Track>()
290 if (tracks.isNotEmpty()) renderer.load(tracks, 0)
291 else _messages.tryEmit(MSG_NOTHING_TO_PLAY)
292 }
293 is BrowseResult.Failure -> {
294 _messages.tryEmit(MSG_LOAD_FAILED)
295 }
296 }
297 }
298 }
299
300 // ── Observer-aware transport routing ──────────────────────────────
301 // When the raw Cast observer is active, every transport command must
302 // go to the observed device over the raw protocol — not to the local
303 // renderer (which is idle/paused underneath). Without this routing,
304 // play/pause/next/prev/seek on phone silently act on the local
305 // renderer and the observed stream never responds, making observer
306 // mode appear completely broken on phone and tablet.
307 private val observing: Boolean get() = rawCast.observing.value
308
309 init {
310 // Entering observer mode while local music is playing would leave
311 // both audible with no way to reach the local controls (the UI
312 // swaps to the observed state). Pause local playback the moment
313 // observation starts.
314 viewModelScope.launch {
315 rawCast.observing.collect { obs ->
316 if (obs && state.value.isPlaying) {
317 runCatching { renderer.pause() }
318 }
319 }
320 }
321 }
322
323 fun togglePlay() {
324 viewModelScope.launch {
325 if (observing) {
326 if (rawCast.playbackState.value.isPlaying) rawCast.pause() else rawCast.play()
327 } else {
328 if (state.value.isPlaying) renderer.pause() else renderer.play()
329 }
330 }
331 }
332
333 fun next() = viewModelScope.launch {
334 if (observing) rawCast.next() else renderer.next()
335 }
336 fun previous() = viewModelScope.launch {
337 if (observing) rawCast.previous() else renderer.previous()
338 }
339 /** Stop this device's own player and nothing else — used by Exit,
340 * which must not touch the group (review C.8). */
341 fun stopLocalOnly() = viewModelScope.launch { renderer.stop() }
342
343 fun stop() = viewModelScope.launch {
344 if (observing) rawCast.stop() else renderer.stop()
345 }
346 fun seekTo(position: Duration) = viewModelScope.launch {
347 if (!obsAllows { it.canSeek }) return@launch
348 if (observing) rawCast.seek(position) else renderer.seekTo(position)
349 }
350
351 // Queue manipulation — used by QueueScreen.
352 // skipToQueueItem routes through the observer when following a Cast
353 // session: tapping a queue row jumps the OBSERVED playback via
354 // QUEUE_UPDATE. Remove/move/clear stay local-only — the QueueScreen
355 // hides them in observer mode (editing the receiver queue from a
356 // follower would desync the sender's authoritative local copy).
357 fun skipToQueueItem(index: Int) = viewModelScope.launch {
358 if (observing) rawCast.jumpToQueueItem(index)
359 else renderer.skipToQueueItem(index)
360 }
361 fun removeFromQueue(index: Int) = viewModelScope.launch { renderer.removeFromQueue(index) }
362 fun moveInQueue(from: Int, to: Int) = viewModelScope.launch { renderer.moveInQueue(from, to) }
363 fun clearQueue() = viewModelScope.launch { renderer.clearQueue() }
364
365 /** Cycle OFF → ALL → ONE → OFF. Matches the three-state repeat control users expect
366 * from decades of media apps. */
367 fun cycleRepeat() = viewModelScope.launch {
368 // Maintainer, 2026-10-29: cycle from the OBSERVED repeat mode while
369 // following a Cast group. Reading the idle local renderer's mode
370 // here meant every press sent ALL (local stays OFF).
371 val current = if (observing) rawCast.playbackState.value.repeatMode else state.value.repeatMode
372 val next = when (current) {
373 RepeatMode.OFF -> RepeatMode.ALL
374 RepeatMode.ALL -> RepeatMode.ONE
375 RepeatMode.ONE -> RepeatMode.OFF
376 }
377 // Routed like every other transport control: the observed house
378 // session when following (I.15, design decision 2026-09-02).
379 if (observing) rawCast.setRepeatMode(next) else renderer.setRepeatMode(next)
380 }
381
382 fun toggleShuffle() = viewModelScope.launch {
383 // Maintainer, 2026-10-29: flip the OBSERVED shuffle flag while
384 // following a Cast group. Flipping the idle local flag always sent
385 // "on", so shuffle on the group could never be turned off.
386 val on = !(if (observing) rawCast.playbackState.value.shuffleEnabled else state.value.shuffleEnabled)
387 if (observing) rawCast.setShuffle(on) else renderer.setShuffle(on)
388 }
389
390 // ── Volume control (Issue 6) ─────────────────────────────────────
391 // For touch interfaces without hardware volume keys (HP Chromebase).
392 // Adjusts volume by a fixed step (5%) per tap, with the current
393 // level exposed for toast/overlay display.
394
395 fun adjustVolume(delta: Int) {
396 viewModelScope.launch {
397 if (observing) {
398 val cur = rawCast.playbackState.value.volumePercent
399 rawCast.setVolume((cur + delta).coerceIn(0, 100))
400 } else {
401 val current = state.value.volumePercent
402 renderer.setVolume((current + delta).coerceIn(0, 100))
403 }
404 }
405 }
406
407 fun setVolumeTo(percent: Int) {
408 viewModelScope.launch {
409 val clamped = percent.coerceIn(0, 100)
410 if (observing) rawCast.setVolume(clamped)
411 else renderer.setVolume(clamped)
412 }
413 }
414
415 // ── Observed-track metadata enrichment ──────────────────────────
416 // Raw-cast protocol ships title/artist/album/art but NOT year, mime
417 // type, sample frequency, bit depth, or track number — those are UPnP
418 // metadata fields the receiver doesn't include in MEDIA_STATUS. To
419 // populate the NowPlaying metadata row + Track Nº + id3-tag sheet
420 // correctly while observing, we search the user's preferred UPnP
421 // server by (title, artist, album) and merge any technical fields
422 // from the matching local DIDL track back into the observed track.
423 //
424 // Cache by composite key so identical track changes (pause/resume,
425 // position ticks) don't re-search. Cache survives the lifetime of
426 // this ViewModel which is the NowPlaying session — reasonable: a
427 // 10-track CCA queue produces 10 search calls total, not 10/s.
428
429 private val enrichmentCache = mutableMapOf<String, DidlItem.Track>()
430
431 /** Composite key for cache lookup. Includes album because two tracks
432 * by the same artist with the same title (live + studio cuts, e.g.)
433 * can have different technical metadata. */
434 private fun enrichKey(t: DidlItem.Track): String =
435 "${t.title.trim().lowercase()}|${(t.artist ?: "").trim().lowercase()}|${(t.album ?: "").trim().lowercase()}"
436
437 /** Inputs that drive enrichment. Combined so we recompute only when
438 * observing-flag, observed track identity, or preferred server changes —
439 * not on every position tick. */
440 private val enrichmentInputs: Flow<Triple<Boolean, DidlItem.Track?, String?>> =
441 combine(
442 rawCast.observing,
443 rawCast.playbackState.map { it.current }.distinctUntilChanged { a, b ->
444 a?.id == b?.id && a?.title == b?.title && a?.album == b?.album
445 },
446 settings.preferredServerUdn,
447 ) { observing, observed, udn -> Triple(observing, observed, udn) }
448
449 /** The track to display when observing — observed track enriched with
450 * UPnP-side technical metadata when a match exists in the library.
451 * When not observing, emits null and the UI falls back to the raw
452 * observed track or local renderer track. */
453 @OptIn(ExperimentalCoroutinesApi::class)
454 val observedTrackEnriched: StateFlow<DidlItem.Track?> = enrichmentInputs
455 .mapLatest { (observing, observed, _) ->
456 if (!observing || observed == null) return@mapLatest null
457 // One lookup per track (design decision 2026-09-02, I.22): the
458 // observer enriches upstream and its published state already
459 // carries year/trackNumber/format. This flow only mirrors that
460 // state now; the second UPnP search it used to run — and the
461 // stale previous track it held while that search ran with no
462 // timeout (review N.20) — are gone.
463 observed
464 }.stateIn(
465 scope = viewModelScope,
466 started = SharingStarted.WhileSubscribed(5_000),
467 initialValue = null,
468 )
469
470 private suspend fun lookupLocal(udn: String, observed: DidlItem.Track): DidlItem.Track? {
471 val title = observed.title.takeIf { it.isNotBlank() } ?: return null
472 val artist = observed.artist?.takeIf { it.isNotBlank() }
473 val criteria = buildString {
474 append("upnp:class derivedfrom \"object.item.audioItem\"")
475 append(" and dc:title = \"${escapeForCriteria(title)}\"")
476 if (artist != null) append(" and upnp:artist = \"${escapeForCriteria(artist)}\"")
477 }
478 return when (val r = cd.search(udn, criteria, maxResults = 5L)) {
479 is BrowseResult.Success -> r.items.filterIsInstance<DidlItem.Track>().let { hits ->
480 val obsAlbum = observed.album?.trim()?.lowercase()
481 if (obsAlbum != null) hits.firstOrNull { it.album?.trim()?.lowercase() == obsAlbum }
482 ?: hits.firstOrNull()
483 else hits.firstOrNull()
484 }
485 is BrowseResult.Failure -> null
486 }
487 }
488
489 /** Same escape rules as the private one in ContentDirectoryClient — kept
490 * here so this VM doesn't reach across the package boundary. */
491 private fun escapeForCriteria(s: String): String =
492 s.replace("\\", "\\\\").replace("\"", "\\\"")
493
494 private companion object {
495 // Maintainer, 2026-10-29: the lines emitted on [messages].
496 const val MSG_LOAD_FAILED = "Couldn't load tracks from the server"
497 const val MSG_NOTHING_TO_PLAY = "Nothing to play there"
498 }
499
500 private fun mergeMetadata(observed: DidlItem.Track, local: DidlItem.Track): DidlItem.Track =
501 observed.copy(
502 // Keep observed identity (id, title, artist, album, artUri) so
503 // anything keyed on those still resolves to the observed entity.
504 // Pull in technical/derived fields from local.
505 date = observed.date ?: local.date,
506 trackNumber = observed.trackNumber ?: local.trackNumber,
507 discNumber = observed.discNumber ?: local.discNumber,
508 genre = observed.genre ?: local.genre,
509 composer = observed.composer ?: local.composer,
510 mimeType = observed.mimeType ?: local.mimeType,
511 sampleFrequency = observed.sampleFrequency ?: local.sampleFrequency,
512 bitsPerSample = observed.bitsPerSample ?: local.bitsPerSample,
513 channels = observed.channels ?: local.channels,
514 bitrate = observed.bitrate ?: local.bitrate,
515 // parentId from local enables "More tracks from this album"
516 // when observing — previously empty for raw-cast tracks.
517 parentId = local.parentId.ifBlank { observed.parentId },
518 )
519}