package dev.stave.ui.lyrics
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.rememberCoroutineScope
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 kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.data.LyricsHighlightColor
import dev.stave.lyrics.LyricsResult
import dev.stave.lyrics.LyricsViewModel
import dev.stave.ui.components.AlbumArt
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.AccentSage
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 dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
/**
* Full-screen lyrics viewer. Layout:
* - Album art pinned to the upper-left corner (~280dp square), inside standard
* screen padding. Acts as visual context for the track.
* - Lyrics column occupies the full screen width, text center-aligned. The list
* is vertically centered by auto-scrolling such that the current line ends up
* in the middle of the viewport, giving a karaoke-style "the line you're on
* is always front and center" feel. Art in the corner stays out of the way of
* the typically short lyric lines; when a long line does extend left, it sits
* above or below the art band depending on scroll position.
* - Current line is rendered per the three granular settings: size multiplier,
* bold toggle, and color choice (amber/white/sage).
*/
@Composable
fun LyricsScreen(
onBack: () -> Unit,
onRequestPlayPauseFocus: () -> Unit = {},
viewModel: LyricsViewModel = hiltViewModel(),
) {
BackHandler {
viewModel.close()
onBack()
}
LaunchedEffect(Unit) {
viewModel.openForCurrent()
// Default focus on the play/pause button in the bottom NowPlayingBar.
// Without this, focus lands on whatever was previously focused (often
// the album-art expand zone), which feels disorienting on TV — the
// user expects play/pause as the primary action when watching lyrics.
onRequestPlayPauseFocus()
}
val state by viewModel.state.collectAsState()
val highlightSize by viewModel.highlightSize.collectAsState()
val highlightBold by viewModel.highlightBold.collectAsState()
val highlightColor by viewModel.highlightColor.collectAsState()
val showMetadata by viewModel.showMetadata.collectAsState()
val currentIndex = state.currentLineIndex
val result = state.result
val track = state.track
val interFamily = staveInterFamily()
val listState = rememberLazyListState()
// User-scrollable lyrics with auto-scroll backoff.
//
// When the user manually scrolls the lyrics (drag on phone, D-pad
// when focused on TV), pause auto-scroll for 8 seconds. After that,
// resume centering on the current line. This mirrors how YouTube
// Music, Apple Music, and Spotify handle lyric scrolling.
//
// Detection: track our own animateScrollToItem windows with
// [autoScrollingUntilMs]. If [listState.isScrollInProgress] is true
// OUTSIDE one of our windows, the user is dragging or D-padding —
// bump [userScrollUntilMs] forward.
var autoScrollingUntilMs by remember { mutableStateOf(0L) }
var userScrollUntilMs by remember { mutableStateOf(0L) }
LaunchedEffect(listState) {
androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
.collect { scrolling ->
if (scrolling && System.currentTimeMillis() > autoScrollingUntilMs) {
userScrollUntilMs = System.currentTimeMillis() + 8_000L
}
}
}
LaunchedEffect(currentIndex) {
if (currentIndex >= 0 && result is LyricsResult.Loaded && result.synced.isNotEmpty()) {
if (System.currentTimeMillis() < userScrollUntilMs) return@LaunchedEffect
val viewportPx = listState.layoutInfo.viewportSize.height
val offset = -(viewportPx / 2)
// Mark ourselves as the scrolling party for ~1.5s — long
// enough for the animateScrollToItem animation to settle.
autoScrollingUntilMs = System.currentTimeMillis() + 1_500L
runCatching {
listState.animateScrollToItem(currentIndex + 1, offset)
}
}
}
LaunchedEffect(track?.id, result) {
if (result is LyricsResult.Loaded && result.synced.isNotEmpty()) {
val target = currentIndex.coerceAtLeast(0) + 1
val viewportPx = listState.layoutInfo.viewportSize.height
val offset = -(viewportPx / 2)
autoScrollingUntilMs = System.currentTimeMillis() + 500L
runCatching {
listState.scrollToItem(target, offset)
}
userScrollUntilMs = 0L
}
}
Box(
Modifier
.fillMaxSize()
.padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical())
) {
// Lyrics column FIRST — drawn underneath. Full width, vertically centered.
// Album art in the upper-left will be drawn on top of anything in this
// column, so a wide lyric line that would otherwise touch the corner is
// painted over by the art rather than crowding around it.
// Track the current lyric line window-Y so metadata can flow around it
var currentLineWindowY by remember { mutableFloatStateOf(-1f) }
var currentLineHeight by remember { mutableFloatStateOf(0f) }
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center),
contentAlignment = Alignment.Center,
) {
when (result) {
LyricsResult.Loading -> Placeholder("Fetching lyrics…", interFamily)
LyricsResult.Instrumental -> Placeholder(
"This track is marked instrumental — no lyrics available.",
interFamily,
)
LyricsResult.NotFound -> Placeholder(
"No lyrics found.\n\n" +
"Consider submitting lyrics at lrclib.net, or placing a .lrc file " +
"next to the audio file on your server.",
interFamily,
)
LyricsResult.PrivacyDisabled -> Placeholder(
"Online lyrics lookup is disabled in privacy settings.\n\n" +
"Earhorn will still show lyrics from .lrc files placed next to the " +
"audio file on your server.",
interFamily,
)
is LyricsResult.Error -> Placeholder(
"Couldn't fetch lyrics: ${result.message}",
interFamily,
)
is LyricsResult.Loaded -> LoadedBody(
result = result,
currentIndex = currentIndex,
listState = listState,
highlightSize = highlightSize,
highlightBold = highlightBold,
highlightColor = resolveHighlightColor(highlightColor),
interFamily = interFamily,
onCurrentLinePositioned = { y, h ->
currentLineWindowY = y
currentLineHeight = h
},
)
}
}
// Album art AFTER lyrics so it draws on top (z-order in a Compose Box is
// defined by child order, later = on top). 180dp with 16dp inset from
// the screen-padding frame plus 12dp bottom padding — the art is an
// overlay, so its bottom edge can visually crash into the enlarged
// current lyric when the list is near its top range. The size + bottom
// padding combo gives the lyrics clear breathing room underneath.
//
// Phone (any orientation) skips the corner art and the metadata
// chips entirely. At 411dp portrait OR 914dp landscape on a phone,
// the 180dp art + 340dp metadata strip and the centered lyrics
// can't coexist without overlapping into a mash of text and
// image. Width-based detection (isCompactWidth) only catches
// portrait — landscape phone is still ~914dp wide which falls in
// the "expanded" bucket. UiMode disambiguates: if it's not TV,
// it's a phone (or tablet) where the bottom NowPlaying bar
// already provides art + track context, so the corner overlays
// are pure noise. The TV layout retains the corner art and
// metadata strip for living-room viewing.
val uiMode = androidx.compose.ui.platform.LocalConfiguration.current.uiMode
val isAndroidTv =
(uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
if (isAndroidTv) {
AlbumArt(
model = track?.albumArtUri,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 16.dp, start = 16.dp, bottom = 12.dp)
.size(180.dp)
.clip(RoundedCornerShape(2.dp)),
)
}
// Metadata chips — upper-right corner. Compact bulleted grid showing
// artist, album, format, genre, year when available. Togglable via
// the "Show lyrics metadata" setting. Same TV-only gating as the
// corner art above.
if (showMetadata && track != null && isAndroidTv) {
MetadataChips(
track = track,
exclusionWindowY = currentLineWindowY,
exclusionHeight = currentLineHeight,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 16.dp, end = 8.dp)
.width(340.dp),
)
}
// "Refresh lyrics" — only shown when a refetch could help (Loaded, NotFound,
// Error). Hidden when we're loading, the track is instrumental (refetch can't
// discover lyrics that don't exist), or privacy is blocking the network.
val canRefresh = when (result) {
is LyricsResult.Loaded -> true
LyricsResult.NotFound -> true
is LyricsResult.Error -> true
else -> false
}
if (canRefresh && track != null) {
RefreshLyricsButton(
onClick = {
viewModel.refresh()
onRequestPlayPauseFocus()
},
interFamily = interFamily,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
}
}
@Composable
private fun RefreshLyricsButton(
onClick: () -> Unit,
interFamily: FontFamily,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 20.dp, vertical = 10.dp),
) {
Text(
"Refresh lyrics",
style = MaterialTheme.typography.labelLarge.copy(fontFamily = interFamily),
color = OnPrimary,
)
}
}
private fun resolveHighlightColor(choice: LyricsHighlightColor): Color = when (choice) {
LyricsHighlightColor.AMBER -> AccentAmber
LyricsHighlightColor.WHITE -> Color(0xFFF5F0E6) // matches OnPrimary
LyricsHighlightColor.SAGE -> AccentSage
}
@Composable
private fun Placeholder(text: String, interFamily: FontFamily) {
Text(
text,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = interFamily),
color = OnMuted,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun LoadedBody(
result: LyricsResult.Loaded,
currentIndex: Int,
listState: LazyListState,
highlightSize: Float,
highlightBold: Boolean,
highlightColor: Color,
interFamily: FontFamily,
onCurrentLinePositioned: (windowY: Float, height: Float) -> Unit = { _, _ -> },
) {
// D-pad manual scrolling: the list/column is focusable, and while it
// can scroll in the pressed direction it consumes DPAD Up/Down to
// scroll ~a third of a viewport. At the ends the event is NOT
// consumed, so focus can leave the lyrics naturally. Manual D-pad
// scrolling trips the same isScrollInProgress detection as touch, so
// the existing 8s auto-scroll backoff applies unchanged. Previously
// the lyrics were simply unreachable by D-pad (field-reported: with
// loaded-but-untracked lyrics there was no way to scroll them).
val dpadScope = rememberCoroutineScope()
when {
result.synced.isNotEmpty() -> {
// Full width, content-width individual items centered via textAlign.
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.onKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false
when (ev.key) {
Key.DirectionDown -> if (listState.canScrollForward) {
dpadScope.launch { listState.animateScrollBy(360f) }; true
} else false
Key.DirectionUp -> if (listState.canScrollBackward) {
dpadScope.launch { listState.animateScrollBy(-360f) }; true
} else false
else -> false
}
}
.focusable(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Top+bottom padding rows so the first and last lyric lines can
// scroll to the vertical center of the viewport rather than
// getting stuck near the edges.
item { Spacer(Modifier.height(200.dp)) }
itemsIndexed(result.synced) { idx, line ->
val isCurrent = idx == currentIndex
val isPast = idx < currentIndex
val lineModifier = if (isCurrent) {
Modifier.onGloballyPositioned { coords ->
onCurrentLinePositioned(
coords.positionInWindow().y,
coords.size.height.toFloat(),
)
}
} else Modifier
val style = lineStyle(
isCurrent = isCurrent,
isPast = isPast,
highlightSize = highlightSize,
highlightBold = highlightBold,
highlightColor = highlightColor,
interFamily = interFamily,
)
val color = when {
isCurrent -> highlightColor
isPast -> OnQuiet
else -> OnMuted
}
Text(
line.text,
style = style,
color = color,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
item { Spacer(Modifier.height(200.dp)) }
}
}
!result.plain.isNullOrBlank() -> {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.onKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onKeyEvent false
when (ev.key) {
Key.DirectionDown -> if (scroll.canScrollForward) {
dpadScope.launch { scroll.animateScrollBy(360f) }; true
} else false
Key.DirectionUp -> if (scroll.canScrollBackward) {
dpadScope.launch { scroll.animateScrollBy(-360f) }; true
} else false
else -> false
}
}
.focusable()
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
result.plain.split('\n').forEach { rawLine ->
val trimmed = rawLine.trimEnd('\r')
if (trimmed.isNotBlank()) {
Text(
trimmed,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = interFamily),
color = OnPrimary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
} else {
Spacer(Modifier.height(8.dp))
}
}
}
}
else -> Placeholder("LRCLIB returned empty lyrics for this track.", interFamily)
}
}
/** Resolve the TextStyle for a single lyric line. Non-current lines use the base
* titleMedium style; the current line multiplies fontSize by the user's setting,
* optionally applies bold, and keeps the Inter family throughout.
*
* lineHeight scales with fontSize for the current line — without it, a scaled
* font still uses the BASE line-height, so a wrapped line of the current
* lyric crashes into the line above. 1.3× fontSize is a conservative ratio
* that keeps wrapped lines readable without opening huge gaps on single-line
* lyrics. */
@Composable
private fun lineStyle(
isCurrent: Boolean,
isPast: Boolean,
highlightSize: Float,
highlightBold: Boolean,
highlightColor: Color,
interFamily: FontFamily,
): TextStyle {
val base = MaterialTheme.typography.titleMedium.copy(fontFamily = interFamily)
if (!isCurrent) return base
val scaledSize = base.fontSize.value * highlightSize
return base.copy(
fontSize = scaledSize.sp,
lineHeight = (scaledSize * 1.3f).sp,
fontWeight = if (highlightBold) FontWeight.Bold else base.fontWeight,
)
}
/**
* Column variant that flows children around an exclusion zone. Items that would
* overlap the zone are shifted below it, creating a gap for the current lyric line.
*/
@Composable
private fun FlowAroundColumn(
exclusionLocalTop: Float,
exclusionHeight: Float,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val gap = 20f // breathing room above and below the exclusion zone
Layout(
content = content,
modifier = modifier,
) { measurables, constraints ->
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val placeables = measurables.map { it.measure(childConstraints) }
val spacing = 4.dp.roundToPx()
val exTop = (exclusionLocalTop - gap).toInt()
val exBottom = (exclusionLocalTop + exclusionHeight + gap).toInt()
val hasExclusion = exclusionLocalTop >= 0
// Calculate positions
val positions = mutableListOf<Int>()
var y = 0
for (p in placeables) {
if (hasExclusion && y + p.height > exTop && y < exBottom) {
y = exBottom
}
positions.add(y)
y += p.height + spacing
}
layout(constraints.maxWidth, y) {
placeables.forEachIndexed { i, p ->
p.placeRelative(constraints.maxWidth - p.width, positions[i])
}
}
}
}
/** Right-aligned metadata column — upper-right of the lyrics screen.
* Year sits at top, larger than the rest, as the visual anchor. Below it,
* one row per technical field: file type (codec), sample frequency, bit
* depth or bitrate, channels. Genres go at the bottom (one row per genre,
* since multi-genre tags like "Jazz / Fusion" split into separate rows). */
@Composable
private fun MetadataChips(
track: dev.stave.domain.model.DidlItem.Track,
exclusionWindowY: Float = -1f,
exclusionHeight: Float = 0f,
modifier: Modifier = Modifier,
) {
val interFamily = staveInterFamily()
val year = track.date?.let { Regex("""\d{4}""").find(it)?.value }
// Codec: strip the leading "audio/" and any "x-"/"vnd." prefixes so
// "audio/x-flac" displays as "FLAC". Falls back to null when mimeType
// is missing.
val codec = track.mimeType
?.substringAfter('/')
?.removePrefix("x-")
?.removePrefix("vnd.")
?.uppercase()
?.takeIf { it.isNotBlank() }
// Sample frequency in kHz, e.g. "44.1 kHz".
val freq = track.sampleFrequency?.let { "${it / 1000.0} kHz" }
// Bit depth for lossless ("16-bit") OR bitrate for lossy ("320 kbps").
// Servers typically populate one or the other depending on format.
val rate = when {
track.bitsPerSample != null -> "${track.bitsPerSample}-bit"
track.bitrate != null -> "${track.bitrate / 1000} kbps"
else -> null
}
val channels = track.channels?.let { ch ->
when (ch) {
1 -> "Mono"
2 -> "Stereo"
else -> "${ch}ch"
}
}
// Parse genres: split on "/" and "," so multi-genre tags stack one per row.
val genres = track.genre
?.split(Regex("""[/\\]{1,2}|,"""))
?.map { it.trim() }
?.filter { it.isNotBlank() }
?: emptyList()
val hasAnything = year != null || codec != null || freq != null || rate != null ||
channels != null || genres.isNotEmpty()
if (!hasAnything) return
// Custom layout that flows items around the current lyric line
var containerWindowY by remember { mutableFloatStateOf(0f) }
FlowAroundColumn(
exclusionLocalTop = if (exclusionWindowY >= 0) exclusionWindowY - containerWindowY else -1f,
exclusionHeight = exclusionHeight,
modifier = modifier.onGloballyPositioned { coords ->
containerWindowY = coords.positionInWindow().y
},
) {
// Year — anchor, larger and prominent. Inter (numeric).
if (year != null) {
Text(
year,
style = MaterialTheme.typography.headlineMedium.copy(fontFamily = interFamily),
color = OnQuiet,
textAlign = TextAlign.End,
)
Spacer(Modifier.height(4.dp))
}
// Technical rows — one per field, Inter (numeric/technical content).
// Each gated by null so missing fields collapse cleanly without
// leaving blank rows.
val techStyle = MaterialTheme.typography.labelMedium.copy(fontFamily = interFamily)
codec?.let {
Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
}
freq?.let {
Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
}
rate?.let {
Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
}
channels?.let {
Text(it, style = techStyle, color = OnQuiet, textAlign = TextAlign.End, maxLines = 1)
}
// Genres — each on its own row, Fraunces (text content).
if (genres.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
genres.forEach { genre ->
Text(
withInterDigits(genre),
style = MaterialTheme.typography.titleSmall,
color = OnQuiet,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}