EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

LocalPlaybackGate.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.playback
2
3import dev.stave.cast.raw.CastObserverApi
4import dev.stave.ui.events.UiEvents
5import kotlinx.coroutines.CancellationException
6import kotlinx.coroutines.CoroutineScope
7import kotlinx.coroutines.Dispatchers
8import kotlinx.coroutines.SupervisorJob
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import 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 */
28class 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}