package dev.stave.playback import dev.stave.cast.raw.CastObserverApi import dev.stave.ui.events.UiEvents import kotlinx.coroutines.CancellationException 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.launch /** * "Stop following and play here?" — ported from Android on the maintainer's * answer 4 (2026-09-04). Every action that would start local audio while the * desktop is following the group parks here until the person answers. Yes: * stop following, run the action. No: nothing happens, the follow continues. * When not following, the action runs at once. * * Starters that go through here (audit, as on Android 2026-09-02): Party / * random tracks, playlists, Now Playing's play/enqueue-and-play, and every * Browse starter — play a track in context, Play all on a container, the * per-genre "Play 42 random…", and Play next into an EMPTY queue (that * starts audio). Deliberately not gated: Add to queue, and Play next onto a * non-empty queue — they don't start audio. */ class LocalPlaybackGate( private val rawCast: CastObserverApi, private val uiEvents: UiEvents? = null, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var pending: (suspend () -> Unit)? = null /** Runs after the parked/immediate action has actually run (Android's * hook so a screen can navigate only when something played; I.69 / * Android review N.31). Dropped with the action on [dismiss]. */ private var pendingOnRan: (() -> Unit)? = null private val _confirmVisible = MutableStateFlow(false) val confirmVisible: StateFlow = _confirmVisible.asStateFlow() // Parameter order matters (Android note): Kotlin gives a trailing lambda // to the LAST parameter, so `request { … }` is the action and // `request(onRan) { … }` adds the completion. fun request(onRan: (() -> Unit)? = null, action: suspend () -> Unit) { if (!rawCast.observing.value) { run(action, onRan) return } pending = action pendingOnRan = onRan _confirmVisible.value = true } fun confirm() { _confirmVisible.value = false rawCast.disconnect() val action = pending val onRan = pendingOnRan pending = null pendingOnRan = null action?.let { run(it, onRan) } } fun dismiss() { pending = null pendingOnRan = null _confirmVisible.value = false } private fun run(action: suspend () -> Unit, onRan: (() -> Unit)?) { scope.launch { try { action() } catch (c: CancellationException) { throw c } catch (t: Throwable) { // Was a silent printStackTrace (Team 5 V12): say something. System.err.println("[Earhorn/Gate] gated action threw (${t.javaClass.simpleName}):") t.printStackTrace(System.err) uiEvents?.post(MSG_FAILED) return@launch } runOnRan(onRan) } } /** Run [onRan], logging and dropping a failure — this scope outlives the * UI, so the callback may touch a screen that has since gone (Android, * Android parity, 2026-10-29). */ fun runOnRan(onRan: (() -> Unit)?) { if (onRan == null) return try { onRan() } catch (t: Throwable) { System.err.println("[Earhorn/Gate] onRan failed: $t") } } private companion object { const val MSG_FAILED = "Something went wrong — couldn't start playback." } }