@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
package dev.stave.ui.servers
import androidx.compose.foundation.Image
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.QueueMusic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.R
import dev.stave.domain.model.MediaServer
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmberSoft
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.AccentSage
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.isCompactWidth
import kotlinx.coroutines.delay
@Composable
fun ServersScreen(
onServerClick: (String) -> Unit,
onLocalClick: () -> Unit,
onSettingsClick: () -> Unit,
onOpenQueue: () -> Unit,
onOpenSearch: () -> Unit,
onOpenRandom: () -> Unit,
viewModel: ServersViewModel = hiltViewModel(),
// Maintainer, 2026-10-29: header Cast action (opens the NavHost-level
// Cast/Observe picker); hidden when not wired.
onOpenCast: (() -> Unit)? = null,
) {
val servers by viewModel.servers.collectAsState()
val rescanState by viewModel.rescanState.collectAsState()
// Maintainer, 2026-10-29: discovery takes a few seconds, so an empty list
// right after opening (or after Refresh Library) means "still looking",
// not "nothing there". searchRound bumps on Refresh Library to restart
// the 6s window.
var searching by remember { mutableStateOf(true) }
var searchRound by remember { mutableStateOf(0) }
LaunchedEffect(searchRound) {
searching = true
delay(6_000)
searching = false
}
val onRefreshLibrary: () -> Unit = {
viewModel.rescan()
searching = true
searchRound++
}
Column(
Modifier
.fillMaxSize()
.padding(
horizontal = adaptiveScreenPaddingHorizontal(),
vertical = adaptiveScreenPaddingVertical(),
)
) {
// Header — adaptive: side-by-side on TV/tablet, stacked on phone portrait.
// On compact widths the actions are reduced to a single icon row at the
// top of the screen. The "Earhorn Music" headline + "Pick a source to
// begin" subtitle that the TV layout shows are dropped: on phone the
// launcher icon and app name already make the context obvious, the
// subtitle is dead weight, and the server list right below is its own
// visual call-to-action.
if (isCompactWidth()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CompactServersAction(
modifier = Modifier.weight(1f),
onClick = onOpenQueue,
) {
Icon(
Icons.Filled.QueueMusic,
contentDescription = "Queue",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
CompactServersAction(
modifier = Modifier.weight(1f),
onClick = onOpenSearch,
) {
Icon(
Icons.Filled.Search,
contentDescription = "Search",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
CompactServersAction(
modifier = Modifier.weight(1f),
onClick = onOpenRandom,
) {
Icon(
Icons.Filled.Shuffle,
contentDescription = "Random",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
// Maintainer, 2026-10-29: Cast action, same cell style as the rest.
if (onOpenCast != null) {
CompactServersAction(
modifier = Modifier.weight(1f),
onClick = onOpenCast,
) {
Icon(
Icons.Filled.Cast,
contentDescription = "Cast",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
}
CompactServersAction(
modifier = Modifier.weight(1f),
// Maintainer, 2026-10-29: also restarts the "searching" window.
onClick = onRefreshLibrary,
) {
Icon(
Icons.Filled.Refresh,
contentDescription = "Refresh Library",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
CompactServersAction(
modifier = Modifier.weight(1f),
onClick = onSettingsClick,
) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings",
tint = OnPrimary,
modifier = Modifier.size(24.dp),
)
}
}
} else {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Column {
Text(
"Earhorn Music",
style = MaterialTheme.typography.displayMedium,
color = OnPrimary
)
Text(
"Select a server to begin",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
HeaderAction(label = "Queue", onClick = onOpenQueue)
HeaderAction(label = "Search", onClick = onOpenSearch)
HeaderAction(label = "Random", onClick = onOpenRandom)
// Maintainer, 2026-10-29: Cast action added; Refresh Library
// also restarts the "searching" window.
if (onOpenCast != null) {
HeaderAction(label = "Cast", onClick = onOpenCast)
}
HeaderAction(label = "Refresh Library", onClick = onRefreshLibrary)
HeaderAction(label = "Settings", onClick = onSettingsClick)
}
}
}
// 40dp of post-header air is a TV-viewing-distance allowance; on a
// touch device in landscape it read as dead space above the server
// list (field-rejected). TV keeps its original 40dp untouched.
val srvCfg = androidx.compose.ui.platform.LocalConfiguration.current
val srvTv = (srvCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
Spacer(Modifier.height(if (isCompactWidth()) 12.dp else if (srvTv) 40.dp else 16.dp))
// Local-music entry — phone-only feature. On TV the Servers screen
// must look exactly as it did before this row was added (no "On this
// device" entry). Local-files browsing requires the runtime media
// permission and a Column-based hub that doesn't fit Android TV's
// D-pad / leanback model anyway.
// Maintainer, 2026-10-29: was isCompactWidth(), which also hid the row
// on phone landscape, tablets and Chromebase; the intent is "not on TV".
val showLocalRow = !srvTv
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (showLocalRow) {
item(key = "__local__") {
LocalSourceRow(onClick = onLocalClick)
}
}
// Compute the UDN currently being rescanned (if any) and whether
// the live `servers` list still contains it. If the user kicked
// off a rescan and the server has dropped off the registry, we
// still want to render SOMETHING for that row — otherwise the
// entry seemingly "vanishes" from the picker with no explanation.
val rescanningUdn = when (val s = rescanState) {
is RescanState.Requesting -> s.udn
is RescanState.WaitingForServer -> s.udn
is RescanState.Completed -> s.udn
is RescanState.Failed -> s.udn
RescanState.Idle -> null
}
val rescanningFriendlyName = when (val s = rescanState) {
is RescanState.Requesting -> s.friendlyName
is RescanState.WaitingForServer -> s.friendlyName
is RescanState.Completed -> s.friendlyName
is RescanState.Failed -> s.friendlyName
RescanState.Idle -> null
}
val rescanningServerMissing = rescanningUdn != null &&
servers.none { it.udn == rescanningUdn }
if (servers.isEmpty() && !rescanningServerMissing) {
item(key = "__empty_servers__") {
// Maintainer, 2026-10-29: searching/TV select the copy.
EmptyServersState(searching = searching, isTv = srvTv)
}
} else {
items(servers, key = { it.udn }) { server ->
ServerRow(
server = server,
rescanState = rescanState,
onRescan = { viewModel.rescanLibrary(server.udn) },
onClick = {
viewModel.rememberChoice(server.udn)
onServerClick(server.udn)
},
)
}
if (rescanningServerMissing && rescanningUdn != null && rescanningFriendlyName != null) {
item(key = "__rescan_placeholder_${rescanningUdn}") {
RescanPlaceholderRow(
friendlyName = rescanningFriendlyName,
rescanState = rescanState,
)
}
}
}
}
}
}
@Composable
private fun LocalSourceRow(onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"On this device",
style = MaterialTheme.typography.titleLarge,
color = OnPrimary
)
Text(
"Music files stored locally — works offline, no UPnP server required",
style = MaterialTheme.typography.bodyMedium,
color = OnMuted
)
}
Text(
"Open ▸",
style = MaterialTheme.typography.labelLarge,
color = OnQuiet
)
}
}
}
@Composable
private fun ServerRow(
server: MediaServer,
rescanState: RescanState,
onRescan: () -> Unit,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
// Decide which inline status, if any, applies to this row. Rescan is a
// per-server operation so we look for an exact udn match. Anything else
// means we render the normal subtitle.
val rowRescan: RescanState? = when (rescanState) {
is RescanState.Requesting -> rescanState.takeIf { it.udn == server.udn }
is RescanState.WaitingForServer -> rescanState.takeIf { it.udn == server.udn }
is RescanState.Completed -> rescanState.takeIf { it.udn == server.udn }
is RescanState.Failed -> rescanState.takeIf { it.udn == server.udn }
RescanState.Idle -> null
}
val anyRescanInFlight = rescanState is RescanState.Requesting ||
rescanState is RescanState.WaitingForServer
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.staveFocusable(interaction)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
withInterDigits(server.friendlyName),
style = MaterialTheme.typography.titleLarge,
color = OnPrimary
)
// Subtitle: usually "manufacturer · modelName"; while a rescan
// is in flight for THIS server, replace it with a status line
// so the user knows their tap registered.
val subtitle = when (rowRescan) {
is RescanState.Requesting -> "Sending rescan command…"
is RescanState.WaitingForServer ->
if (rowRescan.seenDisappear) "Rescanning library — server restarting…"
else "Waiting for server to restart…"
is RescanState.Completed -> "Rescan complete — library refreshed"
is RescanState.Failed -> "Rescan failed: ${rowRescan.message}"
// null (no rescan for this row) and Idle (caller set
// rowRescan = null already, so this is unreachable in
// practice but required for exhaustive-when).
null, RescanState.Idle -> listOfNotNull(server.manufacturer, server.modelName)
.joinToString(" \u00b7 ")
.takeIf { it.isNotBlank() } ?: server.baseUrl
}
Text(
withInterDigits(subtitle),
style = MaterialTheme.typography.bodyMedium,
color = when (rowRescan) {
is RescanState.Completed -> AccentSage
is RescanState.Failed -> OnPrimary
else -> OnMuted
},
)
}
// Rescan action — only for MinimServer rows. Disabled (visibly
// dimmed and onClick is a no-op) while ANY rescan is in flight,
// so a second tap on the same row or a tap on a different one
// can't queue up a parallel operation. Sits to the left of the
// "Open ▸" hint so the main affordance (tap row to open) stays
// dominant.
if (server.isMinimServer) {
Spacer(Modifier.size(8.dp))
RescanAction(
enabled = !anyRescanInFlight,
busyForThisRow = rowRescan is RescanState.Requesting ||
rowRescan is RescanState.WaitingForServer,
onClick = onRescan,
)
Spacer(Modifier.size(8.dp))
}
Text(
"Open ▸",
style = MaterialTheme.typography.labelLarge,
color = OnQuiet
)
}
}
}
/** Small chip-style action used per-MinimServer row. Lives inside the
* parent ServerRow's clickable area but its own clickable consumes the tap
* so navigation doesn't fire when the user is aiming for the rescan.
*
* Three visual states:
* - enabled + not busy: solid label "Rescan ↻"
* - enabled + busy for THIS row: label "Rescanning…" with the Refresh
* icon (caller decides; we just dim it)
* - disabled (some OTHER row is rescanning): dimmed label, no-op click
*
* Visually subordinate to the row's main "Open ▸" hint by being darker and
* smaller — Rescan is the secondary action, opening is primary. */
@Composable
/** Compact icon-only rescan button. Earlier versions used "Rescan ↻"
* as a label-pill, but that competes for horizontal space with the
* row's title + subtitle + "Open ▸" hint, crushing the column. The
* rescan action is rare and the per-row subtitle already narrates
* what's happening, so a small ↻ glyph is enough as the affordance. */
private fun RescanAction(
enabled: Boolean,
busyForThisRow: Boolean,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.size(32.dp)
.background(AccentAmberSoft)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(
interaction,
indication = null,
enabled = enabled,
) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
"\u21bb",
style = MaterialTheme.typography.titleMedium,
color = if (enabled || busyForThisRow) AccentSage else OnQuiet,
)
}
}
/** Placeholder row shown when a rescan is in flight for a server that has
* dropped off the UPnP registry (mid-restart). Without this row, the
* picker visually loses the server entirely the moment MinimServer goes
* silent — the user has no indication their tap is still in progress and
* often assumes the server crashed or the app forgot the request. The
* row is non-interactive (the rescan can't be cancelled and the server
* can't be browsed until it comes back) but mirrors the visual style of
* a real ServerRow so it doesn't look like a different kind of entity. */
@Composable
private fun RescanPlaceholderRow(
friendlyName: String,
rescanState: RescanState,
) {
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
withInterDigits(friendlyName),
style = MaterialTheme.typography.titleLarge,
color = OnMuted,
)
val subtitle = when (rescanState) {
is RescanState.Requesting -> "Sending rescan command…"
is RescanState.WaitingForServer ->
if (rescanState.seenDisappear) "Rescanning library — server restarting…"
else "Waiting for server to restart…"
is RescanState.Completed -> "Rescan complete — library refreshed"
is RescanState.Failed -> "Rescan failed: ${rescanState.message}"
RescanState.Idle -> "Rescanning…"
}
Text(
withInterDigits(subtitle),
style = MaterialTheme.typography.bodyMedium,
color = when (rescanState) {
is RescanState.Completed -> AccentSage
is RescanState.Failed -> OnPrimary
else -> OnMuted
},
)
}
}
}
}
// Maintainer, 2026-10-29: "searching" copy while discovery is still running;
// afterwards "No servers found" with the Refresh Library instruction
// (press on TV, tap elsewhere). Replaces the misleading "No Music Found /
// library is empty / press Rescan" text.
@Composable
private fun EmptyServersState(searching: Boolean, isTv: Boolean) {
Column(
Modifier.fillMaxWidth().padding(vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Brand illustration — the Earhorn badge. Matches the mockup aesthetic.
// Sized 240dp — large enough to read across a a room but not overwhelming.
Image(
painter = painterResource(R.drawable.earhorn_badge),
contentDescription = null,
modifier = Modifier.size(240.dp),
)
Spacer(Modifier.height(24.dp))
if (searching) {
Text(
"Searching your network for music servers…",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
} else {
Text(
"No servers found",
style = MaterialTheme.typography.displaySmall,
color = OnPrimary,
)
Spacer(Modifier.height(12.dp))
Text(
"Make sure your UPnP/DLNA server is running and on the same network, then " +
(if (isTv) "press" else "tap") + " Refresh Library.",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
}
}
}
@Composable
private fun HeaderAction(label: String, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 20.dp, vertical = 10.dp)
) {
Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
}
}
/** Icon-only action cell for the compact Servers strip. Same pattern Browse
* uses for its compact action grid: caller passes `Modifier.weight(1f)` so
* the five cells share the row width evenly. Vertical padding is sized so
* the cell ends up matching the height [HeaderAction] would have produced
* with text — keeps the row from looking unusually short next to the
* server list below. */
@Composable
private fun CompactServersAction(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}