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

LyricsScreen.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.lyrics
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.height
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.width
17import androidx.compose.foundation.lazy.LazyColumn
18import androidx.compose.foundation.focusable
19import androidx.compose.foundation.gestures.animateScrollBy
20import androidx.compose.runtime.rememberCoroutineScope
21import androidx.compose.ui.input.key.Key
22import androidx.compose.ui.input.key.KeyEventType
23import androidx.compose.ui.input.key.key
24import androidx.compose.ui.input.key.onKeyEvent
25import androidx.compose.ui.input.key.type
26import kotlinx.coroutines.launch
27import androidx.compose.foundation.lazy.LazyListState
28import androidx.compose.foundation.lazy.itemsIndexed
29import androidx.compose.foundation.lazy.rememberLazyListState
30import androidx.compose.foundation.rememberScrollState
31import androidx.compose.foundation.shape.RoundedCornerShape
32import androidx.compose.foundation.verticalScroll
33import androidx.compose.runtime.Composable
34import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.collectAsState
36import androidx.compose.runtime.getValue
37import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.remember
39import androidx.compose.runtime.setValue
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.layout.Layout
42import androidx.compose.ui.layout.positionInWindow
43import androidx.compose.runtime.mutableFloatStateOf
44import androidx.compose.ui.Modifier
45import androidx.compose.ui.layout.onGloballyPositioned
46import androidx.compose.ui.layout.positionInRoot
47import androidx.compose.ui.platform.LocalConfiguration
48import androidx.compose.ui.draw.clip
49import androidx.compose.ui.graphics.Color
50import androidx.compose.ui.text.TextStyle
51import androidx.compose.ui.text.font.FontFamily
52import androidx.compose.ui.text.font.FontWeight
53import androidx.compose.ui.text.style.TextAlign
54import androidx.compose.ui.text.style.TextOverflow
55import androidx.compose.ui.unit.dp
56import androidx.compose.ui.unit.sp
57import androidx.hilt.navigation.compose.hiltViewModel
58import androidx.tv.material3.MaterialTheme
59import androidx.tv.material3.Text
60import dev.stave.data.LyricsHighlightColor
61import dev.stave.lyrics.LyricsResult
62import dev.stave.lyrics.LyricsViewModel
63import dev.stave.ui.components.AlbumArt
64import dev.stave.ui.components.staveFocusable
65import dev.stave.ui.theme.AccentAmber
66import dev.stave.ui.theme.withInterDigits
67import dev.stave.ui.theme.AccentSage
68import dev.stave.ui.theme.OnMuted
69import dev.stave.ui.theme.OnPrimary
70import dev.stave.ui.theme.OnQuiet
71import dev.stave.ui.theme.SurfaceRaised
72import dev.stave.ui.theme.staveInterFamily
73import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
74import dev.stave.ui.theme.adaptiveScreenPaddingVertical
75
76/**
77 * Full-screen lyrics viewer. Layout:
78 * - Album art pinned to the upper-left corner (~280dp square), inside standard
79 * screen padding. Acts as visual context for the track.
80 * - Lyrics column occupies the full screen width, text center-aligned. The list
81 * is vertically centered by auto-scrolling such that the current line ends up
82 * in the middle of the viewport, giving a karaoke-style "the line you're on
83 * is always front and center" feel. Art in the corner stays out of the way of
84 * the typically short lyric lines; when a long line does extend left, it sits
85 * above or below the art band depending on scroll position.
86 * - Current line is rendered per the three granular settings: size multiplier,
87 * bold toggle, and color choice (amber/white/sage).
88 */
89@Composable
90fun LyricsScreen(
91 onBack: () -> Unit,
92 onRequestPlayPauseFocus: () -> Unit = {},
93 viewModel: LyricsViewModel = hiltViewModel(),
94) {
95 BackHandler {
96 viewModel.close()
97 onBack()
98 }
99
100 LaunchedEffect(Unit) {
101 viewModel.openForCurrent()
102 // Default focus on the play/pause button in the bottom NowPlayingBar.
103 // Without this, focus lands on whatever was previously focused (often
104 // the album-art expand zone), which feels disorienting on TV — the
105 // user expects play/pause as the primary action when watching lyrics.
106 onRequestPlayPauseFocus()
107 }
108
109 val state by viewModel.state.collectAsState()
110 val highlightSize by viewModel.highlightSize.collectAsState()
111 val highlightBold by viewModel.highlightBold.collectAsState()
112 val highlightColor by viewModel.highlightColor.collectAsState()
113 val showMetadata by viewModel.showMetadata.collectAsState()
114 val currentIndex = state.currentLineIndex
115 val result = state.result
116 val track = state.track
117
118 val interFamily = staveInterFamily()
119
120 val listState = rememberLazyListState()
121
122 // User-scrollable lyrics with auto-scroll backoff.
123 //
124 // When the user manually scrolls the lyrics (drag on phone, D-pad
125 // when focused on TV), pause auto-scroll for 8 seconds. After that,
126 // resume centering on the current line. This mirrors how YouTube
127 // Music, Apple Music, and Spotify handle lyric scrolling.
128 //
129 // Detection: track our own animateScrollToItem windows with
130 // [autoScrollingUntilMs]. If [listState.isScrollInProgress] is true
131 // OUTSIDE one of our windows, the user is dragging or D-padding —
132 // bump [userScrollUntilMs] forward.
133 var autoScrollingUntilMs by remember { mutableStateOf(0L) }
134 var userScrollUntilMs by remember { mutableStateOf(0L) }
135 LaunchedEffect(listState) {
136 androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
137 .collect { scrolling ->
138 if (scrolling && System.currentTimeMillis() > autoScrollingUntilMs) {
139 userScrollUntilMs = System.currentTimeMillis() + 8_000L
140 }
141 }
142 }
143
144 LaunchedEffect(currentIndex) {
145 if (currentIndex >= 0 && result is LyricsResult.Loaded && result.synced.isNotEmpty()) {
146 if (System.currentTimeMillis() < userScrollUntilMs) return@LaunchedEffect
147 val viewportPx = listState.layoutInfo.viewportSize.height
148 val offset = -(viewportPx / 2)
149 // Mark ourselves as the scrolling party for ~1.5s — long
150 // enough for the animateScrollToItem animation to settle.
151 autoScrollingUntilMs = System.currentTimeMillis() + 1_500L
152 runCatching {
153 listState.animateScrollToItem(currentIndex + 1, offset)
154 }
155 }
156 }
157 LaunchedEffect(track?.id, result) {
158 if (result is LyricsResult.Loaded && result.synced.isNotEmpty()) {
159 val target = currentIndex.coerceAtLeast(0) + 1
160 val viewportPx = listState.layoutInfo.viewportSize.height
161 val offset = -(viewportPx / 2)
162 autoScrollingUntilMs = System.currentTimeMillis() + 500L
163 runCatching {
164 listState.scrollToItem(target, offset)
165 }
166 userScrollUntilMs = 0L
167 }
168 }
169
170 Box(
171 Modifier
172 .fillMaxSize()
173 .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
174 ) {
175 // Lyrics column FIRST — drawn underneath. Full width, vertically centered.
176 // Album art in the upper-left will be drawn on top of anything in this
177 // column, so a wide lyric line that would otherwise touch the corner is
178 // painted over by the art rather than crowding around it.
179 // Track the current lyric line window-Y so metadata can flow around it
180 var currentLineWindowY by remember { mutableFloatStateOf(-1f) }
181 var currentLineHeight by remember { mutableFloatStateOf(0f) }
182
183 Box(
184 Modifier
185 .fillMaxSize()
186 .align(Alignment.Center),
187 contentAlignment = Alignment.Center,
188 ) {
189 when (result) {
190 LyricsResult.Loading -> Placeholder("Fetching lyrics…", interFamily)
191 LyricsResult.Instrumental -> Placeholder(
192 "This track is marked instrumental — no lyrics available.",
193 interFamily,
194 )
195 LyricsResult.NotFound -> Placeholder(
196 "No lyrics found.\n\n" +
197 "Consider submitting lyrics at lrclib.net, or placing a .lrc file " +
198 "next to the audio file on your server.",
199 interFamily,
200 )
201 LyricsResult.PrivacyDisabled -> Placeholder(
202 "Online lyrics lookup is disabled in privacy settings.\n\n" +
203 "Earhorn will still show lyrics from .lrc files placed next to the " +
204 "audio file on your server.",
205 interFamily,
206 )
207 is LyricsResult.Error -> Placeholder(
208 "Couldn't fetch lyrics: ${result.message}",
209 interFamily,
210 )
211 is LyricsResult.Loaded -> LoadedBody(
212 result = result,
213 currentIndex = currentIndex,
214 listState = listState,
215 highlightSize = highlightSize,
216 highlightBold = highlightBold,
217 highlightColor = resolveHighlightColor(highlightColor),
218 interFamily = interFamily,
219 onCurrentLinePositioned = { y, h ->
220 currentLineWindowY = y
221 currentLineHeight = h
222 },
223 )
224 }
225 }
226
227 // Album art AFTER lyrics so it draws on top (z-order in a Compose Box is
228 // defined by child order, later = on top). 180dp with 16dp inset from
229 // the screen-padding frame plus 12dp bottom padding — the art is an
230 // overlay, so its bottom edge can visually crash into the enlarged
231 // current lyric when the list is near its top range. The size + bottom
232 // padding combo gives the lyrics clear breathing room underneath.
233 //
234 // Phone (any orientation) skips the corner art and the metadata
235 // chips entirely. At 411dp portrait OR 914dp landscape on a phone,
236 // the 180dp art + 340dp metadata strip and the centered lyrics
237 // can't coexist without overlapping into a mash of text and
238 // image. Width-based detection (isCompactWidth) only catches
239 // portrait — landscape phone is still ~914dp wide which falls in
240 // the "expanded" bucket. UiMode disambiguates: if it's not TV,
241 // it's a phone (or tablet) where the bottom NowPlaying bar
242 // already provides art + track context, so the corner overlays
243 // are pure noise. The TV layout retains the corner art and
244 // metadata strip for living-room viewing.
245 val uiMode = androidx.compose.ui.platform.LocalConfiguration.current.uiMode
246 val isAndroidTv =
247 (uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
248 android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
249 if (isAndroidTv) {
250 AlbumArt(
251 model = track?.albumArtUri,
252 modifier = Modifier
253 .align(Alignment.TopStart)
254 .padding(top = 16.dp, start = 16.dp, bottom = 12.dp)
255 .size(180.dp)
256 .clip(RoundedCornerShape(2.dp)),
257 )
258 }
259
260 // Metadata chips — upper-right corner. Compact bulleted grid showing
261 // artist, album, format, genre, year when available. Togglable via
262 // the "Show lyrics metadata" setting. Same TV-only gating as the
263 // corner art above.
264 if (showMetadata && track != null && isAndroidTv) {
265 MetadataChips(
266 track = track,
267 exclusionWindowY = currentLineWindowY,
268 exclusionHeight = currentLineHeight,
269 modifier = Modifier
270 .align(Alignment.TopEnd)
271 .padding(top = 16.dp, end = 8.dp)
272 .width(340.dp),
273 )
274 }
275
276 // "Refresh lyrics" — only shown when a refetch could help (Loaded, NotFound,
277 // Error). Hidden when we're loading, the track is instrumental (refetch can't
278 // discover lyrics that don't exist), or privacy is blocking the network.
279 val canRefresh = when (result) {
280 is LyricsResult.Loaded -> true
281 LyricsResult.NotFound -> true
282 is LyricsResult.Error -> true
283 else -> false
284 }
285 if (canRefresh && track != null) {
286 RefreshLyricsButton(
287 onClick = {
288 viewModel.refresh()
289 onRequestPlayPauseFocus()
290 },
291 interFamily = interFamily,
292 modifier = Modifier.align(Alignment.BottomEnd),
293 )
294 }
295 }
296}
297
298@Composable
299private fun RefreshLyricsButton(
300 onClick: () -> Unit,
301 interFamily: FontFamily,
302 modifier: Modifier = Modifier,
303) {
304 val interaction = remember { MutableInteractionSource() }
305 Box(
306 modifier
307 .background(SurfaceRaised)
308 .staveFocusable(interaction, cornerRadiusDp = 6)
309 .clickable(interaction, indication = null) { onClick() }
310 .padding(horizontal = 20.dp, vertical = 10.dp),
311 ) {
312 Text(
313 "Refresh lyrics",
314 style = MaterialTheme.typography.labelLarge.copy(fontFamily = interFamily),
315 color = OnPrimary,
316 )
317 }
318}
319
320private fun resolveHighlightColor(choice: LyricsHighlightColor): Color = when (choice) {
321 LyricsHighlightColor.AMBER -> AccentAmber
322 LyricsHighlightColor.WHITE -> Color(0xFFF5F0E6) // matches OnPrimary
323 LyricsHighlightColor.SAGE -> AccentSage
324}
325
326@Composable
327private fun Placeholder(text: String, interFamily: FontFamily) {
328 Text(
329 text,
330 style = MaterialTheme.typography.bodyLarge.copy(fontFamily = interFamily),
331 color = OnMuted,
332 textAlign = TextAlign.Center,
333 modifier = Modifier.fillMaxWidth(),
334 )
335}
336
337@Composable
338private fun LoadedBody(
339 result: LyricsResult.Loaded,
340 currentIndex: Int,
341 listState: LazyListState,
342 highlightSize: Float,
343 highlightBold: Boolean,
344 highlightColor: Color,
345 interFamily: FontFamily,
346 onCurrentLinePositioned: (windowY: Float, height: Float) -> Unit = { _, _ -> },
347) {
348 // D-pad manual scrolling: the list/column is focusable, and while it
349 // can scroll in the pressed direction it consumes DPAD Up/Down to
350 // scroll ~a third of a viewport. At the ends the event is NOT
351 // consumed, so focus can leave the lyrics naturally. Manual D-pad
352 // scrolling trips the same isScrollInProgress detection as touch, so
353 // the existing 8s auto-scroll backoff applies unchanged. Previously
354 // the lyrics were simply unreachable by D-pad (field-reported: with
355 // loaded-but-untracked lyrics there was no way to scroll them).
356 val dpadScope = rememberCoroutineScope()
357 when {
358 result.synced.isNotEmpty() -> {
359 // Full width, content-width individual items centered via textAlign.
360 LazyColumn(
361 state = listState,
362 modifier = Modifier
363 .fillMaxSize()
364 .onKeyEvent { ev ->
365 if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false
366 when (ev.key) {
367 Key.DirectionDown -> if (listState.canScrollForward) {
368 dpadScope.launch { listState.animateScrollBy(360f) }; true
369 } else false
370 Key.DirectionUp -> if (listState.canScrollBackward) {
371 dpadScope.launch { listState.animateScrollBy(-360f) }; true
372 } else false
373 else -> false
374 }
375 }
376 .focusable(),
377 verticalArrangement = Arrangement.spacedBy(14.dp),
378 horizontalAlignment = Alignment.CenterHorizontally,
379 ) {
380 // Top+bottom padding rows so the first and last lyric lines can
381 // scroll to the vertical center of the viewport rather than
382 // getting stuck near the edges.
383 item { Spacer(Modifier.height(200.dp)) }
384 itemsIndexed(result.synced) { idx, line ->
385 val isCurrent = idx == currentIndex
386 val isPast = idx < currentIndex
387 val lineModifier = if (isCurrent) {
388 Modifier.onGloballyPositioned { coords ->
389 onCurrentLinePositioned(
390 coords.positionInWindow().y,
391 coords.size.height.toFloat(),
392 )
393 }
394 } else Modifier
395 val style = lineStyle(
396 isCurrent = isCurrent,
397 isPast = isPast,
398 highlightSize = highlightSize,
399 highlightBold = highlightBold,
400 highlightColor = highlightColor,
401 interFamily = interFamily,
402 )
403 val color = when {
404 isCurrent -> highlightColor
405 isPast -> OnQuiet
406 else -> OnMuted
407 }
408 Text(
409 line.text,
410 style = style,
411 color = color,
412 textAlign = TextAlign.Center,
413 modifier = Modifier.fillMaxWidth(),
414 )
415 }
416 item { Spacer(Modifier.height(200.dp)) }
417 }
418 }
419 !result.plain.isNullOrBlank() -> {
420 val scroll = rememberScrollState()
421 Column(
422 modifier = Modifier
423 .fillMaxSize()
424 .onKeyEvent { ev ->
425 if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false
426 when (ev.key) {
427 Key.DirectionDown -> if (scroll.canScrollForward) {
428 dpadScope.launch { scroll.animateScrollBy(360f) }; true
429 } else false
430 Key.DirectionUp -> if (scroll.canScrollBackward) {
431 dpadScope.launch { scroll.animateScrollBy(-360f) }; true
432 } else false
433 else -> false
434 }
435 }
436 .focusable()
437 .verticalScroll(scroll),
438 verticalArrangement = Arrangement.spacedBy(8.dp),
439 horizontalAlignment = Alignment.CenterHorizontally,
440 ) {
441 result.plain.split('\n').forEach { rawLine ->
442 val trimmed = rawLine.trimEnd('\r')
443 if (trimmed.isNotBlank()) {
444 Text(
445 trimmed,
446 style = MaterialTheme.typography.bodyLarge.copy(fontFamily = interFamily),
447 color = OnPrimary,
448 textAlign = TextAlign.Center,
449 modifier = Modifier.fillMaxWidth(),
450 )
451 } else {
452 Spacer(Modifier.height(8.dp))
453 }
454 }
455 }
456 }
457 else -> Placeholder("LRCLIB returned empty lyrics for this track.", interFamily)
458 }
459}
460
461/** Resolve the TextStyle for a single lyric line. Non-current lines use the base
462 * titleMedium style; the current line multiplies fontSize by the user's setting,
463 * optionally applies bold, and keeps the Inter family throughout.
464 *
465 * lineHeight scales with fontSize for the current line — without it, a scaled
466 * font still uses the BASE line-height, so a wrapped line of the current
467 * lyric crashes into the line above. 1.3× fontSize is a conservative ratio
468 * that keeps wrapped lines readable without opening huge gaps on single-line
469 * lyrics. */
470@Composable
471private fun lineStyle(
472 isCurrent: Boolean,
473 isPast: Boolean,
474 highlightSize: Float,
475 highlightBold: Boolean,
476 highlightColor: Color,
477 interFamily: FontFamily,
478): TextStyle {
479 val base = MaterialTheme.typography.titleMedium.copy(fontFamily = interFamily)
480 if (!isCurrent) return base
481 val scaledSize = base.fontSize.value * highlightSize
482 return base.copy(
483 fontSize = scaledSize.sp,
484 lineHeight = (scaledSize * 1.3f).sp,
485 fontWeight = if (highlightBold) FontWeight.Bold else base.fontWeight,
486 )
487}
488
489/**
490 * Column variant that flows children around an exclusion zone. Items that would
491 * overlap the zone are shifted below it, creating a gap for the current lyric line.
492 */
493@Composable
494private fun FlowAroundColumn(
495 exclusionLocalTop: Float,
496 exclusionHeight: Float,
497 modifier: Modifier = Modifier,
498 content: @Composable () -> Unit,
499) {
500 val gap = 20f // breathing room above and below the exclusion zone
501 Layout(
502 content = content,
503 modifier = modifier,
504 ) { measurables, constraints ->
505 val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
506 val placeables = measurables.map { it.measure(childConstraints) }
507 val spacing = 4.dp.roundToPx()
508 val exTop = (exclusionLocalTop - gap).toInt()
509 val exBottom = (exclusionLocalTop + exclusionHeight + gap).toInt()
510 val hasExclusion = exclusionLocalTop >= 0
511
512 // Calculate positions
513 val positions = mutableListOf<Int>()
514 var y = 0
515 for (p in placeables) {
516 if (hasExclusion && y + p.height > exTop && y < exBottom) {
517 y = exBottom
518 }
519 positions.add(y)
520 y += p.height + spacing
521 }
522
523 layout(constraints.maxWidth, y) {
524 placeables.forEachIndexed { i, p ->
525 p.placeRelative(constraints.maxWidth - p.width, positions[i])
526 }
527 }
528 }
529}
530
531/** Right-aligned metadata column — upper-right of the lyrics screen.
532 * Year sits at top, larger than the rest, as the visual anchor. Below it,
533 * one row per technical field: file type (codec), sample frequency, bit
534 * depth or bitrate, channels. Genres go at the bottom (one row per genre,
535 * since multi-genre tags like "Jazz / Fusion" split into separate rows). */
536@Composable
537private fun MetadataChips(
538 track: dev.stave.domain.model.DidlItem.Track,
539 exclusionWindowY: Float = -1f,
540 exclusionHeight: Float = 0f,
541 modifier: Modifier = Modifier,
542) {
543 val interFamily = staveInterFamily()
544
545 val year = track.date?.let { Regex("""\d{4}""").find(it)?.value }
546
547 // Codec: strip the leading "audio/" and any "x-"/"vnd." prefixes so
548 // "audio/x-flac" displays as "FLAC". Falls back to null when mimeType
549 // is missing.
550 val codec = track.mimeType
551 ?.substringAfter('/')
552 ?.removePrefix("x-")
553 ?.removePrefix("vnd.")
554 ?.uppercase()
555 ?.takeIf { it.isNotBlank() }
556
557 // Sample frequency in kHz, e.g. "44.1 kHz".
558 val freq = track.sampleFrequency?.let { "${it / 1000.0} kHz" }
559
560 // Bit depth for lossless ("16-bit") OR bitrate for lossy ("320 kbps").
561 // Servers typically populate one or the other depending on format.
562 val rate = when {
563 track.bitsPerSample != null -> "${track.bitsPerSample}-bit"
564 track.bitrate != null -> "${track.bitrate / 1000} kbps"
565 else -> null
566 }
567
568 val channels = track.channels?.let { ch ->
569 when (ch) {
570 1 -> "Mono"
571 2 -> "Stereo"
572 else -> "${ch}ch"
573 }
574 }
575
576 // Parse genres: split on "/" and "," so multi-genre tags stack one per row.
577 val genres = track.genre
578 ?.split(Regex("""[/\\]{1,2}|,"""))
579 ?.map { it.trim() }
580 ?.filter { it.isNotBlank() }
581 ?: emptyList()
582
583 val hasAnything = year != null || codec != null || freq != null || rate != null ||
584 channels != null || genres.isNotEmpty()
585 if (!hasAnything) return
586
587 // Custom layout that flows items around the current lyric line
588 var containerWindowY by remember { mutableFloatStateOf(0f) }
589 FlowAroundColumn(
590 exclusionLocalTop = if (exclusionWindowY >= 0) exclusionWindowY - containerWindowY else -1f,
591 exclusionHeight = exclusionHeight,
592 modifier = modifier.onGloballyPositioned { coords ->
593 containerWindowY = coords.positionInWindow().y
594 },
595 ) {
596 // Year — anchor, larger and prominent. Inter (numeric).
597 if (year != null) {
598 Text(
599 year,
600 style = MaterialTheme.typography.headlineMedium.copy(fontFamily = interFamily),
601 color = OnQuiet,
602 textAlign = TextAlign.End,
603 )
604 Spacer(Modifier.height(4.dp))
605 }
606 // Technical rows — one per field, Inter (numeric/technical content).
607 // Each gated by null so missing fields collapse cleanly without
608 // leaving blank rows.
609 val techStyle = MaterialTheme.typography.labelMedium.copy(fontFamily = interFamily)
610 codec?.let {
611 Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
612 }
613 freq?.let {
614 Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
615 }
616 rate?.let {
617 Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
618 }
619 channels?.let {
620 Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
621 }
622 // Genres — each on its own row, Fraunces (text content).
623 if (genres.isNotEmpty()) {
624 Spacer(Modifier.height(4.dp))
625 genres.forEach { genre ->
626 Text(
627 withInterDigits(genre),
628 style = MaterialTheme.typography.titleSmall,
629 color = OnQuiet,
630 textAlign = TextAlign.End,
631 maxLines = 1,
632 overflow = TextOverflow.Ellipsis,
633 )
634 }
635 }
636 }
637}