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