package dev.stave.ui.nowplaying
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.stave.cast.raw.CastObserverApi
import dev.stave.ui.safeLaunch
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.PlaybackState
import dev.stave.domain.renderer.Renderer
import dev.stave.domain.renderer.RepeatMode
import dev.stave.data.SessionStore
import dev.stave.data.SettingsStore
import dev.stave.data.StoredSession
import dev.stave.playlist.M3uPlaylistStore
import dev.stave.playlist.M3uPlaylistWriter
import dev.stave.playlist.PlaylistFile
import dev.stave.upnp.BrowseResult
import dev.stave.upnp.ContentDirectoryClient
import dev.stave.upnp.MediaCollector
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import dev.stave.ui.copy.UserMessages
import dev.stave.ui.events.UiEvents
import java.io.File
import kotlin.time.Duration
class NowPlayingViewModel(
private val renderer: Renderer,
private val rawCast: CastObserverApi,
private val cd: ContentDirectoryClient,
private val collector: MediaCollector,
private val playlistWriter: M3uPlaylistWriter,
private val playlistStore: M3uPlaylistStore,
private val sessionStore: SessionStore,
private val settings: SettingsStore,
private val uiEvents: UiEvents,
/** "Stop following and play here?" — every local audio starter goes
* through it (design decision, 2026-09-04). */
val playGate: dev.stave.playback.LocalPlaybackGate,
) : ViewModel() {
/** Local-renderer state. The UI should rarely read this directly — use
* [tvState] / [tvTrack] instead, which transparently switch to the
* observed Cast session's state while the user is following a CCA group.
* This field is still exposed for code paths that operate on the *local*
* queue specifically (queue editing, M3U save, etc.). */
val state: StateFlow<PlaybackState> = renderer.state
/** True while following a Cast session. UI surfaces that must behave
* differently in follower mode (read-only queue, hidden edit
* affordances) gate on this rather than re-deriving it, so the swap
* happens at one chokepoint. */
val observing: StateFlow<Boolean> = rawCast.observing
/** The renderer actually in use: a UPnP UDN, `cast:<id>`, or null while
* this computer plays (Android parity, 2026-10-29). The saved choice can name
* a renderer that isn't attached (not discovered yet, or dropped out), so
* "Playing on X" must key on this, not on the setting. */
val activeRemoteUdn: StateFlow<String?> =
(renderer as? dev.stave.upnp.renderer.RendererCoordinator)?.activeRemoteUdn
?: MutableStateFlow<String?>(null).asStateFlow()
// ── Observer-mode state surface ──────────────────────────────────
// While the user is following a Chromecast group, what's audibly playing
// on the group has nothing to do with the local renderer's queue — it's
// whatever some other sender (typically the phone) loaded. The UI needs
// to render THAT, not the local-renderer state. Three derived flows make
// it work:
//
// tvState : PlaybackState ← rawCast.playbackState OR renderer.state
// tvTrack : DidlItem.Track? ← following: null when the session is dead,
// else enriched ∨ observed ∨ lastObserved;
// not following: local
// observedTrackEnriched: DidlItem.Track? ← observed track + UPnP technical fields
//
// [tvState] / [tvTrack] are what NowPlayingPane, MprisIntegration, and
// anything else that renders "what's playing now" should bind to.
/** Debounced liveness of the followed session (Android F2 on the glass,
* Android parity, 2026-09-03): believed live at once, dead only after
* [SESSION_DEAD_DEBOUNCE_MS], because the raw flag flaps to false for
* under a second at every track boundary. False when not following. */
@OptIn(ExperimentalCoroutinesApi::class)
val observedSessionLive: StateFlow<Boolean> =
combine(rawCast.observing, rawCast.sessionLive) { obs, live -> obs to live }
.transformLatest { (obs, live) ->
when {
!obs -> emit(false)
live -> emit(true)
else -> { delay(SESSION_DEAD_DEBOUNCE_MS); emit(false) }
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Has this follow ever been live? Picks "Waiting for…" over
* "Connecting to…" once a session has ended. */
private val sawLive = MutableStateFlow(false)
/** Following, not live, for [SESSION_DEAD_DEBOUNCE_MS] (Android 0.4.23:
* an idle device reads "Waiting for…", not "Connecting to…" forever). */
@OptIn(ExperimentalCoroutinesApi::class)
private val waitedLong: StateFlow<Boolean> =
combine(rawCast.observing, observedSessionLive) { obs, live -> obs && !live }
.transformLatest { waiting ->
emit(false)
if (waiting) { delay(SESSION_DEAD_DEBOUNCE_MS); emit(true) }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Empty-state line while following and nothing can be shown:
* "Connecting to X…" until the first live report, "Waiting for X…"
* once a session has ended or nothing has played for 20 s. Null when
* not following or the session is live (the UI then says its own
* "Nothing playing"). */
val followEmptyLabel: StateFlow<String?> = combine(
rawCast.observing,
observedSessionLive,
sawLive,
waitedLong,
rawCast.connectedDeviceName,
) { obs, live, saw, waited, name ->
val who = name ?: "device"
when {
!obs || live -> null
saw || waited -> "Waiting for $who…"
else -> "Connecting to $who…"
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** The followed session is a live stream (no duration, e.g. radio):
* seeking is refused and the progress bar has nothing to scrub. */
val observedIsLiveStream: StateFlow<Boolean> = combine(
rawCast.observing,
rawCast.playbackState,
) { obs, st -> obs && st.current != null && st.duration <= Duration.ZERO }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/** Persists the last non-null observed track. Bridges brief MEDIA_STATUS
* gaps between tracks where the Cast receiver emits a status without a
* media block (PAUSE/SEEK refreshes) or with an empty status array
* (the millisecond between two tracks). Without this, [tvTrack] would
* fall back to the local renderer's stale track, which has a different
* `albumArtUri`, producing a flash to the wrong art between tracks.
*
* Note: [RawCastClient.handleMediaStatus] already preserves track
* metadata across no-media-block updates, so this flow's main job is
* catching the empty-status-array case and bridging brief disconnect
* blips. Belt-and-suspenders. */
private val _lastObservedTrack = MutableStateFlow<DidlItem.Track?>(null)
init {
// Drive [_lastObservedTrack] from the observer's stream. When observing
// a live session and current != null → store it. When NOT observing,
// or the session is dead → clear it, so a dead song can't come back
// (Android obsSticky). The Android version did this in a
// composable-side LaunchedEffect; per the porting note we hoist it to
// the VM so it's a single source of truth and MPRIS can read it too.
viewModelScope.launch {
combine(rawCast.observing, observedSessionLive, rawCast.playbackState) { obs, live, st ->
Triple(obs, live, st.current)
}.collect { (obs, live, cur) ->
when {
!obs || !live -> _lastObservedTrack.value = null
cur != null -> _lastObservedTrack.value = cur
// live && cur == null: keep the previous value to bridge the gap
}
}
}
viewModelScope.launch {
combine(rawCast.observing, observedSessionLive) { obs, live -> obs to live }
.collect { (obs, live) ->
if (!obs) sawLive.value = false else if (live) sawLive.value = true
}
}
// Following while local music plays would leave both audible, with
// the local controls out of reach (the UI swaps to the observed
// session). Pause local playback the moment following starts —
// ported from Android, adopted for desktop.
viewModelScope.launch {
rawCast.observing.collect { obs ->
// Only THIS computer's audio: `renderer` is the coordinator, so
// when the desktop is casting or driving a UPnP amp, pausing it
// would stop the cast group (or the amp) — never what Follow means.
// isBuffering too: a track still opening would start anyway.
val local = activeRemoteUdn.value == null
val sounding = state.value.isPlaying || state.value.isBuffering
if (obs && local && sounding) {
try {
renderer.pause()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println("[Earhorn] couldn't pause local playback on follow: ${e.message}")
}
}
}
}
}
// ── Observed-track metadata enrichment ──────────────────────────
// The Cast wire protocol carries title/artist/album/art but NOT year,
// mime type, sample frequency, bit depth, or track number — those are
// UPnP-side facts the receiver doesn't include in MEDIA_STATUS. We
// search the user's preferred UPnP server by (title, artist, album)
// when the observed track changes, cache the result by composite key,
// and merge the technical fields back into the displayed track. Cache
// is VM-lifetime — a 10-track CCA queue produces 10 search calls total,
// not 10/sec. Lookup failure leaves the raw observed track unchanged
// (graceful degrade).
/** LRU, bounded: the VM lives as long as the app. Only touched from the
* enrichment transformLatest, which runs one block at a time. */
private val enrichmentCache = object : LinkedHashMap<String, DidlItem.Track>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, DidlItem.Track>?): Boolean =
size > ENRICH_CACHE_SIZE
}
/** Composite key. Includes album because two tracks by the same artist
* with the same title (live + studio cuts, e.g.) can have different
* technical metadata and we want both to be reachable. */
private fun enrichKey(t: DidlItem.Track): String =
"${t.title.trim().lowercase()}|" +
"${(t.artist ?: "").trim().lowercase()}|" +
"${(t.album ?: "").trim().lowercase()}"
/** Inputs that drive enrichment. Combined so we recompute only on
* observing-flag / observed-track / preferred-server changes — NOT on
* every position tick. The track distinct-key is conservative on
* purpose: title+album differing is rare without id changing too, but
* the cost of an extra search is one UPnP call. */
private val enrichmentInputs =
combine(
rawCast.observing,
rawCast.playbackState.map { it.current }
.distinctUntilChanged { a, b ->
a?.id == b?.id && a?.title == b?.title && a?.album == b?.album
},
settings.preferredServerUdn,
) { observing, observed, udn -> Triple(observing, observed, udn) }
/** The UPnP match for the current observed track, tagged with its
* [enrichKey]. Cleared the moment the track changes, so a slow or
* hanging search can never leave the PREVIOUS track's match in place
* (the old mapLatest/stateIn kept showing the old song until the
* search returned). Bounded by [ENRICH_TIMEOUT_MS]. */
@OptIn(ExperimentalCoroutinesApi::class)
private val enrichmentMatch: StateFlow<Pair<String, DidlItem.Track>?> = enrichmentInputs
.transformLatest { (observing, observed, udn) ->
if (!observing || observed == null || udn == null) {
emit(null)
return@transformLatest
}
val key = enrichKey(observed)
enrichmentCache[key]?.let {
emit(key to it)
return@transformLatest
}
emit(null)
val match = try {
withTimeoutOrNull(ENRICH_TIMEOUT_MS) { lookupLocal(udn, observed) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
if (match != null) {
enrichmentCache[key] = match
emit(key to match)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** Observed track with UPnP-side technical metadata merged in when a
* match for THIS track exists; the raw observed track otherwise. Null
* when not observing or nothing is reported. Built from the live
* observed track every time, so art or fields that arrive in a later
* status are never hidden behind an older snapshot. */
val observedTrackEnriched: StateFlow<DidlItem.Track?> = combine(
rawCast.observing,
rawCast.playbackState.map { it.current },
enrichmentMatch,
) { observing, observed, match ->
when {
!observing || observed == null -> null
match != null && match.first == enrichKey(observed) -> mergeMetadata(observed, match.second)
else -> observed
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** Effective playback state for "what's playing now" rendering. Flips
* between the observed CCA session and the local renderer based on
* [CastObserverApi.observing]. NowPlayingPane, MprisIntegration, and
* anything else displaying current-playback state should bind here. */
@OptIn(ExperimentalCoroutinesApi::class)
val tvState: StateFlow<PlaybackState> = rawCast.observing
.flatMapLatest { obs ->
if (obs) rawCast.playbackState else renderer.state
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = renderer.state.value,
)
/** Effective "currently displayed track".
* - Following, session dead (after the debounce): null, so every surface
* shows the empty state with [followEmptyLabel] instead of painting
* the dead song (Android F2).
* - Following, live: [observedTrackEnriched] (the observed track with
* UPnP fields merged), bridged by [_lastObservedTrack] across the
* brief gaps between MEDIA_STATUS broadcasts.
* - Not following: the local renderer's track.
* While following there is deliberately NO fallback to the local track:
* it painted the idle local queue's song as if the group were playing it. */
val tvTrack: StateFlow<DidlItem.Track?> = combine(
rawCast.observing,
observedSessionLive,
observedTrackEnriched,
_lastObservedTrack,
renderer.state.map { it.current },
) { obs, live, enriched, lastObs, local ->
when {
!obs -> local
!live -> null
else -> enriched ?: lastObs
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private suspend fun lookupLocal(udn: String, observed: DidlItem.Track): DidlItem.Track? {
val title = observed.title.takeIf { it.isNotBlank() } ?: return null
val artist = observed.artist?.takeIf { it.isNotBlank() }
val criteria = buildString {
append("upnp:class derivedfrom \"object.item.audioItem\"")
append(" and dc:title = \"${escapeForCriteria(title)}\"")
if (artist != null) append(" and upnp:artist = \"${escapeForCriteria(artist)}\"")
}
return when (val r = cd.search(udn, criteria, maxResults = 5L)) {
is BrowseResult.Success -> r.items.filterIsInstance<DidlItem.Track>().let { hits ->
val obsAlbum = observed.album?.trim()?.lowercase()
if (obsAlbum != null) hits.firstOrNull { it.album?.trim()?.lowercase() == obsAlbum }
?: hits.firstOrNull()
else hits.firstOrNull()
}
is BrowseResult.Failure -> null
}
}
/** Same escape rules as the private one in ContentDirectoryClient — kept
* here so this VM doesn't reach across the package boundary. */
private fun escapeForCriteria(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
private fun mergeMetadata(observed: DidlItem.Track, local: DidlItem.Track): DidlItem.Track =
observed.copy(
// Keep observed identity (id, title, artist, album, artUri) so
// anything keyed on those still resolves to the observed entity.
// Pull in technical/derived fields from local.
date = observed.date ?: local.date,
trackNumber = observed.trackNumber ?: local.trackNumber,
discNumber = observed.discNumber ?: local.discNumber,
genre = observed.genre ?: local.genre,
composer = observed.composer ?: local.composer,
mimeType = observed.mimeType ?: local.mimeType,
sampleFrequency = observed.sampleFrequency ?: local.sampleFrequency,
bitsPerSample = observed.bitsPerSample ?: local.bitsPerSample,
channels = observed.channels ?: local.channels,
bitrate = observed.bitrate ?: local.bitrate,
// parentId from local enables "More tracks from this album"
// when observing — previously empty for raw-cast tracks.
parentId = local.parentId.ifBlank { observed.parentId },
)
// ── Sleep timer ─────────────────────────────────────────────────
// Countdown runs in viewModelScope. UI observes [sleepTimerMinutesLeft]:
// null = no timer, non-null = minutes remaining (updated every 60s).
// When the timer reaches zero, playback stops automatically.
private val _sleepTimerMinutesLeft = MutableStateFlow<Int?>(null)
val sleepTimerMinutesLeft: StateFlow<Int?> = _sleepTimerMinutesLeft.asStateFlow()
private var sleepTimerJob: Job? = null
fun startSleepTimer(minutes: Int) {
cancelSleepTimer()
_sleepTimerMinutesLeft.value = minutes
sleepTimerJob = safeLaunch {
var remaining = minutes
while (remaining > 0) {
delay(60_000L) // 1 minute
remaining--
_sleepTimerMinutesLeft.value = remaining
}
// Timer expired — stop playback
// design decision 2026-09-04: the sleep timer stops what is
// playing — the observed session while following.
if (rawCast.observing.value) rawCast.stop() else renderer.stop()
_sleepTimerMinutesLeft.value = null
}
}
fun cancelSleepTimer() {
sleepTimerJob?.cancel()
sleepTimerJob = null
_sleepTimerMinutesLeft.value = null
}
init {
// Apply saved volume on startup. Without this the renderer boots at 100%
// every time, regardless of how the user had it last session.
safeLaunch {
renderer.setVolume(settings.volumePercent.value)
}
}
fun playTracks(tracks: List<DidlItem.Track>, startIndex: Int) {
playGate.request { renderer.load(tracks, startIndex) }
}
fun enqueueTracks(tracks: List<DidlItem.Track>) {
safeLaunch { renderer.enqueue(tracks) }
}
fun playNextTracks(tracks: List<DidlItem.Track>) {
// Starts audio only when the queue is empty; gated in that case.
if (state.value.queue.isEmpty()) playGate.request { renderer.playNext(tracks) }
else safeLaunch { renderer.playNext(tracks) }
}
// Container variants — collect all tracks under the container first, then apply the
// same action. Runs off-screen via viewModelScope; deep containers (big genres)
// may take a few seconds. Mirrors BrowseViewModel's equivalents so the behaviour
// is identical regardless of where the container action is invoked from.
fun playContainer(udn: String, containerId: String) {
playGate.request {
collectContainerOrSay(udn, containerId)?.let { renderer.load(it, 0) }
}
}
fun enqueueContainer(udn: String, containerId: String) {
safeLaunch {
collectContainerOrSay(udn, containerId)?.let { renderer.enqueue(it) }
}
}
fun playNextContainer(udn: String, containerId: String) {
// Starts audio only when the queue is empty; gated in that case
// (Android parity, audit 2026-09-02).
val action: suspend () -> Unit = {
collectContainerOrSay(udn, containerId)?.let { renderer.playNext(it) }
}
if (state.value.queue.isEmpty()) playGate.request(action = action)
else safeLaunch { action() }
}
/** Android parity (2026-10-29): messages instead of silence — say why nothing
* happened rather than doing nothing. */
private suspend fun collectContainerOrSay(udn: String, containerId: String): List<DidlItem.Track>? =
when (val r = collector.collect(udn, containerId)) {
MediaCollector.Collected.Failed -> {
uiEvents.post("Couldn't load tracks from the server")
null
}
is MediaCollector.Collected.Tracks -> r.tracks.ifEmpty {
uiEvents.post("Nothing to play there")
null
}
}
/**
* Append [tracks] to the queue, then immediately jump playback to the first one.
* Non-destructive (anything already queued stays in place) but instantaneous — the user
* doesn't have to wait for the existing queue to drain. Complementary to [playNextTracks]
* (which inserts just-after-current but keeps current playing) and [playTracks] (which
* clobbers the queue entirely).
*/
fun enqueueAndPlay(tracks: List<DidlItem.Track>) {
if (tracks.isEmpty()) return
playGate.request {
val startIndex = state.value.queue.size
renderer.enqueue(tracks)
renderer.skipToQueueItem(startIndex)
renderer.play()
}
}
/**
* Random tracks action — fetches [count] random tracks from the active server and
* starts playing them immediately. Matches BubbleUPnP's equivalent feature.
*/
fun loadRandomTracks(udn: String, count: Int) {
playGate.request {
try {
when (val r = cd.randomTracks(udn, count)) {
is BrowseResult.Success -> {
val tracks = r.items.filterIsInstance<DidlItem.Track>()
if (tracks.isEmpty()) {
uiEvents.post("No tracks found on server.")
} else {
renderer.load(tracks, 0)
}
}
is BrowseResult.Failure -> {
// Raw server detail goes to the log; the screen gets plain words.
System.err.println("[Earhorn] random tracks failed: ${r.message}")
uiEvents.post(UserMessages.RANDOM_FAILED)
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
// Last-resort catch. Anything that escapes here would otherwise propagate
// to the default JVM exception handler and produce a modal crash dialog.
// Log the stack for debugging, surface a clean message to the user.
t.printStackTrace()
uiEvents.post(UserMessages.RANDOM_FAILED)
}
}
}
// ── Transport commands ───────────────────────────────────────────
// When the user is following a CCA group, these need to act on the
// *observed* session, not the local renderer. Each method checks
// `rawCast.observing.value` and dispatches accordingly. Queue editing
// operations (skipToQueueItem, removeFromQueue, moveInQueue, clearQueue)
// stay on the local renderer — the queue on the cast group belongs to
// whoever is driving it (the phone), and editing it from here isn't
// a thing the cast wire protocol exposes.
fun togglePlay() {
safeLaunch {
if (rawCast.observing.value) {
if (tvState.value.isPlaying) rawCast.pause() else rawCast.play()
} else {
if (state.value.isPlaying) renderer.pause() else renderer.play()
}
}
}
/** Set absolute volume (0-100%). While observing, sends SET_VOLUME to the
* cast device (controls the CCA group's volume); otherwise sets local
* renderer volume. Persisted to SettingsStore in the local case so next
* launch starts at the user's last level — we don't persist while
* observing because that volume belongs to a different device's session.
* Likewise not while a UPnP or Cast renderer is active (review 1 F24): the
* saved value seeds the LOCAL player at launch. */
fun setVolume(percent: Int) = safeLaunch {
if (rawCast.observing.value) {
rawCast.setVolume(percent)
} else {
renderer.setVolume(percent)
if (activeRemoteUdn.value == null) settings.setVolumePercent(percent)
}
}
/** Bump volume by one configured scroll step. [direction] is +1 (louder) or -1
* (quieter); the step size comes from SettingsStore.volumeScrollStep. Clamps at
* 0 and 100 in the renderer so scrolling past the bounds is a no-op. Persists
* the resulting value (local only — see [setVolume]). */
fun bumpVolume(direction: Int) = safeLaunch {
val step = settings.volumeScrollStep.value
val delta = if (direction >= 0) step else -step
if (rawCast.observing.value) {
val now = tvState.value.volumePercent
val next = (now + delta).coerceIn(0, 100)
rawCast.setVolume(next)
} else {
val now = state.value.volumePercent
val next = (now + delta).coerceIn(0, 100)
renderer.setVolume(next)
if (activeRemoteUdn.value == null) settings.setVolumePercent(next)
}
}
fun next() = safeLaunch {
if (rawCast.observing.value) rawCast.next() else renderer.next()
}
fun previous() = safeLaunch {
if (rawCast.observing.value) rawCast.previous() else renderer.previous()
}
fun stop() = safeLaunch {
if (rawCast.observing.value) rawCast.stop() else renderer.stop()
}
fun seekTo(position: Duration) = safeLaunch {
if (rawCast.observing.value) {
// Nothing to seek on a live stream; the observer refuses it too.
if (observedIsLiveStream.value) return@safeLaunch
rawCast.seek(position)
} else {
renderer.seekTo(position)
}
}
// Queue manipulation.
//
// Jump routes by state like the transport commands: while following, the
// queue on screen IS the receiver's queue, and clicking a row sends a
// position-only QUEUE_UPDATE (the one queue operation a follower may
// perform — the driving sender's index tracker follows via its status
// callbacks). Editing operations (remove/move/clear) stay local-only BY
// DESIGN: the driving sender's local queue is authoritative and full-
// reloads the receiver on its own edits, so a follower edit would be
// silently clobbered and desync the sender in the interim. The UI hides
// those affordances in follower mode.
fun skipToQueueItem(index: Int) = safeLaunch {
if (rawCast.observing.value) rawCast.jumpToQueueItem(index)
else renderer.skipToQueueItem(index)
}
fun removeFromQueue(index: Int) = safeLaunch { renderer.removeFromQueue(index) }
fun moveInQueue(from: Int, to: Int) = safeLaunch { renderer.moveInQueue(from, to) }
fun clearQueue() = safeLaunch { renderer.clearQueue() }
/** Cycle OFF → ALL → ONE → OFF. Matches the three-state repeat control users expect
* from decades of media apps. */
fun cycleRepeat() = safeLaunch {
val next = when (tvState.value.repeatMode) {
RepeatMode.OFF -> RepeatMode.ALL
RepeatMode.ALL -> RepeatMode.ONE
RepeatMode.ONE -> RepeatMode.OFF
}
// design decision 2026-09-04: while following, Shuffle and Repeat
// act on the group like every other transport control.
if (rawCast.observing.value) rawCast.setRepeatMode(next) else renderer.setRepeatMode(next)
}
fun toggleShuffle() = safeLaunch {
val on = !tvState.value.shuffleEnabled
if (rawCast.observing.value) rawCast.setShuffle(on) else renderer.setShuffle(on)
}
/**
* Write the current queue to [destination] as an Extended M3U playlist.
* The queue itself isn't cleared — save is non-destructive. No-op on an empty queue.
*/
fun saveQueueTo(destination: File) = safeLaunch {
val tracks = state.value.queue
if (tracks.isEmpty()) {
uiEvents.post("Queue is empty — nothing to save.")
return@safeLaunch
}
val ok = playlistWriter.write(destination, tracks)
uiEvents.post(
if (ok) "Saved ${tracks.size} tracks to ${destination.name}"
else "Couldn't write playlist to ${destination.name}"
)
}
/**
* Load the contents of [file] (a .m3u) into the renderer, replacing the current
* queue and starting playback from the first track. No-op if the file is missing,
* malformed, or has no playable entries.
*/
fun loadFromPlaylist(file: File) = playGate.request {
val tracks = playlistStore.load(file)
if (tracks.isEmpty()) {
uiEvents.post("Couldn't read any tracks from ${file.name}")
return@request
}
renderer.load(tracks, 0)
uiEvents.post("Loaded ${tracks.size} tracks from ${file.nameWithoutExtension}")
}
/**
* Save the current queue to the playlist library with an auto-generated name.
* No dialog — for users who just want to bookmark what they're listening to
* with one click. The name is derived from the queue contents: if all tracks
* share an artist+album, that's the name; if one artist dominates, "Artist mix";
* else a timestamp. [M3uPlaylistStore] adds "(2)", "(3)" etc. on filename
* collision so repeated quick-saves of similar queues don't clobber each other.
*/
fun quickSaveToLibrary() = safeLaunch {
val tracks = state.value.queue
if (tracks.isEmpty()) {
uiEvents.post("Queue is empty — nothing to save.")
return@safeLaunch
}
val written = playlistStore.saveToLibrary(deriveAutoName(tracks), tracks)
uiEvents.post(
if (written != null) "Saved to library as ${written.nameWithoutExtension}"
else "Quick-save failed"
)
}
private fun deriveAutoName(tracks: List<DidlItem.Track>): String {
val first = tracks.first()
val artists = tracks.mapNotNull { it.artist ?: it.albumArtist }.toSet()
val albums = tracks.mapNotNull { it.album }.toSet()
// Single-album queue — probably the user queued up a whole record.
if (artists.size == 1 && albums.size == 1) {
return "${first.artist ?: first.albumArtist ?: "Unknown"} - ${first.album}"
}
// Single-artist multi-album — likely a discography-ish mix.
if (artists.size == 1) {
return "${first.artist ?: first.albumArtist ?: "Unknown"} mix"
}
// Dominated by one artist (>60%) — call it theirs.
val artistCounts = tracks.mapNotNull { it.artist ?: it.albumArtist }
.groupingBy { it }
.eachCount()
val (dominant, count) = artistCounts.maxByOrNull { it.value } ?: return ""
if (count * 10 > tracks.size * 6) {
return "$dominant mix"
}
// Otherwise fall through to the store's timestamp default by returning blank.
return ""
}
/**
* Load an .m3u playlist [file] from disk, replace the current queue with its
* tracks, and start playing from the first track. Empty/malformed playlists
* post a UI toast and leave the current queue alone.
*/
fun loadPlaylist(file: java.io.File) = playGate.request {
val tracks = playlistStore.load(file)
if (tracks.isEmpty()) {
uiEvents.post("Playlist was empty or couldn't be read.")
return@request
}
renderer.load(tracks, startIndex = 0, autoplay = true)
uiEvents.post("Loaded ${tracks.size} track${if (tracks.size == 1) "" else "s"}")
}
/**
* Load an .m3u playlist [file] and append its tracks to the end of the current
* queue. Useful for building up a listening session from multiple saved lists
* without losing what's already queued.
*/
fun appendPlaylist(file: java.io.File) = safeLaunch {
val tracks = playlistStore.load(file)
if (tracks.isEmpty()) {
uiEvents.post("Playlist was empty or couldn't be read.")
return@safeLaunch
}
renderer.enqueue(tracks)
uiEvents.post("Appended ${tracks.size} track${if (tracks.size == 1) "" else "s"}")
}
/** Returns the list of saved library playlists, newest first. */
suspend fun listLibraryPlaylists(): List<PlaylistFile> = playlistStore.listLibrary()
/**
* Restore the last persisted session: load its queue at its stored index, seek
* to the stored position, leave paused. Called once from [restoreSessionIfAny]
* at startup. If nothing is saved, no-op.
*
* Playback state is NOT restored (always paused). Users opening the app
* shouldn't be surprised by sudden audio — hitting Space to resume is one
* keystroke away and makes the resume intentional.
*/
fun restoreSessionIfAny() = safeLaunch {
val session = sessionStore.load() ?: return@safeLaunch
if (session.queue.isEmpty()) return@safeLaunch
val safeIndex = session.queueIndex.coerceIn(0, session.queue.lastIndex)
// autoplay=false preserves the intent: restore where you left off, but
// don't start blasting audio the moment the app opens. User hits Space
// to resume if they want to.
renderer.load(session.queue, safeIndex, autoplay = false)
// Seek best-effort. libvlc sometimes refuses seek before the stream is
// buffered; a small delay lets the first few packets arrive.
kotlinx.coroutines.delay(300)
if (session.position.inWholeMilliseconds > 0) {
runCatching { renderer.seekTo(session.position) }
}
}
/**
* Snapshot the current playback state and persist it. Called on graceful shutdown
* from Main.kt's onQuit handler. Empty queue → wipes the saved session so a
* fresh launch doesn't restore something the user intentionally cleared.
*
* This is a suspend function rather than a fire-and-forget launch because
* the quit path needs to know when the write is done before calling
* [exitApplication]. The caller runs it inside runBlocking.
*/
private companion object {
/** Android parity (2026-09-03) (Android SESSION_DEAD_DEBOUNCE_MS). */
const val SESSION_DEAD_DEBOUNCE_MS = 20_000L
/** Bound on one enrichment search; the display never waits on it. */
const val ENRICH_TIMEOUT_MS = 5_000L
const val ENRICH_CACHE_SIZE = 256
}
suspend fun snapshotSession() {
val s = state.value
sessionStore.save(
StoredSession(
queue = s.queue,
queueIndex = s.queueIndex,
position = s.position,
)
)
}
}