home and lyrics: with the player on the left, the map's cards move to its far edge, and lyrics light up word by word

The docked player and side lyrics use the theme's surface instead of the album colour. Lyrics keep the line being sung near the middle, fade at the edges, and fill in each word for BetterLyrics songs, which now keep their word timings.
This commit is contained in:
Seb3thehacker committed 2026-10-25 19:44:19 -07:00
1 parent 46c2e92684
commit 08ac6fe117
6 files changed
+263 -82

No files matched your search

@@ -106,12 +106,14 @@ private fun AppSurface(
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) } val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
// The card column moves the map's centre but isn't taken out of the stable area: apps size // The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards. // their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) } val cardsAtEnd = LocalCardsAtEnd.current
LaunchedEffect(template, size, measured, measuredStable) { val cardEdge = cardEdge
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured) val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
val stable = if (!isContentTemplate(template)) { val stable = if (!isContentTemplate(template)) {
visibleAreaFor(template, width, height, measuredStable) visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
} else { } else {
visible visible
} }
@@ -64,7 +64,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* The home screen: the map on the left, the docked player on the right when asked for. * The home screen: the map, with the docked player beside it when asked for. With the player on
* the left, the map app's cards move to the map's far edge, so the player, the map and the cards
* each keep their own space.
* *
* The map is whatever navigation app was used last, brought back on its own. The player sits in * The map is whatever navigation app was used last, brought back on its own. The player sits in
* the nav bar's pill until its dock button moves it here; the column closes itself back to a * the nav bar's pill until its dock button moves it here; the column closes itself back to a
@@ -106,7 +108,9 @@ fun CarHome() {
.weight(if (splitScreen) 2.1f else 1f) .weight(if (splitScreen) 2.1f else 1f)
.fillMaxHeight(), .fillMaxHeight(),
) { ) {
MapPane(navStatus, frame) androidx.compose.runtime.CompositionLocalProvider(LocalCardsAtEnd provides (splitScreen && playerLeft)) {
MapPane(navStatus, frame)
}
} }
if (splitScreen && !playerLeft) { if (splitScreen && !playerLeft) {
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
@@ -175,7 +179,9 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { // The theme's surface rather than the art's colour: a slab of album colour beside the map
// fought it for attention, and the art says the same thing in less space.
Surface(modifier = modifier, color = scheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) { BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
// Whatever height the controls below leave over, so the art grows on a taller // Whatever height the controls below leave over, so the art grows on a taller
// screen and shrinks rather than pushing the buttons off a short one. // screen and shrinks rather than pushing the buttons off a short one.
@@ -226,32 +232,32 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
} }
/** /**
* Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a * Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock * player goes back to the nav bar meanwhile and names the song there; its dock button swaps these
* button swaps these back out for the controls. * back out for the controls.
*/ */
@Composable @Composable
private fun SideLyrics(now: NowPlaying, modifier: Modifier) { private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val context = LocalContext.current val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { Surface(modifier = modifier, color = scheme.surfaceContainer) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
Text( art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
} }
Spacer(Modifier.size(10.dp))
Text(
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp) GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
} }
Spacer(Modifier.height(12.dp))
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall) LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
} }
} }
@@ -49,6 +49,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -382,8 +385,10 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
} }
/** /**
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with * The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
* the current line in the accent colour; unsynced ones are plain scrolling text. * sung sits a little above the middle in bold, lines already sung dim, and the list fades out at
* both edges instead of cutting a line in half. Word-timed lyrics also light up each word as it's
* sung. Unsynced ones are plain scrolling text.
*/ */
@Composable @Composable
internal fun LyricsPanel( internal fun LyricsPanel(
@@ -402,16 +407,6 @@ internal fun LyricsPanel(
?.let { value = LyricsState.Found(it) } ?.let { value = LyricsState.Found(it) }
} }
} }
val list = rememberLazyListState()
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(300)
}
}
when (val state = lyrics) { when (val state = lyrics) {
LyricsState.Loading -> Notice("Looking for lyrics…", null) LyricsState.Loading -> Notice("Looking for lyrics…", null)
@@ -419,33 +414,74 @@ internal fun LyricsPanel(
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.", if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.",
null, null,
) )
is LyricsState.Found -> { is LyricsState.Found -> Column(modifier) {
val lines = state.result.lines if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1 else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
LaunchedEffect(current) { // Debug builds name the service the lyrics came from, to see which one answers.
val offset = if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) 1 else 0 if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
if (current >= 0) list.animateScrollToItem((current - 1 + offset).coerceAtLeast(0)) val from = state.result.source.ifEmpty { "an older cache" }
} val kind = when {
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) { state.result.lines.any { it.words.isNotEmpty() } -> "word-timed"
// Debug builds name the service the lyrics came from, to see which one answers. state.result.synced -> "synced"
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) item { else -> "plain"
val from = state.result.source.ifEmpty { "an older cache" }
Text(
"From $from · ${if (state.result.synced) "synced" else "plain"}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary,
)
} }
itemsIndexed(lines) { index, line -> Text(
"From $from · $kind",
Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
}
@Composable
private fun SyncedLyrics(
now: NowPlaying,
result: LyricsResult,
lineStyle: androidx.compose.ui.text.TextStyle,
modifier: Modifier,
) {
val lines = result.lines
val wordTimed = lines.any { it.words.isNotEmpty() }
// Every frame while words are filling in, so a word doesn't light up in steps; otherwise a
// few times a second is enough to move the line along.
var position by remember { mutableLongStateOf(now.currentPosition()) }
LaunchedEffect(now, wordTimed) {
while (true) {
position = now.currentPosition()
if (wordTimed && now.state == PlaybackState.STATE_PLAYING) androidx.compose.runtime.withFrameMillis { }
else delay(300)
}
}
// Only the line changing recomposes the list; the moving position is read by the one line.
val current by remember(lines) {
androidx.compose.runtime.derivedStateOf { lines.indexOfLast { it.timeMs <= position } }
}
val list = rememberLazyListState()
LaunchedEffect(current) {
if (current >= 0) list.animateScrollToItem(current)
}
val scheme = MaterialTheme.colorScheme
val sung = scheme.onSurfaceVariant.copy(alpha = 0.55f)
BoxWithConstraints(modifier.fadingEdges()) {
LazyColumn(
Modifier.fillMaxSize(),
state = list,
// The padding lets the first line start, and the last line end, near the middle.
contentPadding = PaddingValues(top = maxHeight * 0.32f, bottom = maxHeight * 0.6f),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
itemsIndexed(lines) { index, line ->
if (index == current) {
CurrentLine(line, { position }, lineStyle)
} else {
Text( Text(
line.text.ifEmpty { " " }, line.text.ifEmpty { " " },
style = lineStyle, style = lineStyle,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal, color = if (index < current) sung else scheme.onSurfaceVariant,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
index == current -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
} }
@@ -453,6 +489,72 @@ internal fun LyricsPanel(
} }
} }
/**
* The line being sung, in bold and a size up. With word timings, words already sung are in the
* accent colour, the one being sung fills in letter by letter, and the rest wait dimmed.
*/
@Composable
private fun CurrentLine(line: app.seb3thehacker.gearslip.media.LyricLine, position: () -> Long, lineStyle: androidx.compose.ui.text.TextStyle) {
val scheme = MaterialTheme.colorScheme
val style = lineStyle.copy(fontWeight = FontWeight.Bold, fontSize = lineStyle.fontSize * 1.15f)
if (line.words.isEmpty()) {
Text(line.text.ifEmpty { " " }, style = style, color = scheme.primary)
return
}
val lit = scheme.primary
val waiting = scheme.onSurfaceVariant.copy(alpha = 0.7f)
val now = position()
val text = androidx.compose.ui.text.buildAnnotatedString {
line.words.forEachIndexed { i, word ->
// The line's first word has no leading space to trim, the last no trailing one.
val shown = if (i == line.words.lastIndex) word.text.trimEnd() else word.text
val progress = when {
now >= word.endMs -> 1f
now <= word.startMs -> 0f
else -> (now - word.startMs).toFloat() / (word.endMs - word.startMs).coerceAtLeast(1)
}
val letters = shown.trimEnd().length
val filled = progress * letters
shown.forEachIndexed { c, ch ->
val amount = (filled - c).coerceIn(0f, 1f)
withStyle(androidx.compose.ui.text.SpanStyle(color = androidx.compose.ui.graphics.lerp(waiting, lit, amount))) {
append(ch)
}
}
}
}
Text(text, style = style)
}
@Composable
private fun PlainLyrics(result: LyricsResult, lineStyle: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
LazyColumn(
modifier.fadingEdges(),
contentPadding = PaddingValues(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(result.lines) { line ->
Text(line.text.ifEmpty { " " }, style = lineStyle, color = MaterialTheme.colorScheme.onSurface)
}
}
}
/** Fades the top and bottom edges out, so a line scrolling past doesn't end in a hard cut. */
private fun Modifier.fadingEdges(): Modifier = this
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
drawRect(
androidx.compose.ui.graphics.Brush.verticalGradient(
0f to androidx.compose.ui.graphics.Color.Transparent,
0.14f to androidx.compose.ui.graphics.Color.Black,
0.86f to androidx.compose.ui.graphics.Color.Black,
1f to androidx.compose.ui.graphics.Color.Transparent,
),
blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
)
}
private sealed interface LyricsState { private sealed interface LyricsState {
data object Loading : LyricsState data object Loading : LyricsState
data object Missing : LyricsState data object Missing : LyricsState
@@ -99,11 +99,12 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
* A template that isn't map-backed covers the surface completely, and reporting an empty * A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering. * rectangle is how the app is told to stop bothering.
*/ */
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) { fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null, cardsAtEnd: Boolean = false): Rect = when (template) {
is NavigationTemplate -> measured ?: Rect(0, 0, width, height) is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height) -> measured ?: if (cardsAtEnd) Rect(0, 0, (width * (1 - CARD_COLUMN_FRACTION)).toInt(), height)
else Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height) null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0) else -> Rect(0, 0, 0, 0)
} }
@@ -152,6 +153,17 @@ internal class ChromeCover {
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null } internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
/**
* The app's cards go to the far edge of the map and its buttons to the near one, set while the
* player is docked on the left: the player, the map and the cards each keep their own space
* instead of the cards crowding up against the player.
*/
internal val LocalCardsAtEnd = compositionLocalOf { false }
/** The edge the app's cards cover, and the one its map buttons do. */
internal val cardEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.END else CoverEdge.START
internal val buttonEdge @Composable get() = if (LocalCardsAtEnd.current) CoverEdge.START else CoverEdge.END
/** /**
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather * The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
* than following the slide, so the app is told once instead of every frame. * than following the slide, so the app is told once instead of every frame.
@@ -196,12 +208,15 @@ private fun NavigationBars(
) { ) {
val connection = LocalCarAppConnection.current val connection = LocalCarAppConnection.current
val pan by connection.panMode.collectAsState() val pan by connection.panMode.collectAsState()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
val buttonEdge = buttonEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
val column = cardColumnWidth(maxWidth) val column = cardColumnWidth(maxWidth)
Row(Modifier.fillMaxSize()) { val cards = @Composable {
if (routing != null || estimate != null) { if (routing != null || estimate != null) {
Column( Column(
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp), Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) } routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
@@ -209,14 +224,21 @@ private fun NavigationBars(
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) } estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
} }
} }
Spacer(Modifier.weight(1f)) }
val buttons = @Composable {
RightEdgeControls( RightEdgeControls(
if (pan) null else template.actionStrip, if (pan) null else template.actionStrip,
template.mapActionStrip, template.mapActionStrip,
Modifier.padding(12.dp), Modifier.padding(12.dp),
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END), mapActionsModifier = Modifier.covers("nav-buttons", buttonEdge),
alignment = if (atEnd) Alignment.Start else Alignment.End,
) )
} }
Row(Modifier.fillMaxSize()) {
if (atEnd) buttons() else cards()
Spacer(Modifier.weight(1f))
if (atEnd) cards() else buttons()
}
} }
} }
@@ -445,12 +467,14 @@ private fun MapPaneLayout(
// Pan mode hands the whole screen to the map, leaving only its own controls. // Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by connection.panMode.collectAsState() val pan by connection.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet() val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
val atEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) { androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) { if (!pan) {
ChromeSurface( ChromeSurface(
Modifier Modifier
.align(Alignment.TopStart) .align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
.covers("map-pane", CoverEdge.START) .covers("map-pane", cardEdge)
.padding(12.dp) .padding(12.dp)
.width(cardColumnWidth(maxWidth) - 24.dp) .width(cardColumnWidth(maxWidth) - 24.dp)
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) }, .let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
@@ -463,7 +487,8 @@ private fun MapPaneLayout(
RightEdgeControls( RightEdgeControls(
if (pan) null else actionStrip, if (pan) null else actionStrip,
mapActionStrip, mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp), Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
alignment = if (atEnd) Alignment.Start else Alignment.End,
) )
} }
} }
@@ -228,6 +228,8 @@ internal fun RightEdgeControls(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** For the stack of map buttons alone, without the row of the app's own above it. */ /** For the stack of map buttons alone, without the row of the app's own above it. */
mapActionsModifier: Modifier = Modifier, mapActionsModifier: Modifier = Modifier,
/** End normally; Start when the app's cards have moved to the far edge and these to the near one. */
alignment: Alignment.Horizontal = Alignment.End,
) { ) {
val connection = LocalCarAppConnection.current val connection = LocalCarAppConnection.current
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
@@ -262,7 +264,7 @@ internal fun RightEdgeControls(
} }
} }
}, },
horizontalAlignment = Alignment.End, horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (actions.isNotEmpty()) { if (actions.isNotEmpty()) {
@@ -273,7 +275,7 @@ internal fun RightEdgeControls(
if (mapActions.isNotEmpty()) { if (mapActions.isNotEmpty()) {
Column( Column(
mapActionsModifier, mapActionsModifier,
horizontalAlignment = Alignment.End, horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
mapActions.forEach { ActionButton(it) } mapActions.forEach { ActionButton(it) }
@@ -21,7 +21,14 @@ import java.net.URLEncoder
import java.security.MessageDigest import java.security.MessageDigest
import kotlin.math.abs import kotlin.math.abs
class LyricLine(val timeMs: Long, val text: String) /**
* One line. [words] is filled only for word-timed lyrics, and joined back together it reads as
* [text]; each word carries the space after it, if any, so a word split into syllables stays whole.
*/
class LyricLine(val timeMs: Long, val text: String, val words: List<LyricWord> = emptyList())
/** A word, or a syllable of one, sung from [startMs] to [endMs]. */
class LyricWord(val startMs: Long, val endMs: Long, val text: String)
/** /**
* [synced] means every line carries a time, so the current one can be followed along. [source] * [synced] means every line carries a time, so the current one can be followed along. [source]
@@ -64,7 +71,7 @@ object Lyrics {
private fun quality(result: LyricsResult?): Int = when { private fun quality(result: LyricsResult?): Int = when {
result == null -> 0 result == null -> 0
!result.synced -> 1 !result.synced -> 1
result.source == "BetterLyrics" -> BEST result.lines.any { it.words.isNotEmpty() } -> BEST
else -> 2 else -> 2
} }
@@ -158,10 +165,20 @@ object Lyrics {
val lines = json.getJSONArray("lines").let { arr -> val lines = json.getJSONArray("lines").let { arr ->
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val line = arr.getJSONObject(i) val line = arr.getJSONObject(i)
LyricLine(line.getLong("t"), line.getString("x")) val words = line.optJSONArray("w")?.let { w ->
(0 until w.length()).map { j ->
val word = w.getJSONArray(j)
LyricWord(word.getLong(0), word.getLong(1), word.getString(2))
}
}.orEmpty()
LyricLine(line.getLong("t"), line.getString("x"), words)
} }
} }
LyricsResult(lines, json.getBoolean("synced"), json.optString("source")) val source = json.optString("source")
// Saved before word timings were kept: fetch again rather than wait a day for an upgrade
// that skips the source it came from.
if (source == "BetterLyrics" && lines.none { it.words.isNotEmpty() }) return null
LyricsResult(lines, json.getBoolean("synced"), source)
}.getOrNull() }.getOrNull()
/** When the song's sources were last asked; 0 for entries from before the date was kept. */ /** When the song's sources were last asked; 0 for entries from before the date was kept. */
@@ -172,7 +189,11 @@ object Lyrics {
runCatching { runCatching {
val lines = JSONArray() val lines = JSONArray()
result.lines.forEach { line -> result.lines.forEach { line ->
lines.put(JSONObject().put("t", line.timeMs).put("x", line.text)) val entry = JSONObject().put("t", line.timeMs).put("x", line.text)
if (line.words.isNotEmpty()) {
entry.put("w", JSONArray(line.words.map { JSONArray().put(it.startMs).put(it.endMs).put(it.text) }))
}
lines.put(entry)
} }
val json = JSONObject().put("synced", result.synced).put("source", result.source) val json = JSONObject().put("synced", result.synced).put("source", result.source)
.put("checked", checked).put("lines", lines) .put("checked", checked).put("lines", lines)
@@ -201,8 +222,9 @@ object Lyrics {
} }
/** /**
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span>`s with spaces between. * Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
* Background vocals, translations and romanisations are left out, so a line reads as sung. * spaces between; a word sung in syllables is several spans with no space. Background vocals,
* translations and romanisations are left out, so a line reads as sung.
*/ */
private fun parseTtml(ttml: String): LyricsResult? { private fun parseTtml(ttml: String): LyricsResult? {
val parser = Xml.newPullParser().apply { val parser = Xml.newPullParser().apply {
@@ -215,28 +237,50 @@ object Lyrics {
var firstSpanBegin: Long? = null var firstSpanBegin: Long? = null
var skipDepth = 0 var skipDepth = 0
val text = StringBuilder() val text = StringBuilder()
// The span being read, and the words of the line so far, each with the space after it.
var spanBegin: Long? = null
var spanEnd: Long? = null
val words = mutableListOf<Triple<Long, Long, StringBuilder>>()
var event = parser.eventType var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) { while (event != XmlPullParser.END_DOCUMENT) {
when (event) { when (event) {
XmlPullParser.START_TAG -> when { XmlPullParser.START_TAG -> when {
parser.name == "p" -> { parser.name == "p" -> {
inP = true; text.clear(); firstSpanBegin = null inP = true; text.clear(); firstSpanBegin = null; words.clear()
lineBegin = attr(parser, "begin")?.let(::ttmlMs) lineBegin = attr(parser, "begin")?.let(::ttmlMs)
} }
inP && skipDepth > 0 -> skipDepth++ inP && skipDepth > 0 -> skipDepth++
inP && parser.name == "span" -> { inP && parser.name == "span" -> {
val role = attr(parser, "role") val role = attr(parser, "role")
if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1 if (role == "x-bg" || role == "x-translation" || role == "x-roman") skipDepth = 1
else if (firstSpanBegin == null) firstSpanBegin = attr(parser, "begin")?.let(::ttmlMs) else {
spanBegin = attr(parser, "begin")?.let(::ttmlMs)
spanEnd = attr(parser, "end")?.let(::ttmlMs)
if (firstSpanBegin == null) firstSpanBegin = spanBegin
}
}
}
XmlPullParser.TEXT -> if (inP && skipDepth == 0) {
val raw = parser.text
text.append(raw)
val begin = spanBegin
val end = spanEnd
if (begin != null && end != null && raw.isNotBlank()) {
val word = raw.replace(Regex("\\s+"), " ").trimStart()
words.add(Triple(begin, end, StringBuilder(word)))
} else if (raw.isNotEmpty() && raw.isBlank() && words.isNotEmpty() && !words.last().third.endsWith(" ")) {
words.last().third.append(' ')
} }
} }
XmlPullParser.TEXT -> if (inP && skipDepth == 0) text.append(parser.text)
XmlPullParser.END_TAG -> when { XmlPullParser.END_TAG -> when {
inP && skipDepth > 0 -> skipDepth-- inP && skipDepth > 0 -> skipDepth--
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
parser.name == "p" -> { parser.name == "p" -> {
inP = false inP = false
val words = text.toString().replace(Regex("\\s+"), " ").trim() val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
if (words.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, words)) val line = if (timed.isNotEmpty()) timed.joinToString("") { it.text }.trim()
else text.toString().replace(Regex("\\s+"), " ").trim()
if (line.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, line, timed))
} }
} }
} }