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<Boolean> = _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."
}
}