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.RawCastObserver
4import kotlinx.coroutines.CoroutineScope
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.SupervisorJob
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.first
11import kotlinx.coroutines.launch
12import javax.inject.Inject
13import javax.inject.Singleton
14
15/**
16 * Universal gate between "the user tapped something that starts LOCAL
17 * playback" and the renderer, active while this device is FOLLOWING a
18 * Cast session (observer mode).
19 *
20 * Rationale (field report): while observing an audible CCA group, any
21 * local-playback starter — Music Now, Play Album, tapping a track,
22 * Play Next into an empty queue, local-library play — used to start a
23 * second audio source under the live observation, with the UI still
24 * showing the observed session's metadata. Two songs in the room, one
25 * screen. Every start now routes through [request]: it runs
26 * immediately when not observing, and otherwise parks the action and
27 * raises a single app-wide confirmation ("stop following and play
28 * here?"). Confirming disconnects the observer FIRST, then runs the
29 * parked action — so the action executes into a clean local-playback
30 * state.
31 *
32 * Deliberately NOT gated: plain Queue/Play-Next adds onto a non-empty
33 * queue (they don't start audio), queue "Play from here" while
34 * observing (that's an OBSERVER action, routed to the receiver), all
35 * transport controls while observing (observer-routed), and the UPnP
36 * renderer service (an external control point pushing to this device
37 * is an explicit remote command; blocking it behind an on-screen
38 * confirm would break the UPnP contract mid-action).
39 */
40@Singleton
41class LocalPlaybackGate @Inject constructor(
42 private val rawCast: RawCastObserver,
43 private val settings: dev.stave.data.SettingsStore,
44 private val renderer: dev.stave.domain.renderer.Renderer,
45) {
46 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
47
48 private var pending: (suspend () -> Unit)? = null
49 /** Runs after the parked/immediate action has actually run — the hook
50 * screens use to navigate to Now Playing only when something played
51 * (design decision 2026-09-02; I.69 / review N.31). */
52 private var pendingOnRan: (() -> Unit)? = null
53
54 /** design decision 2026-09-02: confirming "play here" on an ARMED wall
55 * screen disarms it, so kiosk does not yank the screen back mid-song
56 * (review N.5: the presence rule saw no session and handed back after
57 * 20 s). When that local playback STOPS — queue exhausted, or Stop
58 * pressed; pause is not stop — the screen goes back to kiosk on its own.
59 * This flow carries the package to return to; MainActivity collects it. */
60 private val _returnToHausAfterLocal = MutableStateFlow<String?>(null)
61 val returnToHausAfterLocal: StateFlow<String?> = _returnToHausAfterLocal.asStateFlow()
62 private var localWatch: kotlinx.coroutines.Job? = null
63
64 /** How long a disarmed fixed screen sits on its stopped local playback
65 * before going back to kiosk (maintainer: "a few minutes, not right away"). */
66 private val LOCAL_STOP_GRACE_MS = 3 * 60_000L
67
68 /** MainActivity calls this once it has acted on [returnToHausAfterLocal]. */
69 fun consumeReturnToHaus() { _returnToHausAfterLocal.value = null }
70
71 private val _confirmVisible = MutableStateFlow(false)
72 /** True while the "stop following and play here?" sheet should show. */
73 val confirmVisible: StateFlow<Boolean> = _confirmVisible.asStateFlow()
74
75 /** Name of the followed device/group, for the sheet copy. */
76 val observedDeviceName: StateFlow<String?> get() = rawCast.connectedDeviceName
77
78 /** Run [action] now — unless observing, in which case park it and
79 * raise the confirmation. */
80 // Parameter order matters: Kotlin gives a trailing lambda to the LAST
81 // parameter. With `action` last, `request { … }` is the action and
82 // `request(onRan) { … }` adds the completion. (The reverse order shipped in
83 // 8dbf0342 and failed the maintainer's build on every call site.)
84 fun request(onRan: (() -> Unit)? = null, action: suspend () -> Unit) {
85 if (!rawCast.observing.value) {
86 // Maintainer, 2026-10-29: onRan through runOnRan (see there).
87 scope.launch { action(); runOnRan(onRan) }
88 return
89 }
90 pending = action
91 pendingOnRan = onRan
92 _confirmVisible.value = true
93 }
94
95 /** User confirmed: leave observer mode, then run the parked action. */
96 fun confirm() {
97 _confirmVisible.value = false
98 rawCast.disconnect()
99 val action = pending
100 val onRan = pendingOnRan
101 pending = null
102 pendingOnRan = null
103 scope.launch {
104 // Disarm an armed fixed screen for the duration of the local
105 // playback the person just asked for; remember where to go
106 // back to when it stops.
107 val armedPkg = runCatching { settings.kioskReturnPackage.first() }.getOrNull()
108 if (!armedPkg.isNullOrBlank()) {
109 runCatching { settings.setKioskReturnPackage(null) }
110 watchLocalPlaybackThenReturn(armedPkg)
111 }
112 action?.invoke()
113 runOnRan(onRan) // Maintainer, 2026-10-29: see runOnRan
114 }
115 }
116
117 /** Maintainer, 2026-10-29: this scope outlives the UI, and onRan callbacks
118 * capture a NavController — navigating on a host that has since been
119 * disposed threw on the main thread and killed the process. Run the
120 * callback, log and drop a failure. Public so ViewModels that invoke
121 * onRan themselves inside the action (only once tracks actually
122 * loaded) get the same guard. */
123 fun runOnRan(onRan: (() -> Unit)?) {
124 if (onRan == null) return
125 runCatching { onRan() }
126 .onFailure { android.util.Log.w("EarhornGate", "onRan (navigation) failed", it) }
127 }
128
129 private fun watchLocalPlaybackThenReturn(pkg: String) {
130 localWatch?.cancel()
131 localWatch = scope.launch {
132 var sawPlaying = false
133 var stoppedAtMs: Long? = null
134 // "Stopped" = the renderer's stopped axis (Stop pressed, queue
135 // ended, or nothing loaded). Paused is NOT stopped. the maintainer's
136 // rule 2026-09-02: the screen goes back to kiosk a few minutes
137 // after playback stops, not the instant it does — so a person
138 // who reaches the end of an album and puts on another isn't
139 // yanked away. Playing again during the wait cancels it.
140 while (true) {
141 val st = renderer.state.value
142 if (st.isPlaying) { sawPlaying = true; stoppedAtMs = null }
143 if (sawPlaying && st.isStopped) {
144 val since = stoppedAtMs ?: android.os.SystemClock.elapsedRealtime().also { stoppedAtMs = it }
145 if (android.os.SystemClock.elapsedRealtime() - since >= LOCAL_STOP_GRACE_MS) {
146 _returnToHausAfterLocal.value = pkg
147 return@launch
148 }
149 }
150 kotlinx.coroutines.delay(1_000)
151 }
152 }
153 }
154
155 /** User cancelled: drop the parked action, observation untouched. */
156 fun dismiss() {
157 pending = null
158 pendingOnRan = null
159 _confirmVisible.value = false
160 }
161
162 /** "Stop following & play here" reached from a button that is not a
163 * parked action (Browse's Music Now confirm, review N.43): same
164 * disarm-and-return-later rule as [confirm], then the caller starts
165 * its playback itself. */
166 fun leaveObservingForLocal() {
167 rawCast.disconnect()
168 scope.launch {
169 val armedPkg = runCatching { settings.kioskReturnPackage.first() }.getOrNull()
170 if (!armedPkg.isNullOrBlank()) {
171 runCatching { settings.setKioskReturnPackage(null) }
172 watchLocalPlaybackThenReturn(armedPkg)
173 }
174 }
175 }
176}