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

CastPickerSheet.kt

package dev.stave.ui.cast

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// Maintainer, 2026-10-29: scrollable portrait diagnostics.
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import kotlinx.coroutines.launch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.cast.CastIntegration
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceBase
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.withInterDigits

/**
 * Compose-native Cast device picker. Matches the visual language of ActionSheet
 * (black scrim + right-edge panel) and is D-pad navigable without relying on
 * AppCompat dialogs that don't play nice with our Material-themed Activity.
 *
 * Issue 3: Devices are split into two sections — Speaker Groups at the top,
 * individual speakers below — matching the TV RawCastPickerSheet's two-column
 * layout adapted for touch.
 *
 * Issue 2: When [rawCast] is provided, an "Observe a device" section appears
 * below the Cast-to section, allowing a second phone to observe and control
 * a Cast session started by another sender (same capability the TV has via
 * RawCastPickerSheet).
 */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun CastPickerSheet(
    cast: CastIntegration,
    visible: Boolean,
    onDismiss: () -> Unit,
    rawCast: dev.stave.cast.raw.RawCastObserver? = null,
    /** Invoked after an observe target is selected (see RawCastPickerSheet). */
    onObserveStarted: (() -> Unit)? = null,
) {
    if (!visible) return

    // Scope for pick attempts. Previously each tap allocated a MainScope()
    // that was never cancelled (audit 2026-09-02). This one belongs to the
    // composition; a pick outlives the sheet only through the observer's
    // own bring-up, which is scope-independent by design.
    val pickScope = androidx.compose.runtime.rememberCoroutineScope()

    // Maintainer, 2026-10-29: an observe pick can take up to ~20 s (TLS
    // connect + handshake) and showed nothing. The tapped row now says
    // "Connecting…" until the result is in, and further taps are ignored
    // meanwhile. Same pattern as RawCastPickerSheet (TV). On failure the
    // observer's own toast ("Couldn't reach …") still fires.
    var connectingTo by remember { mutableStateOf<String?>(null) }
    val observePick: (dev.stave.cast.raw.RawCastObserver, dev.stave.cast.raw.RawCastObserver.CastDevice) -> Unit =
        { observer, device ->
            if (connectingTo == null) {
                connectingTo = device.id
                // Maintainer, 2026-10-29: casting and observing could run at once —
                // the cast session was then hidden and Stop went to the group.
                // Once following succeeds, leave our own cast session — WITHOUT
                // stopping the speaker, so following the speaker you were just
                // casting to keeps its music playing (the explicit "Stop
                // casting" row is the way to stop it). On failure the cast
                // session is kept, like the previous observation is.
                pickScope.launch {
                    val ok = observer.observeOrRestore(device)
                    connectingTo = null
                    if (ok) {
                        if (cast.isCasting.value) cast.detachWithoutStopping()
                        onDismiss()
                        onObserveStarted?.invoke()
                    }
                }
            }
        }

    // Discovery lifecycle — scoped to picker visibility.
    DisposableEffect(Unit) {
        cast.startDiscovery()
        rawCast?.startDiscovery()
        onDispose {
            cast.stopDiscovery()
            rawCast?.stopDiscovery()
        }
    }

    BackHandler(onBack = onDismiss)

    val rawDevices by cast.devices.collectAsState()
    // Maintainer, 2026-10-29: active cast session, for the "Stop casting to …"
    // row at the top of the Cast-to section (both orientations). Both
    // flavours expose isCasting/session; libre's are always false/null.
    val isCasting by cast.isCasting.collectAsState()
    val castSession by cast.session.collectAsState()
    val stopCastingLabel =
        "Stop casting to " + (castSession?.deviceName?.takeIf { it.isNotBlank() } ?: "device")
    // Dedupe + sort. Cast framework occasionally reports the same physical
    // device under multiple route IDs across a connection drop (the old
    // route lingers in MediaRouter for ~30s before the framework
    // garbage-collects it). Collapse to one entry per (friendlyName, isGroup)
    // pair, keeping the LAST ID seen — when a speaker reconnects to WiFi,
    // MediaRouter reports the stale route first and the fresh one later.
    // associateBy overwrites, so the fresh route wins. Then sort
    // alphabetically so the list is stable as routes appear/disappear.
    val devices = remember(rawDevices) {
        rawDevices
            .associateBy { it.friendlyName.trim().lowercase() to it.isGroup }
            .values.toList()
            .sortedBy { it.friendlyName.trim().lowercase() }
    }
    val firstFocus = remember { FocusRequester() }
    LaunchedEffect(devices.isNotEmpty()) {
        if (devices.isNotEmpty()) {
            runCatching { firstFocus.requestFocus() }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.55f))
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) { onDismiss() }
            .onPreviewKeyEvent { event ->
                event.key == Key.DirectionLeft || event.key == Key.DirectionRight
            }
            // Focus trap (see RawCastPickerSheet for full rationale): the
            // key-event guard above only intercepts Left/Right — Up/Down
            // could still spatially escape onto invisible focusables
            // behind the scrim on D-pad-driven devices. Cancelling exit
            // pins all directional navigation inside the sheet.
            .focusProperties { exit = { FocusRequester.Cancel } }
            .focusGroup(),
        contentAlignment = Alignment.CenterEnd,
    ) {
        val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
            android.content.res.Configuration.ORIENTATION_LANDSCAPE
        if (isLandscape) {
            // Raw-cast (observe) state — mirrors the portrait branch's
            // collection. The observe section was portrait-only until now
            // (field-reported: tablet landscape offered only Cast-to
            // targets with no observation available at all).
            val lRawDevices = if (rawCast != null) {
                val st by rawCast.devices.collectAsState()
                st
            } else emptyList()
            val lRawObserving = if (rawCast != null) {
                val st by rawCast.observing.collectAsState()
                st
            } else false
            val lRawConnName = if (rawCast != null) {
                val st by rawCast.connectedDeviceName.collectAsState()
                st
            } else null
            // Landscape: compact header across the top, then the three
            // device categories side by side — Speaker Groups, Individual
            // Speakers, Observe — each its own independently-scrolling
            // column, all visible at once. (First landing of this layout
            // crashed with "Key was already used": a code-generation
            // escaping fault emitted the key template as LITERAL text
            // strings instead of interpolated ids, so every row shared
            // one key. Keys below are real templates.)
            Column(
                Modifier
                    .fillMaxSize()
                    .background(SurfaceBase)
                    .clickable(
                        interactionSource = remember { MutableInteractionSource() },
                        indication = null,
                    ) { /* consume click so scrim dismiss doesn't fire */ }
                    .padding(horizontal = 28.dp, vertical = 24.dp),
            ) {
                // Single-line header: bold "Cast to…" runs straight into
                // the unbolded blurb on ONE line — the stacked variant
                // wrapped "automatically" onto a second line in landscape
                // (field-rejected). The blurb baseline-aligns with the
                // headline and single-line width-shrinks into the
                // remaining row width (the original proven shrink use:
                // one line, width overflow only), so a single line is
                // guaranteed on any landscape width.
                Row(Modifier.fillMaxWidth()) {
                    Text(
                        "Cast to…",
                        style = MaterialTheme.typography.headlineMedium,
                        color = OnPrimary,
                        maxLines = 1,
                        modifier = Modifier.alignByBaseline(),
                    )
                    var blurbSize by remember { mutableStateOf(17f) }
                    var blurbReady by remember { mutableStateOf(false) }
                    Text(
                        " a speaker or group, or observe & control an ongoing Cast. Groups sync automatically.",
                        style = MaterialTheme.typography.bodyLarge.copy(fontSize = blurbSize.sp),
                        color = OnMuted,
                        maxLines = 1,
                        softWrap = false,
                        onTextLayout = { r ->
                            if (r.hasVisualOverflow && blurbSize > 10f) blurbSize -= 1f
                            else blurbReady = true
                        },
                        modifier = Modifier
                            .alignByBaseline()
                            .weight(1f)
                            .drawWithContent { if (blurbReady) drawContent() },
                    )
                    Spacer(Modifier.width(16.dp))
                    CloseButton(onClick = onDismiss)
                }
                Spacer(Modifier.height(20.dp))
                // Maintainer, 2026-10-29: a way to stop casting from the picker.
                if (isCasting) {
                    DeviceRow(
                        name = stopCastingLabel,
                        isGroup = false,
                        onClick = { cast.disconnect(); onDismiss() },
                        focusRequester = null,
                    )
                    Spacer(Modifier.height(12.dp))
                }
                if (devices.isEmpty() && lRawDevices.isEmpty() && !lRawObserving) {
                    Text(
                        "Searching for devices…",
                        style = MaterialTheme.typography.bodyLarge,
                        color = OnMuted,
                    )
                } else {
                    val groups = devices.filter { it.isGroup }
                    val individuals = devices.filter { !it.isGroup }
                    val rawCastLocal = rawCast
                    val showObserve =
                        rawCastLocal != null && (lRawDevices.isNotEmpty() || lRawObserving)
                    Row(
                        Modifier.weight(1f),
                        horizontalArrangement = Arrangement.spacedBy(24.dp),
                    ) {
                        // Column 1 — Speaker Groups
                        Column(Modifier.weight(1f).fillMaxHeight()) {
                            Text(
                                "Cast to Group",
                                style = MaterialTheme.typography.labelLarge,
                                color = OnMuted,
                                modifier = Modifier.padding(bottom = 6.dp),
                            )
                            if (groups.isEmpty()) {
                                Text(
                                    "None found",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = OnQuiet,
                                )
                            } else {
                                LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                    items(groups, key = { "lg:" + it.id }) { device ->
                                        DeviceRow(
                                            name = device.friendlyName,
                                            isGroup = true,
                                            onClick = { cast.connect(device.id); onDismiss() },
                                            focusRequester = if (device.id == devices.firstOrNull()?.id) firstFocus else null,
                                        )
                                    }
                                }
                            }
                        }
                        // Column 2 — Individual Speakers
                        Column(Modifier.weight(1f).fillMaxHeight()) {
                            Text(
                                "Cast to a Speaker",
                                style = MaterialTheme.typography.labelLarge,
                                color = OnMuted,
                                modifier = Modifier.padding(bottom = 6.dp),
                            )
                            if (individuals.isEmpty()) {
                                Text(
                                    "None found",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = OnQuiet,
                                )
                            } else {
                                LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                    items(individuals, key = { "li:" + it.id }) { device ->
                                        DeviceRow(
                                            name = device.friendlyName,
                                            isGroup = false,
                                            onClick = { cast.connect(device.id); onDismiss() },
                                            focusRequester = if (groups.isEmpty() && device.id == individuals.firstOrNull()?.id) firstFocus else null,
                                        )
                                    }
                                }
                            }
                        }
                        // Column 3 — Observe (only when there is anything
                        // to observe or an active session to disconnect)
                        if (showObserve && rawCastLocal != null) {
                            Column(Modifier.weight(1f).fillMaxHeight()) {
                                Text(
                                    "Observe a Cast",
                                    style = MaterialTheme.typography.labelLarge,
                                    color = OnMuted,
                                    modifier = Modifier.padding(bottom = 6.dp),
                                )
                                LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                    if (lRawObserving) {
                                        item(key = "lo:disconnect") {
                                            DeviceRow(
                                                name = "Disconnect from " + (lRawConnName ?: "device"),
                                                isGroup = false,
                                                onClick = { rawCastLocal.disconnect(); onDismiss() },
                                                focusRequester = null,
                                            )
                                        }
                                    }
                                    items(lRawDevices, key = { "lo:" + it.id }) { device ->
                                        DeviceRow(
                                            name = device.name + if (device.isGroup) " (group)" else "",
                                            isGroup = device.isGroup,
                                            onClick = { observePick(rawCastLocal, device) },
                                            focusRequester = null,
                                            connecting = connectingTo == device.id,
                                        )
                                    }
                                }
                            }
                        }
                    }
                }
            }

        } else {
            // Portrait: right-edge panel
            Column(
                Modifier
                    .fillMaxHeight()
                    // Never wider than the screen (review N.36, the maintainer's
                    // ruling 2026-09-02): a fixed 480dp put the left column
                    // off every portrait phone. TV/tablet unchanged.
                    .fillMaxWidth()
                    .widthIn(max = 480.dp)
                    .background(SurfaceBase)
                    .clickable(
                        interactionSource = remember { MutableInteractionSource() },
                        indication = null,
                    ) { /* consume */ }
                    .padding(horizontal = 28.dp, vertical = 48.dp),
            ) {
            Row(
                Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(
                    "Cast to…",
                    style = MaterialTheme.typography.titleLarge,
                    color = OnPrimary,
                    modifier = Modifier.weight(1f),
                )
                CloseButton(onClick = onDismiss)
            }
            Spacer(Modifier.height(4.dp))
            Text(
                // Copy parity with landscape: identical words continuing
                // from "Cast to…" above; stacked (wrapping) rather than
                // inline because this 480dp panel cannot hold the ~90-char
                // line at readable size — landscape's single-line shrink
                // needs ~600dp even at its floor.
                "a speaker or group, or observe & control an ongoing Cast. Groups sync automatically.",
                style = MaterialTheme.typography.bodyMedium,
                color = OnMuted,
                maxLines = 3,
            )
            Spacer(Modifier.height(28.dp))

            // Maintainer, 2026-10-29: portrait restructured — the Observe section
            // used to live inside the `else` of `if (devices.isEmpty())`, so with
            // no Cast-SDK devices (always on libre, where cast.available is false)
            // the user could neither follow a Cast nor stop following one. Now:
            // Cast-to section (only when cast.available), then Observe ALWAYS
            // (when rawCast != null). Raw-cast state is collected up front.
            // Issue 2: Collect raw-cast state BEFORE layout.
            @Suppress("SimplifiableCallChain")
            val rawDeviceList = if (rawCast != null) {
                val s by rawCast.devices.collectAsState()
                s
            } else emptyList()
            val rawIsObserving = if (rawCast != null) {
                val s by rawCast.observing.collectAsState()
                s
            } else false
            val rawConnName = if (rawCast != null) {
                val s by rawCast.connectedDeviceName.collectAsState()
                s ?: ""
            } else ""

            // Maintainer, 2026-10-29: a way to stop casting from the picker.
            if (isCasting) {
                DeviceRow(
                    name = stopCastingLabel,
                    isGroup = false,
                    onClick = { cast.disconnect(); onDismiss() },
                    focusRequester = null,
                )
                Spacer(Modifier.height(12.dp))
            }

            // Maintainer, 2026-10-29: when !cast.available (no Cast SDK in this
            // build) neither branch runs — no Cast-to section or diagnostics,
            // only the Observe section below.
            if (cast.available && devices.isEmpty()) {
                val diag by cast.diagnostic.collectAsState()
                // Maintainer, 2026-10-29: diagnostics scroll inside a weighted
                // (non-filling) column so a long route list can't push the
                // Observe section off the panel.
                Column(
                    Modifier
                        .weight(1f, fill = false)
                        .verticalScroll(rememberScrollState()),
                ) {
                // Self-diagnostic panel. Replaces the lonely "Searching for
                // devices…" label with something that shows WHY the list is
                // empty — the user-visible equivalent of logcat since this app
                // is shipped without in-app log access.
                Text(
                    "Searching for devices…",
                    style = MaterialTheme.typography.bodyLarge,
                    color = OnMuted,
                )
                Spacer(Modifier.height(20.dp))
                DiagLine("Play Services", diag.playServices)
                DiagLine("Cast framework", if (diag.castContextOk) "initialized" else "missing")
                // When Cast init failed, show the actual exception class + message
                // here so we can diagnose "missing... but why?" — the most likely
                // cause on Android TV is that CastContext.getSharedInstance
                // threw with a specific reason (missing OptionsProvider class,
                // Play Services component unavailable, etc.).
                diag.castInitError?.let { err ->
                    Text(
                        withInterDigits("  \u2514 $err"),
                        style = MaterialTheme.typography.labelSmall,
                        color = OnQuiet,
                        modifier = Modifier.fillMaxWidth(),
                    )
                }
                DiagLine("MediaRouter", if (diag.mediaRouterOk) "ready" else "missing")
                DiagLine(
                    "Routes discovered",
                    "${diag.rawRouteCount} total, ${diag.filteredDeviceCount} pickable",
                )
                DiagLine("Polling tick", diag.tick.toString())
                if (diag.rawRouteNames.isNotEmpty()) {
                    Spacer(Modifier.height(12.dp))
                    Text(
                        "All routes seen:",
                        style = MaterialTheme.typography.labelSmall,
                        color = OnQuiet,
                    )
                    diag.rawRouteNames.forEach { label ->
                        Text(
                            withInterDigits("  \u00b7 $label"),
                            style = MaterialTheme.typography.labelSmall,
                            color = OnQuiet,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                }
                }  // Maintainer, 2026-10-29: end scrollable diagnostics Column
            } else if (cast.available) {
                // Issue 3: Two side-by-side columns — groups left,
                // individuals right — matching the TV RawCastPickerSheet.
                val groups = devices.filter { it.isGroup }
                val individuals = devices.filter { !it.isGroup }

                // Two-column device picker
                Row(
                    modifier = Modifier.weight(1f),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                ) {
                    // Left column — Speaker Groups
                    Column(Modifier.weight(1f)) {
                        Text(
                            "Cast to Group",
                            style = MaterialTheme.typography.labelLarge,
                            color = OnMuted,
                            modifier = Modifier.padding(bottom = 6.dp),
                        )
                        if (groups.isEmpty()) {
                            Text("No groups found", style = MaterialTheme.typography.bodySmall, color = OnQuiet)
                        } else {
                            LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                items(groups, key = { "g:${it.id}" }) { device ->
                                    DeviceRow(
                                        name = device.friendlyName,
                                        isGroup = true,
                                        onClick = { cast.connect(device.id); onDismiss() },
                                        focusRequester = if (device.id == groups.first().id) firstFocus else null,
                                    )
                                }
                            }
                        }
                    }
                    // Right column — Individual Speakers
                    Column(Modifier.weight(1f)) {
                        Text(
                            "Cast to a Speaker",
                            style = MaterialTheme.typography.labelLarge,
                            color = OnMuted,
                            modifier = Modifier.padding(bottom = 6.dp),
                        )
                        if (individuals.isEmpty()) {
                            Text("No speakers found", style = MaterialTheme.typography.bodySmall, color = OnQuiet)
                        } else {
                            LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                                items(individuals, key = { "i:${it.id}" }) { device ->
                                    DeviceRow(
                                        name = device.friendlyName,
                                        isGroup = false,
                                        onClick = { cast.connect(device.id); onDismiss() },
                                        focusRequester = null,
                                    )
                                }
                            }
                        }
                    }
                }
            }

            // Observe section — below the Cast-to section.
            // Maintainer, 2026-10-29: now shown ALWAYS when rawCast != null (was
            // only when Cast-SDK devices existed): Disconnect row while
            // observing, the followable device list, or a "looking" line.
            if (rawCast != null) {
                Spacer(Modifier.height(16.dp))
                Text(
                    "Observe a Cast",
                    style = MaterialTheme.typography.labelLarge,
                    color = OnMuted,
                    modifier = Modifier.padding(bottom = 6.dp),
                )
                if (rawIsObserving) {
                    DeviceRow(
                        name = "Disconnect from ${rawConnName.ifBlank { "device" }}",
                        isGroup = false,
                        onClick = { rawCast.disconnect(); onDismiss() },
                        focusRequester = null,
                    )
                    Spacer(Modifier.height(6.dp))
                }
                if (rawDeviceList.isNotEmpty()) {
                    LazyColumn(
                        modifier = Modifier.weight(1f, fill = false),
                        verticalArrangement = Arrangement.spacedBy(6.dp),
                    ) {
                        items(rawDeviceList, key = { "obs:${it.id}" }) { device ->
                            DeviceRow(
                                name = device.name + if (device.isGroup) " (group)" else "",
                                isGroup = device.isGroup,
                                onClick = { observePick(rawCast, device) },
                                focusRequester = null,
                                connecting = connectingTo == device.id,
                            )
                        }
                    }
                } else {
                    Text(
                        "Looking for Cast devices to follow…",
                        style = MaterialTheme.typography.bodySmall,
                        color = OnQuiet,
                    )
                }
            }
        }  // end portrait Column
        }  // end else
    }
}

