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(null) val returnToHausAfterLocal: StateFlow = _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 = _confirmVisible.asStateFlow() /** Name of the followed device/group, for the sheet copy. */ val observedDeviceName: StateFlow 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) } } } }