MetadataScreen.kt
package dev.stave.ui.metadata
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
// Maintainer, 2026-10-29: focusable rows for D-pad.
import androidx.compose.foundation.focusable
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import androidx.compose.foundation.interaction.MutableInteractionSource
import dev.stave.domain.model.DidlItem
import dev.stave.ui.components.staveFocusable
import dev.stave.domain.model.parseGenres
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.upnp.AudioMetadataReader
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
sealed interface TagState {
data object Idle : TagState
data object Loading : TagState
data class Loaded(val fields: Map<String, String>) : TagState
data class Error(val message: String) : TagState
}
@HiltViewModel
class MetadataViewModel @Inject constructor(
private val handoff: MetadataHandoff,
private val reader: AudioMetadataReader,
) : ViewModel() {
// Grab the pending track exactly once, then drop it so a stale reference can't leak
// into a later unrelated open.
val track: DidlItem.Track? = handoff.pending.also { handoff.pending = null }
private val _tags = MutableStateFlow<TagState>(TagState.Idle)
val tags: StateFlow<TagState> = _tags.asStateFlow()
fun loadFileTags() {
val url = track?.resourceUrl ?: return
if (_tags.value !is TagState.Idle) return // already loading / loaded / errored
_tags.value = TagState.Loading
viewModelScope.launch {
val result = reader.readTags(url)
_tags.value = if (result.isEmpty()) {
TagState.Error("No embedded tags found.")
} else {
TagState.Loaded(result)
}
}
}
}
@Composable
fun MetadataScreen(
onBack: () -> Unit,
viewModel: MetadataViewModel = hiltViewModel(),
) {
BackHandler(onBack = onBack)
val track = viewModel.track
val tags by viewModel.tags.collectAsState()
LaunchedEffect(track?.id) {
if (track != null) viewModel.loadFileTags()
}
if (track == null) {
Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
Text(
"No track selected.",
style = MaterialTheme.typography.headlineSmall,
color = OnMuted,
)
}
return
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Column {
Text(
withInterDigits(track.title),
style = MaterialTheme.typography.displaySmall,
color = OnPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
listOfNotNull(track.artist, track.album).joinToString(" · ").takeIf { it.isNotBlank() }?.let {
Text(withInterDigits(it), style = MaterialTheme.typography.titleMedium, color = OnMuted)
}
// Wide-touch layouts tighten the header gap — 20dp under a
// two-line title burned a visible band of landscape height
// (field-rejected). TV keeps the original spacing.
val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
Spacer(Modifier.height(
if (dev.stave.ui.theme.isCompactWidth() || mdTv) 20.dp else 10.dp,
))
}
}
item { SectionHeader("From the server") }
items(upnpRows(track)) { (label, value) -> Row(label, value) }
item {
val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
Spacer(Modifier.height(
if (dev.stave.ui.theme.isCompactWidth() || mdTv) 24.dp else 12.dp,
))
SectionHeader("From the file")
}
when (val s = tags) {
is TagState.Idle, is TagState.Loading -> item {
Text(
"Reading tags…",
style = MaterialTheme.typography.bodyMedium,
color = OnQuiet,
modifier = Modifier.padding(start = 4.dp, top = 8.dp),
)
}
is TagState.Error -> item {
Text(
s.message,
style = MaterialTheme.typography.bodyMedium,
color = OnQuiet,
modifier = Modifier.padding(start = 4.dp, top = 8.dp),
)
}
is TagState.Loaded -> items(s.fields.entries.toList()) { entry ->
Row(entry.key, entry.value)
}
}
}
}
private fun upnpRows(t: DidlItem.Track): List<Pair<String, String>> = buildList {
t.artist?.let { add("Artist" to it) }
t.albumArtist?.let { add("Album artist" to it) }
t.composer?.let { add("Composer" to it) }
t.album?.let { add("Album" to it) }
run {
val tokens = parseGenres(t.genre)
if (tokens.isNotEmpty()) add("Genre" to tokens.joinToString(", "))
}
t.date?.let { add("Date" to it) }
t.trackNumber?.let { add("Track #" to it.toString()) }
t.discNumber?.let { add("Disc #" to it.toString()) }
t.duration?.let { add("Duration" to formatDuration(it)) }
t.mimeType?.let { add("MIME type" to it) }
t.sampleFrequency?.let { add("Sample rate" to "${it / 1000.0} kHz") }
t.bitsPerSample?.let { add("Bit depth" to "$it bit") }
t.bitrate?.let { add("Bitrate" to "${it / 1000} kbps") }
t.channels?.let { add("Channels" to it.toString()) }
add("URL" to t.resourceUrl)
// Everything else from extras that we didn't surface above
t.extras.forEach { (k, v) ->
if (v.isNotBlank()) add(k.removePrefix("upnp:").removePrefix("dc:") to v)
}
}
private fun formatDuration(d: kotlin.time.Duration): String {
val total = d.inWholeSeconds
val m = total / 60
val s = total % 60
return "%d:%02d".format(m, s)
}
@Composable
private fun SectionHeader(title: String) {
Column {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(48.dp)
.height(2.dp)
.background(AccentAmber),
)
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun Row(label: String, value: String) {
val interaction = remember { MutableInteractionSource() }
Row(
Modifier
.fillMaxWidth()
// Maintainer, 2026-10-29: rows had staveFocusable (focus border only)
// but nothing focusable, so the list had no D-pad targets and the
// "From the file" tags below the fold were unreachable on TV.
// focusable() feeds the same interaction source the border reads,
// and brings the focused row into view so D-pad scrolls the list.
.focusable(interactionSource = interaction)
.staveFocusable(interaction)
.padding(vertical = 3.dp),
) {
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = OnQuiet,
modifier = Modifier.width(180.dp),
)
Text(
withInterDigits(value),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
modifier = Modifier.fillMaxWidth(),
)
}
}
// LazyListScope.items for Pair<String, String> and single-string lists.
// Wrapping in a small `items` helper so the when-branch above stays readable.
private fun androidx.compose.foundation.lazy.LazyListScope.items(
list: List<Pair<String, String>>,
content: @Composable (Pair<String, String>) -> Unit,
) {
items(list.size) { i -> content(list[i]) }
}
@JvmName("itemsEntries")
private fun androidx.compose.foundation.lazy.LazyListScope.items(
entries: List<Map.Entry<String, String>>,
content: @Composable (Map.Entry<String, String>) -> Unit,
) {
items(entries.size) { i -> content(entries[i]) }
}