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

DetailScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.detail
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.ExperimentalFoundationApi
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.combinedClickable
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.defaultMinSize
16import androidx.compose.foundation.layout.PaddingValues
17import androidx.compose.foundation.layout.systemBarsPadding
18import androidx.compose.foundation.layout.fillMaxWidth
19import androidx.compose.foundation.layout.height
20import androidx.compose.foundation.layout.heightIn
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.width
24import androidx.compose.foundation.layout.widthIn
25import androidx.compose.foundation.lazy.LazyColumn
26import androidx.compose.foundation.lazy.items
27import androidx.compose.foundation.rememberScrollState
28import androidx.compose.foundation.shape.RoundedCornerShape
29import androidx.compose.foundation.verticalScroll
30import androidx.compose.material.icons.Icons
31import androidx.compose.material.icons.filled.Add
32import androidx.compose.material.icons.filled.Info
33import androidx.compose.material.icons.filled.PlayArrow
34import androidx.compose.foundation.Image
35import androidx.compose.material3.Icon
36import androidx.compose.runtime.Composable
37import androidx.compose.runtime.LaunchedEffect
38import androidx.compose.runtime.collectAsState
39import androidx.compose.runtime.getValue
40import androidx.compose.runtime.mutableStateOf
41import androidx.compose.foundation.layout.aspectRatio
42import dev.stave.data.SettingsStore
43import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.platform.LocalConfiguration
45import androidx.compose.runtime.remember
46import androidx.compose.runtime.setValue
47import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
50import androidx.compose.ui.focus.FocusRequester
51import androidx.compose.ui.focus.focusProperties
52import androidx.compose.ui.focus.focusRequester
53import androidx.compose.ui.focus.onFocusChanged
54import androidx.compose.ui.input.key.Key
55import androidx.compose.ui.input.key.KeyEventType
56import androidx.compose.ui.input.key.key
57import androidx.compose.ui.input.key.onKeyEvent
58import androidx.compose.ui.input.key.type
59import androidx.compose.ui.graphics.painter.Painter
60import androidx.compose.ui.graphics.Color
61import androidx.compose.ui.graphics.vector.rememberVectorPainter
62import androidx.compose.ui.res.painterResource
63import androidx.compose.ui.text.style.TextAlign
64import androidx.compose.ui.text.style.TextOverflow
65import androidx.compose.ui.unit.Dp
66import androidx.compose.ui.unit.dp
67import androidx.hilt.navigation.compose.hiltViewModel
68import androidx.tv.material3.MaterialTheme
69import androidx.tv.material3.Text
70import dev.stave.R
71import dev.stave.domain.model.DidlItem
72import dev.stave.domain.model.MetadataField
73import dev.stave.ui.components.ActionSheet
74import dev.stave.ui.components.AlbumArt
75import dev.stave.ui.components.SheetAction
76import dev.stave.ui.components.onDpadLongPress
77import dev.stave.ui.components.staveFocusable
78import dev.stave.ui.theme.OnMuted
79import dev.stave.ui.theme.OnPrimary
80import dev.stave.ui.theme.OnQuiet
81import dev.stave.ui.theme.SurfaceRaised
82import kotlin.time.Duration
83import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
84import dev.stave.ui.theme.adaptiveScreenPaddingVertical
85import dev.stave.ui.theme.isCompactHeight
86import dev.stave.ui.theme.isCompactWidth
87import dev.stave.ui.theme.staveInterFamily
88import dev.stave.ui.theme.withInterDigits
89
90@Composable
91fun DetailScreen(
92 udn: String,
93 containerId: String,
94 onPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
95 onEnqueue: (tracks: List<DidlItem.Track>) -> Unit,
96 onPlayNext: (tracks: List<DidlItem.Track>) -> Unit,
97 onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit,
98 onMoreByArtist: (artistName: String) -> Unit,
99 onTracksByArtist: (artistName: String) -> Unit,
100 onShowMetadata: (track: DidlItem.Track) -> Unit,
101 queueIsEmpty: Boolean,
102 onBack: () -> Unit,
103 viewModel: DetailViewModel = hiltViewModel(),
104) {
105 val state by viewModel.state.collectAsState()
106
107 LaunchedEffect(udn, containerId) {
108 viewModel.load(udn, containerId)
109 }
110
111 BackHandler(onBack = onBack)
112
113 // Confirm-replace sheet payload — non-null when we need the user to confirm clearing a
114 // non-empty queue before starting. Carries the startIndex too so clicking track 5 mid-
115 // album goes to track 5 (not track 1) after confirming. "Always warn before clearing."
116 var confirmPlay: ConfirmPlay? by remember { mutableStateOf(null) }
117
118 // Track-level long-press action sheet target. Null = closed. Holds the track + the list
119 // it was in + its index so Play-from-here has full context.
120 var trackAction: TrackAction? by remember { mutableStateOf(null) }
121
122 // Album-info modal visibility. The viewmodel owns the actual fetch and
123 // result state — this flag just gates the modal render. Resetting happens
124 // on dismiss so re-opening triggers a fresh fetch (cheap; OkHttp caches).
125 var albumInfoOpen by remember { mutableStateOf(false) }
126 val albumInfoState by viewModel.albumInfo.collectAsState()
127
128 Box(Modifier.fillMaxSize().padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())) {
129 when (val s = state) {
130 DetailUiState.Loading -> CenteredMessage("Loading album…", Modifier.fillMaxSize())
131 // Maintainer, 2026-10-29: error state gets a focusable Retry (auto-focused for TV
132 // D-pad) that re-runs the load; it was a dead end with nothing focusable.
133 is DetailUiState.Error -> DetailErrorWithRetry(
134 message = "Couldn't load album: ${s.message}",
135 onRetry = { viewModel.load(udn, containerId) },
136 )
137 is DetailUiState.Content -> DetailContent(
138 content = s,
139 queueIsEmpty = queueIsEmpty,
140 onPlayNow = { tracks, startIndex -> onPlay(tracks, startIndex) },
141 onRequestConfirmPlay = { tracks, startIndex ->
142 confirmPlay = ConfirmPlay(tracks, startIndex)
143 },
144 onEnqueue = onEnqueue,
145 onEnqueueAndPlay = onEnqueueAndPlay,
146 onPlayNext = onPlayNext,
147 onMoreByArtist = onMoreByArtist,
148 onAlbumInfo = {
149 albumInfoOpen = true
150 viewModel.loadAlbumInfo()
151 },
152 onTrackLongPress = { track, siblings, idx ->
153 trackAction = TrackAction(track, siblings, idx)
154 },
155 )
156 }
157 }
158
159 if (albumInfoOpen) {
160 AlbumInfoModal(
161 state = albumInfoState,
162 albumTitle = (state as? DetailUiState.Content)?.albumTitle ?: "",
163 onDismiss = {
164 albumInfoOpen = false
165 viewModel.resetAlbumInfo()
166 },
167 )
168 }
169
170 confirmPlay?.let { payload ->
171 val content = state as? DetailUiState.Content
172 ActionSheet(
173 title = "Clear queue and play?",
174 subtitle = content?.albumTitle?.let { "This will replace the current queue with \"$it\"." },
175 actions = listOf(
176 SheetAction("▶", "Play album") {
177 onPlay(payload.tracks, payload.startIndex)
178 },
179 ),
180 onDismiss = { confirmPlay = null },
181 )
182 }
183
184 trackAction?.let { target ->
185 val artistForMenu = target.track.albumArtist ?: target.track.artist
186 ActionSheet(
187 title = target.track.title,
188 subtitle = listOfNotNull(target.track.artist, target.track.album).joinToString(" · ")
189 .ifBlank { null },
190 actions = buildList {
191 // Enqueue and play — matches the single-click default and is the primary
192 // action users want 95% of the time: "play this track now without destroying
193 // my queue".
194 add(SheetAction("⏭", "Enqueue and play") {
195 onEnqueueAndPlay(listOf(target.track))
196 })
197 add(SheetAction("+", "Queue this track") {
198 onEnqueue(listOf(target.track))
199 })
200 add(SheetAction("⤵", "Play next") {
201 onPlayNext(listOf(target.track))
202 })
203 if (target.index < target.siblings.lastIndex) {
204 add(SheetAction("▼", "Queue from here to end") {
205 onEnqueue(target.siblings.drop(target.index))
206 })
207 }
208 // Destructive: replaces the entire queue with this track. Renamed from
209 // "Play now" — the new name makes the destructive semantics obvious.
210 // Positioned below the non-destructive options so the user reads through
211 // the safe ones first.
212 add(SheetAction("▶", "Play from new playlist") {
213 if (queueIsEmpty) onPlay(listOf(target.track), 0)
214 else confirmPlay = ConfirmPlay(listOf(target.track), 0)
215 })
216 // Navigation away from the album — jump to other work by this artist.
217 artistForMenu?.let { a ->
218 add(SheetAction("♫", "Albums by $a") { onMoreByArtist(a) })
219 add(SheetAction("♪", "Tracks by $a") { onTracksByArtist(a) })
220 }
221 add(SheetAction("ⓘ", "Show tags") {
222 onShowMetadata(target.track)
223 })
224 },
225 onDismiss = { trackAction = null },
226 )
227 }
228}
229
230private data class ConfirmPlay(val tracks: List<DidlItem.Track>, val startIndex: Int)
231private data class TrackAction(
232 val track: DidlItem.Track,
233 val siblings: List<DidlItem.Track>,
234 val index: Int,
235)
236
237@Composable
238private fun DetailContent(
239 content: DetailUiState.Content,
240 queueIsEmpty: Boolean,
241 onPlayNow: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
242 onRequestConfirmPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
243 onEnqueue: (tracks: List<DidlItem.Track>) -> Unit,
244 onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit,
245 onPlayNext: (tracks: List<DidlItem.Track>) -> Unit,
246 onMoreByArtist: (artistName: String) -> Unit,
247 onAlbumInfo: () -> Unit,
248 onTrackLongPress: (track: DidlItem.Track, siblings: List<DidlItem.Track>, index: Int) -> Unit,
249) {
250 val firstTrack = content.tracks.firstOrNull()
251 val moreButtonFocus = remember { FocusRequester() }
252 var lastFocusedTrackId by remember { mutableStateOf<String?>(null) }
253 val trackFocusRequesters = remember { mutableMapOf<String, FocusRequester>() }
254
255 val leftyContext = LocalContext.current
256 val leftyStore = remember { SettingsStore(leftyContext) }
257 val lefty by leftyStore.leftyMode.collectAsState(initial = false)
258 var showFullArt by remember { mutableStateOf(false) }
259 val compact = isCompactWidth()
260 val largePortrait = dev.stave.ui.theme.isLargePortrait()
261 if (compact || largePortrait) {
262 // Compact (phone) AND large-portrait (Chromebase / tablet in
263 // portrait) layout: art + actions + title at the top, track list
264 // filling the remaining height below. The TV Row layout puts a
265 // 360dp left pane next to a weight(1f) track list — on a portrait
266 // device (whether a 411dp phone or a 600dp+ Chromebase rotated
267 // portrait) that layout wastes vertical space and shows the album
268 // art at 1/3 screen height. Stacking vertically lets the art and
269 // tracks both use the full width.
270 Column(Modifier.fillMaxSize()) {
271 // Header: art fills width minus vertical button column.
272 // Lefty toggle swaps button column to the left side.
273 val buttonColumn: @Composable () -> Unit = {
274 Column(
275 verticalArrangement = Arrangement.spacedBy(8.dp),
276 modifier = Modifier.padding(top = 4.dp),
277 ) {
278 IconAction(
279 icon = rememberVectorPainter(Icons.Filled.Add),
280 description = "Queue album",
281 ) {
282 if (content.tracks.isNotEmpty()) onEnqueue(content.tracks)
283 }
284 IconAction(
285 icon = rememberVectorPainter(Icons.Filled.PlayArrow),
286 description = "Play album",
287 ) {
288 if (content.tracks.isEmpty()) return@IconAction
289 if (queueIsEmpty) onPlayNow(content.tracks, 0)
290 else onRequestConfirmPlay(content.tracks, 0)
291 }
292 IconAction(
293 icon = painterResource(R.drawable.ic_headphones_vinyl),
294 description = content.artistName?.let { "More by $it" } ?: "More",
295 enabled = content.artistName != null,
296 ) {
297 // Audit 2026-09-02: this returned when the QUEUE was empty,
298 // so "More by artist" did nothing on any device with nothing
299 // queued. Navigation has no dependency on the queue.
300 val artist = content.artistName ?: return@IconAction
301 onMoreByArtist(artist)
302 }
303 IconAction(
304 icon = rememberVectorPainter(Icons.Filled.Info),
305 description = "Album info",
306 enabled = content.tracks.isNotEmpty(),
307 ) {
308 onAlbumInfo()
309 }
310 }
311 }
312
313 // Track count + year on a single line, bullet-separated. The year
314 // moved here from the title column so the artwork header reads
315 // top-down as: title → artist → buttons, and the metadata footer
316 // groups "how many tracks, what year" as the single descriptive
317 // line above the list.
318 val headerYear = firstTrack?.date?.let { raw ->
319 val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) }
320 yr ?: raw.takeIf { it.isNotBlank() }
321 }
322 val headerLine = buildString {
323 append("${content.tracks.size} tracks")
324 if (headerYear != null) {
325 append(" \u00b7 ")
326 append(headerYear)
327 }
328 }
329 LazyColumn(
330 modifier = Modifier.fillMaxWidth().weight(1f),
331 verticalArrangement = Arrangement.spacedBy(4.dp),
332 ) {
333 // Header items — scroll with the tracklist
334 item(key = "header_art") {
335 Row(
336 Modifier.fillMaxWidth(),
337 horizontalArrangement = Arrangement.spacedBy(8.dp),
338 ) {
339 if (lefty) buttonColumn()
340 AlbumArt(
341 model = content.albumArtUri,
342 modifier = Modifier
343 .weight(1f)
344 .aspectRatio(1f)
345 .clip(RoundedCornerShape(2.dp)),
346 )
347 if (!lefty) buttonColumn()
348 }
349 }
350 item(key = "header_title") {
351 Column {
352 Spacer(Modifier.height(10.dp))
353 Text(
354 withInterDigits(content.albumTitle),
355 style = MaterialTheme.typography.titleLarge,
356 color = OnPrimary,
357 maxLines = 2,
358 overflow = TextOverflow.Ellipsis,
359 )
360 firstTrack?.let { track ->
361 val primaryArtist = track.albumArtist ?: track.artist
362 if (!primaryArtist.isNullOrBlank()) {
363 Spacer(Modifier.height(2.dp))
364 Text(
365 withInterDigits(primaryArtist),
366 style = MaterialTheme.typography.bodyMedium,
367 color = OnMuted,
368 maxLines = 2,
369 overflow = TextOverflow.Ellipsis,
370 )
371 }
372 }
373 }
374 }
375 item(key = "header_meta") {
376 Text(
377 withInterDigits(headerLine),
378 style = MaterialTheme.typography.labelLarge,
379 color = OnMuted,
380 )
381 Spacer(Modifier.height(8.dp))
382 }
383 items(content.tracks, key = { it.id }) { track ->
384 val idx = content.tracks.indexOf(track)
385 val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() }
386 TrackLine(
387 track = track,
388 leftFocus = moreButtonFocus,
389 focusRequester = fr,
390 onFocused = { lastFocusedTrackId = track.id },
391 onClick = { onEnqueueAndPlay(listOf(track)) },
392 onLongClick = { onTrackLongPress(track, content.tracks, idx) },
393 )
394 }
395 }
396 }
397 return
398 }
399
400 // ── TV / wide layout — original Row-based code ──
401 // Phone landscape (isCompactWidth=false, ~914dp) falls here too.
402 // Detect TV vs phone-landscape so we can add system bar inset
403 // padding on phone (display cutout on the left edge in landscape).
404 val detailConfig = LocalConfiguration.current
405 val isDetailTv = (detailConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
406 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
407 Row(
408 Modifier
409 .fillMaxSize()
410 .then(
411 if (isDetailTv) Modifier
412 else Modifier.systemBarsPadding()
413 )
414 ) {
415 // visual middle of its column rather than hard-left. verticalScroll lets long metadata
416 // blocks overflow gracefully when there are many enabled fields — D-pad can't focus
417 // into pure text, but the important focusables (the three action buttons) live at the
418 // top of the column so the user never needs to scroll to interact.
419 // Maintainer, 2026-10-29: phone landscape (~245dp usable height) squeezed the icon row
420 // to 25dp and the title to 0. On compact height only, the left column scrolls and the
421 // art shrinks to 120dp; TV and portrait keep the original fixed column and 210dp art.
422 val compactHeight = isCompactHeight()
423 val leftColumnScroll = rememberScrollState()
424 Column(
425 Modifier
426 .width(360.dp)
427 .padding(end = 40.dp)
428 .then(if (compactHeight) Modifier.verticalScroll(leftColumnScroll) else Modifier),
429 horizontalAlignment = Alignment.CenterHorizontally,
430 ) {
431 // 210dp art keeps the header compact; with metadata moved to the right column
432 // there's no need to grow it back up to fill vertical space.
433 AlbumArt(
434 model = content.albumArtUri,
435 modifier = Modifier
436 .size(if (compactHeight) 120.dp else 210.dp)
437 .clip(RoundedCornerShape(2.dp)),
438 )
439 Spacer(Modifier.height(10.dp))
440
441 // Icon-button action row. The right-most button carries moreButtonFocus so
442 // track rows' focusProperties.left can pin to it deterministically. All
443 // four IconActions render at the same 32dp contentSize so the row reads
444 // as visually uniform; per the compact layout's same fix, hand-tuning
445 // the headphones glyph smaller than the +/▶ Material icons read as
446 // inconsistent rather than balanced.
447 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
448 IconAction(
449 icon = rememberVectorPainter(Icons.Filled.Add),
450 description = "Queue album",
451 ) {
452 if (content.tracks.isNotEmpty()) onEnqueue(content.tracks)
453 }
454 IconAction(
455 icon = rememberVectorPainter(Icons.Filled.PlayArrow),
456 description = "Play album",
457 ) {
458 if (content.tracks.isEmpty()) return@IconAction
459 if (queueIsEmpty) onPlayNow(content.tracks, 0)
460 else onRequestConfirmPlay(content.tracks, 0)
461 }
462 IconAction(
463 icon = painterResource(R.drawable.ic_headphones_vinyl),
464 description = content.artistName?.let { "More by $it" } ?: "More",
465 enabled = content.artistName != null,
466 ) {
467 // Audit 2026-09-02: same inverted guard as the compact layout —
468 // "More by artist" did nothing while the queue was empty.
469 val artist = content.artistName ?: return@IconAction
470 onMoreByArtist(artist)
471 }
472 // Album info — opens a modal showing the ID3 COMMENT tag from the
473 // first track (most ripped libraries put album reviews / liner
474 // notes here), or a sibling .txt file when the COMMENT is empty.
475 // Loader is best-effort: when neither source has anything the
476 // modal shows a "no info available" message rather than not
477 // opening at all, so the button never feels broken.
478 //
479 // Right-most position carries moreButtonFocus (track rows' leftward
480 // focus target) and the right-edge key handler that restores focus
481 // to the last-focused track row.
482 IconAction(
483 icon = rememberVectorPainter(Icons.Filled.Info),
484 description = "Album info",
485 enabled = content.tracks.isNotEmpty(),
486 modifier = Modifier
487 .focusRequester(moreButtonFocus)
488 .onKeyEvent { event ->
489 // Intercept Right to restore the remembered-row focus. Without
490 // this, spatial Right from the right-most action button walks
491 // by Y and lands on whichever row is nearest — which drifts by
492 // a couple of rows on each Left→Right cycle as the list
493 // auto-scrolls.
494 if (event.type == KeyEventType.KeyDown &&
495 event.key == Key.DirectionRight
496 ) {
497 val id = lastFocusedTrackId
498 val fr = id?.let { trackFocusRequesters[it] }
499 if (fr != null) {
500 runCatching { fr.requestFocus() }
501 return@onKeyEvent true
502 }
503 }
504 false
505 },
506 ) {
507 onAlbumInfo()
508 }
509 }
510
511 Spacer(Modifier.height(10.dp))
512 Text(
513 withInterDigits(content.albumTitle),
514 style = MaterialTheme.typography.headlineMedium,
515 color = OnPrimary,
516 maxLines = 2,
517 overflow = TextOverflow.Ellipsis,
518 textAlign = TextAlign.Center,
519 )
520 firstTrack?.let { track ->
521 val primaryArtist = track.albumArtist ?: track.artist
522 // Year: prefer the 4-digit leading chunk; fall back to the whole
523 // date string when it's something like "2004-03" (uncommon but
524 // valid UPnP date form).
525 val yearOrDate = track.date?.let { raw ->
526 val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) }
527 yr ?: raw.takeIf { it.isNotBlank() }
528 }
529 if (!primaryArtist.isNullOrBlank()) {
530 Spacer(Modifier.height(4.dp))
531 Text(
532 withInterDigits(primaryArtist),
533 style = MaterialTheme.typography.bodyLarge,
534 color = OnMuted,
535 textAlign = TextAlign.Center,
536 )
537 }
538 if (yearOrDate != null) {
539 Spacer(Modifier.height(2.dp))
540 Text(
541 yearOrDate,
542 style = MaterialTheme.typography.bodyMedium.copy(fontFamily = staveInterFamily()),
543 color = OnMuted,
544 textAlign = TextAlign.Center,
545 )
546 }
547 // Album-y extras (publisher/label, rights, producer) pulled from
548 // the UPnP DIDL properties bag. Keys we consider "album-y" are
549 // ones that describe the RELEASE rather than the track — label
550 // and publisher are the most common. We skip long blob keys
551 // (description, lyrics) because they'd overwhelm the column.
552 AlbumExtrasBlock(track.extras)
553 }
554 // Technical specs — moved down here beneath the title/artist/year block
555 // into the open space of the left column. Generous vertical air above
556 // (24dp) so the specs read as their own grouping, separate from the
557 // subtitle text directly above.
558 Spacer(Modifier.height(24.dp))
559 firstTrack?.let { TechnicalSpecBlock(track = it, enabled = content.enabledFields) }
560 // Rich embedded-tag display (ID3v2 / FLAC Vorbis / OGG / MP4 iTunes)
561 // still lives in the "..." → View metadata action via MetadataScreen.
562 }
563
564 // Right pane: track list with a metadata footer. Putting metadata here rather than the
565 // left column means it rides the LazyColumn's scroll — users can page down through the
566 // tracks and the metadata appears after the last track. No separate scroll container
567 // on the left to cause disorienting column-level auto-scrolling when focus crosses over.
568 Column(Modifier.weight(1f)) {
569 Text(
570 withInterDigits("${content.tracks.size} tracks"),
571 style = MaterialTheme.typography.labelLarge,
572 color = OnMuted
573 )
574 Spacer(Modifier.height(12.dp))
575 LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
576 items(content.tracks, key = { it.id }) { track ->
577 val idx = content.tracks.indexOf(track)
578 val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() }
579 TrackLine(
580 track = track,
581 leftFocus = moreButtonFocus,
582 focusRequester = fr,
583 onFocused = { lastFocusedTrackId = track.id },
584 onClick = { onEnqueueAndPlay(listOf(track)) },
585 onLongClick = { onTrackLongPress(track, content.tracks, idx) },
586 )
587 }
588 }
589 }
590 }
591}
592
593/**
594 * Icon action button, 56dp square. Raised surface background, amber border on focus.
595 * [enabled] greys the icon when false (used for "More" when no parent was discovered).
596 */
597@Composable
598private fun IconAction(
599 icon: Painter,
600 description: String,
601 enabled: Boolean = true,
602 tinted: Boolean = true,
603 contentSize: Dp = 32.dp,
604 modifier: Modifier = Modifier,
605 onClick: () -> Unit,
606) {
607 val interaction = remember { MutableInteractionSource() }
608 Box(
609 modifier
610 .size(56.dp)
611 .background(SurfaceRaised)
612 .staveFocusable(interaction, cornerRadiusDp = 8)
613 .clickable(interaction, indication = null, enabled = enabled) { onClick() },
614 contentAlignment = Alignment.Center,
615 ) {
616 if (tinted) {
617 // Material vector icons — tint with the theme color so they read
618 // uniformly with the rest of the icon row. contentSize defaults to
619 // 32dp (up from Icon's 24dp default) so + and ▶ have visible
620 // presence at TV viewing distance, while leaving room around each
621 // glyph for the focus ring to sit clear of the artwork.
622 Icon(
623 painter = icon,
624 contentDescription = description,
625 tint = if (enabled) OnPrimary else OnQuiet,
626 modifier = Modifier.size(contentSize),
627 )
628 } else {
629 // Raster art — preserve the artwork's own colors, outlines, and
630 // alpha. Uses alpha to grey out when disabled rather than tint.
631 Image(
632 painter = icon,
633 contentDescription = description,
634 alpha = if (enabled) 1f else 0.35f,
635 modifier = Modifier.size(contentSize),
636 )
637 }
638 }
639}
640
641@Composable
642private fun TechnicalSpecBlock(track: DidlItem.Track, enabled: Set<MetadataField>) {
643 // Compact single-line-per-spec summary. Deliberately minimal — the old
644 // MetadataBlock showed everything DIDL-accessible (artist, composer, genre
645 // duplicates of what's already in the subtitle) which was noise. Rich
646 // embedded-tag info is available from the "..." → View metadata action,
647 // which uses AudioMetadataReader to fetch ID3/FLAC/Vorbis/MP4 tags.
648 val specs = buildList {
649 if (MetadataField.FORMAT in enabled) {
650 track.mimeType?.substringAfter('/')?.uppercase()?.let { add(it) }
651 }
652 if (MetadataField.BIT_DEPTH in enabled) {
653 track.bitsPerSample?.let { add("${it}-bit") }
654 }
655 if (MetadataField.SAMPLE_RATE in enabled) {
656 track.sampleFrequency?.let { add("${"%.1f".format(it / 1000.0)} kHz") }
657 }
658 if (MetadataField.CHANNELS in enabled) {
659 track.channels?.let { add(if (it == 2) "Stereo" else if (it == 1) "Mono" else "${it}ch") }
660 }
661 if (MetadataField.BITRATE in enabled) {
662 // Only show bitrate when useful — mostly lossy formats (mp3, aac, ogg).
663 // FLAC/ALAC bitrate is a derived number that just takes space.
664 val isLossy = track.mimeType?.let { mime ->
665 mime.contains("mpeg", true) || mime.contains("mp3", true) ||
666 mime.contains("aac", true) || mime.contains("m4a", true) ||
667 mime.contains("ogg", true) || mime.contains("opus", true)
668 } == true
669 if (isLossy) track.bitrate?.let { add("${it / 1000} kbps") }
670 }
671 }
672 if (specs.isEmpty()) return
673 Text(
674 withInterDigits(specs.joinToString(" \u00b7 ")),
675 style = MaterialTheme.typography.labelMedium,
676 color = OnQuiet,
677 textAlign = TextAlign.Center,
678 maxLines = 2,
679 )
680}
681
682/** Small stack of album-level metadata lines pulled from the UPnP DIDL extras
683 * bag. Not every server exposes these; when nothing matches, renders nothing.
684 *
685 * The curated key list is ordered by how commonly it's album-y rather than
686 * track-y, and keys are presented in a deterministic order so the visual
687 * rhythm stays consistent across tracks. "Label" wins over the raw UPnP
688 * "publisher" tag because "Label" is how music listeners think about it.
689 *
690 * Skipped: keys whose value is very long (lyrics, description) since they'd
691 * overwhelm the column, and keys we already surface as typed fields (genre,
692 * track number, album artist, etc.). */
693@Composable
694private fun AlbumExtrasBlock(extras: Map<String, String>) {
695 if (extras.isEmpty()) return
696 // Map from a human label to a list of candidate UPnP property keys. First
697 // non-blank hit wins. Keys are matched case-insensitively.
698 val entries = listOf(
699 "Label" to listOf("label", "dc:publisher", "publisher"),
700 "Producer" to listOf("upnp:producer", "producer"),
701 "Rights" to listOf("dc:rights", "rights"),
702 ).mapNotNull { (label, keys) ->
703 val value = keys.asSequence()
704 .mapNotNull { k -> extras.entries.firstOrNull { it.key.equals(k, ignoreCase = true) }?.value }
705 .firstOrNull { it.isNotBlank() && it.length <= 120 }
706 value?.let { label to it }
707 }
708 if (entries.isEmpty()) return
709 Spacer(Modifier.height(6.dp))
710 entries.forEach { (label, value) ->
711 Text(
712 withInterDigits("$label: $value"),
713 style = MaterialTheme.typography.labelMedium,
714 color = OnQuiet,
715 textAlign = TextAlign.Center,
716 maxLines = 2,
717 overflow = TextOverflow.Ellipsis,
718 )
719 }
720}
721
722@OptIn(ExperimentalFoundationApi::class)
723@Composable
724private fun TrackLine(
725 track: DidlItem.Track,
726 leftFocus: FocusRequester,
727 focusRequester: FocusRequester,
728 onFocused: () -> Unit,
729 onClick: () -> Unit,
730 onLongClick: () -> Unit,
731) {
732 val interaction = remember { MutableInteractionSource() }
733 // Scale up text and touch targets on large portrait screens (HP
734 // Chromebase, tablets in portrait) where phone-sized rows are too
735 // small to tap comfortably on a 21" touchscreen.
736 val large = dev.stave.ui.theme.isLargePortrait()
737 val rowPadding = if (large) PaddingValues(start = 8.dp, end = 16.dp, top = 12.dp, bottom = 10.dp)
738 else PaddingValues(start = 2.dp, end = 8.dp, top = 4.dp, bottom = 2.dp)
739 val numberStyle = if (large) MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily())
740 else MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily())
741 val titleStyle = if (large) MaterialTheme.typography.titleLarge
742 else MaterialTheme.typography.titleSmall
743 val durationStyle = if (large) MaterialTheme.typography.titleSmall.copy(fontFamily = staveInterFamily())
744 else MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily())
745 val numberMinWidth = if (large) 56.dp else 40.dp
746
747 Box(
748 Modifier
749 .fillMaxWidth()
750 .background(SurfaceRaised)
751 // Pin Left navigation to the left-column "..." button. Without this, spatial
752 // Left would hunt by Y-coordinate — OK for rows near the button row's Y, but
753 // rows toward the bottom of the list would land on + or play, or sometimes
754 // fall through to the bar-shortcut. Explicit pinning is deterministic.
755 .focusProperties { left = leftFocus }
756 .focusRequester(focusRequester)
757 .onFocusChanged { if (it.isFocused) onFocused() }
758 .staveFocusable(interaction)
759 .onDpadLongPress(onLongClick)
760 .combinedClickable(
761 interactionSource = interaction,
762 indication = null,
763 onClick = onClick,
764 onLongClick = onLongClick,
765 )
766 .padding(rowPadding)
767 ) {
768 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
769 track.trackNumber?.let {
770 Text(
771 it.toString(),
772 style = numberStyle,
773 color = OnQuiet,
774 textAlign = TextAlign.End,
775 maxLines = 1,
776 modifier = Modifier.defaultMinSize(minWidth = numberMinWidth),
777 )
778 }
779 Text(
780 withInterDigits(track.title),
781 style = titleStyle,
782 color = OnPrimary,
783 maxLines = 1,
784 overflow = TextOverflow.Ellipsis,
785 modifier = Modifier.weight(1f)
786 )
787 track.duration?.let {
788 Text(
789 it.formatTime(),
790 style = durationStyle,
791 color = OnMuted,
792 )
793 }
794 }
795 }
796}
797
798@Composable
799private fun CenteredMessage(text: String, modifier: Modifier = Modifier) {
800 Box(modifier, contentAlignment = Alignment.Center) {
801 Text(text, style = MaterialTheme.typography.bodyLarge, color = OnMuted)
802 }
803}
804
805// Maintainer, 2026-10-29: error message + Retry button (same pattern as BrowseScreen's
806// ErrorWithRetry). The button requests focus on entry so TV D-pad Center retries directly.
807@Composable
808private fun DetailErrorWithRetry(message: String, onRetry: () -> Unit) {
809 val retryFocus = remember { FocusRequester() }
810 LaunchedEffect(Unit) {
811 runCatching { retryFocus.requestFocus() }
812 }
813 val interaction = remember { MutableInteractionSource() }
814 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
815 Column(
816 horizontalAlignment = Alignment.CenterHorizontally,
817 verticalArrangement = Arrangement.spacedBy(16.dp),
818 ) {
819 Text(
820 message,
821 style = MaterialTheme.typography.bodyLarge,
822 color = OnMuted,
823 textAlign = TextAlign.Center,
824 )
825 Box(
826 Modifier
827 .focusRequester(retryFocus)
828 .background(SurfaceRaised)
829 .staveFocusable(interaction, cornerRadiusDp = 6)
830 .clickable(interaction, indication = null) { onRetry() }
831 .padding(horizontal = 24.dp, vertical = 10.dp)
832 ) {
833 Text("Retry", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
834 }
835 }
836 }
837}
838
839private fun Duration.formatTime(): String {
840 val total = inWholeSeconds
841 return "%d:%02d".format(total / 60, total % 60)
842}
843
844/**
845 * Modal overlay showing the album-info text fetched by [DetailViewModel].
846 *
847 * Renders one of three states based on [state]:
848 * - [AlbumInfoState.Loading] — "Loading…" placeholder
849 * - [AlbumInfoState.Loaded] — scrollable prose
850 * - [AlbumInfoState.Empty] — "No information available" message
851 * - [AlbumInfoState.Idle] — should never reach UI; treated as Loading
852 *
853 * Dismiss handling: Back key, scrim tap, or D-pad-press anywhere outside
854 * the card all call [onDismiss]. Inside the card, scroll is consumed by
855 * the prose column; D-pad Up/Down advances the scroll, D-pad Back closes.
856 */
857@Composable
858private fun AlbumInfoModal(
859 state: AlbumInfoState,
860 albumTitle: String,
861 onDismiss: () -> Unit,
862) {
863 BackHandler(onBack = onDismiss)
864 Box(
865 Modifier
866 .fillMaxSize()
867 .background(Color.Black.copy(alpha = 0.75f))
868 .clickable(
869 interactionSource = remember { MutableInteractionSource() },
870 indication = null,
871 onClick = onDismiss,
872 ),
873 contentAlignment = Alignment.Center,
874 ) {
875 Column(
876 Modifier
877 // Never wider than the screen (design decision 2026-09-02,
878 // I.52): min 640dp overflowed every phone.
879 .fillMaxWidth(0.92f)
880 .widthIn(max = 920.dp)
881 .heightIn(max = 540.dp)
882 .clip(RoundedCornerShape(16.dp))
883 .background(SurfaceRaised)
884 // Absorb clicks so the scrim's dismiss-on-tap doesn't fire when
885 // the user clicks within the card itself.
886 .clickable(
887 interactionSource = remember { MutableInteractionSource() },
888 indication = null,
889 onClick = {},
890 )
891 .padding(horizontal = 32.dp, vertical = 28.dp),
892 ) {
893 Text(
894 "Album info",
895 style = MaterialTheme.typography.titleLarge,
896 color = OnPrimary,
897 )
898 if (albumTitle.isNotBlank()) {
899 Spacer(Modifier.height(4.dp))
900 Text(
901 withInterDigits(albumTitle),
902 style = MaterialTheme.typography.bodyMedium,
903 color = OnMuted,
904 maxLines = 2,
905 overflow = TextOverflow.Ellipsis,
906 )
907 }
908 Spacer(Modifier.height(20.dp))
909 // Body. Loading and Empty states render once; Loaded scrolls.
910 when (state) {
911 is AlbumInfoState.Loaded -> {
912 val scroll = rememberScrollState()
913 Column(
914 Modifier
915 .fillMaxWidth()
916 .verticalScroll(scroll),
917 ) {
918 Text(
919 withInterDigits(state.text),
920 style = MaterialTheme.typography.bodyLarge,
921 color = OnPrimary,
922 )
923 }
924 }
925 AlbumInfoState.Empty -> Text(
926 "No information available for this album.",
927 style = MaterialTheme.typography.bodyLarge,
928 color = OnMuted,
929 )
930 // Loading and Idle render the same placeholder. Idle is
931 // theoretically unreachable here (the parent only opens the
932 // modal after calling loadAlbumInfo()), but covering it keeps
933 // the when-exhaustiveness satisfied and avoids a flash if the
934 // viewmodel is reset mid-render.
935 AlbumInfoState.Loading,
936 AlbumInfoState.Idle -> Text(
937 "Loading…",
938 style = MaterialTheme.typography.bodyLarge,
939 color = OnMuted,
940 )
941 }
942 Spacer(Modifier.height(20.dp))
943 Text(
944 "Press Back to close.",
945 style = MaterialTheme.typography.labelMedium,
946 color = OnQuiet,
947 )
948 }
949 }
950}