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

NowPlayingViewModel.kt

package dev.stave.ui.nowplaying

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.stave.cast.CastIntegration
import dev.stave.cast.CastSession
import dev.stave.domain.model.DidlItem
import dev.stave.domain.renderer.PlaybackState
import dev.stave.cast.raw.RawCastObserver
import dev.stave.data.SettingsStore
import dev.stave.domain.model.RemoteRenderer
import dev.stave.domain.renderer.Renderer
import dev.stave.upnp.UpnpServiceHolder
import dev.stave.domain.renderer.RepeatMode
import dev.stave.upnp.BrowseResult
import dev.stave.upnp.ContentDirectoryClient
import dev.stave.upnp.MediaCollector
import dev.stave.upnp.renderer.RendererCoordinator
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration

@HiltViewModel
class NowPlayingViewModel @Inject constructor(
    val playbackGate: dev.stave.playback.LocalPlaybackGate,
    private val renderer: Renderer,
    private val cd: ContentDirectoryClient,
    private val collector: MediaCollector,
    private val cast: CastIntegration,
    private val settings: SettingsStore,
    val rawCast: RawCastObserver,
    val radioStatus: dev.stave.cast.radio.RadioStatusClient,
    private val upnpHolder: UpnpServiceHolder,
) : ViewModel() {

    val state: StateFlow<PlaybackState> = renderer.state

    // Maintainer, 2026-10-29: short user-facing lines (a play that loaded no
    // tracks) for the screens to show; those plays used to fail silently.
    private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 8)
    val messages: SharedFlow<String> = _messages.asSharedFlow()

    // ── Renderer output indicator ───────────────────────────────
    val observedCaps = rawCast.observedCaps

    /** True when [action] is permitted right now. Local playback: always.
     *  Observed session: per the receiver's own capability bits. The UI
     *  dims from the same source; this is the belt to that suspender —
     *  a d-pad or race-through press on a dead control becomes a logged
     *  no-op instead of a dead command on the wire. */
    private fun obsAllows(picker: (dev.stave.cast.raw.RawCastObserver.ObservedCaps) -> Boolean): Boolean {
        if (!rawCast.observing.value) return true
        val caps = rawCast.observedCaps.value ?: return true
        return picker(caps).also {
            if (!it) android.util.Log.i("EarhornCast",
                "transport action refused: receiver does not support it on this session")
        }
    }

    val domEthaUrl: StateFlow<String?> = settings.domEthaUrl.stateIn(
        scope = viewModelScope,
        started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
        initialValue = null,
    )

    val cornerButtonDomEtha: StateFlow<Boolean> = settings.cornerButtonDomEtha.stateIn(
        scope = viewModelScope,
        started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
        initialValue = false,
    )

    val activeRendererUdn: StateFlow<String?> = settings.activeRendererUdn.stateIn(
        scope = viewModelScope,
        started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
        initialValue = null,
    )
    val renderers: StateFlow<List<RemoteRenderer>> = upnpHolder.renderers

    /** Maintainer, 2026-10-29: the remote renderer audio is ACTUALLY going to
     *  (null = this device). [activeRendererUdn] is only the saved choice;
     *  the coordinator stays local while that renderer is undiscovered, so
     *  "Playing on X" claims must read this instead. The injected [renderer]
     *  is the RendererCoordinator (RendererModule.provideRenderer); any other
     *  Renderer is local-only, hence the null fallback. */
    val activeRemoteUdn: StateFlow<String?> =
        (renderer as? RendererCoordinator)?.activeRemoteUdn
            ?: MutableStateFlow<String?>(null).asStateFlow()

    fun setActiveRenderer(udn: String?) {
        viewModelScope.launch { settings.setActiveRendererUdn(udn) }
    }

    // ── Cast wiring (retained but disabled) ─────────────────────────
    // Cast infrastructure stays injected and functional so re-enabling
    // is a single UI change. The NowPlayingScreen no longer exposes the
    // cast button; a sleep timer takes its slot instead.

    val isCasting: StateFlow<Boolean> = cast.isCasting
    val castIntegration: CastIntegration = cast
    private val _showCastPicker = Channel<Unit>(Channel.BUFFERED)
    val showCastPicker: Flow<Unit> = _showCastPicker.receiveAsFlow()

    fun toggleCast() {
        // Always open the picker, whether casting or not. This lets
        // the user switch speakers mid-session without stopping.
        // Disconnect is exclusively the stop button's job.
        val ok = _showCastPicker.trySend(Unit).isSuccess
        android.util.Log.i("EarhornCast", "toggleCast: showCastPicker emitted=$ok isCasting=${isCasting.value}")
    }

    // ── 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 = viewModelScope.launch {
            var remaining = minutes
            while (remaining > 0) {
                delay(60_000L) // 1 minute
                remaining--
                _sleepTimerMinutesLeft.value = remaining
            }
            // Timer expired — stop playback. Same routing as the Stop
            // button: the observed cast session when observing, else the
            // local renderer. (Audit 2026-09-02: this called renderer.stop()
            // unconditionally, so on a screen following the group's music
            // the timer counted down and did nothing.)
            if (observing) rawCast.stop() else renderer.stop()
            _sleepTimerMinutesLeft.value = null
        }
    }

    fun cancelSleepTimer() {
        sleepTimerJob?.cancel()
        sleepTimerJob = null
        _sleepTimerMinutesLeft.value = null
    }

    // Audit 2026-09-02: every starter below that begins audio now goes
    // through the LocalPlaybackGate, as its own contract (and the NavHost
    // comment) already claimed. They did not: Detail/More/Browse track
    // taps, album Play, playlists and local random all reached
    // renderer.load directly, so a fixed screen following the group started
    // a second, invisible audio source under the observed display, and
    // the only visible Stop then went to the group's group.
    /** [onRan] fires after the action actually ran — immediately when not
     *  following, after the person confirms when following. Screens use it
     *  to navigate to Now Playing only then (design decision 2026-09-02). */
    fun playTracks(tracks: List<DidlItem.Track>, startIndex: Int, onRan: (() -> Unit)? = null) {
        playbackGate.request(onRan) { renderer.load(tracks, startIndex) }
    }

    fun enqueueTracks(tracks: List<DidlItem.Track>) {
        // Queue-only: adds to a queue, starts nothing. Not gated (gate contract).
        viewModelScope.launch { renderer.enqueue(tracks) }
    }

    fun playNextTracks(tracks: List<DidlItem.Track>) {
        // Play Next into an EMPTY queue starts audio; onto a non-empty
        // queue it only inserts. Gate the former only.
        if (state.value.queue.isEmpty()) playbackGate.request { renderer.playNext(tracks) }
        else viewModelScope.launch { renderer.playNext(tracks) }
    }

    // Container variants — collect all tracks under the container first, then apply the
    // same action. Runs off-screen; 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, onRan: (() -> Unit)? = null) {
        // Maintainer, 2026-10-29: nothing collected (MediaCollector returns an
        // empty list on failure too) used to fail silently and still open Now
        // Playing. Say so, and navigate (onRan) only after tracks loaded.
        playbackGate.request {
            val tracks = collector.collectTracks(udn, containerId)
            if (tracks.isNotEmpty()) {
                renderer.load(tracks, 0)
                playbackGate.runOnRan(onRan)
            } else {
                _messages.tryEmit(MSG_NOTHING_TO_PLAY)
            }
        }
    }

    fun enqueueContainer(udn: String, containerId: String) {
        viewModelScope.launch {
            val tracks = collector.collectTracks(udn, containerId)
            if (tracks.isNotEmpty()) renderer.enqueue(tracks)
            else _messages.tryEmit(MSG_NOTHING_TO_PLAY) // Maintainer, 2026-10-29: was silent
        }
    }

    fun playNextContainer(udn: String, containerId: String) {
        val starter: suspend () -> Unit = {
            val tracks = collector.collectTracks(udn, containerId)
            if (tracks.isNotEmpty()) renderer.playNext(tracks)
            else _messages.tryEmit(MSG_NOTHING_TO_PLAY) // Maintainer, 2026-10-29: was silent
        }
        if (state.value.queue.isEmpty()) playbackGate.request(action = starter)
        else viewModelScope.launch { starter() }
    }

    /**
     * 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). Starts audio → gated.
     */
    fun enqueueAndPlay(tracks: List<DidlItem.Track>, onRan: (() -> Unit)? = null) {
        if (tracks.isEmpty()) return
        playbackGate.request(onRan) {
            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.
     *
     * On server failure or when no tracks come back it emits a line on [messages]
     * and does not navigate (decision, 2026-10-29).
     */
    fun loadRandomTracks(udn: String, count: Int, onRan: (() -> Unit)? = null) {
        // Maintainer, 2026-10-29: a failed/empty fetch was silent yet still
        // opened Now Playing. Say so, and navigate (onRan) only after tracks
        // actually loaded.
        playbackGate.request {
            when (val r = cd.randomTracks(udn, count)) {
                is BrowseResult.Success -> {
                    val tracks = r.items.filterIsInstance<DidlItem.Track>()
                    if (tracks.isNotEmpty()) {
                        renderer.load(tracks, 0)
                        playbackGate.runOnRan(onRan)
                    } else {
                        _messages.tryEmit(MSG_NOTHING_TO_PLAY)
                    }
                }
                is BrowseResult.Failure -> {
                    _messages.tryEmit(MSG_LOAD_FAILED)
                }
            }
        }
    }

    /**
     * Genre-scoped random — fetches [count] random tracks matching [genreName]
     * from the active server and starts playing them immediately. Used by the
     * "Play 42 random tracks" long-press action on genre containers.
     *
     * The genre exclusion list in Settings does NOT apply here — the user
     * explicitly chose this genre, so filtering it out would be contradictory.
     */
    fun loadRandomTracksByGenre(udn: String, genreName: String, count: Int = 42) {
        playbackGate.request {
            // Maintainer, 2026-10-29: failed/empty fetches were silent; say so.
            when (val r = cd.randomTracksByGenre(udn, genreName, count)) {
                is BrowseResult.Success -> {
                    val tracks = r.items.filterIsInstance<DidlItem.Track>()
                    if (tracks.isNotEmpty()) renderer.load(tracks, 0)
                    else _messages.tryEmit(MSG_NOTHING_TO_PLAY)
                }
                is BrowseResult.Failure -> {
                    _messages.tryEmit(MSG_LOAD_FAILED)
                }
            }
        }
    }

    // ── Observer-aware transport routing ──────────────────────────────
    // When the raw Cast observer is active, every transport command must
    // go to the observed device over the raw protocol — not to the local
    // renderer (which is idle/paused underneath). Without this routing,
    // play/pause/next/prev/seek on phone silently act on the local
    // renderer and the observed stream never responds, making observer
    // mode appear completely broken on phone and tablet.
    private val observing: Boolean get() = rawCast.observing.value

    init {
        // Entering observer mode while local music is playing would leave
        // both audible with no way to reach the local controls (the UI
        // swaps to the observed state). Pause local playback the moment
        // observation starts.
        viewModelScope.launch {
            rawCast.observing.collect { obs ->
                if (obs && state.value.isPlaying) {
                    runCatching { renderer.pause() }
                }
            }
        }
    }

    fun togglePlay() {
        viewModelScope.launch {
            if (observing) {
                if (rawCast.playbackState.value.isPlaying) rawCast.pause() else rawCast.play()
            } else {
                if (state.value.isPlaying) renderer.pause() else renderer.play()
            }
        }
    }

    fun next() = viewModelScope.launch {
        if (observing) rawCast.next() else renderer.next()
    }
    fun previous() = viewModelScope.launch {
        if (observing) rawCast.previous() else renderer.previous()
    }
    /** Stop this device's own player and nothing else — used by Exit,
     *  which must not touch the group (review C.8). */
    fun stopLocalOnly() = viewModelScope.launch { renderer.stop() }

    fun stop() = viewModelScope.launch {
        if (observing) rawCast.stop() else renderer.stop()
    }
    fun seekTo(position: Duration) = viewModelScope.launch {
        if (!obsAllows { it.canSeek }) return@launch
        if (observing) rawCast.seek(position) else renderer.seekTo(position)
    }

    // Queue manipulation — used by QueueScreen.
    // skipToQueueItem routes through the observer when following a Cast
    // session: tapping a queue row jumps the OBSERVED playback via
    // QUEUE_UPDATE. Remove/move/clear stay local-only — the QueueScreen
    // hides them in observer mode (editing the receiver queue from a
    // follower would desync the sender's authoritative local copy).
    fun skipToQueueItem(index: Int) = viewModelScope.launch {
        if (observing) rawCast.jumpToQueueItem(index)
        else renderer.skipToQueueItem(index)
    }
    fun removeFromQueue(index: Int) = viewModelScope.launch { renderer.removeFromQueue(index) }
    fun moveInQueue(from: Int, to: Int) = viewModelScope.launch { renderer.moveInQueue(from, to) }
    fun clearQueue() = viewModelScope.launch { renderer.clearQueue() }

    /** Cycle OFF → ALL → ONE → OFF. Matches the three-state repeat control users expect
     *  from decades of media apps. */
    fun cycleRepeat() = viewModelScope.launch {
        // Maintainer, 2026-10-29: cycle from the OBSERVED repeat mode while
        // following a Cast group. Reading the idle local renderer's mode
        // here meant every press sent ALL (local stays OFF).
        val current = if (observing) rawCast.playbackState.value.repeatMode else state.value.repeatMode
        val next = when (current) {
            RepeatMode.OFF -> RepeatMode.ALL
            RepeatMode.ALL -> RepeatMode.ONE
            RepeatMode.ONE -> RepeatMode.OFF
        }
        // Routed like every other transport control: the observed house
        // session when following (I.15, design decision 2026-09-02).
        if (observing) rawCast.setRepeatMode(next) else renderer.setRepeatMode(next)
    }

    fun toggleShuffle() = viewModelScope.launch {
        // Maintainer, 2026-10-29: flip the OBSERVED shuffle flag while
        // following a Cast group. Flipping the idle local flag always sent
        // "on", so shuffle on the group could never be turned off.
        val on = !(if (observing) rawCast.playbackState.value.shuffleEnabled else state.value.shuffleEnabled)
        if (observing) rawCast.setShuffle(on) else renderer.setShuffle(on)
    }

    // ── Volume control (Issue 6) ─────────────────────────────────────
    // For touch interfaces without hardware volume keys (HP Chromebase).
    // Adjusts volume by a fixed step (5%) per tap, with the current
    // level exposed for toast/overlay display.

    fun adjustVolume(delta: Int) {
        viewModelScope.launch {
            if (observing) {
                val cur = rawCast.playbackState.value.volumePercent
                rawCast.setVolume((cur + delta).coerceIn(0, 100))
            } else {
                val current = state.value.volumePercent
                renderer.setVolume((current + delta).coerceIn(0, 100))
            }
        }
    }

    fun setVolumeTo(percent: Int) {
        viewModelScope.launch {
            val clamped = percent.coerceIn(0, 100)
            if (observing) rawCast.setVolume(clamped)
            else renderer.setVolume(clamped)
        }
    }

    // ── Observed-track metadata enrichment ──────────────────────────
    // Raw-cast protocol ships title/artist/album/art but NOT year, mime
    // type, sample frequency, bit depth, or track number — those are UPnP
    // metadata fields the receiver doesn't include in MEDIA_STATUS. To
    // populate the NowPlaying metadata row + Track Nº + id3-tag sheet
    // correctly while observing, we search the user's preferred UPnP
    // server by (title, artist, album) and merge any technical fields
    // from the matching local DIDL track back into the observed track.
    //
    // Cache by composite key so identical track changes (pause/resume,
    // position ticks) don't re-search. Cache survives the lifetime of
    // this ViewModel which is the NowPlaying session — reasonable: a
    // 10-track CCA queue produces 10 search calls total, not 10/s.

    private val enrichmentCache = mutableMapOf<String, DidlItem.Track>()

    /** Composite key for cache lookup. Includes album because two tracks
     *  by the same artist with the same title (live + studio cuts, e.g.)
     *  can have different technical metadata. */
    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 when
     *  observing-flag, observed track identity, or preferred server changes —
     *  not on every position tick. */
    private val enrichmentInputs: Flow<Triple<Boolean, DidlItem.Track?, String?>> =
        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 track to display when observing — observed track enriched with
     *  UPnP-side technical metadata when a match exists in the library.
     *  When not observing, emits null and the UI falls back to the raw
     *  observed track or local renderer track. */
    @OptIn(ExperimentalCoroutinesApi::class)
    val observedTrackEnriched: StateFlow<DidlItem.Track?> = enrichmentInputs
        .mapLatest { (observing, observed, _) ->
            if (!observing || observed == null) return@mapLatest null
            // One lookup per track (design decision 2026-09-02, I.22): the
            // observer enriches upstream and its published state already
            // carries year/trackNumber/format. This flow only mirrors that
            // state now; the second UPnP search it used to run — and the
            // stale previous track it held while that search ran with no
            // timeout (review N.20) — are gone.
            observed
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            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 companion object {
        // Maintainer, 2026-10-29: the lines emitted on [messages].
        const val MSG_LOAD_FAILED = "Couldn't load tracks from the server"
        const val MSG_NOTHING_TO_PLAY = "Nothing to play there"
    }

    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 },
        )
}