LocalPlaybackGate.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.playback |
| 2 | ||
| 3 | import dev.stave.cast.raw.CastObserverApi | |
| 4 | import dev.stave.ui.events.UiEvents | |
| 5 | import kotlinx.coroutines.CancellationException | |
| 6 | import kotlinx.coroutines.CoroutineScope | |
| 7 | import kotlinx.coroutines.Dispatchers | |
| 8 | import kotlinx.coroutines.SupervisorJob | |
| 9 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 10 | import kotlinx.coroutines.flow.StateFlow | |
| 11 | import kotlinx.coroutines.flow.asStateFlow | |
| 12 | import kotlinx.coroutines.launch | |
| 13 | ||
| 14 | /** | |
| 15 | * "Stop following and play here?" — ported from Android on the maintainer's | |
| 16 | * answer 4 (2026-09-04). Every action that would start local audio while the | |
| 17 | * desktop is following the group parks here until the person answers. Yes: | |
| 18 | * stop following, run the action. No: nothing happens, the follow continues. | |
| 19 | * When not following, the action runs at once. | |
| 20 | * | |
| 21 | * Starters that go through here (audit, as on Android 2026-09-02): Party / | |
| 22 | * random tracks, playlists, Now Playing's play/enqueue-and-play, and every | |
| 23 | * Browse starter — play a track in context, Play all on a container, the | |
| 24 | * per-genre "Play 42 random…", and Play next into an EMPTY queue (that | |
| 25 | * starts audio). Deliberately not gated: Add to queue, and Play next onto a | |
| 26 | * non-empty queue — they don't start audio. | |
| 27 | */ | |
| 28 | class LocalPlaybackGate( | |
| 29 | private val rawCast: CastObserverApi, | |
| 30 | private val uiEvents: UiEvents? = null, | |
| 31 | ) { | |
| 32 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) | |
| 33 | private var pending: (suspend () -> Unit)? = null | |
| 34 | ||
| 35 | /** Runs after the parked/immediate action has actually run (Android's | |
| 36 | * hook so a screen can navigate only when something played; I.69 / | |
| 37 | * Android review N.31). Dropped with the action on [dismiss]. */ | |
| 38 | private var pendingOnRan: (() -> Unit)? = null | |
| 39 | ||
| 40 | private val _confirmVisible = MutableStateFlow(false) | |
| 41 | val confirmVisible: StateFlow<Boolean> = _confirmVisible.asStateFlow() | |
| 42 | ||
| 43 | // Parameter order matters (Android note): Kotlin gives a trailing lambda | |
| 44 | // to the LAST parameter, so `request { … }` is the action and | |
| 45 | // `request(onRan) { … }` adds the completion. | |
| 46 | fun request(onRan: (() -> Unit)? = null, action: suspend () -> Unit) { | |
| 47 | if (!rawCast.observing.value) { | |
| 48 | run(action, onRan) | |
| 49 | return | |
| 50 | } | |
| 51 | pending = action | |
| 52 | pendingOnRan = onRan | |
| 53 | _confirmVisible.value = true | |
| 54 | } | |
| 55 | ||
| 56 | fun confirm() { | |
| 57 | _confirmVisible.value = false | |
| 58 | rawCast.disconnect() | |
| 59 | val action = pending | |
| 60 | val onRan = pendingOnRan | |
| 61 | pending = null | |
| 62 | pendingOnRan = null | |
| 63 | action?.let { run(it, onRan) } | |
| 64 | } | |
| 65 | ||
| 66 | fun dismiss() { | |
| 67 | pending = null | |
| 68 | pendingOnRan = null | |
| 69 | _confirmVisible.value = false | |
| 70 | } | |
| 71 | ||
| 72 | private fun run(action: suspend () -> Unit, onRan: (() -> Unit)?) { | |
| 73 | scope.launch { | |
| 74 | try { | |
| 75 | action() | |
| 76 | } catch (c: CancellationException) { | |
| 77 | throw c | |
| 78 | } catch (t: Throwable) { | |
| 79 | // Was a silent printStackTrace (Team 5 V12): say something. | |
| 80 | System.err.println("[Earhorn/Gate] gated action threw (${t.javaClass.simpleName}):") | |
| 81 | t.printStackTrace(System.err) | |
| 82 | uiEvents?.post(MSG_FAILED) | |
| 83 | return@launch | |
| 84 | } | |
| 85 | runOnRan(onRan) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | /** Run [onRan], logging and dropping a failure — this scope outlives the | |
| 90 | * UI, so the callback may touch a screen that has since gone (Android, | |
| 91 | * Android parity, 2026-10-29). */ | |
| 92 | fun runOnRan(onRan: (() -> Unit)?) { | |
| 93 | if (onRan == null) return | |
| 94 | try { | |
| 95 | onRan() | |
| 96 | } catch (t: Throwable) { | |
| 97 | System.err.println("[Earhorn/Gate] onRan failed: $t") | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | private companion object { | |
| 102 | const val MSG_FAILED = "Something went wrong — couldn't start playback." | |
| 103 | } | |
| 104 | } |


