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 dev.stave.cast.raw.CastObserverApi
6import dev.stave.ui.safeLaunch
7import dev.stave.domain.model.DidlItem
8import dev.stave.domain.renderer.PlaybackState
9import dev.stave.domain.renderer.Renderer
10import dev.stave.domain.renderer.RepeatMode
11import dev.stave.data.SessionStore
12import dev.stave.data.SettingsStore
13import dev.stave.data.StoredSession
14import dev.stave.playlist.M3uPlaylistStore
15import dev.stave.playlist.M3uPlaylistWriter
16import dev.stave.playlist.PlaylistFile
17import dev.stave.upnp.BrowseResult
18import dev.stave.upnp.ContentDirectoryClient
19import dev.stave.upnp.MediaCollector
20import kotlinx.coroutines.CancellationException
21import kotlinx.coroutines.ExperimentalCoroutinesApi
22import kotlinx.coroutines.Job
23import kotlinx.coroutines.delay
24import kotlinx.coroutines.flow.MutableStateFlow
25import kotlinx.coroutines.flow.SharingStarted
26import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.combine
29import kotlinx.coroutines.flow.distinctUntilChanged
30import kotlinx.coroutines.flow.flatMapLatest
31import kotlinx.coroutines.flow.map
32import kotlinx.coroutines.flow.stateIn
33import kotlinx.coroutines.flow.transformLatest
34import kotlinx.coroutines.launch
35import kotlinx.coroutines.withTimeoutOrNull
36import dev.stave.ui.copy.UserMessages
37import dev.stave.ui.events.UiEvents
38import java.io.File
39import kotlin.time.Duration
40
41class NowPlayingViewModel(
42 private val renderer: Renderer,
43 private val rawCast: CastObserverApi,
44 private val cd: ContentDirectoryClient,
45 private val collector: MediaCollector,
46 private val playlistWriter: M3uPlaylistWriter,
47 private val playlistStore: M3uPlaylistStore,
48 private val sessionStore: SessionStore,
49 private val settings: SettingsStore,
50 private val uiEvents: UiEvents,
51 /** "Stop following and play here?" — every local audio starter goes
52 * through it (design decision, 2026-09-04). */
53 val playGate: dev.stave.playback.LocalPlaybackGate,
54) : ViewModel() {
55
56 /** Local-renderer state. The UI should rarely read this directly — use
57 * [tvState] / [tvTrack] instead, which transparently switch to the
58 * observed Cast session's state while the user is following a CCA group.
59 * This field is still exposed for code paths that operate on the *local*
60 * queue specifically (queue editing, M3U save, etc.). */
61 val state: StateFlow<PlaybackState> = renderer.state
62
63 /** True while following a Cast session. UI surfaces that must behave
64 * differently in follower mode (read-only queue, hidden edit
65 * affordances) gate on this rather than re-deriving it, so the swap
66 * happens at one chokepoint. */
67 val observing: StateFlow<Boolean> = rawCast.observing
68
69 /** The renderer actually in use: a UPnP UDN, `cast:<id>`, or null while
70 * this computer plays (Android parity, 2026-10-29). The saved choice can name
71 * a renderer that isn't attached (not discovered yet, or dropped out), so
72 * "Playing on X" must key on this, not on the setting. */
73 val activeRemoteUdn: StateFlow<String?> =
74 (renderer as? dev.stave.upnp.renderer.RendererCoordinator)?.activeRemoteUdn
75 ?: MutableStateFlow<String?>(null).asStateFlow()
76
77 // ── Observer-mode state surface ──────────────────────────────────
78 // While the user is following a Chromecast group, what's audibly playing
79 // on the group has nothing to do with the local renderer's queue — it's
80 // whatever some other sender (typically the phone) loaded. The UI needs
81 // to render THAT, not the local-renderer state. Three derived flows make
82 // it work:
83 //
84 // tvState : PlaybackState ← rawCast.playbackState OR renderer.state
85 // tvTrack : DidlItem.Track? ← following: null when the session is dead,
86 // else enriched ∨ observed ∨ lastObserved;
87 // not following: local
88 // observedTrackEnriched: DidlItem.Track? ← observed track + UPnP technical fields
89 //
90 // [tvState] / [tvTrack] are what NowPlayingPane, MprisIntegration, and
91 // anything else that renders "what's playing now" should bind to.
92
93 /** Debounced liveness of the followed session (Android F2 on the glass,
94 * Android parity, 2026-09-03): believed live at once, dead only after
95 * [SESSION_DEAD_DEBOUNCE_MS], because the raw flag flaps to false for
96 * under a second at every track boundary. False when not following. */
97 @OptIn(ExperimentalCoroutinesApi::class)
98 val observedSessionLive: StateFlow<Boolean> =
99 combine(rawCast.observing, rawCast.sessionLive) { obs, live -> obs to live }
100 .transformLatest { (obs, live) ->
101 when {
102 !obs -> emit(false)
103 live -> emit(true)
104 else -> { delay(SESSION_DEAD_DEBOUNCE_MS); emit(false) }
105 }
106 }
107 .distinctUntilChanged()
108 .stateIn(viewModelScope, SharingStarted.Eagerly, false)
109
110 /** Has this follow ever been live? Picks "Waiting for…" over
111 * "Connecting to…" once a session has ended. */
112 private val sawLive = MutableStateFlow(false)
113
114 /** Following, not live, for [SESSION_DEAD_DEBOUNCE_MS] (Android 0.4.23:
115 * an idle device reads "Waiting for…", not "Connecting to…" forever). */
116 @OptIn(ExperimentalCoroutinesApi::class)
117 private val waitedLong: StateFlow<Boolean> =
118 combine(rawCast.observing, observedSessionLive) { obs, live -> obs && !live }
119 .transformLatest { waiting ->
120 emit(false)
121 if (waiting) { delay(SESSION_DEAD_DEBOUNCE_MS); emit(true) }
122 }
123 .stateIn(viewModelScope, SharingStarted.Eagerly, false)
124
125 /** Empty-state line while following and nothing can be shown:
126 * "Connecting to X…" until the first live report, "Waiting for X…"
127 * once a session has ended or nothing has played for 20 s. Null when
128 * not following or the session is live (the UI then says its own
129 * "Nothing playing"). */
130 val followEmptyLabel: StateFlow<String?> = combine(
131 rawCast.observing,
132 observedSessionLive,
133 sawLive,
134 waitedLong,
135 rawCast.connectedDeviceName,
136 ) { obs, live, saw, waited, name ->
137 val who = name ?: "device"
138 when {
139 !obs || live -> null
140 saw || waited -> "Waiting for $who…"
141 else -> "Connecting to $who…"
142 }
143 }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
144
145 /** The followed session is a live stream (no duration, e.g. radio):
146 * seeking is refused and the progress bar has nothing to scrub. */
147 val observedIsLiveStream: StateFlow<Boolean> = combine(
148 rawCast.observing,
149 rawCast.playbackState,
150 ) { obs, st -> obs && st.current != null && st.duration <= Duration.ZERO }
151 .distinctUntilChanged()
152 .stateIn(viewModelScope, SharingStarted.Eagerly, false)
153
154 /** Persists the last non-null observed track. Bridges brief MEDIA_STATUS
155 * gaps between tracks where the Cast receiver emits a status without a
156 * media block (PAUSE/SEEK refreshes) or with an empty status array
157 * (the millisecond between two tracks). Without this, [tvTrack] would
158 * fall back to the local renderer's stale track, which has a different
159 * `albumArtUri`, producing a flash to the wrong art between tracks.
160 *
161 * Note: [RawCastClient.handleMediaStatus] already preserves track
162 * metadata across no-media-block updates, so this flow's main job is
163 * catching the empty-status-array case and bridging brief disconnect
164 * blips. Belt-and-suspenders. */
165 private val _lastObservedTrack = MutableStateFlow<DidlItem.Track?>(null)
166
167 init {
168 // Drive [_lastObservedTrack] from the observer's stream. When observing
169 // a live session and current != null → store it. When NOT observing,
170 // or the session is dead → clear it, so a dead song can't come back
171 // (Android obsSticky). The Android version did this in a
172 // composable-side LaunchedEffect; per the porting note we hoist it to
173 // the VM so it's a single source of truth and MPRIS can read it too.
174 viewModelScope.launch {
175 combine(rawCast.observing, observedSessionLive, rawCast.playbackState) { obs, live, st ->
176 Triple(obs, live, st.current)
177 }.collect { (obs, live, cur) ->
178 when {
179 !obs || !live -> _lastObservedTrack.value = null
180 cur != null -> _lastObservedTrack.value = cur
181 // live && cur == null: keep the previous value to bridge the gap
182 }
183 }
184 }
185 viewModelScope.launch {
186 combine(rawCast.observing, observedSessionLive) { obs, live -> obs to live }
187 .collect { (obs, live) ->
188 if (!obs) sawLive.value = false else if (live) sawLive.value = true
189 }
190 }
191 // Following while local music plays would leave both audible, with
192 // the local controls out of reach (the UI swaps to the observed
193 // session). Pause local playback the moment following starts —
194 // ported from Android, adopted for desktop.
195 viewModelScope.launch {
196 rawCast.observing.collect { obs ->
197 // Only THIS computer's audio: `renderer` is the coordinator, so
198 // when the desktop is casting or driving a UPnP amp, pausing it
199 // would stop the cast group (or the amp) — never what Follow means.
200 // isBuffering too: a track still opening would start anyway.
201 val local = activeRemoteUdn.value == null
202 val sounding = state.value.isPlaying || state.value.isBuffering
203 if (obs && local && sounding) {
204 try {
205 renderer.pause()
206 } catch (e: CancellationException) {
207 throw e
208 } catch (e: Exception) {
209 System.err.println("[Earhorn] couldn't pause local playback on follow: ${e.message}")
210 }
211 }
212 }
213 }
214 }
215
216 // ── Observed-track metadata enrichment ──────────────────────────
217 // The Cast wire protocol carries title/artist/album/art but NOT year,
218 // mime type, sample frequency, bit depth, or track number — those are
219 // UPnP-side facts the receiver doesn't include in MEDIA_STATUS. We
220 // search the user's preferred UPnP server by (title, artist, album)
221 // when the observed track changes, cache the result by composite key,
222 // and merge the technical fields back into the displayed track. Cache
223 // is VM-lifetime — a 10-track CCA queue produces 10 search calls total,
224 // not 10/sec. Lookup failure leaves the raw observed track unchanged
225 // (graceful degrade).
226
227 /** LRU, bounded: the VM lives as long as the app. Only touched from the
228 * enrichment transformLatest, which runs one block at a time. */
229 private val enrichmentCache = object : LinkedHashMap<String, DidlItem.Track>(64, 0.75f, true) {
230 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, DidlItem.Track>?): Boolean =
231 size > ENRICH_CACHE_SIZE
232 }
233
234 /** Composite key. Includes album because two tracks by the same artist
235 * with the same title (live + studio cuts, e.g.) can have different
236 * technical metadata and we want both to be reachable. */
237 private fun enrichKey(t: DidlItem.Track): String =
238 "${t.title.trim().lowercase()}|" +
239 "${(t.artist ?: "").trim().lowercase()}|" +
240 "${(t.album ?: "").trim().lowercase()}"
241
242 /** Inputs that drive enrichment. Combined so we recompute only on
243 * observing-flag / observed-track / preferred-server changes — NOT on
244 * every position tick. The track distinct-key is conservative on
245 * purpose: title+album differing is rare without id changing too, but
246 * the cost of an extra search is one UPnP call. */
247 private val enrichmentInputs =
248 combine(
249 rawCast.observing,
250 rawCast.playbackState.map { it.current }
251 .distinctUntilChanged { a, b ->
252 a?.id == b?.id && a?.title == b?.title && a?.album == b?.album
253 },
254 settings.preferredServerUdn,
255 ) { observing, observed, udn -> Triple(observing, observed, udn) }
256
257 /** The UPnP match for the current observed track, tagged with its
258 * [enrichKey]. Cleared the moment the track changes, so a slow or
259 * hanging search can never leave the PREVIOUS track's match in place
260 * (the old mapLatest/stateIn kept showing the old song until the
261 * search returned). Bounded by [ENRICH_TIMEOUT_MS]. */
262 @OptIn(ExperimentalCoroutinesApi::class)
263 private val enrichmentMatch: StateFlow<Pair<String, DidlItem.Track>?> = enrichmentInputs
264 .transformLatest { (observing, observed, udn) ->
265 if (!observing || observed == null || udn == null) {
266 emit(null)
267 return@transformLatest
268 }
269 val key = enrichKey(observed)
270 enrichmentCache[key]?.let {
271 emit(key to it)
272 return@transformLatest
273 }
274 emit(null)
275 val match = try {
276 withTimeoutOrNull(ENRICH_TIMEOUT_MS) { lookupLocal(udn, observed) }
277 } catch (e: CancellationException) {
278 throw e
279 } catch (e: Exception) {
280 null
281 }
282 if (match != null) {
283 enrichmentCache[key] = match
284 emit(key to match)
285 }
286 }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
287
288 /** Observed track with UPnP-side technical metadata merged in when a
289 * match for THIS track exists; the raw observed track otherwise. Null
290 * when not observing or nothing is reported. Built from the live
291 * observed track every time, so art or fields that arrive in a later
292 * status are never hidden behind an older snapshot. */
293 val observedTrackEnriched: StateFlow<DidlItem.Track?> = combine(
294 rawCast.observing,
295 rawCast.playbackState.map { it.current },
296 enrichmentMatch,
297 ) { observing, observed, match ->
298 when {
299 !observing || observed == null -> null
300 match != null && match.first == enrichKey(observed) -> mergeMetadata(observed, match.second)
301 else -> observed
302 }
303 }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
304
305 /** Effective playback state for "what's playing now" rendering. Flips
306 * between the observed CCA session and the local renderer based on
307 * [CastObserverApi.observing]. NowPlayingPane, MprisIntegration, and
308 * anything else displaying current-playback state should bind here. */
309 @OptIn(ExperimentalCoroutinesApi::class)
310 val tvState: StateFlow<PlaybackState> = rawCast.observing
311 .flatMapLatest { obs ->
312 if (obs) rawCast.playbackState else renderer.state
313 }
314 .stateIn(
315 scope = viewModelScope,
316 started = SharingStarted.Eagerly,
317 initialValue = renderer.state.value,
318 )
319
320 /** Effective "currently displayed track".
321 * - Following, session dead (after the debounce): null, so every surface
322 * shows the empty state with [followEmptyLabel] instead of painting
323 * the dead song (Android F2).
324 * - Following, live: [observedTrackEnriched] (the observed track with
325 * UPnP fields merged), bridged by [_lastObservedTrack] across the
326 * brief gaps between MEDIA_STATUS broadcasts.
327 * - Not following: the local renderer's track.
328 * While following there is deliberately NO fallback to the local track:
329 * it painted the idle local queue's song as if the group were playing it. */
330 val tvTrack: StateFlow<DidlItem.Track?> = combine(
331 rawCast.observing,
332 observedSessionLive,
333 observedTrackEnriched,
334 _lastObservedTrack,
335 renderer.state.map { it.current },
336 ) { obs, live, enriched, lastObs, local ->
337 when {
338 !obs -> local
339 !live -> null
340 else -> enriched ?: lastObs
341 }
342 }.stateIn(
343 scope = viewModelScope,
344 started = SharingStarted.Eagerly,
345 initialValue = null,
346 )
347
348 private suspend fun lookupLocal(udn: String, observed: DidlItem.Track): DidlItem.Track? {
349 val title = observed.title.takeIf { it.isNotBlank() } ?: return null
350 val artist = observed.artist?.takeIf { it.isNotBlank() }
351 val criteria = buildString {
352 append("upnp:class derivedfrom \"object.item.audioItem\"")
353 append(" and dc:title = \"${escapeForCriteria(title)}\"")
354 if (artist != null) append(" and upnp:artist = \"${escapeForCriteria(artist)}\"")
355 }
356 return when (val r = cd.search(udn, criteria, maxResults = 5L)) {
357 is BrowseResult.Success -> r.items.filterIsInstance<DidlItem.Track>().let { hits ->
358 val obsAlbum = observed.album?.trim()?.lowercase()
359 if (obsAlbum != null) hits.firstOrNull { it.album?.trim()?.lowercase() == obsAlbum }
360 ?: hits.firstOrNull()
361 else hits.firstOrNull()
362 }
363 is BrowseResult.Failure -> null
364 }
365 }
366
367 /** Same escape rules as the private one in ContentDirectoryClient — kept
368 * here so this VM doesn't reach across the package boundary. */
369 private fun escapeForCriteria(s: String): String =
370 s.replace("\\", "\\\\").replace("\"", "\\\"")
371
372 private fun mergeMetadata(observed: DidlItem.Track, local: DidlItem.Track): DidlItem.Track =
373 observed.copy(
374 // Keep observed identity (id, title, artist, album, artUri) so
375 // anything keyed on those still resolves to the observed entity.
376 // Pull in technical/derived fields from local.
377 date = observed.date ?: local.date,
378 trackNumber = observed.trackNumber ?: local.trackNumber,
379 discNumber = observed.discNumber ?: local.discNumber,
380 genre = observed.genre ?: local.genre,
381 composer = observed.composer ?: local.composer,
382 mimeType = observed.mimeType ?: local.mimeType,
383 sampleFrequency = observed.sampleFrequency ?: local.sampleFrequency,
384 bitsPerSample = observed.bitsPerSample ?: local.bitsPerSample,
385 channels = observed.channels ?: local.channels,
386 bitrate = observed.bitrate ?: local.bitrate,
387 // parentId from local enables "More tracks from this album"
388 // when observing — previously empty for raw-cast tracks.
389 parentId = local.parentId.ifBlank { observed.parentId },
390 )
391
392 // ── Sleep timer ─────────────────────────────────────────────────
393 // Countdown runs in viewModelScope. UI observes [sleepTimerMinutesLeft]:
394 // null = no timer, non-null = minutes remaining (updated every 60s).
395 // When the timer reaches zero, playback stops automatically.
396
397 private val _sleepTimerMinutesLeft = MutableStateFlow<Int?>(null)
398 val sleepTimerMinutesLeft: StateFlow<Int?> = _sleepTimerMinutesLeft.asStateFlow()
399
400 private var sleepTimerJob: Job? = null
401
402 fun startSleepTimer(minutes: Int) {
403 cancelSleepTimer()
404 _sleepTimerMinutesLeft.value = minutes
405 sleepTimerJob = safeLaunch {
406 var remaining = minutes
407 while (remaining > 0) {
408 delay(60_000L) // 1 minute
409 remaining--
410 _sleepTimerMinutesLeft.value = remaining
411 }
412 // Timer expired — stop playback
413 // design decision 2026-09-04: the sleep timer stops what is
414 // playing — the observed session while following.
415 if (rawCast.observing.value) rawCast.stop() else renderer.stop()
416 _sleepTimerMinutesLeft.value = null
417 }
418 }
419
420 fun cancelSleepTimer() {
421 sleepTimerJob?.cancel()
422 sleepTimerJob = null
423 _sleepTimerMinutesLeft.value = null
424 }
425
426 init {
427 // Apply saved volume on startup. Without this the renderer boots at 100%
428 // every time, regardless of how the user had it last session.
429 safeLaunch {
430 renderer.setVolume(settings.volumePercent.value)
431 }
432 }
433
434 fun playTracks(tracks: List<DidlItem.Track>, startIndex: Int) {
435 playGate.request { renderer.load(tracks, startIndex) }
436 }
437
438 fun enqueueTracks(tracks: List<DidlItem.Track>) {
439 safeLaunch { renderer.enqueue(tracks) }
440 }
441
442 fun playNextTracks(tracks: List<DidlItem.Track>) {
443 // Starts audio only when the queue is empty; gated in that case.
444 if (state.value.queue.isEmpty()) playGate.request { renderer.playNext(tracks) }
445 else safeLaunch { renderer.playNext(tracks) }
446 }
447
448 // Container variants — collect all tracks under the container first, then apply the
449 // same action. Runs off-screen via viewModelScope; deep containers (big genres)
450 // may take a few seconds. Mirrors BrowseViewModel's equivalents so the behaviour
451 // is identical regardless of where the container action is invoked from.
452
453 fun playContainer(udn: String, containerId: String) {
454 playGate.request {
455 collectContainerOrSay(udn, containerId)?.let { renderer.load(it, 0) }
456 }
457 }
458
459 fun enqueueContainer(udn: String, containerId: String) {
460 safeLaunch {
461 collectContainerOrSay(udn, containerId)?.let { renderer.enqueue(it) }
462 }
463 }
464
465 fun playNextContainer(udn: String, containerId: String) {
466 // Starts audio only when the queue is empty; gated in that case
467 // (Android parity, audit 2026-09-02).
468 val action: suspend () -> Unit = {
469 collectContainerOrSay(udn, containerId)?.let { renderer.playNext(it) }
470 }
471 if (state.value.queue.isEmpty()) playGate.request(action = action)
472 else safeLaunch { action() }
473 }
474
475 /** Android parity (2026-10-29): messages instead of silence — say why nothing
476 * happened rather than doing nothing. */
477 private suspend fun collectContainerOrSay(udn: String, containerId: String): List<DidlItem.Track>? =
478 when (val r = collector.collect(udn, containerId)) {
479 MediaCollector.Collected.Failed -> {
480 uiEvents.post("Couldn't load tracks from the server")
481 null
482 }
483 is MediaCollector.Collected.Tracks -> r.tracks.ifEmpty {
484 uiEvents.post("Nothing to play there")
485 null
486 }
487 }
488
489 /**
490 * Append [tracks] to the queue, then immediately jump playback to the first one.
491 * Non-destructive (anything already queued stays in place) but instantaneous — the user
492 * doesn't have to wait for the existing queue to drain. Complementary to [playNextTracks]
493 * (which inserts just-after-current but keeps current playing) and [playTracks] (which
494 * clobbers the queue entirely).
495 */
496 fun enqueueAndPlay(tracks: List<DidlItem.Track>) {
497 if (tracks.isEmpty()) return
498 playGate.request {
499 val startIndex = state.value.queue.size
500 renderer.enqueue(tracks)
501 renderer.skipToQueueItem(startIndex)
502 renderer.play()
503 }
504 }
505
506 /**
507 * Random tracks action — fetches [count] random tracks from the active server and
508 * starts playing them immediately. Matches BubbleUPnP's equivalent feature.
509 */
510 fun loadRandomTracks(udn: String, count: Int) {
511 playGate.request {
512 try {
513 when (val r = cd.randomTracks(udn, count)) {
514 is BrowseResult.Success -> {
515 val tracks = r.items.filterIsInstance<DidlItem.Track>()
516 if (tracks.isEmpty()) {
517 uiEvents.post("No tracks found on server.")
518 } else {
519 renderer.load(tracks, 0)
520 }
521 }
522 is BrowseResult.Failure -> {
523 // Raw server detail goes to the log; the screen gets plain words.
524 System.err.println("[Earhorn] random tracks failed: ${r.message}")
525 uiEvents.post(UserMessages.RANDOM_FAILED)
526 }
527 }
528 } catch (e: CancellationException) {
529 throw e
530 } catch (t: Throwable) {
531 // Last-resort catch. Anything that escapes here would otherwise propagate
532 // to the default JVM exception handler and produce a modal crash dialog.
533 // Log the stack for debugging, surface a clean message to the user.
534 t.printStackTrace()
535 uiEvents.post(UserMessages.RANDOM_FAILED)
536 }
537 }
538 }
539
540 // ── Transport commands ───────────────────────────────────────────
541 // When the user is following a CCA group, these need to act on the
542 // *observed* session, not the local renderer. Each method checks
543 // `rawCast.observing.value` and dispatches accordingly. Queue editing
544 // operations (skipToQueueItem, removeFromQueue, moveInQueue, clearQueue)
545 // stay on the local renderer — the queue on the cast group belongs to
546 // whoever is driving it (the phone), and editing it from here isn't
547 // a thing the cast wire protocol exposes.
548
549 fun togglePlay() {
550 safeLaunch {
551 if (rawCast.observing.value) {
552 if (tvState.value.isPlaying) rawCast.pause() else rawCast.play()
553 } else {
554 if (state.value.isPlaying) renderer.pause() else renderer.play()
555 }
556 }
557 }
558
559 /** Set absolute volume (0-100%). While observing, sends SET_VOLUME to the
560 * cast device (controls the CCA group's volume); otherwise sets local
561 * renderer volume. Persisted to SettingsStore in the local case so next
562 * launch starts at the user's last level — we don't persist while
563 * observing because that volume belongs to a different device's session.
564 * Likewise not while a UPnP or Cast renderer is active (review 1 F24): the
565 * saved value seeds the LOCAL player at launch. */
566 fun setVolume(percent: Int) = safeLaunch {
567 if (rawCast.observing.value) {
568 rawCast.setVolume(percent)
569 } else {
570 renderer.setVolume(percent)
571 if (activeRemoteUdn.value == null) settings.setVolumePercent(percent)
572 }
573 }
574
575 /** Bump volume by one configured scroll step. [direction] is +1 (louder) or -1
576 * (quieter); the step size comes from SettingsStore.volumeScrollStep. Clamps at
577 * 0 and 100 in the renderer so scrolling past the bounds is a no-op. Persists
578 * the resulting value (local only — see [setVolume]). */
579 fun bumpVolume(direction: Int) = safeLaunch {
580 val step = settings.volumeScrollStep.value
581 val delta = if (direction >= 0) step else -step
582 if (rawCast.observing.value) {
583 val now = tvState.value.volumePercent
584 val next = (now + delta).coerceIn(0, 100)
585 rawCast.setVolume(next)
586 } else {
587 val now = state.value.volumePercent
588 val next = (now + delta).coerceIn(0, 100)
589 renderer.setVolume(next)
590 if (activeRemoteUdn.value == null) settings.setVolumePercent(next)
591 }
592 }
593
594 fun next() = safeLaunch {
595 if (rawCast.observing.value) rawCast.next() else renderer.next()
596 }
597 fun previous() = safeLaunch {
598 if (rawCast.observing.value) rawCast.previous() else renderer.previous()
599 }
600 fun stop() = safeLaunch {
601 if (rawCast.observing.value) rawCast.stop() else renderer.stop()
602 }
603 fun seekTo(position: Duration) = safeLaunch {
604 if (rawCast.observing.value) {
605 // Nothing to seek on a live stream; the observer refuses it too.
606 if (observedIsLiveStream.value) return@safeLaunch
607 rawCast.seek(position)
608 } else {
609 renderer.seekTo(position)
610 }
611 }
612
613 // Queue manipulation.
614 //
615 // Jump routes by state like the transport commands: while following, the
616 // queue on screen IS the receiver's queue, and clicking a row sends a
617 // position-only QUEUE_UPDATE (the one queue operation a follower may
618 // perform — the driving sender's index tracker follows via its status
619 // callbacks). Editing operations (remove/move/clear) stay local-only BY
620 // DESIGN: the driving sender's local queue is authoritative and full-
621 // reloads the receiver on its own edits, so a follower edit would be
622 // silently clobbered and desync the sender in the interim. The UI hides
623 // those affordances in follower mode.
624 fun skipToQueueItem(index: Int) = safeLaunch {
625 if (rawCast.observing.value) rawCast.jumpToQueueItem(index)
626 else renderer.skipToQueueItem(index)
627 }
628 fun removeFromQueue(index: Int) = safeLaunch { renderer.removeFromQueue(index) }
629 fun moveInQueue(from: Int, to: Int) = safeLaunch { renderer.moveInQueue(from, to) }
630 fun clearQueue() = safeLaunch { renderer.clearQueue() }
631
632 /** Cycle OFF → ALL → ONE → OFF. Matches the three-state repeat control users expect
633 * from decades of media apps. */
634 fun cycleRepeat() = safeLaunch {
635 val next = when (tvState.value.repeatMode) {
636 RepeatMode.OFF -> RepeatMode.ALL
637 RepeatMode.ALL -> RepeatMode.ONE
638 RepeatMode.ONE -> RepeatMode.OFF
639 }
640 // design decision 2026-09-04: while following, Shuffle and Repeat
641 // act on the group like every other transport control.
642 if (rawCast.observing.value) rawCast.setRepeatMode(next) else renderer.setRepeatMode(next)
643 }
644
645 fun toggleShuffle() = safeLaunch {
646 val on = !tvState.value.shuffleEnabled
647 if (rawCast.observing.value) rawCast.setShuffle(on) else renderer.setShuffle(on)
648 }
649
650 /**
651 * Write the current queue to [destination] as an Extended M3U playlist.
652 * The queue itself isn't cleared — save is non-destructive. No-op on an empty queue.
653 */
654 fun saveQueueTo(destination: File) = safeLaunch {
655 val tracks = state.value.queue
656 if (tracks.isEmpty()) {
657 uiEvents.post("Queue is empty — nothing to save.")
658 return@safeLaunch
659 }
660 val ok = playlistWriter.write(destination, tracks)
661 uiEvents.post(
662 if (ok) "Saved ${tracks.size} tracks to ${destination.name}"
663 else "Couldn't write playlist to ${destination.name}"
664 )
665 }
666
667 /**
668 * Load the contents of [file] (a .m3u) into the renderer, replacing the current
669 * queue and starting playback from the first track. No-op if the file is missing,
670 * malformed, or has no playable entries.
671 */
672 fun loadFromPlaylist(file: File) = playGate.request {
673 val tracks = playlistStore.load(file)
674 if (tracks.isEmpty()) {
675 uiEvents.post("Couldn't read any tracks from ${file.name}")
676 return@request
677 }
678 renderer.load(tracks, 0)
679 uiEvents.post("Loaded ${tracks.size} tracks from ${file.nameWithoutExtension}")
680 }
681
682 /**
683 * Save the current queue to the playlist library with an auto-generated name.
684 * No dialog — for users who just want to bookmark what they're listening to
685 * with one click. The name is derived from the queue contents: if all tracks
686 * share an artist+album, that's the name; if one artist dominates, "Artist mix";
687 * else a timestamp. [M3uPlaylistStore] adds "(2)", "(3)" etc. on filename
688 * collision so repeated quick-saves of similar queues don't clobber each other.
689 */
690 fun quickSaveToLibrary() = safeLaunch {
691 val tracks = state.value.queue
692 if (tracks.isEmpty()) {
693 uiEvents.post("Queue is empty — nothing to save.")
694 return@safeLaunch
695 }
696 val written = playlistStore.saveToLibrary(deriveAutoName(tracks), tracks)
697 uiEvents.post(
698 if (written != null) "Saved to library as ${written.nameWithoutExtension}"
699 else "Quick-save failed"
700 )
701 }
702
703 private fun deriveAutoName(tracks: List<DidlItem.Track>): String {
704 val first = tracks.first()
705 val artists = tracks.mapNotNull { it.artist ?: it.albumArtist }.toSet()
706 val albums = tracks.mapNotNull { it.album }.toSet()
707
708 // Single-album queue — probably the user queued up a whole record.
709 if (artists.size == 1 && albums.size == 1) {
710 return "${first.artist ?: first.albumArtist ?: "Unknown"} - ${first.album}"
711 }
712 // Single-artist multi-album — likely a discography-ish mix.
713 if (artists.size == 1) {
714 return "${first.artist ?: first.albumArtist ?: "Unknown"} mix"
715 }
716 // Dominated by one artist (>60%) — call it theirs.
717 val artistCounts = tracks.mapNotNull { it.artist ?: it.albumArtist }
718 .groupingBy { it }
719 .eachCount()
720 val (dominant, count) = artistCounts.maxByOrNull { it.value } ?: return ""
721 if (count * 10 > tracks.size * 6) {
722 return "$dominant mix"
723 }
724 // Otherwise fall through to the store's timestamp default by returning blank.
725 return ""
726 }
727
728
729 /**
730 * Load an .m3u playlist [file] from disk, replace the current queue with its
731 * tracks, and start playing from the first track. Empty/malformed playlists
732 * post a UI toast and leave the current queue alone.
733 */
734 fun loadPlaylist(file: java.io.File) = playGate.request {
735 val tracks = playlistStore.load(file)
736 if (tracks.isEmpty()) {
737 uiEvents.post("Playlist was empty or couldn't be read.")
738 return@request
739 }
740 renderer.load(tracks, startIndex = 0, autoplay = true)
741 uiEvents.post("Loaded ${tracks.size} track${if (tracks.size == 1) "" else "s"}")
742 }
743
744 /**
745 * Load an .m3u playlist [file] and append its tracks to the end of the current
746 * queue. Useful for building up a listening session from multiple saved lists
747 * without losing what's already queued.
748 */
749 fun appendPlaylist(file: java.io.File) = safeLaunch {
750 val tracks = playlistStore.load(file)
751 if (tracks.isEmpty()) {
752 uiEvents.post("Playlist was empty or couldn't be read.")
753 return@safeLaunch
754 }
755 renderer.enqueue(tracks)
756 uiEvents.post("Appended ${tracks.size} track${if (tracks.size == 1) "" else "s"}")
757 }
758
759 /** Returns the list of saved library playlists, newest first. */
760 suspend fun listLibraryPlaylists(): List<PlaylistFile> = playlistStore.listLibrary()
761
762 /**
763 * Restore the last persisted session: load its queue at its stored index, seek
764 * to the stored position, leave paused. Called once from [restoreSessionIfAny]
765 * at startup. If nothing is saved, no-op.
766 *
767 * Playback state is NOT restored (always paused). Users opening the app
768 * shouldn't be surprised by sudden audio — hitting Space to resume is one
769 * keystroke away and makes the resume intentional.
770 */
771 fun restoreSessionIfAny() = safeLaunch {
772 val session = sessionStore.load() ?: return@safeLaunch
773 if (session.queue.isEmpty()) return@safeLaunch
774 val safeIndex = session.queueIndex.coerceIn(0, session.queue.lastIndex)
775 // autoplay=false preserves the intent: restore where you left off, but
776 // don't start blasting audio the moment the app opens. User hits Space
777 // to resume if they want to.
778 renderer.load(session.queue, safeIndex, autoplay = false)
779 // Seek best-effort. libvlc sometimes refuses seek before the stream is
780 // buffered; a small delay lets the first few packets arrive.
781 kotlinx.coroutines.delay(300)
782 if (session.position.inWholeMilliseconds > 0) {
783 runCatching { renderer.seekTo(session.position) }
784 }
785 }
786
787 /**
788 * Snapshot the current playback state and persist it. Called on graceful shutdown
789 * from Main.kt's onQuit handler. Empty queue → wipes the saved session so a
790 * fresh launch doesn't restore something the user intentionally cleared.
791 *
792 * This is a suspend function rather than a fire-and-forget launch because
793 * the quit path needs to know when the write is done before calling
794 * [exitApplication]. The caller runs it inside runBlocking.
795 */
796 private companion object {
797 /** Android parity (2026-09-03) (Android SESSION_DEAD_DEBOUNCE_MS). */
798 const val SESSION_DEAD_DEBOUNCE_MS = 20_000L
799 /** Bound on one enrichment search; the display never waits on it. */
800 const val ENRICH_TIMEOUT_MS = 5_000L
801 const val ENRICH_CACHE_SIZE = 256
802 }
803
804 suspend fun snapshotSession() {
805 val s = state.value
806 sessionStore.save(
807 StoredSession(
808 queue = s.queue,
809 queueIndex = s.queueIndex,
810 position = s.position,
811 )
812 )
813 }
814}