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

CastPickerSheet.kt

713 lines · 34.1 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.widthIn
16import androidx.compose.foundation.layout.height
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.width
19import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.items
21// Maintainer, 2026-10-29: scrollable portrait diagnostics.
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.verticalScroll
24import kotlinx.coroutines.launch
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.DisposableEffect
27import androidx.compose.runtime.LaunchedEffect
28import androidx.compose.runtime.collectAsState
29import androidx.compose.runtime.getValue
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.focus.FocusRequester
36import androidx.compose.ui.focus.focusProperties
37import androidx.compose.foundation.focusGroup
38import androidx.compose.ui.focus.focusRequester
39import androidx.compose.ui.graphics.Color
40import androidx.compose.ui.input.key.Key
41import androidx.compose.ui.input.key.key
42import androidx.compose.ui.input.key.onPreviewKeyEvent
43import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.draw.drawWithContent
45import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.sp
47import androidx.tv.material3.MaterialTheme
48import androidx.tv.material3.Text
49import dev.stave.cast.CastIntegration
50import dev.stave.ui.components.staveFocusable
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
57
58/**
59 * Compose-native Cast device picker. Matches the visual language of ActionSheet
60 * (black scrim + right-edge panel) and is D-pad navigable without relying on
61 * AppCompat dialogs that don't play nice with our Material-themed Activity.
62 *
63 * Issue 3: Devices are split into two sections — Speaker Groups at the top,
64 * individual speakers below — matching the TV RawCastPickerSheet's two-column
65 * layout adapted for touch.
66 *
67 * Issue 2: When [rawCast] is provided, an "Observe a device" section appears
68 * below the Cast-to section, allowing a second phone to observe and control
69 * a Cast session started by another sender (same capability the TV has via
70 * RawCastPickerSheet).
71 */
72@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
73@Composable
74fun CastPickerSheet(
75 cast: CastIntegration,
76 visible: Boolean,
77 onDismiss: () -> Unit,
78 rawCast: dev.stave.cast.raw.RawCastObserver? = null,
79 /** Invoked after an observe target is selected (see RawCastPickerSheet). */
80 onObserveStarted: (() -> Unit)? = null,
81) {
82 if (!visible) return
83
84 // Scope for pick attempts. Previously each tap allocated a MainScope()
85 // that was never cancelled (audit 2026-09-02). This one belongs to the
86 // composition; a pick outlives the sheet only through the observer's
87 // own bring-up, which is scope-independent by design.
88 val pickScope = androidx.compose.runtime.rememberCoroutineScope()
89
90 // Maintainer, 2026-10-29: an observe pick can take up to ~20 s (TLS
91 // connect + handshake) and showed nothing. The tapped row now says
92 // "Connecting…" until the result is in, and further taps are ignored
93 // meanwhile. Same pattern as RawCastPickerSheet (TV). On failure the
94 // observer's own toast ("Couldn't reach …") still fires.
95 var connectingTo by remember { mutableStateOf<String?>(null) }
96 val observePick: (dev.stave.cast.raw.RawCastObserver, dev.stave.cast.raw.RawCastObserver.CastDevice) -> Unit =
97 { observer, device ->
98 if (connectingTo == null) {
99 connectingTo = device.id
100 // Maintainer, 2026-10-29: casting and observing could run at once —
101 // the cast session was then hidden and Stop went to the group.
102 // Once following succeeds, leave our own cast session — WITHOUT
103 // stopping the speaker, so following the speaker you were just
104 // casting to keeps its music playing (the explicit "Stop
105 // casting" row is the way to stop it). On failure the cast
106 // session is kept, like the previous observation is.
107 pickScope.launch {
108 val ok = observer.observeOrRestore(device)
109 connectingTo = null
110 if (ok) {
111 if (cast.isCasting.value) cast.detachWithoutStopping()
112 onDismiss()
113 onObserveStarted?.invoke()
114 }
115 }
116 }
117 }
118
119 // Discovery lifecycle — scoped to picker visibility.
120 DisposableEffect(Unit) {
121 cast.startDiscovery()
122 rawCast?.startDiscovery()
123 onDispose {
124 cast.stopDiscovery()
125 rawCast?.stopDiscovery()
126 }
127 }
128
129 BackHandler(onBack = onDismiss)
130
131 val rawDevices by cast.devices.collectAsState()
132 // Maintainer, 2026-10-29: active cast session, for the "Stop casting to …"
133 // row at the top of the Cast-to section (both orientations). Both
134 // flavours expose isCasting/session; libre's are always false/null.
135 val isCasting by cast.isCasting.collectAsState()
136 val castSession by cast.session.collectAsState()
137 val stopCastingLabel =
138 "Stop casting to " + (castSession?.deviceName?.takeIf { it.isNotBlank() } ?: "device")
139 // Dedupe + sort. Cast framework occasionally reports the same physical
140 // device under multiple route IDs across a connection drop (the old
141 // route lingers in MediaRouter for ~30s before the framework
142 // garbage-collects it). Collapse to one entry per (friendlyName, isGroup)
143 // pair, keeping the LAST ID seen — when a speaker reconnects to WiFi,
144 // MediaRouter reports the stale route first and the fresh one later.
145 // associateBy overwrites, so the fresh route wins. Then sort
146 // alphabetically so the list is stable as routes appear/disappear.
147 val devices = remember(rawDevices) {
148 rawDevices
149 .associateBy { it.friendlyName.trim().lowercase() to it.isGroup }
150 .values.toList()
151 .sortedBy { it.friendlyName.trim().lowercase() }
152 }
153 val firstFocus = remember { FocusRequester() }
154 LaunchedEffect(devices.isNotEmpty()) {
155 if (devices.isNotEmpty()) {
156 runCatching { firstFocus.requestFocus() }
157 }
158 }
159
160 Box(
161 Modifier
162 .fillMaxSize()
163 .background(Color.Black.copy(alpha = 0.55f))
164 .clickable(
165 interactionSource = remember { MutableInteractionSource() },
166 indication = null,
167 ) { onDismiss() }
168 .onPreviewKeyEvent { event ->
169 event.key == Key.DirectionLeft || event.key == Key.DirectionRight
170 }
171 // Focus trap (see RawCastPickerSheet for full rationale): the
172 // key-event guard above only intercepts Left/Right — Up/Down
173 // could still spatially escape onto invisible focusables
174 // behind the scrim on D-pad-driven devices. Cancelling exit
175 // pins all directional navigation inside the sheet.
176 .focusProperties { exit = { FocusRequester.Cancel } }
177 .focusGroup(),
178 contentAlignment = Alignment.CenterEnd,
179 ) {
180 val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
181 android.content.res.Configuration.ORIENTATION_LANDSCAPE
182 if (isLandscape) {
183 // Raw-cast (observe) state — mirrors the portrait branch's
184 // collection. The observe section was portrait-only until now
185 // (field-reported: tablet landscape offered only Cast-to
186 // targets with no observation available at all).
187 val lRawDevices = if (rawCast != null) {
188 val st by rawCast.devices.collectAsState()
189 st
190 } else emptyList()
191 val lRawObserving = if (rawCast != null) {
192 val st by rawCast.observing.collectAsState()
193 st
194 } else false
195 val lRawConnName = if (rawCast != null) {
196 val st by rawCast.connectedDeviceName.collectAsState()
197 st
198 } else null
199 // Landscape: compact header across the top, then the three
200 // device categories side by side — Speaker Groups, Individual
201 // Speakers, Observe — each its own independently-scrolling
202 // column, all visible at once. (First landing of this layout
203 // crashed with "Key was already used": a code-generation
204 // escaping fault emitted the key template as LITERAL text
205 // strings instead of interpolated ids, so every row shared
206 // one key. Keys below are real templates.)
207 Column(
208 Modifier
209 .fillMaxSize()
210 .background(SurfaceBase)
211 .clickable(
212 interactionSource = remember { MutableInteractionSource() },
213 indication = null,
214 ) { /* consume click so scrim dismiss doesn't fire */ }
215 .padding(horizontal = 28.dp, vertical = 24.dp),
216 ) {
217 // Single-line header: bold "Cast to…" runs straight into
218 // the unbolded blurb on ONE line — the stacked variant
219 // wrapped "automatically" onto a second line in landscape
220 // (field-rejected). The blurb baseline-aligns with the
221 // headline and single-line width-shrinks into the
222 // remaining row width (the original proven shrink use:
223 // one line, width overflow only), so a single line is
224 // guaranteed on any landscape width.
225 Row(Modifier.fillMaxWidth()) {
226 Text(
227 "Cast to…",
228 style = MaterialTheme.typography.headlineMedium,
229 color = OnPrimary,
230 maxLines = 1,
231 modifier = Modifier.alignByBaseline(),
232 )
233 var blurbSize by remember { mutableStateOf(17f) }
234 var blurbReady by remember { mutableStateOf(false) }
235 Text(
236 " a speaker or group, or observe & control an ongoing Cast. Groups sync automatically.",
237 style = MaterialTheme.typography.bodyLarge.copy(fontSize = blurbSize.sp),
238 color = OnMuted,
239 maxLines = 1,
240 softWrap = false,
241 onTextLayout = { r ->
242 if (r.hasVisualOverflow && blurbSize > 10f) blurbSize -= 1f
243 else blurbReady = true
244 },
245 modifier = Modifier
246 .alignByBaseline()
247 .weight(1f)
248 .drawWithContent { if (blurbReady) drawContent() },
249 )
250 Spacer(Modifier.width(16.dp))
251 CloseButton(onClick = onDismiss)
252 }
253 Spacer(Modifier.height(20.dp))
254 // Maintainer, 2026-10-29: a way to stop casting from the picker.
255 if (isCasting) {
256 DeviceRow(
257 name = stopCastingLabel,
258 isGroup = false,
259 onClick = { cast.disconnect(); onDismiss() },
260 focusRequester = null,
261 )
262 Spacer(Modifier.height(12.dp))
263 }
264 if (devices.isEmpty() && lRawDevices.isEmpty() && !lRawObserving) {
265 Text(
266 "Searching for devices…",
267 style = MaterialTheme.typography.bodyLarge,
268 color = OnMuted,
269 )
270 } else {
271 val groups = devices.filter { it.isGroup }
272 val individuals = devices.filter { !it.isGroup }
273 val rawCastLocal = rawCast
274 val showObserve =
275 rawCastLocal != null && (lRawDevices.isNotEmpty() || lRawObserving)
276 Row(
277 Modifier.weight(1f),
278 horizontalArrangement = Arrangement.spacedBy(24.dp),
279 ) {
280 // Column 1 — Speaker Groups
281 Column(Modifier.weight(1f).fillMaxHeight()) {
282 Text(
283 "Cast to Group",
284 style = MaterialTheme.typography.labelLarge,
285 color = OnMuted,
286 modifier = Modifier.padding(bottom = 6.dp),
287 )
288 if (groups.isEmpty()) {
289 Text(
290 "None found",
291 style = MaterialTheme.typography.bodySmall,
292 color = OnQuiet,
293 )
294 } else {
295 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
296 items(groups, key = { "lg:" + it.id }) { device ->
297 DeviceRow(
298 name = device.friendlyName,
299 isGroup = true,
300 onClick = { cast.connect(device.id); onDismiss() },
301 focusRequester = if (device.id == devices.firstOrNull()?.id) firstFocus else null,
302 )
303 }
304 }
305 }
306 }
307 // Column 2 — Individual Speakers
308 Column(Modifier.weight(1f).fillMaxHeight()) {
309 Text(
310 "Cast to a Speaker",
311 style = MaterialTheme.typography.labelLarge,
312 color = OnMuted,
313 modifier = Modifier.padding(bottom = 6.dp),
314 )
315 if (individuals.isEmpty()) {
316 Text(
317 "None found",
318 style = MaterialTheme.typography.bodySmall,
319 color = OnQuiet,
320 )
321 } else {
322 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
323 items(individuals, key = { "li:" + it.id }) { device ->
324 DeviceRow(
325 name = device.friendlyName,
326 isGroup = false,
327 onClick = { cast.connect(device.id); onDismiss() },
328 focusRequester = if (groups.isEmpty() && device.id == individuals.firstOrNull()?.id) firstFocus else null,
329 )
330 }
331 }
332 }
333 }
334 // Column 3 — Observe (only when there is anything
335 // to observe or an active session to disconnect)
336 if (showObserve && rawCastLocal != null) {
337 Column(Modifier.weight(1f).fillMaxHeight()) {
338 Text(
339 "Observe a Cast",
340 style = MaterialTheme.typography.labelLarge,
341 color = OnMuted,
342 modifier = Modifier.padding(bottom = 6.dp),
343 )
344 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
345 if (lRawObserving) {
346 item(key = "lo:disconnect") {
347 DeviceRow(
348 name = "Disconnect from " + (lRawConnName ?: "device"),
349 isGroup = false,
350 onClick = { rawCastLocal.disconnect(); onDismiss() },
351 focusRequester = null,
352 )
353 }
354 }
355 items(lRawDevices, key = { "lo:" + it.id }) { device ->
356 DeviceRow(
357 name = device.name + if (device.isGroup) " (group)" else "",
358 isGroup = device.isGroup,
359 onClick = { observePick(rawCastLocal, device) },
360 focusRequester = null,
361 connecting = connectingTo == device.id,
362 )
363 }
364 }
365 }
366 }
367 }
368 }
369 }
370
371 } else {
372 // Portrait: right-edge panel
373 Column(
374 Modifier
375 .fillMaxHeight()
376 // Never wider than the screen (review N.36, the maintainer's
377 // ruling 2026-09-02): a fixed 480dp put the left column
378 // off every portrait phone. TV/tablet unchanged.
379 .fillMaxWidth()
380 .widthIn(max = 480.dp)
381 .background(SurfaceBase)
382 .clickable(
383 interactionSource = remember { MutableInteractionSource() },
384 indication = null,
385 ) { /* consume */ }
386 .padding(horizontal = 28.dp, vertical = 48.dp),
387 ) {
388 Row(
389 Modifier.fillMaxWidth(),
390 verticalAlignment = Alignment.CenterVertically,
391 ) {
392 Text(
393 "Cast to…",
394 style = MaterialTheme.typography.titleLarge,
395 color = OnPrimary,
396 modifier = Modifier.weight(1f),
397 )
398 CloseButton(onClick = onDismiss)
399 }
400 Spacer(Modifier.height(4.dp))
401 Text(
402 // Copy parity with landscape: identical words continuing
403 // from "Cast to…" above; stacked (wrapping) rather than
404 // inline because this 480dp panel cannot hold the ~90-char
405 // line at readable size — landscape's single-line shrink
406 // needs ~600dp even at its floor.
407 "a speaker or group, or observe & control an ongoing Cast. Groups sync automatically.",
408 style = MaterialTheme.typography.bodyMedium,
409 color = OnMuted,
410 maxLines = 3,
411 )
412 Spacer(Modifier.height(28.dp))
413
414 // Maintainer, 2026-10-29: portrait restructured — the Observe section
415 // used to live inside the `else` of `if (devices.isEmpty())`, so with
416 // no Cast-SDK devices (always on libre, where cast.available is false)
417 // the user could neither follow a Cast nor stop following one. Now:
418 // Cast-to section (only when cast.available), then Observe ALWAYS
419 // (when rawCast != null). Raw-cast state is collected up front.
420 // Issue 2: Collect raw-cast state BEFORE layout.
421 @Suppress("SimplifiableCallChain")
422 val rawDeviceList = if (rawCast != null) {
423 val s by rawCast.devices.collectAsState()
424 s
425 } else emptyList()
426 val rawIsObserving = if (rawCast != null) {
427 val s by rawCast.observing.collectAsState()
428 s
429 } else false
430 val rawConnName = if (rawCast != null) {
431 val s by rawCast.connectedDeviceName.collectAsState()
432 s ?: ""
433 } else ""
434
435 // Maintainer, 2026-10-29: a way to stop casting from the picker.
436 if (isCasting) {
437 DeviceRow(
438 name = stopCastingLabel,
439 isGroup = false,
440 onClick = { cast.disconnect(); onDismiss() },
441 focusRequester = null,
442 )
443 Spacer(Modifier.height(12.dp))
444 }
445
446 // Maintainer, 2026-10-29: when !cast.available (no Cast SDK in this
447 // build) neither branch runs — no Cast-to section or diagnostics,
448 // only the Observe section below.
449 if (cast.available && devices.isEmpty()) {
450 val diag by cast.diagnostic.collectAsState()
451 // Maintainer, 2026-10-29: diagnostics scroll inside a weighted
452 // (non-filling) column so a long route list can't push the
453 // Observe section off the panel.
454 Column(
455 Modifier
456 .weight(1f, fill = false)
457 .verticalScroll(rememberScrollState()),
458 ) {
459 // Self-diagnostic panel. Replaces the lonely "Searching for
460 // devices…" label with something that shows WHY the list is
461 // empty — the user-visible equivalent of logcat since this app
462 // is shipped without in-app log access.
463 Text(
464 "Searching for devices…",
465 style = MaterialTheme.typography.bodyLarge,
466 color = OnMuted,
467 )
468 Spacer(Modifier.height(20.dp))
469 DiagLine("Play Services", diag.playServices)
470 DiagLine("Cast framework", if (diag.castContextOk) "initialized" else "missing")
471 // When Cast init failed, show the actual exception class + message
472 // here so we can diagnose "missing... but why?" — the most likely
473 // cause on Android TV is that CastContext.getSharedInstance
474 // threw with a specific reason (missing OptionsProvider class,
475 // Play Services component unavailable, etc.).
476 diag.castInitError?.let { err ->
477 Text(
478 withInterDigits(" \u2514 $err"),
479 style = MaterialTheme.typography.labelSmall,
480 color = OnQuiet,
481 modifier = Modifier.fillMaxWidth(),
482 )
483 }
484 DiagLine("MediaRouter", if (diag.mediaRouterOk) "ready" else "missing")
485 DiagLine(
486 "Routes discovered",
487 "${diag.rawRouteCount} total, ${diag.filteredDeviceCount} pickable",
488 )
489 DiagLine("Polling tick", diag.tick.toString())
490 if (diag.rawRouteNames.isNotEmpty()) {
491 Spacer(Modifier.height(12.dp))
492 Text(
493 "All routes seen:",
494 style = MaterialTheme.typography.labelSmall,
495 color = OnQuiet,
496 )
497 diag.rawRouteNames.forEach { label ->
498 Text(
499 withInterDigits(" \u00b7 $label"),
500 style = MaterialTheme.typography.labelSmall,
501 color = OnQuiet,
502 maxLines = 1,
503 overflow = TextOverflow.Ellipsis,
504 )
505 }
506 }
507 } // Maintainer, 2026-10-29: end scrollable diagnostics Column
508 } else if (cast.available) {
509 // Issue 3: Two side-by-side columns — groups left,
510 // individuals right — matching the TV RawCastPickerSheet.
511 val groups = devices.filter { it.isGroup }
512 val individuals = devices.filter { !it.isGroup }
513
514 // Two-column device picker
515 Row(
516 modifier = Modifier.weight(1f),
517 horizontalArrangement = Arrangement.spacedBy(12.dp),
518 ) {
519 // Left column — Speaker Groups
520 Column(Modifier.weight(1f)) {
521 Text(
522 "Cast to Group",
523 style = MaterialTheme.typography.labelLarge,
524 color = OnMuted,
525 modifier = Modifier.padding(bottom = 6.dp),
526 )
527 if (groups.isEmpty()) {
528 Text("No groups found", style = MaterialTheme.typography.bodySmall, color = OnQuiet)
529 } else {
530 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
531 items(groups, key = { "g:${it.id}" }) { device ->
532 DeviceRow(
533 name = device.friendlyName,
534 isGroup = true,
535 onClick = { cast.connect(device.id); onDismiss() },
536 focusRequester = if (device.id == groups.first().id) firstFocus else null,
537 )
538 }
539 }
540 }
541 }
542 // Right column — Individual Speakers
543 Column(Modifier.weight(1f)) {
544 Text(
545 "Cast to a Speaker",
546 style = MaterialTheme.typography.labelLarge,
547 color = OnMuted,
548 modifier = Modifier.padding(bottom = 6.dp),
549 )
550 if (individuals.isEmpty()) {
551 Text("No speakers found", style = MaterialTheme.typography.bodySmall, color = OnQuiet)
552 } else {
553 LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
554 items(individuals, key = { "i:${it.id}" }) { device ->
555 DeviceRow(
556 name = device.friendlyName,
557 isGroup = false,
558 onClick = { cast.connect(device.id); onDismiss() },
559 focusRequester = null,
560 )
561 }
562 }
563 }
564 }
565 }
566 }
567
568 // Observe section — below the Cast-to section.
569 // Maintainer, 2026-10-29: now shown ALWAYS when rawCast != null (was
570 // only when Cast-SDK devices existed): Disconnect row while
571 // observing, the followable device list, or a "looking" line.
572 if (rawCast != null) {
573 Spacer(Modifier.height(16.dp))
574 Text(
575 "Observe a Cast",
576 style = MaterialTheme.typography.labelLarge,
577 color = OnMuted,
578 modifier = Modifier.padding(bottom = 6.dp),
579 )
580 if (rawIsObserving) {
581 DeviceRow(
582 name = "Disconnect from ${rawConnName.ifBlank { "device" }}",
583 isGroup = false,
584 onClick = { rawCast.disconnect(); onDismiss() },
585 focusRequester = null,
586 )
587 Spacer(Modifier.height(6.dp))
588 }
589 if (rawDeviceList.isNotEmpty()) {
590 LazyColumn(
591 modifier = Modifier.weight(1f, fill = false),
592 verticalArrangement = Arrangement.spacedBy(6.dp),
593 ) {
594 items(rawDeviceList, key = { "obs:${it.id}" }) { device ->
595 DeviceRow(
596 name = device.name + if (device.isGroup) " (group)" else "",
597 isGroup = device.isGroup,
598 onClick = { observePick(rawCast, device) },
599 focusRequester = null,
600 connecting = connectingTo == device.id,
601 )
602 }
603 }
604 } else {
605 Text(
606 "Looking for Cast devices to follow…",
607 style = MaterialTheme.typography.bodySmall,
608 color = OnQuiet,
609 )
610 }
611 }
612 } // end portrait Column
613 } // end else
614 }
615}
616
617@Composable
618private fun DeviceRow(
619 name: String,
620 isGroup: Boolean,
621 onClick: () -> Unit,
622 focusRequester: FocusRequester?,
623 connecting: Boolean = false,
624) {
625 val interaction = remember { MutableInteractionSource() }
626 Box(
627 Modifier
628 .fillMaxWidth()
629 .background(SurfaceRaised)
630 .let { if (focusRequester != null) it.focusRequester(focusRequester) else it }
631 .staveFocusable(interaction, cornerRadiusDp = 6)
632 .clickable(interaction, indication = null) { onClick() }
633 .padding(horizontal = 18.dp, vertical = 14.dp),
634 ) {
635 Row(verticalAlignment = Alignment.CenterVertically) {
636 Text(
637 if (isGroup) "◉" else "○",
638 style = MaterialTheme.typography.titleMedium,
639 color = OnPrimary,
640 modifier = Modifier.width(28.dp),
641 )
642 Column(Modifier.weight(1f)) {
643 Text(
644 withInterDigits(name),
645 style = MaterialTheme.typography.bodyLarge,
646 color = OnPrimary,
647 maxLines = 2,
648 overflow = TextOverflow.Ellipsis,
649 )
650 if (connecting) {
651 Text(
652 "Connecting…",
653 style = MaterialTheme.typography.bodySmall,
654 color = OnPrimary,
655 )
656 } else if (isGroup) {
657 Text(
658 "Speaker Group",
659 style = MaterialTheme.typography.bodySmall,
660 color = OnMuted,
661 )
662 }
663 }
664 }
665 }
666}
667
668/** Visible way out of the picker (decision, 2026-10-29): the panel covers
669 * the whole screen on phones, so tap-outside never reached the scrim and
670 * Back was the only exit. */
671@Composable
672private fun CloseButton(onClick: () -> Unit) {
673 val interaction = remember { MutableInteractionSource() }
674 Box(
675 Modifier
676 .background(SurfaceRaised)
677 .staveFocusable(interaction, cornerRadiusDp = 6)
678 .clickable(interaction, indication = null) { onClick() }
679 .padding(horizontal = 16.dp, vertical = 10.dp),
680 ) {
681 Text(
682 "✕ Close",
683 style = MaterialTheme.typography.titleMedium,
684 color = OnPrimary,
685 )
686 }
687}
688
689/** Two-column row for the diagnostic panel: label on the left, value on the
690 * right. Styled as muted metadata — intentionally small to stay out of the
691 * way when Cast is actually working (this panel only appears in the empty
692 * state). */
693@Composable
694private fun DiagLine(label: String, value: String) {
695 Row(
696 Modifier.fillMaxWidth().padding(vertical = 2.dp),
697 horizontalArrangement = Arrangement.SpaceBetween,
698 ) {
699 Text(
700 label,
701 style = MaterialTheme.typography.labelSmall,
702 color = OnMuted,
703 )
704 Text(
705 withInterDigits(value),
706 style = MaterialTheme.typography.labelSmall,
707 color = OnPrimary,
708 maxLines = 1,
709 overflow = TextOverflow.Ellipsis,
710 modifier = Modifier.padding(start = 16.dp),
711 )
712 }
713}