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

MetadataScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.metadata
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5// Maintainer, 2026-10-29: focusable rows for D-pad.
6import androidx.compose.foundation.focusable
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.width
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.runtime.Composable
19import androidx.compose.runtime.LaunchedEffect
20import androidx.compose.runtime.collectAsState
21import androidx.compose.runtime.getValue
22import androidx.compose.runtime.remember
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.text.style.TextOverflow
25import androidx.compose.ui.unit.dp
26import androidx.hilt.navigation.compose.hiltViewModel
27import androidx.lifecycle.ViewModel
28import androidx.lifecycle.viewModelScope
29import androidx.tv.material3.MaterialTheme
30import androidx.tv.material3.Text
31import dagger.hilt.android.lifecycle.HiltViewModel
32import androidx.compose.foundation.interaction.MutableInteractionSource
33import dev.stave.domain.model.DidlItem
34import dev.stave.ui.components.staveFocusable
35import dev.stave.domain.model.parseGenres
36import dev.stave.ui.theme.AccentAmber
37import dev.stave.ui.theme.withInterDigits
38import dev.stave.ui.theme.OnMuted
39import dev.stave.ui.theme.OnPrimary
40import dev.stave.ui.theme.OnQuiet
41import dev.stave.upnp.AudioMetadataReader
42import kotlinx.coroutines.flow.MutableStateFlow
43import kotlinx.coroutines.flow.StateFlow
44import kotlinx.coroutines.flow.asStateFlow
45import kotlinx.coroutines.launch
46import javax.inject.Inject
47import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
48import dev.stave.ui.theme.adaptiveScreenPaddingVertical
49
50sealed interface TagState {
51 data object Idle : TagState
52 data object Loading : TagState
53 data class Loaded(val fields: Map<String, String>) : TagState
54 data class Error(val message: String) : TagState
55}
56
57@HiltViewModel
58class MetadataViewModel @Inject constructor(
59 private val handoff: MetadataHandoff,
60 private val reader: AudioMetadataReader,
61) : ViewModel() {
62
63 // Grab the pending track exactly once, then drop it so a stale reference can't leak
64 // into a later unrelated open.
65 val track: DidlItem.Track? = handoff.pending.also { handoff.pending = null }
66
67 private val _tags = MutableStateFlow<TagState>(TagState.Idle)
68 val tags: StateFlow<TagState> = _tags.asStateFlow()
69
70 fun loadFileTags() {
71 val url = track?.resourceUrl ?: return
72 if (_tags.value !is TagState.Idle) return // already loading / loaded / errored
73 _tags.value = TagState.Loading
74 viewModelScope.launch {
75 val result = reader.readTags(url)
76 _tags.value = if (result.isEmpty()) {
77 TagState.Error("No embedded tags found.")
78 } else {
79 TagState.Loaded(result)
80 }
81 }
82 }
83}
84
85@Composable
86fun MetadataScreen(
87 onBack: () -> Unit,
88 viewModel: MetadataViewModel = hiltViewModel(),
89) {
90 BackHandler(onBack = onBack)
91 val track = viewModel.track
92 val tags by viewModel.tags.collectAsState()
93
94 LaunchedEffect(track?.id) {
95 if (track != null) viewModel.loadFileTags()
96 }
97
98 if (track == null) {
99 Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
100 Text(
101 "No track selected.",
102 style = MaterialTheme.typography.headlineSmall,
103 color = OnMuted,
104 )
105 }
106 return
107 }
108
109 LazyColumn(
110 modifier = Modifier
111 .fillMaxSize()
112 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
113 verticalArrangement = Arrangement.spacedBy(8.dp),
114 ) {
115 item {
116 Column {
117 Text(
118 withInterDigits(track.title),
119 style = MaterialTheme.typography.displaySmall,
120 color = OnPrimary,
121 maxLines = 2,
122 overflow = TextOverflow.Ellipsis,
123 )
124 listOfNotNull(track.artist, track.album).joinToString(" · ").takeIf { it.isNotBlank() }?.let {
125 Text(withInterDigits(it), style = MaterialTheme.typography.titleMedium, color = OnMuted)
126 }
127 // Wide-touch layouts tighten the header gap — 20dp under a
128 // two-line title burned a visible band of landscape height
129 // (field-rejected). TV keeps the original spacing.
130 val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
131 val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
132 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
133 Spacer(Modifier.height(
134 if (dev.stave.ui.theme.isCompactWidth() || mdTv) 20.dp else 10.dp,
135 ))
136 }
137 }
138
139 item { SectionHeader("From the server") }
140 items(upnpRows(track)) { (label, value) -> Row(label, value) }
141
142 item {
143 val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
144 val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
145 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
146 Spacer(Modifier.height(
147 if (dev.stave.ui.theme.isCompactWidth() || mdTv) 24.dp else 12.dp,
148 ))
149 SectionHeader("From the file")
150 }
151 when (val s = tags) {
152 is TagState.Idle, is TagState.Loading -> item {
153 Text(
154 "Reading tags…",
155 style = MaterialTheme.typography.bodyMedium,
156 color = OnQuiet,
157 modifier = Modifier.padding(start = 4.dp, top = 8.dp),
158 )
159 }
160 is TagState.Error -> item {
161 Text(
162 s.message,
163 style = MaterialTheme.typography.bodyMedium,
164 color = OnQuiet,
165 modifier = Modifier.padding(start = 4.dp, top = 8.dp),
166 )
167 }
168 is TagState.Loaded -> items(s.fields.entries.toList()) { entry ->
169 Row(entry.key, entry.value)
170 }
171 }
172 }
173}
174
175private fun upnpRows(t: DidlItem.Track): List<Pair<String, String>> = buildList {
176 t.artist?.let { add("Artist" to it) }
177 t.albumArtist?.let { add("Album artist" to it) }
178 t.composer?.let { add("Composer" to it) }
179 t.album?.let { add("Album" to it) }
180 run {
181 val tokens = parseGenres(t.genre)
182 if (tokens.isNotEmpty()) add("Genre" to tokens.joinToString(", "))
183 }
184 t.date?.let { add("Date" to it) }
185 t.trackNumber?.let { add("Track #" to it.toString()) }
186 t.discNumber?.let { add("Disc #" to it.toString()) }
187 t.duration?.let { add("Duration" to formatDuration(it)) }
188 t.mimeType?.let { add("MIME type" to it) }
189 t.sampleFrequency?.let { add("Sample rate" to "${it / 1000.0} kHz") }
190 t.bitsPerSample?.let { add("Bit depth" to "$it bit") }
191 t.bitrate?.let { add("Bitrate" to "${it / 1000} kbps") }
192 t.channels?.let { add("Channels" to it.toString()) }
193 add("URL" to t.resourceUrl)
194 // Everything else from extras that we didn't surface above
195 t.extras.forEach { (k, v) ->
196 if (v.isNotBlank()) add(k.removePrefix("upnp:").removePrefix("dc:") to v)
197 }
198}
199
200private fun formatDuration(d: kotlin.time.Duration): String {
201 val total = d.inWholeSeconds
202 val m = total / 60
203 val s = total % 60
204 return "%d:%02d".format(m, s)
205}
206
207@Composable
208private fun SectionHeader(title: String) {
209 Column {
210 Text(
211 title,
212 style = MaterialTheme.typography.titleMedium,
213 color = OnPrimary,
214 )
215 Spacer(Modifier.height(4.dp))
216 Box(
217 Modifier
218 .width(48.dp)
219 .height(2.dp)
220 .background(AccentAmber),
221 )
222 Spacer(Modifier.height(8.dp))
223 }
224}
225
226@Composable
227private fun Row(label: String, value: String) {
228 val interaction = remember { MutableInteractionSource() }
229 Row(
230 Modifier
231 .fillMaxWidth()
232 // Maintainer, 2026-10-29: rows had staveFocusable (focus border only)
233 // but nothing focusable, so the list had no D-pad targets and the
234 // "From the file" tags below the fold were unreachable on TV.
235 // focusable() feeds the same interaction source the border reads,
236 // and brings the focused row into view so D-pad scrolls the list.
237 .focusable(interactionSource = interaction)
238 .staveFocusable(interaction)
239 .padding(vertical = 3.dp),
240 ) {
241 Text(
242 label,
243 style = MaterialTheme.typography.bodySmall,
244 color = OnQuiet,
245 modifier = Modifier.width(180.dp),
246 )
247 Text(
248 withInterDigits(value),
249 style = MaterialTheme.typography.bodyMedium,
250 color = OnMuted,
251 modifier = Modifier.fillMaxWidth(),
252 )
253 }
254}
255
256// LazyListScope.items for Pair<String, String> and single-string lists.
257// Wrapping in a small `items` helper so the when-branch above stays readable.
258private fun androidx.compose.foundation.lazy.LazyListScope.items(
259 list: List<Pair<String, String>>,
260 content: @Composable (Pair<String, String>) -> Unit,
261) {
262 items(list.size) { i -> content(list[i]) }
263}
264
265@JvmName("itemsEntries")
266private fun androidx.compose.foundation.lazy.LazyListScope.items(
267 entries: List<Map.Entry<String, String>>,
268 content: @Composable (Map.Entry<String, String>) -> Unit,
269) {
270 items(entries.size) { i -> content(entries[i]) }
271}