@Composable
private fun DeviceRow(
    name: String,
    isGroup: Boolean,
    onClick: () -> Unit,
    focusRequester: FocusRequester?,
    connecting: Boolean = false,
) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .fillMaxWidth()
            .background(SurfaceRaised)
            .let { if (focusRequester != null) it.focusRequester(focusRequester) else it }
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 18.dp, vertical = 14.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(
                if (isGroup) "◉" else "○",
                style = MaterialTheme.typography.titleMedium,
                color = OnPrimary,
                modifier = Modifier.width(28.dp),
            )
            Column(Modifier.weight(1f)) {
                Text(
                    withInterDigits(name),
                    style = MaterialTheme.typography.bodyLarge,
                    color = OnPrimary,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
                if (connecting) {
                    Text(
                        "Connecting…",
                        style = MaterialTheme.typography.bodySmall,
                        color = OnPrimary,
                    )
                } else if (isGroup) {
                    Text(
                        "Speaker Group",
                        style = MaterialTheme.typography.bodySmall,
                        color = OnMuted,
                    )
                }
            }
        }
    }
}

/** Visible way out of the picker (decision, 2026-10-29): the panel covers
 *  the whole screen on phones, so tap-outside never reached the scrim and
 *  Back was the only exit. */
@Composable
private fun CloseButton(onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 16.dp, vertical = 10.dp),
    ) {
        Text(
            "✕  Close",
            style = MaterialTheme.typography.titleMedium,
            color = OnPrimary,
        )
    }
}

/** Two-column row for the diagnostic panel: label on the left, value on the
 *  right. Styled as muted metadata — intentionally small to stay out of the
 *  way when Cast is actually working (this panel only appears in the empty
 *  state). */
@Composable
private fun DiagLine(label: String, value: String) {
    Row(
        Modifier.fillMaxWidth().padding(vertical = 2.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(
            label,
            style = MaterialTheme.typography.labelSmall,
            color = OnMuted,
        )
        Text(
            withInterDigits(value),
            style = MaterialTheme.typography.labelSmall,
            color = OnPrimary,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.padding(start = 16.dp),
        )
    }
}