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

LocalPlaybackGate.kt

package dev.stave.playback

import dev.stave.cast.raw.RawCastObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Universal gate between "the user tapped something that starts LOCAL
 * playback" and the renderer, active while this device is FOLLOWING a
 * Cast session (observer mode).
 *
 * Rationale (field report): while observing an audible CCA group, any
 * local-playback starter — Music Now, Play Album, tapping a track,
 * Play Next into an empty queue, local-library play — used to start a
 * second audio source under the live observation, with the UI still
 * showing the observed session's metadata. Two songs in the room, one
 * screen. Every start now routes through [request]: it runs
 * immediately when not observing, and otherwise parks the action and
 * raises a single app-wide confirmation ("stop following and play
 * here?"). Confirming disconnects the observer FIRST, then runs the
 * parked action — so the action executes into a clean local-playback
 * state.
 *
 * Deliberately NOT gated: plain Queue/Play-Next adds onto a non-empty
 * queue (they don't start audio), queue "Play from here" while
 * observing (that's an OBSERVER action, routed to the receiver), all
 * transport controls while observing (observer-routed), and the UPnP
 * renderer service (an external control point pushing to this device
 * is an explicit remote command; blocking it behind an on-screen
 * confirm would break the UPnP contract mid-action).
 */
@Singleton
class LocalPlaybackGate @Inject constructor(
    private val rawCast: RawCastObserver,
    private val settings: dev.stave.data.SettingsStore,
    private val renderer: dev.stave.domain.renderer.Renderer,
) {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    private var pending: (suspend () -> Unit)? = null
    /** Runs after the parked/immediate action has actually run — the hook
     *  screens use to navigate to Now Playing only when something played
     *  (design decision 2026-09-02; I.69 / review N.31). */
    private var pendingOnRan: (() -> Unit)? = null

    /** design decision 2026-09-02: confirming "play here" on an ARMED wall
     *  screen disarms it, so kiosk does not yank the screen back mid-song
     *  (review N.5: the presence rule saw no session and handed back after
     *  20 s). When that local playback STOPS — queue exhausted, or Stop
     *  pressed; pause is not stop — the screen goes back to kiosk on its own.
     *  This flow carries the package to return to; MainActivity collects it. */
    private val _returnToHausAfterLocal = MutableStateFlow<String?>(null)
    val returnToHausAfterLocal: StateFlow<String?> = _returnToHausAfterLocal.asStateFlow()
    private var localWatch: kotlinx.coroutines.Job? = null

    /** How long a disarmed fixed screen sits on its stopped local playback
     *  before going back to kiosk (maintainer: "a few minutes, not right away"). */
    private val LOCAL_STOP_GRACE_MS = 3 * 60_000L

    /** MainActivity calls this once it has acted on [returnToHausAfterLocal]. */
    fun consumeReturnToHaus() { _returnToHausAfterLocal.value = null }

    private val _confirmVisible = MutableStateFlow(false)
    /** True while the "stop following and play here?" sheet should show. */
    val confirmVisible: StateFlow<Boolean> = _confirmVisible.asStateFlow()

    /** Name of the followed device/group, for the sheet copy. */
    val observedDeviceName: StateFlow<String?> get() = rawCast.connectedDeviceName

    /** Run [action] now — unless observing, in which case park it and
     *  raise the confirmation. */
    // Parameter order matters: Kotlin gives a trailing lambda to the LAST
    // parameter. With `action` last, `request { … }` is the action and
    // `request(onRan) { … }` adds the completion. (The reverse order shipped in
    // 8dbf0342 and failed the maintainer's build on every call site.)
    fun request(onRan: (() -> Unit)? = null, action: suspend () -> Unit) {
        if (!rawCast.observing.value) {
            // Maintainer, 2026-10-29: onRan through runOnRan (see there).
            scope.launch { action(); runOnRan(onRan) }
            return
        }
        pending = action
        pendingOnRan = onRan
        _confirmVisible.value = true
    }

    /** User confirmed: leave observer mode, then run the parked action. */
    fun confirm() {
        _confirmVisible.value = false
        rawCast.disconnect()
        val action = pending
        val onRan = pendingOnRan
        pending = null
        pendingOnRan = null
        scope.launch {
            // Disarm an armed fixed screen for the duration of the local
            // playback the person just asked for; remember where to go
            // back to when it stops.
            val armedPkg = runCatching { settings.kioskReturnPackage.first() }.getOrNull()
            if (!armedPkg.isNullOrBlank()) {
                runCatching { settings.setKioskReturnPackage(null) }
                watchLocalPlaybackThenReturn(armedPkg)
            }
            action?.invoke()
            runOnRan(onRan) // Maintainer, 2026-10-29: see runOnRan
        }
    }

    /** Maintainer, 2026-10-29: this scope outlives the UI, and onRan callbacks
     *  capture a NavController — navigating on a host that has since been
     *  disposed threw on the main thread and killed the process. Run the
     *  callback, log and drop a failure. Public so ViewModels that invoke
     *  onRan themselves inside the action (only once tracks actually
     *  loaded) get the same guard. */
    fun runOnRan(onRan: (() -> Unit)?) {
        if (onRan == null) return
        runCatching { onRan() }
            .onFailure { android.util.Log.w("EarhornGate", "onRan (navigation) failed", it) }
    }

    private fun watchLocalPlaybackThenReturn(pkg: String) {
        localWatch?.cancel()
        localWatch = scope.launch {
            var sawPlaying = false
            var stoppedAtMs: Long? = null
            // "Stopped" = the renderer's stopped axis (Stop pressed, queue
            // ended, or nothing loaded). Paused is NOT stopped. the maintainer's
            // rule 2026-09-02: the screen goes back to kiosk a few minutes
            // after playback stops, not the instant it does — so a person
            // who reaches the end of an album and puts on another isn't
            // yanked away. Playing again during the wait cancels it.
            while (true) {
                val st = renderer.state.value
                if (st.isPlaying) { sawPlaying = true; stoppedAtMs = null }
                if (sawPlaying && st.isStopped) {
                    val since = stoppedAtMs ?: android.os.SystemClock.elapsedRealtime().also { stoppedAtMs = it }
                    if (android.os.SystemClock.elapsedRealtime() - since >= LOCAL_STOP_GRACE_MS) {
                        _returnToHausAfterLocal.value = pkg
                        return@launch
                    }
                }
                kotlinx.coroutines.delay(1_000)
            }
        }
    }

    /** User cancelled: drop the parked action, observation untouched. */
    fun dismiss() {
        pending = null
        pendingOnRan = null
        _confirmVisible.value = false
    }

    /** "Stop following & play here" reached from a button that is not a
     *  parked action (Browse's Music Now confirm, review N.43): same
     *  disarm-and-return-later rule as [confirm], then the caller starts
     *  its playback itself. */
    fun leaveObservingForLocal() {
        rawCast.disconnect()
        scope.launch {
            val armedPkg = runCatching { settings.kioskReturnPackage.first() }.getOrNull()
            if (!armedPkg.isNullOrBlank()) {
                runCatching { settings.setKioskReturnPackage(null) }
                watchLocalPlaybackThenReturn(armedPkg)
            }
        }
    }
}