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

ServersScreen.kt

581 lines · 24.7 KB

Changed in: 0.5.0

1@file:OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2
3package dev.stave.ui.servers
4
5import androidx.compose.foundation.Image
6import androidx.compose.foundation.background
7import androidx.compose.foundation.clickable
8import androidx.compose.foundation.interaction.MutableInteractionSource
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Spacer
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.height
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
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.QueueMusic
24import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Search
26import androidx.compose.material.icons.filled.Settings
27import androidx.compose.material.icons.filled.Shuffle
28import androidx.compose.material3.Icon
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.LaunchedEffect
31import androidx.compose.runtime.collectAsState
32import androidx.compose.runtime.getValue
33import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.remember
35import androidx.compose.runtime.setValue
36import androidx.compose.ui.Alignment
37import androidx.compose.ui.Modifier
38import androidx.compose.ui.res.painterResource
39import androidx.compose.ui.unit.dp
40import androidx.hilt.navigation.compose.hiltViewModel
41import androidx.tv.material3.MaterialTheme
42import androidx.tv.material3.Text
43import dev.stave.R
44import dev.stave.domain.model.MediaServer
45import dev.stave.ui.components.staveFocusable
46import dev.stave.ui.theme.AccentAmberSoft
47import dev.stave.ui.theme.withInterDigits
48import dev.stave.ui.theme.AccentSage
49import dev.stave.ui.theme.OnMuted
50import dev.stave.ui.theme.OnPrimary
51import dev.stave.ui.theme.OnQuiet
52import dev.stave.ui.theme.SurfaceRaised
53import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
54import dev.stave.ui.theme.adaptiveScreenPaddingVertical
55import dev.stave.ui.theme.isCompactWidth
56import kotlinx.coroutines.delay
57
58@Composable
59fun ServersScreen(
60 onServerClick: (String) -> Unit,
61 onLocalClick: () -> Unit,
62 onSettingsClick: () -> Unit,
63 onOpenQueue: () -> Unit,
64 onOpenSearch: () -> Unit,
65 onOpenRandom: () -> Unit,
66 viewModel: ServersViewModel = hiltViewModel(),
67 // Maintainer, 2026-10-29: header Cast action (opens the NavHost-level
68 // Cast/Observe picker); hidden when not wired.
69 onOpenCast: (() -> Unit)? = null,
70) {
71 val servers by viewModel.servers.collectAsState()
72 val rescanState by viewModel.rescanState.collectAsState()
73
74 // Maintainer, 2026-10-29: discovery takes a few seconds, so an empty list
75 // right after opening (or after Refresh Library) means "still looking",
76 // not "nothing there". searchRound bumps on Refresh Library to restart
77 // the 6s window.
78 var searching by remember { mutableStateOf(true) }
79 var searchRound by remember { mutableStateOf(0) }
80 LaunchedEffect(searchRound) {
81 searching = true
82 delay(6_000)
83 searching = false
84 }
85 val onRefreshLibrary: () -> Unit = {
86 viewModel.rescan()
87 searching = true
88 searchRound++
89 }
90
91 Column(
92 Modifier
93 .fillMaxSize()
94 .padding(
95 horizontal = adaptiveScreenPaddingHorizontal(),
96 vertical = adaptiveScreenPaddingVertical(),
97 )
98 ) {
99 // Header — adaptive: side-by-side on TV/tablet, stacked on phone portrait.
100 // On compact widths the actions are reduced to a single icon row at the
101 // top of the screen. The "Earhorn Music" headline + "Pick a source to
102 // begin" subtitle that the TV layout shows are dropped: on phone the
103 // launcher icon and app name already make the context obvious, the
104 // subtitle is dead weight, and the server list right below is its own
105 // visual call-to-action.
106 if (isCompactWidth()) {
107 Row(
108 Modifier.fillMaxWidth(),
109 horizontalArrangement = Arrangement.spacedBy(8.dp),
110 ) {
111 CompactServersAction(
112 modifier = Modifier.weight(1f),
113 onClick = onOpenQueue,
114 ) {
115 Icon(
116 Icons.Filled.QueueMusic,
117 contentDescription = "Queue",
118 tint = OnPrimary,
119 modifier = Modifier.size(24.dp),
120 )
121 }
122 CompactServersAction(
123 modifier = Modifier.weight(1f),
124 onClick = onOpenSearch,
125 ) {
126 Icon(
127 Icons.Filled.Search,
128 contentDescription = "Search",
129 tint = OnPrimary,
130 modifier = Modifier.size(24.dp),
131 )
132 }
133 CompactServersAction(
134 modifier = Modifier.weight(1f),
135 onClick = onOpenRandom,
136 ) {
137 Icon(
138 Icons.Filled.Shuffle,
139 contentDescription = "Random",
140 tint = OnPrimary,
141 modifier = Modifier.size(24.dp),
142 )
143 }
144 // Maintainer, 2026-10-29: Cast action, same cell style as the rest.
145 if (onOpenCast != null) {
146 CompactServersAction(
147 modifier = Modifier.weight(1f),
148 onClick = onOpenCast,
149 ) {
150 Icon(
151 Icons.Filled.Cast,
152 contentDescription = "Cast",
153 tint = OnPrimary,
154 modifier = Modifier.size(24.dp),
155 )
156 }
157 }
158 CompactServersAction(
159 modifier = Modifier.weight(1f),
160 // Maintainer, 2026-10-29: also restarts the "searching" window.
161 onClick = onRefreshLibrary,
162 ) {
163 Icon(
164 Icons.Filled.Refresh,
165 contentDescription = "Refresh Library",
166 tint = OnPrimary,
167 modifier = Modifier.size(24.dp),
168 )
169 }
170 CompactServersAction(
171 modifier = Modifier.weight(1f),
172 onClick = onSettingsClick,
173 ) {
174 Icon(
175 Icons.Filled.Settings,
176 contentDescription = "Settings",
177 tint = OnPrimary,
178 modifier = Modifier.size(24.dp),
179 )
180 }
181 }
182 } else {
183 Row(
184 Modifier.fillMaxWidth(),
185 horizontalArrangement = Arrangement.SpaceBetween,
186 verticalAlignment = Alignment.Bottom
187 ) {
188 Column {
189 Text(
190 "Earhorn Music",
191 style = MaterialTheme.typography.displayMedium,
192 color = OnPrimary
193 )
194 Text(
195 "Select a server to begin",
196 style = MaterialTheme.typography.bodyLarge,
197 color = OnMuted
198 )
199 }
200 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
201 HeaderAction(label = "Queue", onClick = onOpenQueue)
202 HeaderAction(label = "Search", onClick = onOpenSearch)
203 HeaderAction(label = "Random", onClick = onOpenRandom)
204 // Maintainer, 2026-10-29: Cast action added; Refresh Library
205 // also restarts the "searching" window.
206 if (onOpenCast != null) {
207 HeaderAction(label = "Cast", onClick = onOpenCast)
208 }
209 HeaderAction(label = "Refresh Library", onClick = onRefreshLibrary)
210 HeaderAction(label = "Settings", onClick = onSettingsClick)
211 }
212 }
213 }
214
215 // 40dp of post-header air is a TV-viewing-distance allowance; on a
216 // touch device in landscape it read as dead space above the server
217 // list (field-rejected). TV keeps its original 40dp untouched.
218 val srvCfg = androidx.compose.ui.platform.LocalConfiguration.current
219 val srvTv = (srvCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
220 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
221 Spacer(Modifier.height(if (isCompactWidth()) 12.dp else if (srvTv) 40.dp else 16.dp))
222
223 // Local-music entry — phone-only feature. On TV the Servers screen
224 // must look exactly as it did before this row was added (no "On this
225 // device" entry). Local-files browsing requires the runtime media
226 // permission and a Column-based hub that doesn't fit Android TV's
227 // D-pad / leanback model anyway.
228 // Maintainer, 2026-10-29: was isCompactWidth(), which also hid the row
229 // on phone landscape, tablets and Chromebase; the intent is "not on TV".
230 val showLocalRow = !srvTv
231 LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
232 if (showLocalRow) {
233 item(key = "__local__") {
234 LocalSourceRow(onClick = onLocalClick)
235 }
236 }
237
238 // Compute the UDN currently being rescanned (if any) and whether
239 // the live `servers` list still contains it. If the user kicked
240 // off a rescan and the server has dropped off the registry, we
241 // still want to render SOMETHING for that row — otherwise the
242 // entry seemingly "vanishes" from the picker with no explanation.
243 val rescanningUdn = when (val s = rescanState) {
244 is RescanState.Requesting -> s.udn
245 is RescanState.WaitingForServer -> s.udn
246 is RescanState.Completed -> s.udn
247 is RescanState.Failed -> s.udn
248 RescanState.Idle -> null
249 }
250 val rescanningFriendlyName = when (val s = rescanState) {
251 is RescanState.Requesting -> s.friendlyName
252 is RescanState.WaitingForServer -> s.friendlyName
253 is RescanState.Completed -> s.friendlyName
254 is RescanState.Failed -> s.friendlyName
255 RescanState.Idle -> null
256 }
257 val rescanningServerMissing = rescanningUdn != null &&
258 servers.none { it.udn == rescanningUdn }
259
260 if (servers.isEmpty() && !rescanningServerMissing) {
261 item(key = "__empty_servers__") {
262 // Maintainer, 2026-10-29: searching/TV select the copy.
263 EmptyServersState(searching = searching, isTv = srvTv)
264 }
265 } else {
266 items(servers, key = { it.udn }) { server ->
267 ServerRow(
268 server = server,
269 rescanState = rescanState,
270 onRescan = { viewModel.rescanLibrary(server.udn) },
271 onClick = {
272 viewModel.rememberChoice(server.udn)
273 onServerClick(server.udn)
274 },
275 )
276 }
277 if (rescanningServerMissing && rescanningUdn != null && rescanningFriendlyName != null) {
278 item(key = "__rescan_placeholder_${rescanningUdn}") {
279 RescanPlaceholderRow(
280 friendlyName = rescanningFriendlyName,
281 rescanState = rescanState,
282 )
283 }
284 }
285 }
286 }
287 }
288}
289
290@Composable
291private fun LocalSourceRow(onClick: () -> Unit) {
292 val interaction = remember { MutableInteractionSource() }
293 Box(
294 Modifier
295 .fillMaxWidth()
296 .background(SurfaceRaised)
297 .staveFocusable(interaction)
298 .clickable(interaction, indication = null) { onClick() }
299 .padding(horizontal = 12.dp, vertical = 10.dp)
300 ) {
301 Row(verticalAlignment = Alignment.CenterVertically) {
302 Column(Modifier.weight(1f)) {
303 Text(
304 "On this device",
305 style = MaterialTheme.typography.titleLarge,
306 color = OnPrimary
307 )
308 Text(
309 "Music files stored locally — works offline, no UPnP server required",
310 style = MaterialTheme.typography.bodyMedium,
311 color = OnMuted
312 )
313 }
314 Text(
315 "Open ▸",
316 style = MaterialTheme.typography.labelLarge,
317 color = OnQuiet
318 )
319 }
320 }
321}
322
323@Composable
324private fun ServerRow(
325 server: MediaServer,
326 rescanState: RescanState,
327 onRescan: () -> Unit,
328 onClick: () -> Unit,
329) {
330 val interaction = remember { MutableInteractionSource() }
331 // Decide which inline status, if any, applies to this row. Rescan is a
332 // per-server operation so we look for an exact udn match. Anything else
333 // means we render the normal subtitle.
334 val rowRescan: RescanState? = when (rescanState) {
335 is RescanState.Requesting -> rescanState.takeIf { it.udn == server.udn }
336 is RescanState.WaitingForServer -> rescanState.takeIf { it.udn == server.udn }
337 is RescanState.Completed -> rescanState.takeIf { it.udn == server.udn }
338 is RescanState.Failed -> rescanState.takeIf { it.udn == server.udn }
339 RescanState.Idle -> null
340 }
341 val anyRescanInFlight = rescanState is RescanState.Requesting ||
342 rescanState is RescanState.WaitingForServer
343 Box(
344 Modifier
345 .fillMaxWidth()
346 .background(SurfaceRaised)
347 .staveFocusable(interaction)
348 .clickable(interaction, indication = null) { onClick() }
349 .padding(horizontal = 12.dp, vertical = 10.dp)
350 ) {
351 Row(verticalAlignment = Alignment.CenterVertically) {
352 Column(Modifier.weight(1f)) {
353 Text(
354 withInterDigits(server.friendlyName),
355 style = MaterialTheme.typography.titleLarge,
356 color = OnPrimary
357 )
358 // Subtitle: usually "manufacturer · modelName"; while a rescan
359 // is in flight for THIS server, replace it with a status line
360 // so the user knows their tap registered.
361 val subtitle = when (rowRescan) {
362 is RescanState.Requesting -> "Sending rescan command…"
363 is RescanState.WaitingForServer ->
364 if (rowRescan.seenDisappear) "Rescanning library — server restarting…"
365 else "Waiting for server to restart…"
366 is RescanState.Completed -> "Rescan complete — library refreshed"
367 is RescanState.Failed -> "Rescan failed: ${rowRescan.message}"
368 // null (no rescan for this row) and Idle (caller set
369 // rowRescan = null already, so this is unreachable in
370 // practice but required for exhaustive-when).
371 null, RescanState.Idle -> listOfNotNull(server.manufacturer, server.modelName)
372 .joinToString(" \u00b7 ")
373 .takeIf { it.isNotBlank() } ?: server.baseUrl
374 }
375 Text(
376 withInterDigits(subtitle),
377 style = MaterialTheme.typography.bodyMedium,
378 color = when (rowRescan) {
379 is RescanState.Completed -> AccentSage
380 is RescanState.Failed -> OnPrimary
381 else -> OnMuted
382 },
383 )
384 }
385 // Rescan action — only for MinimServer rows. Disabled (visibly
386 // dimmed and onClick is a no-op) while ANY rescan is in flight,
387 // so a second tap on the same row or a tap on a different one
388 // can't queue up a parallel operation. Sits to the left of the
389 // "Open ▸" hint so the main affordance (tap row to open) stays
390 // dominant.
391 if (server.isMinimServer) {
392 Spacer(Modifier.size(8.dp))
393 RescanAction(
394 enabled = !anyRescanInFlight,
395 busyForThisRow = rowRescan is RescanState.Requesting ||
396 rowRescan is RescanState.WaitingForServer,
397 onClick = onRescan,
398 )
399 Spacer(Modifier.size(8.dp))
400 }
401 Text(
402 "Open ▸",
403 style = MaterialTheme.typography.labelLarge,
404 color = OnQuiet
405 )
406 }
407 }
408}
409
410/** Small chip-style action used per-MinimServer row. Lives inside the
411 * parent ServerRow's clickable area but its own clickable consumes the tap
412 * so navigation doesn't fire when the user is aiming for the rescan.
413 *
414 * Three visual states:
415 * - enabled + not busy: solid label "Rescan ↻"
416 * - enabled + busy for THIS row: label "Rescanning…" with the Refresh
417 * icon (caller decides; we just dim it)
418 * - disabled (some OTHER row is rescanning): dimmed label, no-op click
419 *
420 * Visually subordinate to the row's main "Open ▸" hint by being darker and
421 * smaller — Rescan is the secondary action, opening is primary. */
422@Composable
423/** Compact icon-only rescan button. Earlier versions used "Rescan ↻"
424 * as a label-pill, but that competes for horizontal space with the
425 * row's title + subtitle + "Open ▸" hint, crushing the column. The
426 * rescan action is rare and the per-row subtitle already narrates
427 * what's happening, so a small ↻ glyph is enough as the affordance. */
428private fun RescanAction(
429 enabled: Boolean,
430 busyForThisRow: Boolean,
431 onClick: () -> Unit,
432) {
433 val interaction = remember { MutableInteractionSource() }
434 Box(
435 Modifier
436 .size(32.dp)
437 .background(AccentAmberSoft)
438 .staveFocusable(interaction, cornerRadiusDp = 6)
439 .clickable(
440 interaction,
441 indication = null,
442 enabled = enabled,
443 ) { onClick() },
444 contentAlignment = Alignment.Center,
445 ) {
446 Text(
447 "\u21bb",
448 style = MaterialTheme.typography.titleMedium,
449 color = if (enabled || busyForThisRow) AccentSage else OnQuiet,
450 )
451 }
452}
453
454/** Placeholder row shown when a rescan is in flight for a server that has
455 * dropped off the UPnP registry (mid-restart). Without this row, the
456 * picker visually loses the server entirely the moment MinimServer goes
457 * silent — the user has no indication their tap is still in progress and
458 * often assumes the server crashed or the app forgot the request. The
459 * row is non-interactive (the rescan can't be cancelled and the server
460 * can't be browsed until it comes back) but mirrors the visual style of
461 * a real ServerRow so it doesn't look like a different kind of entity. */
462@Composable
463private fun RescanPlaceholderRow(
464 friendlyName: String,
465 rescanState: RescanState,
466) {
467 Box(
468 Modifier
469 .fillMaxWidth()
470 .background(SurfaceRaised)
471 .padding(horizontal = 12.dp, vertical = 10.dp)
472 ) {
473 Row(verticalAlignment = Alignment.CenterVertically) {
474 Column(Modifier.weight(1f)) {
475 Text(
476 withInterDigits(friendlyName),
477 style = MaterialTheme.typography.titleLarge,
478 color = OnMuted,
479 )
480 val subtitle = when (rescanState) {
481 is RescanState.Requesting -> "Sending rescan command…"
482 is RescanState.WaitingForServer ->
483 if (rescanState.seenDisappear) "Rescanning library — server restarting…"
484 else "Waiting for server to restart…"
485 is RescanState.Completed -> "Rescan complete — library refreshed"
486 is RescanState.Failed -> "Rescan failed: ${rescanState.message}"
487 RescanState.Idle -> "Rescanning…"
488 }
489 Text(
490 withInterDigits(subtitle),
491 style = MaterialTheme.typography.bodyMedium,
492 color = when (rescanState) {
493 is RescanState.Completed -> AccentSage
494 is RescanState.Failed -> OnPrimary
495 else -> OnMuted
496 },
497 )
498 }
499 }
500 }
501}
502
503// Maintainer, 2026-10-29: "searching" copy while discovery is still running;
504// afterwards "No servers found" with the Refresh Library instruction
505// (press on TV, tap elsewhere). Replaces the misleading "No Music Found /
506// library is empty / press Rescan" text.
507@Composable
508private fun EmptyServersState(searching: Boolean, isTv: Boolean) {
509 Column(
510 Modifier.fillMaxWidth().padding(vertical = 40.dp),
511 horizontalAlignment = Alignment.CenterHorizontally
512 ) {
513 // Brand illustration — the Earhorn badge. Matches the mockup aesthetic.
514 // Sized 240dp — large enough to read across a a room but not overwhelming.
515 Image(
516 painter = painterResource(R.drawable.earhorn_badge),
517 contentDescription = null,
518 modifier = Modifier.size(240.dp),
519 )
520 Spacer(Modifier.height(24.dp))
521 if (searching) {
522 Text(
523 "Searching your network for music servers…",
524 style = MaterialTheme.typography.bodyLarge,
525 color = OnMuted,
526 )
527 } else {
528 Text(
529 "No servers found",
530 style = MaterialTheme.typography.displaySmall,
531 color = OnPrimary,
532 )
533 Spacer(Modifier.height(12.dp))
534 Text(
535 "Make sure your UPnP/DLNA server is running and on the same network, then " +
536 (if (isTv) "press" else "tap") + " Refresh Library.",
537 style = MaterialTheme.typography.bodyLarge,
538 color = OnMuted,
539 )
540 }
541 }
542}
543
544@Composable
545private fun HeaderAction(label: String, onClick: () -> Unit) {
546 val interaction = remember { MutableInteractionSource() }
547 Box(
548 Modifier
549 .background(SurfaceRaised)
550 .staveFocusable(interaction, cornerRadiusDp = 6)
551 .clickable(interaction, indication = null) { onClick() }
552 .padding(horizontal = 20.dp, vertical = 10.dp)
553 ) {
554 Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
555 }
556}
557
558/** Icon-only action cell for the compact Servers strip. Same pattern Browse
559 * uses for its compact action grid: caller passes `Modifier.weight(1f)` so
560 * the five cells share the row width evenly. Vertical padding is sized so
561 * the cell ends up matching the height [HeaderAction] would have produced
562 * with text — keeps the row from looking unusually short next to the
563 * server list below. */
564@Composable
565private fun CompactServersAction(
566 modifier: Modifier = Modifier,
567 onClick: () -> Unit,
568 content: @Composable () -> Unit,
569) {
570 val interaction = remember { MutableInteractionSource() }
571 Box(
572 modifier
573 .background(SurfaceRaised)
574 .staveFocusable(interaction, cornerRadiusDp = 6)
575 .clickable(interaction, indication = null) { onClick() }
576 .padding(vertical = 10.dp),
577 contentAlignment = Alignment.Center,
578 ) {
579 content()
580 }
581}