LocalPlaybackGate.kt
104 lines · 3.9 KB
Changed in: 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 | } |


