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

RawCastPickerSheet.kt

468 lines · 19.2 KB

Changed in: 0.5.0

1package dev.stave.ui.cast
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxHeight
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.height
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.width
18import androidx.compose.foundation.layout.widthIn
19import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.items
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.filled.Cast
23import androidx.compose.material.icons.filled.CastConnected
24import androidx.compose.material.icons.filled.Speaker
25import androidx.compose.material.icons.filled.SpeakerGroup
26import androidx.compose.runtime.Composable
27import androidx.compose.runtime.DisposableEffect
28import androidx.compose.runtime.LaunchedEffect
29import androidx.compose.runtime.collectAsState
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.rememberCoroutineScope
34import androidx.compose.runtime.setValue
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.FocusRequester
38import androidx.compose.ui.focus.focusProperties
39import androidx.compose.foundation.focusGroup
40import androidx.compose.ui.focus.focusRequester
41import androidx.compose.ui.graphics.Color
42import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
44import androidx.tv.material3.Icon
45import androidx.tv.material3.MaterialTheme
46import androidx.tv.material3.Text
47import dev.stave.cast.raw.RawCastObserver
48import dev.stave.cast.raw.RawCastObserver.CastDevice
49import dev.stave.ui.components.staveFocusable
50import dev.stave.ui.theme.AccentAmber
51import dev.stave.ui.theme.OnMuted
52import dev.stave.ui.theme.OnPrimary
53import dev.stave.ui.theme.OnQuiet
54import dev.stave.ui.theme.SurfaceBase
55import dev.stave.ui.theme.SurfaceRaised
56import dev.stave.ui.theme.withInterDigits
57import kotlinx.coroutines.launch
58
59/**
60 * Picker for Chromecast devices and speaker groups discovered via mDNS, used
61 * on Android TV. Selecting an entry connects [RawCastObserver] to that device
62 * over the raw Cast protocol on the resolved port. The connection is
63 * observation-only — we attach to whatever session another sender (BubbleUPnP,
64 * ChromeOS, etc.) has already started, without stealing it.
65 *
66 * UX rules:
67 * - Centered panel over a 55% black scrim. 720dp wide so two columns fit
68 * comfortably side-by-side, modeled on RandomPickerScreen's two-PickerColumn
69 * layout.
70 * - Two columns: Speaker groups on the left, individual speakers on the right.
71 * Each column has its own SectionLabel header and its own LazyColumn for
72 * independent scrolling. D-pad LEFT/RIGHT crosses between columns; UP/DOWN
73 * scrolls within a column. Empty columns show a brief placeholder so the
74 * user isn't staring at half a blank panel.
75 * - Amber 2dp focus border via `staveFocusable`. Initial focus on the first
76 * row of whichever column has items (Groups preferred), or the Disconnect
77 * chip when already observing.
78 * - When already observing, a full-width "Disconnect from X" row in accent
79 * amber sits above the two columns. Switching to a different device just
80 * selects it — [RawCastObserver.observe] internally disconnects the prior
81 * session.
82 * - BackHandler closes the picker. Cancel row sits full-width below the
83 * columns as an explicit dismiss target.
84 */
85@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
86@Composable
87fun RawCastPickerSheet(
88 rawCast: RawCastObserver,
89 visible: Boolean,
90 onDismiss: () -> Unit,
91 /** Invoked right after an observe target is selected and the sheet
92 * dismisses — the NavHost uses it to navigate to NowPlaying so the
93 * user lands on the observed session instead of back on Browse. */
94 onObserveStarted: (() -> Unit)? = null,
95) {
96 if (!visible) return
97
98 val devices by rawCast.devices.collectAsState()
99 val observing by rawCast.observing.collectAsState()
100 val connectedName by rawCast.connectedDeviceName.collectAsState()
101 var connectingTo by remember { mutableStateOf<String?>(null) }
102 val scope = rememberCoroutineScope()
103
104 BackHandler(onBack = onDismiss)
105
106 // Restart discovery each time the picker opens so the freshest LAN state
107 // is shown. stopDiscovery on dispose tears down the multicast lock + serial
108 // resolver — we don't want multicast traffic flowing while the user is
109 // doing anything else with the app.
110 DisposableEffect(Unit) {
111 rawCast.startDiscovery()
112 onDispose { rawCast.stopDiscovery() }
113 }
114
115 val firstFocus = remember { FocusRequester() }
116 // Maintainer, 2026-10-29: when no row below takes firstFocus (nothing found
117 // and not observing; or observing with no device name yet, so no
118 // Disconnect row), the Cancel row takes it — otherwise focus stayed
119 // behind the scrim and Cancel was unreachable by D-pad. Mirrors the
120 // Disconnect / groups / singles conditions below. Also a key, so focus
121 // is re-requested when the holder changes.
122 val cancelGetsInitialFocus = if (observing) connectedName == null else devices.isEmpty()
123 LaunchedEffect(devices.isNotEmpty(), observing, cancelGetsInitialFocus) {
124 kotlinx.coroutines.yield()
125 runCatching { firstFocus.requestFocus() }
126 }
127
128 val groups = devices.filter { it.isGroup }
129 val singles = devices.filter { !it.isGroup }
130
131 Box(
132 Modifier
133 .fillMaxSize()
134 .background(Color.Black.copy(alpha = 0.55f))
135 // Focus trap. This sheet is a plain Box overlay, not a Dialog,
136 // so the D-pad focus engine still sees every focusable on the
137 // screen BEHIND the scrim. Navigating past the edge of the
138 // device columns used to land focus on an invisible background
139 // element with no spatial path back — the only escape was
140 // backing out of the sheet entirely. Cancelling `exit` on the
141 // sheet's focus group pins directional navigation inside it;
142 // Back/dismiss is unaffected (Back is not a focus move).
143 .focusProperties { exit = { FocusRequester.Cancel } }
144 .focusGroup(),
145 contentAlignment = Alignment.Center,
146 ) {
147 Column(
148 Modifier
149 .widthIn(min = 480.dp, max = 720.dp)
150 .fillMaxHeight()
151 .background(SurfaceBase)
152 .padding(horizontal = 32.dp, vertical = 48.dp),
153 ) {
154 Text(
155 if (observing) "Following cast" else "Follow cast on…",
156 style = MaterialTheme.typography.titleLarge,
157 color = OnPrimary,
158 )
159 Spacer(Modifier.height(4.dp))
160 Text(
161 withInterDigits(
162 if (observing) {
163 "Earhorn is mirroring playback from ${connectedName ?: "a Cast device"}."
164 } else {
165 "Pick a Chromecast or speaker group to mirror its current playback."
166 },
167 ),
168 style = MaterialTheme.typography.bodyMedium,
169 color = OnMuted,
170 maxLines = 3,
171 softWrap = true,
172 overflow = TextOverflow.Ellipsis,
173 )
174 Spacer(Modifier.height(20.dp))
175
176 // Disconnect chip — full-width above the two columns when observing.
177 // Gets initial focus so D-pad Center disconnects immediately if the
178 // user opened the picker just to leave.
179 if (observing && connectedName != null) {
180 DisconnectRow(
181 label = "Disconnect from ${connectedName!!}",
182 modifier = Modifier.focusRequester(firstFocus),
183 onClick = {
184 rawCast.disconnect()
185 onDismiss()
186 },
187 )
188 Spacer(Modifier.height(8.dp))
189 Text(
190 "Switch to",
191 style = MaterialTheme.typography.labelLarge,
192 color = OnQuiet,
193 )
194 Spacer(Modifier.height(8.dp))
195 }
196
197 // The two columns. Each column gets its own SectionLabel header and
198 // its own LazyColumn underneath. Row weight=1f for both so they share
199 // the available width evenly.
200 Row(
201 Modifier.weight(1f).fillMaxWidth(),
202 horizontalArrangement = Arrangement.spacedBy(16.dp),
203 ) {
204 // Left column: Speaker groups. First row of this column gets
205 // initial focus when not observing AND groups are present.
206 val groupsGetInitialFocus = !observing && groups.isNotEmpty()
207 PickerColumn(
208 modifier = Modifier.weight(1f).fillMaxHeight(),
209 header = "Speaker groups",
210 items = groups,
211 emptyMessage = "No speaker groups\non this network.",
212 focusRequesterForFirstRow = if (groupsGetInitialFocus) firstFocus else null,
213 connectingId = connectingTo,
214 connectedDeviceName = connectedName,
215 observing = observing,
216 onSelect = { device ->
217 connectingTo = device.id
218 scope.launch {
219 // Keep the previous session if this one can't be
220 // reached; only leave the picker on success.
221 val ok = rawCast.observeOrRestore(device)
222 connectingTo = null
223 if (ok) {
224 onDismiss()
225 onObserveStarted?.invoke()
226 }
227 }
228 },
229 )
230 // Right column: individual speakers. First row of this column
231 // gets initial focus only when groups list is empty.
232 val singlesGetInitialFocus = !observing && groups.isEmpty() && singles.isNotEmpty()
233 PickerColumn(
234 modifier = Modifier.weight(1f).fillMaxHeight(),
235 header = "Speakers",
236 items = singles,
237 emptyMessage = "No individual\nspeakers on\nthis network.",
238 focusRequesterForFirstRow = if (singlesGetInitialFocus) firstFocus else null,
239 connectingId = connectingTo,
240 connectedDeviceName = connectedName,
241 observing = observing,
242 onSelect = { device ->
243 connectingTo = device.id
244 scope.launch {
245 // Keep the previous session if this one can't be
246 // reached; only leave the picker on success.
247 val ok = rawCast.observeOrRestore(device)
248 connectingTo = null
249 if (ok) {
250 onDismiss()
251 onObserveStarted?.invoke()
252 }
253 }
254 },
255 )
256 }
257
258 // When both columns are empty: print a single placeholder where the
259 // columns would be. Discovery may still be in progress.
260 if (devices.isEmpty()) {
261 Spacer(Modifier.height(16.dp))
262 Box(
263 Modifier
264 .fillMaxWidth()
265 .background(SurfaceRaised)
266 .padding(horizontal = 18.dp, vertical = 14.dp),
267 ) {
268 Text(
269 "Scanning for Chromecast devices…",
270 style = MaterialTheme.typography.bodyMedium,
271 color = OnMuted,
272 )
273 }
274 }
275
276 Spacer(Modifier.height(16.dp))
277 CancelRow(
278 onClick = onDismiss,
279 // Maintainer, 2026-10-29: initial focus when no device row has it.
280 modifier = if (cancelGetsInitialFocus) Modifier.focusRequester(firstFocus) else Modifier,
281 )
282 }
283 }
284}
285
286@Composable
287private fun PickerColumn(
288 modifier: Modifier = Modifier,
289 header: String,
290 items: List<CastDevice>,
291 emptyMessage: String,
292 focusRequesterForFirstRow: FocusRequester?,
293 connectingId: String?,
294 connectedDeviceName: String?,
295 observing: Boolean,
296 onSelect: (CastDevice) -> Unit,
297) {
298 Column(modifier) {
299 SectionLabel(header)
300 Spacer(Modifier.height(4.dp))
301 if (items.isEmpty()) {
302 Box(
303 Modifier
304 .fillMaxWidth()
305 .background(SurfaceRaised)
306 .padding(horizontal = 14.dp, vertical = 12.dp),
307 ) {
308 Text(
309 emptyMessage,
310 style = MaterialTheme.typography.bodySmall,
311 color = OnMuted,
312 )
313 }
314 } else {
315 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
316 items(items, key = { it.id }) { device ->
317 val isFirstRow = items.firstOrNull()?.id == device.id
318 CastDeviceRow(
319 name = device.name,
320 sublabel = if (device.isGroup) "Speaker group" else device.model,
321 isGroup = device.isGroup,
322 connecting = connectingId == device.id,
323 currentlyConnected = observing && connectedDeviceName == device.name,
324 modifier = if (isFirstRow && focusRequesterForFirstRow != null) {
325 Modifier.focusRequester(focusRequesterForFirstRow)
326 } else {
327 Modifier
328 },
329 onClick = { onSelect(device) },
330 )
331 }
332 }
333 }
334 }
335}
336
337@Composable
338private fun SectionLabel(text: String) {
339 Text(
340 text,
341 style = MaterialTheme.typography.labelLarge,
342 color = OnQuiet,
343 modifier = Modifier.padding(vertical = 4.dp),
344 )
345}
346
347@Composable
348private fun CastDeviceRow(
349 name: String,
350 sublabel: String,
351 isGroup: Boolean,
352 connecting: Boolean,
353 currentlyConnected: Boolean,
354 modifier: Modifier = Modifier,
355 onClick: () -> Unit,
356) {
357 val interaction = remember { MutableInteractionSource() }
358 Box(
359 modifier
360 .fillMaxWidth()
361 .background(SurfaceRaised)
362 .staveFocusable(interaction, cornerRadiusDp = 6)
363 .clickable(interaction, indication = null) { onClick() }
364 .padding(horizontal = 14.dp, vertical = 12.dp),
365 ) {
366 Row(verticalAlignment = Alignment.CenterVertically) {
367 Icon(
368 imageVector = when {
369 currentlyConnected -> Icons.Filled.CastConnected
370 isGroup -> Icons.Filled.SpeakerGroup
371 else -> Icons.Filled.Speaker
372 },
373 contentDescription = null,
374 tint = if (currentlyConnected) AccentAmber else OnPrimary,
375 modifier = Modifier.width(24.dp),
376 )
377 Spacer(Modifier.width(10.dp))
378 Column(Modifier.weight(1f)) {
379 Text(
380 withInterDigits(name),
381 style = MaterialTheme.typography.bodyMedium,
382 color = if (currentlyConnected) AccentAmber else OnPrimary,
383 maxLines = 2,
384 overflow = TextOverflow.Ellipsis,
385 )
386 Text(
387 when {
388 connecting -> "Connecting…"
389 currentlyConnected -> "Currently following"
390 else -> sublabel
391 },
392 style = MaterialTheme.typography.bodySmall,
393 color = OnMuted,
394 maxLines = 1,
395 overflow = TextOverflow.Ellipsis,
396 )
397 }
398 }
399 }
400}
401
402@Composable
403private fun DisconnectRow(
404 label: String,
405 modifier: Modifier = Modifier,
406 onClick: () -> Unit,
407) {
408 val interaction = remember { MutableInteractionSource() }
409 Box(
410 modifier
411 .fillMaxWidth()
412 .background(SurfaceRaised)
413 .staveFocusable(interaction, cornerRadiusDp = 6)
414 .clickable(interaction, indication = null) { onClick() }
415 .padding(horizontal = 18.dp, vertical = 14.dp),
416 ) {
417 Row(verticalAlignment = Alignment.CenterVertically) {
418 Icon(
419 imageVector = Icons.Filled.Cast,
420 contentDescription = null,
421 tint = AccentAmber,
422 modifier = Modifier.width(28.dp),
423 )
424 Spacer(Modifier.width(12.dp))
425 Text(
426 withInterDigits(label),
427 style = MaterialTheme.typography.bodyLarge,
428 color = AccentAmber,
429 maxLines = 2,
430 softWrap = true,
431 overflow = TextOverflow.Ellipsis,
432 modifier = Modifier.weight(1f),
433 )
434 }
435 }
436}
437
438@Composable
439private fun CancelRow(
440 onClick: () -> Unit,
441 // Maintainer, 2026-10-29: lets the caller attach firstFocus.
442 modifier: Modifier = Modifier,
443) {
444 val interaction = remember { MutableInteractionSource() }
445 Box(
446 modifier
447 .fillMaxWidth()
448 .background(SurfaceRaised)
449 .staveFocusable(interaction, cornerRadiusDp = 6)
450 .clickable(interaction, indication = null) { onClick() }
451 .padding(horizontal = 18.dp, vertical = 14.dp),
452 ) {
453 Row(verticalAlignment = Alignment.CenterVertically) {
454 Text(
455 "✕",
456 style = MaterialTheme.typography.titleMedium,
457 color = OnQuiet,
458 modifier = Modifier.width(28.dp),
459 )
460 Text(
461 "Cancel",
462 style = MaterialTheme.typography.bodyLarge,
463 color = OnQuiet,
464 modifier = Modifier.weight(1f),
465 )
466 }
467 }
468}