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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.CastConnected
import androidx.compose.material.icons.filled.Speaker
import androidx.compose.material.icons.filled.SpeakerGroup
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.cast.raw.RawCastObserver
import dev.stave.cast.raw.RawCastObserver.CastDevice
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmber
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
import kotlinx.coroutines.launch
/**
* Picker for Chromecast devices and speaker groups discovered via mDNS, used
* on Android TV. Selecting an entry connects [RawCastObserver] to that device
* over the raw Cast protocol on the resolved port. The connection is
* observation-only — we attach to whatever session another sender (BubbleUPnP,
* ChromeOS, etc.) has already started, without stealing it.
*
* UX rules:
* - Centered panel over a 55% black scrim. 720dp wide so two columns fit
* comfortably side-by-side, modeled on RandomPickerScreen's two-PickerColumn
* layout.
* - Two columns: Speaker groups on the left, individual speakers on the right.
* Each column has its own SectionLabel header and its own LazyColumn for
* independent scrolling. D-pad LEFT/RIGHT crosses between columns; UP/DOWN
* scrolls within a column. Empty columns show a brief placeholder so the
* user isn't staring at half a blank panel.
* - Amber 2dp focus border via `staveFocusable`. Initial focus on the first
* row of whichever column has items (Groups preferred), or the Disconnect
* chip when already observing.
* - When already observing, a full-width "Disconnect from X" row in accent
* amber sits above the two columns. Switching to a different device just
* selects it — [RawCastObserver.observe] internally disconnects the prior
* session.
* - BackHandler closes the picker. Cancel row sits full-width below the
* columns as an explicit dismiss target.
*/
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun RawCastPickerSheet(
rawCast: RawCastObserver,
visible: Boolean,
onDismiss: () -> Unit,
/** Invoked right after an observe target is selected and the sheet
* dismisses — the NavHost uses it to navigate to NowPlaying so the
* user lands on the observed session instead of back on Browse. */
onObserveStarted: (() -> Unit)? = null,
) {
if (!visible) return
val devices by rawCast.devices.collectAsState()
val observing by rawCast.observing.collectAsState()
val connectedName by rawCast.connectedDeviceName.collectAsState()
var connectingTo by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
BackHandler(onBack = onDismiss)
// Restart discovery each time the picker opens so the freshest LAN state
// is shown. stopDiscovery on dispose tears down the multicast lock + serial
// resolver — we don't want multicast traffic flowing while the user is
// doing anything else with the app.
DisposableEffect(Unit) {
rawCast.startDiscovery()
onDispose { rawCast.stopDiscovery() }
}
val firstFocus = remember { FocusRequester() }
// Maintainer, 2026-10-29: when no row below takes firstFocus (nothing found
// and not observing; or observing with no device name yet, so no
// Disconnect row), the Cancel row takes it — otherwise focus stayed
// behind the scrim and Cancel was unreachable by D-pad. Mirrors the
// Disconnect / groups / singles conditions below. Also a key, so focus
// is re-requested when the holder changes.
val cancelGetsInitialFocus = if (observing) connectedName == null else devices.isEmpty()
LaunchedEffect(devices.isNotEmpty(), observing, cancelGetsInitialFocus) {
kotlinx.coroutines.yield()
runCatching { firstFocus.requestFocus() }
}
val groups = devices.filter { it.isGroup }
val singles = devices.filter { !it.isGroup }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
// Focus trap. This sheet is a plain Box overlay, not a Dialog,
// so the D-pad focus engine still sees every focusable on the
// screen BEHIND the scrim. Navigating past the edge of the
// device columns used to land focus on an invisible background
// element with no spatial path back — the only escape was
// backing out of the sheet entirely. Cancelling `exit` on the
// sheet's focus group pins directional navigation inside it;
// Back/dismiss is unaffected (Back is not a focus move).
.focusProperties { exit = { FocusRequester.Cancel } }
.focusGroup(),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.widthIn(min = 480.dp, max = 720.dp)
.fillMaxHeight()
.background(SurfaceBase)
.padding(horizontal = 32.dp, vertical = 48.dp),
) {
Text(
if (observing) "Following cast" else "Follow cast on…",
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
)
Spacer(Modifier.height(4.dp))
Text(
withInterDigits(
if (observing) {
"Earhorn is mirroring playback from ${connectedName ?: "a Cast device"}."
} else {
"Pick a Chromecast or speaker group to mirror its current playback."
},
),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
maxLines = 3,
softWrap = true,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(20.dp))
// Disconnect chip — full-width above the two columns when observing.
// Gets initial focus so D-pad Center disconnects immediately if the
// user opened the picker just to leave.
if (observing && connectedName != null) {
DisconnectRow(
label = "Disconnect from ${connectedName!!}",
modifier = Modifier.focusRequester(firstFocus),
onClick = {
rawCast.disconnect()
onDismiss()
},
)
Spacer(Modifier.height(8.dp))
Text(
"Switch to",
style = MaterialTheme.typography.labelLarge,
color = OnQuiet,
)
Spacer(Modifier.height(8.dp))
}
// The two columns. Each column gets its own SectionLabel header and
// its own LazyColumn underneath. Row weight=1f for both so they share
// the available width evenly.
Row(
Modifier.weight(1f).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
// Left column: Speaker groups. First row of this column gets
// initial focus when not observing AND groups are present.
val groupsGetInitialFocus = !observing && groups.isNotEmpty()
PickerColumn(
modifier = Modifier.weight(1f).fillMaxHeight(),
header = "Speaker groups",
items = groups,
emptyMessage = "No speaker groups\non this network.",
focusRequesterForFirstRow = if (groupsGetInitialFocus) firstFocus else null,
connectingId = connectingTo,
connectedDeviceName = connectedName,
observing = observing,
onSelect = { device ->
connectingTo = device.id
scope.launch {
// Keep the previous session if this one can't be
// reached; only leave the picker on success.
val ok = rawCast.observeOrRestore(device)
connectingTo = null
if (ok) {
onDismiss()
onObserveStarted?.invoke()
}
}
},
)
// Right column: individual speakers. First row of this column
// gets initial focus only when groups list is empty.
val singlesGetInitialFocus = !observing && groups.isEmpty() && singles.isNotEmpty()
PickerColumn(
modifier = Modifier.weight(1f).fillMaxHeight(),
header = "Speakers",
items = singles,
emptyMessage = "No individual\nspeakers on\nthis network.",
focusRequesterForFirstRow = if (singlesGetInitialFocus) firstFocus else null,
connectingId = connectingTo,
connectedDeviceName = connectedName,
observing = observing,
onSelect = { device ->
connectingTo = device.id
scope.launch {
// Keep the previous session if this one can't be
// reached; only leave the picker on success.
val ok = rawCast.observeOrRestore(device)
connectingTo = null
if (ok) {
onDismiss()
onObserveStarted?.invoke()
}
}
},
)
}
// When both columns are empty: print a single placeholder where the
// columns would be. Discovery may still be in progress.
if (devices.isEmpty()) {
Spacer(Modifier.height(16.dp))
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Text(
"Scanning for Chromecast devices…",
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
)
}
}
Spacer(Modifier.height(16.dp))
CancelRow(
onClick = onDismiss,
// Maintainer, 2026-10-29: initial focus when no device row has it.
modifier = if (cancelGetsInitialFocus) Modifier.focusRequester(firstFocus) else Modifier,
)
}
}
}
@Composable
private fun PickerColumn(
modifier: Modifier = Modifier,
header: String,
items: List<CastDevice>,
emptyMessage: String,
focusRequesterForFirstRow: FocusRequester?,
connectingId: String?,
connectedDeviceName: String?,
observing: Boolean,
onSelect: (CastDevice) -> Unit,
) {
Column(modifier) {
SectionLabel(header)
Spacer(Modifier.height(4.dp))
if (items.isEmpty()) {
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(
emptyMessage,
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(items, key = { it.id }) { device ->
val isFirstRow = items.firstOrNull()?.id == device.id
CastDeviceRow(
name = device.name,
sublabel = if (device.isGroup) "Speaker group" else device.model,
isGroup = device.isGroup,
connecting = connectingId == device.id,
currentlyConnected = observing && connectedDeviceName == device.name,
modifier = if (isFirstRow && focusRequesterForFirstRow != null) {
Modifier.focusRequester(focusRequesterForFirstRow)
} else {
Modifier
},
onClick = { onSelect(device) },
)
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text,
style = MaterialTheme.typography.labelLarge,
color = OnQuiet,
modifier = Modifier.padding(vertical = 4.dp),
)
}
@Composable
private fun CastDeviceRow(
name: String,
sublabel: String,
isGroup: Boolean,
connecting: Boolean,
currentlyConnected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when {
currentlyConnected -> Icons.Filled.CastConnected
isGroup -> Icons.Filled.SpeakerGroup
else -> Icons.Filled.Speaker
},
contentDescription = null,
tint = if (currentlyConnected) AccentAmber else OnPrimary,
modifier = Modifier.width(24.dp),
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
withInterDigits(name),
style = MaterialTheme.typography.bodyMedium,
color = if (currentlyConnected) AccentAmber else OnPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
connecting -> "Connecting…"
currentlyConnected -> "Currently following"
else -> sublabel
},
style = MaterialTheme.typography.bodySmall,
color = OnMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun DisconnectRow(
label: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Cast,
contentDescription = null,
tint = AccentAmber,
modifier = Modifier.width(28.dp),
)
Spacer(Modifier.width(12.dp))
Text(
withInterDigits(label),
style = MaterialTheme.typography.bodyLarge,
color = AccentAmber,
maxLines = 2,
softWrap = true,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun CancelRow(
onClick: () -> Unit,
// Maintainer, 2026-10-29: lets the caller attach firstFocus.
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"✕",
style = MaterialTheme.typography.titleMedium,
color = OnQuiet,
modifier = Modifier.width(28.dp),
)
Text(
"Cancel",
style = MaterialTheme.typography.bodyLarge,
color = OnQuiet,
modifier = Modifier.weight(1f),
)
}
}
}