package dev.stave.ui.detail
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.defaultMinSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.foundation.Image
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.foundation.layout.aspectRatio
import dev.stave.data.SettingsStore
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.DidlItem
import dev.stave.domain.model.MetadataField
import dev.stave.ui.components.ActionSheet
import dev.stave.ui.components.AlbumArt
import dev.stave.ui.components.SheetAction
import dev.stave.ui.components.onDpadLongPress
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.SurfaceRaised
import kotlin.time.Duration
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.isCompactHeight
import dev.stave.ui.theme.isCompactWidth
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
@Composable
fun DetailScreen(
udn: String,
containerId: String,
onPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
onEnqueue: (tracks: List<DidlItem.Track>) -> Unit,
onPlayNext: (tracks: List<DidlItem.Track>) -> Unit,
onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit,
onMoreByArtist: (artistName: String) -> Unit,
onTracksByArtist: (artistName: String) -> Unit,
onShowMetadata: (track: DidlItem.Track) -> Unit,
queueIsEmpty: Boolean,
onBack: () -> Unit,
viewModel: DetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(udn, containerId) {
viewModel.load(udn, containerId)
}
BackHandler(onBack = onBack)
// Confirm-replace sheet payload — non-null when we need the user to confirm clearing a
// non-empty queue before starting. Carries the startIndex too so clicking track 5 mid-
// album goes to track 5 (not track 1) after confirming. "Always warn before clearing."
var confirmPlay: ConfirmPlay? by remember { mutableStateOf(null) }
// Track-level long-press action sheet target. Null = closed. Holds the track + the list
// it was in + its index so Play-from-here has full context.
var trackAction: TrackAction? by remember { mutableStateOf(null) }
// Album-info modal visibility. The viewmodel owns the actual fetch and
// result state — this flag just gates the modal render. Resetting happens
// on dismiss so re-opening triggers a fresh fetch (cheap; OkHttp caches).
var albumInfoOpen by remember { mutableStateOf(false) }
val albumInfoState by viewModel.albumInfo.collectAsState()
Box(Modifier.fillMaxSize().padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())) {
when (val s = state) {
DetailUiState.Loading -> CenteredMessage("Loading album…", Modifier.fillMaxSize())
// Maintainer, 2026-10-29: error state gets a focusable Retry (auto-focused for TV
// D-pad) that re-runs the load; it was a dead end with nothing focusable.
is DetailUiState.Error -> DetailErrorWithRetry(
message = "Couldn't load album: ${s.message}",
onRetry = { viewModel.load(udn, containerId) },
)
is DetailUiState.Content -> DetailContent(
content = s,
queueIsEmpty = queueIsEmpty,
onPlayNow = { tracks, startIndex -> onPlay(tracks, startIndex) },
onRequestConfirmPlay = { tracks, startIndex ->
confirmPlay = ConfirmPlay(tracks, startIndex)
},
onEnqueue = onEnqueue,
onEnqueueAndPlay = onEnqueueAndPlay,
onPlayNext = onPlayNext,
onMoreByArtist = onMoreByArtist,
onAlbumInfo = {
albumInfoOpen = true
viewModel.loadAlbumInfo()
},
onTrackLongPress = { track, siblings, idx ->
trackAction = TrackAction(track, siblings, idx)
},
)
}
}
if (albumInfoOpen) {
AlbumInfoModal(
state = albumInfoState,
albumTitle = (state as? DetailUiState.Content)?.albumTitle ?: "",
onDismiss = {
albumInfoOpen = false
viewModel.resetAlbumInfo()
},
)
}
confirmPlay?.let { payload ->
val content = state as? DetailUiState.Content
ActionSheet(
title = "Clear queue and play?",
subtitle = content?.albumTitle?.let { "This will replace the current queue with \"$it\"." },
actions = listOf(
SheetAction("▶", "Play album") {
onPlay(payload.tracks, payload.startIndex)
},
),
onDismiss = { confirmPlay = null },
)
}
trackAction?.let { target ->
val artistForMenu = target.track.albumArtist ?: target.track.artist
ActionSheet(
title = target.track.title,
subtitle = listOfNotNull(target.track.artist, target.track.album).joinToString(" · ")
.ifBlank { null },
actions = buildList {
// Enqueue and play — matches the single-click default and is the primary
// action users want 95% of the time: "play this track now without destroying
// my queue".
add(SheetAction("⏭", "Enqueue and play") {
onEnqueueAndPlay(listOf(target.track))
})
add(SheetAction("+", "Queue this track") {
onEnqueue(listOf(target.track))
})
add(SheetAction("⤵", "Play next") {
onPlayNext(listOf(target.track))
})
if (target.index < target.siblings.lastIndex) {
add(SheetAction("▼", "Queue from here to end") {
onEnqueue(target.siblings.drop(target.index))
})
}
// Destructive: replaces the entire queue with this track. Renamed from
// "Play now" — the new name makes the destructive semantics obvious.
// Positioned below the non-destructive options so the user reads through
// the safe ones first.
add(SheetAction("▶", "Play from new playlist") {
if (queueIsEmpty) onPlay(listOf(target.track), 0)
else confirmPlay = ConfirmPlay(listOf(target.track), 0)
})
// Navigation away from the album — jump to other work by this artist.
artistForMenu?.let { a ->
add(SheetAction("♫", "Albums by $a") { onMoreByArtist(a) })
add(SheetAction("♪", "Tracks by $a") { onTracksByArtist(a) })
}
add(SheetAction("ⓘ", "Show tags") {
onShowMetadata(target.track)
})
},
onDismiss = { trackAction = null },
)
}
}
private data class ConfirmPlay(val tracks: List<DidlItem.Track>, val startIndex: Int)
private data class TrackAction(
val track: DidlItem.Track,
val siblings: List<DidlItem.Track>,
val index: Int,
)
@Composable
private fun DetailContent(
content: DetailUiState.Content,
queueIsEmpty: Boolean,
onPlayNow: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
onRequestConfirmPlay: (tracks: List<DidlItem.Track>, startIndex: Int) -> Unit,
onEnqueue: (tracks: List<DidlItem.Track>) -> Unit,
onEnqueueAndPlay: (tracks: List<DidlItem.Track>) -> Unit,
onPlayNext: (tracks: List<DidlItem.Track>) -> Unit,
onMoreByArtist: (artistName: String) -> Unit,
onAlbumInfo: () -> Unit,
onTrackLongPress: (track: DidlItem.Track, siblings: List<DidlItem.Track>, index: Int) -> Unit,
) {
val firstTrack = content.tracks.firstOrNull()
val moreButtonFocus = remember { FocusRequester() }
var lastFocusedTrackId by remember { mutableStateOf<String?>(null) }
val trackFocusRequesters = remember { mutableMapOf<String, FocusRequester>() }
val leftyContext = LocalContext.current
val leftyStore = remember { SettingsStore(leftyContext) }
val lefty by leftyStore.leftyMode.collectAsState(initial = false)
var showFullArt by remember { mutableStateOf(false) }
val compact = isCompactWidth()
val largePortrait = dev.stave.ui.theme.isLargePortrait()
if (compact || largePortrait) {
// Compact (phone) AND large-portrait (Chromebase / tablet in
// portrait) layout: art + actions + title at the top, track list
// filling the remaining height below. The TV Row layout puts a
// 360dp left pane next to a weight(1f) track list — on a portrait
// device (whether a 411dp phone or a 600dp+ Chromebase rotated
// portrait) that layout wastes vertical space and shows the album
// art at 1/3 screen height. Stacking vertically lets the art and
// tracks both use the full width.
Column(Modifier.fillMaxSize()) {
// Header: art fills width minus vertical button column.
// Lefty toggle swaps button column to the left side.
val buttonColumn: @Composable () -> Unit = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 4.dp),
) {
IconAction(
icon = rememberVectorPainter(Icons.Filled.Add),
description = "Queue album",
) {
if (content.tracks.isNotEmpty()) onEnqueue(content.tracks)
}
IconAction(
icon = rememberVectorPainter(Icons.Filled.PlayArrow),
description = "Play album",
) {
if (content.tracks.isEmpty()) return@IconAction
if (queueIsEmpty) onPlayNow(content.tracks, 0)
else onRequestConfirmPlay(content.tracks, 0)
}
IconAction(
icon = painterResource(R.drawable.ic_headphones_vinyl),
description = content.artistName?.let { "More by $it" } ?: "More",
enabled = content.artistName != null,
) {
// Audit 2026-09-02: this returned when the QUEUE was empty,
// so "More by artist" did nothing on any device with nothing
// queued. Navigation has no dependency on the queue.
val artist = content.artistName ?: return@IconAction
onMoreByArtist(artist)
}
IconAction(
icon = rememberVectorPainter(Icons.Filled.Info),
description = "Album info",
enabled = content.tracks.isNotEmpty(),
) {
onAlbumInfo()
}
}
}
// Track count + year on a single line, bullet-separated. The year
// moved here from the title column so the artwork header reads
// top-down as: title → artist → buttons, and the metadata footer
// groups "how many tracks, what year" as the single descriptive
// line above the list.
val headerYear = firstTrack?.date?.let { raw ->
val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) }
yr ?: raw.takeIf { it.isNotBlank() }
}
val headerLine = buildString {
append("${content.tracks.size} tracks")
if (headerYear != null) {
append(" \u00b7 ")
append(headerYear)
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Header items — scroll with the tracklist
item(key = "header_art") {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (lefty) buttonColumn()
AlbumArt(
model = content.albumArtUri,
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp)),
)
if (!lefty) buttonColumn()
}
}
item(key = "header_title") {
Column {
Spacer(Modifier.height(10.dp))
Text(
withInterDigits(content.albumTitle),
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
firstTrack?.let { track ->
val primaryArtist = track.albumArtist ?: track.artist
if (!primaryArtist.isNullOrBlank()) {
Spacer(Modifier.height(2.dp))
Text(
withInterDigits(primaryArtist),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
item(key = "header_meta") {
Text(
withInterDigits(headerLine),
style = MaterialTheme.typography.labelLarge,
color = OnMuted,
)
Spacer(Modifier.height(8.dp))
}
items(content.tracks, key = { it.id }) { track ->
val idx = content.tracks.indexOf(track)
val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() }
TrackLine(
track = track,
leftFocus = moreButtonFocus,
focusRequester = fr,
onFocused = { lastFocusedTrackId = track.id },
onClick = { onEnqueueAndPlay(listOf(track)) },
onLongClick = { onTrackLongPress(track, content.tracks, idx) },
)
}
}
}
return
}
// ── TV / wide layout — original Row-based code ──
// Phone landscape (isCompactWidth=false, ~914dp) falls here too.
// Detect TV vs phone-landscape so we can add system bar inset
// padding on phone (display cutout on the left edge in landscape).
val detailConfig = LocalConfiguration.current
val isDetailTv = (detailConfig.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
Row(
Modifier
.fillMaxSize()
.then(
if (isDetailTv) Modifier
else Modifier.systemBarsPadding()
)
) {
// visual middle of its column rather than hard-left. verticalScroll lets long metadata
// blocks overflow gracefully when there are many enabled fields — D-pad can't focus
// into pure text, but the important focusables (the three action buttons) live at the
// top of the column so the user never needs to scroll to interact.
// Maintainer, 2026-10-29: phone landscape (~245dp usable height) squeezed the icon row
// to 25dp and the title to 0. On compact height only, the left column scrolls and the
// art shrinks to 120dp; TV and portrait keep the original fixed column and 210dp art.
val compactHeight = isCompactHeight()
val leftColumnScroll = rememberScrollState()
Column(
Modifier
.width(360.dp)
.padding(end = 40.dp)
.then(if (compactHeight) Modifier.verticalScroll(leftColumnScroll) else Modifier),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// 210dp art keeps the header compact; with metadata moved to the right column
// there's no need to grow it back up to fill vertical space.
AlbumArt(
model = content.albumArtUri,
modifier = Modifier
.size(if (compactHeight) 120.dp else 210.dp)
.clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(10.dp))
// Icon-button action row. The right-most button carries moreButtonFocus so
// track rows' focusProperties.left can pin to it deterministically. All
// four IconActions render at the same 32dp contentSize so the row reads
// as visually uniform; per the compact layout's same fix, hand-tuning
// the headphones glyph smaller than the +/▶ Material icons read as
// inconsistent rather than balanced.
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconAction(
icon = rememberVectorPainter(Icons.Filled.Add),
description = "Queue album",
) {
if (content.tracks.isNotEmpty()) onEnqueue(content.tracks)
}
IconAction(
icon = rememberVectorPainter(Icons.Filled.PlayArrow),
description = "Play album",
) {
if (content.tracks.isEmpty()) return@IconAction
if (queueIsEmpty) onPlayNow(content.tracks, 0)
else onRequestConfirmPlay(content.tracks, 0)
}
IconAction(
icon = painterResource(R.drawable.ic_headphones_vinyl),
description = content.artistName?.let { "More by $it" } ?: "More",
enabled = content.artistName != null,
) {
// Audit 2026-09-02: same inverted guard as the compact layout —
// "More by artist" did nothing while the queue was empty.
val artist = content.artistName ?: return@IconAction
onMoreByArtist(artist)
}
// Album info — opens a modal showing the ID3 COMMENT tag from the
// first track (most ripped libraries put album reviews / liner
// notes here), or a sibling .txt file when the COMMENT is empty.
// Loader is best-effort: when neither source has anything the
// modal shows a "no info available" message rather than not
// opening at all, so the button never feels broken.
//
// Right-most position carries moreButtonFocus (track rows' leftward
// focus target) and the right-edge key handler that restores focus
// to the last-focused track row.
IconAction(
icon = rememberVectorPainter(Icons.Filled.Info),
description = "Album info",
enabled = content.tracks.isNotEmpty(),
modifier = Modifier
.focusRequester(moreButtonFocus)
.onKeyEvent { event ->
// Intercept Right to restore the remembered-row focus. Without
// this, spatial Right from the right-most action button walks
// by Y and lands on whichever row is nearest — which drifts by
// a couple of rows on each Left→Right cycle as the list
// auto-scrolls.
if (event.type == KeyEventType.KeyDown &&
event.key == Key.DirectionRight
) {
val id = lastFocusedTrackId
val fr = id?.let { trackFocusRequesters[it] }
if (fr != null) {
runCatching { fr.requestFocus() }
return@onKeyEvent true
}
}
false
},
) {
onAlbumInfo()
}
}
Spacer(Modifier.height(10.dp))
Text(
withInterDigits(content.albumTitle),
style = MaterialTheme.typography.headlineMedium,
color = OnPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
firstTrack?.let { track ->
val primaryArtist = track.albumArtist ?: track.artist
// Year: prefer the 4-digit leading chunk; fall back to the whole
// date string when it's something like "2004-03" (uncommon but
// valid UPnP date form).
val yearOrDate = track.date?.let { raw ->
val yr = raw.take(4).takeIf { s -> s.length == 4 && s.all(Char::isDigit) }
yr ?: raw.takeIf { it.isNotBlank() }
}
if (!primaryArtist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
withInterDigits(primaryArtist),
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
textAlign = TextAlign.Center,
)
}
if (yearOrDate != null) {
Spacer(Modifier.height(2.dp))
Text(
yearOrDate,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = staveInterFamily()),
color = OnMuted,
textAlign = TextAlign.Center,
)
}
// Album-y extras (publisher/label, rights, producer) pulled from
// the UPnP DIDL properties bag. Keys we consider "album-y" are
// ones that describe the RELEASE rather than the track — label
// and publisher are the most common. We skip long blob keys
// (description, lyrics) because they'd overwhelm the column.
AlbumExtrasBlock(track.extras)
}
// Technical specs — moved down here beneath the title/artist/year block
// into the open space of the left column. Generous vertical air above
// (24dp) so the specs read as their own grouping, separate from the
// subtitle text directly above.
Spacer(Modifier.height(24.dp))
firstTrack?.let { TechnicalSpecBlock(track = it, enabled = content.enabledFields) }
// Rich embedded-tag display (ID3v2 / FLAC Vorbis / OGG / MP4 iTunes)
// still lives in the "..." → View metadata action via MetadataScreen.
}
// Right pane: track list with a metadata footer. Putting metadata here rather than the
// left column means it rides the LazyColumn's scroll — users can page down through the
// tracks and the metadata appears after the last track. No separate scroll container
// on the left to cause disorienting column-level auto-scrolling when focus crosses over.
Column(Modifier.weight(1f)) {
Text(
withInterDigits("${content.tracks.size} tracks"),
style = MaterialTheme.typography.labelLarge,
color = OnMuted
)
Spacer(Modifier.height(12.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(content.tracks, key = { it.id }) { track ->
val idx = content.tracks.indexOf(track)
val fr = trackFocusRequesters.getOrPut(track.id) { FocusRequester() }
TrackLine(
track = track,
leftFocus = moreButtonFocus,
focusRequester = fr,
onFocused = { lastFocusedTrackId = track.id },
onClick = { onEnqueueAndPlay(listOf(track)) },
onLongClick = { onTrackLongPress(track, content.tracks, idx) },
)
}
}
}
}
}
/**
* Icon action button, 56dp square. Raised surface background, amber border on focus.
* [enabled] greys the icon when false (used for "More" when no parent was discovered).
*/
@Composable
private fun IconAction(
icon: Painter,
description: String,
enabled: Boolean = true,
tinted: Boolean = true,
contentSize: Dp = 32.dp,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.size(56.dp)
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 8)
.clickable(interaction, indication = null, enabled = enabled) { onClick() },
contentAlignment = Alignment.Center,
) {
if (tinted) {
// Material vector icons — tint with the theme color so they read
// uniformly with the rest of the icon row. contentSize defaults to
// 32dp (up from Icon's 24dp default) so + and ▶ have visible
// presence at TV viewing distance, while leaving room around each
// glyph for the focus ring to sit clear of the artwork.
Icon(
painter = icon,
contentDescription = description,
tint = if (enabled) OnPrimary else OnQuiet,
modifier = Modifier.size(contentSize),
)
} else {
// Raster art — preserve the artwork's own colors, outlines, and
// alpha. Uses alpha to grey out when disabled rather than tint.
Image(
painter = icon,
contentDescription = description,
alpha = if (enabled) 1f else 0.35f,
modifier = Modifier.size(contentSize),
)
}
}
}
@Composable
private fun TechnicalSpecBlock(track: DidlItem.Track, enabled: Set<MetadataField>) {
// Compact single-line-per-spec summary. Deliberately minimal — the old
// MetadataBlock showed everything DIDL-accessible (artist, composer, genre
// duplicates of what's already in the subtitle) which was noise. Rich
// embedded-tag info is available from the "..." → View metadata action,
// which uses AudioMetadataReader to fetch ID3/FLAC/Vorbis/MP4 tags.
val specs = buildList {
if (MetadataField.FORMAT in enabled) {
track.mimeType?.substringAfter('/')?.uppercase()?.let { add(it) }
}
if (MetadataField.BIT_DEPTH in enabled) {
track.bitsPerSample?.let { add("${it}-bit") }
}
if (MetadataField.SAMPLE_RATE in enabled) {
track.sampleFrequency?.let { add("${"%.1f".format(it / 1000.0)} kHz") }
}
if (MetadataField.CHANNELS in enabled) {
track.channels?.let { add(if (it == 2) "Stereo" else if (it == 1) "Mono" else "${it}ch") }
}
if (MetadataField.BITRATE in enabled) {
// Only show bitrate when useful — mostly lossy formats (mp3, aac, ogg).
// FLAC/ALAC bitrate is a derived number that just takes space.
val isLossy = track.mimeType?.let { mime ->
mime.contains("mpeg", true) || mime.contains("mp3", true) ||
mime.contains("aac", true) || mime.contains("m4a", true) ||
mime.contains("ogg", true) || mime.contains("opus", true)
} == true
if (isLossy) track.bitrate?.let { add("${it / 1000} kbps") }
}
}
if (specs.isEmpty()) return
Text(
withInterDigits(specs.joinToString(" \u00b7 ")),
style = MaterialTheme.typography.labelMedium,
color = OnQuiet,
textAlign = TextAlign.Center,
maxLines = 2,
)
}
/** Small stack of album-level metadata lines pulled from the UPnP DIDL extras
* bag. Not every server exposes these; when nothing matches, renders nothing.
*
* The curated key list is ordered by how commonly it's album-y rather than
* track-y, and keys are presented in a deterministic order so the visual
* rhythm stays consistent across tracks. "Label" wins over the raw UPnP
* "publisher" tag because "Label" is how music listeners think about it.
*
* Skipped: keys whose value is very long (lyrics, description) since they'd
* overwhelm the column, and keys we already surface as typed fields (genre,
* track number, album artist, etc.). */
@Composable
private fun AlbumExtrasBlock(extras: Map<String, String>) {
if (extras.isEmpty()) return
// Map from a human label to a list of candidate UPnP property keys. First
// non-blank hit wins. Keys are matched case-insensitively.
val entries = listOf(
"Label" to listOf("label", "dc:publisher", "publisher"),
"Producer" to listOf("upnp:producer", "producer"),
"Rights" to listOf("dc:rights", "rights"),
).mapNotNull { (label, keys) ->
val value = keys.asSequence()
.mapNotNull { k -> extras.entries.firstOrNull { it.key.equals(k, ignoreCase = true) }?.value }
.firstOrNull { it.isNotBlank() && it.length <= 120 }
value?.let { label to it }
}
if (entries.isEmpty()) return
Spacer(Modifier.height(6.dp))
entries.forEach { (label, value) ->
Text(
withInterDigits("$label: $value"),
style = MaterialTheme.typography.labelMedium,
color = OnQuiet,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TrackLine(
track: DidlItem.Track,
leftFocus: FocusRequester,
focusRequester: FocusRequester,
onFocused: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
// Scale up text and touch targets on large portrait screens (HP
// Chromebase, tablets in portrait) where phone-sized rows are too
// small to tap comfortably on a 21" touchscreen.
val large = dev.stave.ui.theme.isLargePortrait()
val rowPadding = if (large) PaddingValues(start = 8.dp, end = 16.dp, top = 12.dp, bottom = 10.dp)
else PaddingValues(start = 2.dp, end = 8.dp, top = 4.dp, bottom = 2.dp)
val numberStyle = if (large) MaterialTheme.typography.titleMedium.copy(fontFamily = staveInterFamily())
else MaterialTheme.typography.labelMedium.copy(fontFamily = staveInterFamily())
val titleStyle = if (large) MaterialTheme.typography.titleLarge
else MaterialTheme.typography.titleSmall
val durationStyle = if (large) MaterialTheme.typography.titleSmall.copy(fontFamily = staveInterFamily())
else MaterialTheme.typography.labelSmall.copy(fontFamily = staveInterFamily())
val numberMinWidth = if (large) 56.dp else 40.dp
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
// Pin Left navigation to the left-column "..." button. Without this, spatial
// Left would hunt by Y-coordinate — OK for rows near the button row's Y, but
// rows toward the bottom of the list would land on + or play, or sometimes
// fall through to the bar-shortcut. Explicit pinning is deterministic.
.focusProperties { left = leftFocus }
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onFocused() }
.staveFocusable(interaction)
.onDpadLongPress(onLongClick)
.combinedClickable(
interactionSource = interaction,
indication = null,
onClick = onClick,
onLongClick = onLongClick,
)
.padding(rowPadding)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
track.trackNumber?.let {
Text(
it.toString(),
style = numberStyle,
color = OnQuiet,
textAlign = TextAlign.End,
maxLines = 1,
modifier = Modifier.defaultMinSize(minWidth = numberMinWidth),
)
}
Text(
withInterDigits(track.title),
style = titleStyle,
color = OnPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
track.duration?.let {
Text(
it.formatTime(),
style = durationStyle,
color = OnMuted,
)
}
}
}
}
@Composable
private fun CenteredMessage(text: String, modifier: Modifier = Modifier) {
Box(modifier, contentAlignment = Alignment.Center) {
Text(text, style = MaterialTheme.typography.bodyLarge, color = OnMuted)
}
}
// Maintainer, 2026-10-29: error message + Retry button (same pattern as BrowseScreen's
// ErrorWithRetry). The button requests focus on entry so TV D-pad Center retries directly.
@Composable
private fun DetailErrorWithRetry(message: String, onRetry: () -> Unit) {
val retryFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
runCatching { retryFocus.requestFocus() }
}
val interaction = remember { MutableInteractionSource() }
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
textAlign = TextAlign.Center,
)
Box(
Modifier
.focusRequester(retryFocus)
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onRetry() }
.padding(horizontal = 24.dp, vertical = 10.dp)
) {
Text("Retry", style = MaterialTheme.typography.labelLarge, color = OnPrimary)
}
}
}
}
private fun Duration.formatTime(): String {
val total = inWholeSeconds
return "%d:%02d".format(total / 60, total % 60)
}
/**
* Modal overlay showing the album-info text fetched by [DetailViewModel].
*
* Renders one of three states based on [state]:
* - [AlbumInfoState.Loading] — "Loading…" placeholder
* - [AlbumInfoState.Loaded] — scrollable prose
* - [AlbumInfoState.Empty] — "No information available" message
* - [AlbumInfoState.Idle] — should never reach UI; treated as Loading
*
* Dismiss handling: Back key, scrim tap, or D-pad-press anywhere outside
* the card all call [onDismiss]. Inside the card, scroll is consumed by
* the prose column; D-pad Up/Down advances the scroll, D-pad Back closes.
*/
@Composable
private fun AlbumInfoModal(
state: AlbumInfoState,
albumTitle: String,
onDismiss: () -> Unit,
) {
BackHandler(onBack = onDismiss)
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.75f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
// Never wider than the screen (design decision 2026-09-02,
// I.52): min 640dp overflowed every phone.
.fillMaxWidth(0.92f)
.widthIn(max = 920.dp)
.heightIn(max = 540.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceRaised)
// Absorb clicks so the scrim's dismiss-on-tap doesn't fire when
// the user clicks within the card itself.
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
.padding(horizontal = 32.dp, vertical = 28.dp),
) {
Text(
"Album info",
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
)
if (albumTitle.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
withInterDigits(albumTitle),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(20.dp))
// Body. Loading and Empty states render once; Loaded scrolls.
when (state) {
is AlbumInfoState.Loaded -> {
val scroll = rememberScrollState()
Column(
Modifier
.fillMaxWidth()
.verticalScroll(scroll),
) {
Text(
withInterDigits(state.text),
style = MaterialTheme.typography.bodyLarge,
color = OnPrimary,
)
}
}
AlbumInfoState.Empty -> Text(
"No information available for this album.",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
// Loading and Idle render the same placeholder. Idle is
// theoretically unreachable here (the parent only opens the
// modal after calling loadAlbumInfo()), but covering it keeps
// the when-exhaustiveness satisfied and avoids a flash if the
// viewmodel is reset mid-render.
AlbumInfoState.Loading,
AlbumInfoState.Idle -> Text(
"Loading…",
style = MaterialTheme.typography.bodyLarge,
color = OnMuted,
)
}
Spacer(Modifier.height(20.dp))
Text(
"Press Back to close.",
style = MaterialTheme.typography.labelMedium,
color = OnQuiet,
)
}
}
}