mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
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:
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) }
|
||||
// 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.
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
|
||||
LaunchedEffect(template, size, measured, measuredStable) {
|
||||
val cardsAtEnd = LocalCardsAtEnd.current
|
||||
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) {
|
||||
val visible = visibleAreaFor(template, width, height, measured)
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
|
||||
val stable = if (!isContentTemplate(template)) {
|
||||
visibleAreaFor(template, width, height, measuredStable)
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
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 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)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalCardsAtEnd provides (splitScreen && playerLeft)) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
}
|
||||
if (splitScreen && !playerLeft) {
|
||||
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 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)) {
|
||||
// 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.
|
||||
@@ -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
|
||||
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
|
||||
* button swaps these back out for the controls.
|
||||
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
|
||||
* player goes back to the nav bar meanwhile and names the song there; its dock button swaps these
|
||||
* back out for the controls.
|
||||
*/
|
||||
@Composable
|
||||
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.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) }
|
||||
Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
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,
|
||||
)
|
||||
Surface(modifier = modifier, color = scheme.surfaceContainer) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
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)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
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.Modifier
|
||||
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.SolidColor
|
||||
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 current line in the accent colour; unsynced ones are plain scrolling text.
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
|
||||
* 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
|
||||
internal fun LyricsPanel(
|
||||
@@ -402,16 +407,6 @@ internal fun LyricsPanel(
|
||||
?.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) {
|
||||
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.",
|
||||
null,
|
||||
)
|
||||
is LyricsState.Found -> {
|
||||
val lines = state.result.lines
|
||||
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
|
||||
LaunchedEffect(current) {
|
||||
val offset = if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) 1 else 0
|
||||
if (current >= 0) list.animateScrollToItem((current - 1 + offset).coerceAtLeast(0))
|
||||
}
|
||||
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
// Debug builds name the service the lyrics came from, to see which one answers.
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) item {
|
||||
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,
|
||||
)
|
||||
is LyricsState.Found -> Column(modifier) {
|
||||
if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||
// Debug builds name the service the lyrics came from, to see which one answers.
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||
val from = state.result.source.ifEmpty { "an older cache" }
|
||||
val kind = when {
|
||||
state.result.lines.any { it.words.isNotEmpty() } -> "word-timed"
|
||||
state.result.synced -> "synced"
|
||||
else -> "plain"
|
||||
}
|
||||
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(
|
||||
line.text.ifEmpty { " " },
|
||||
style = lineStyle,
|
||||
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!state.result.synced -> MaterialTheme.colorScheme.onSurface
|
||||
index == current -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
color = if (index < current) sung else scheme.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 {
|
||||
data object Loading : 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
|
||||
* 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 MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||
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)
|
||||
else -> Rect(0, 0, 0, 0)
|
||||
}
|
||||
@@ -152,6 +153,17 @@ internal class ChromeCover {
|
||||
|
||||
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
|
||||
* 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 pan by connection.panMode.collectAsState()
|
||||
val atEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val buttonEdge = buttonEdge
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
val cards = @Composable {
|
||||
if (routing != null || estimate != null) {
|
||||
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),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
@@ -209,14 +224,21 @@ private fun NavigationBars(
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
val buttons = @Composable {
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
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.
|
||||
val pan by connection.panMode.collectAsState()
|
||||
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()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.covers("map-pane", CoverEdge.START)
|
||||
.align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
|
||||
.covers("map-pane", cardEdge)
|
||||
.padding(12.dp)
|
||||
.width(cardColumnWidth(maxWidth) - 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(
|
||||
if (pan) null else actionStrip,
|
||||
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,
|
||||
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||
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 actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
@@ -262,7 +264,7 @@ internal fun RightEdgeControls(
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.isNotEmpty()) {
|
||||
@@ -273,7 +275,7 @@ internal fun RightEdgeControls(
|
||||
if (mapActions.isNotEmpty()) {
|
||||
Column(
|
||||
mapActionsModifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
mapActions.forEach { ActionButton(it) }
|
||||
|
||||
@@ -21,7 +21,14 @@ import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
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]
|
||||
@@ -64,7 +71,7 @@ object Lyrics {
|
||||
private fun quality(result: LyricsResult?): Int = when {
|
||||
result == null -> 0
|
||||
!result.synced -> 1
|
||||
result.source == "BetterLyrics" -> BEST
|
||||
result.lines.any { it.words.isNotEmpty() } -> BEST
|
||||
else -> 2
|
||||
}
|
||||
|
||||
@@ -158,10 +165,20 @@ object Lyrics {
|
||||
val lines = json.getJSONArray("lines").let { arr ->
|
||||
(0 until arr.length()).map { 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()
|
||||
|
||||
/** When the song's sources were last asked; 0 for entries from before the date was kept. */
|
||||
@@ -172,7 +189,11 @@ object Lyrics {
|
||||
runCatching {
|
||||
val lines = JSONArray()
|
||||
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)
|
||||
.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.
|
||||
* Background vocals, translations and romanisations are left out, so a line reads as sung.
|
||||
* Apple-style TTML: one `<p begin=…>` per line, its words in `<span begin=… end=…>`s with
|
||||
* 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? {
|
||||
val parser = Xml.newPullParser().apply {
|
||||
@@ -215,28 +237,50 @@ object Lyrics {
|
||||
var firstSpanBegin: Long? = null
|
||||
var skipDepth = 0
|
||||
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
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when {
|
||||
parser.name == "p" -> {
|
||||
inP = true; text.clear(); firstSpanBegin = null
|
||||
inP = true; text.clear(); firstSpanBegin = null; words.clear()
|
||||
lineBegin = attr(parser, "begin")?.let(::ttmlMs)
|
||||
}
|
||||
inP && skipDepth > 0 -> skipDepth++
|
||||
inP && parser.name == "span" -> {
|
||||
val role = attr(parser, "role")
|
||||
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 {
|
||||
inP && skipDepth > 0 -> skipDepth--
|
||||
inP && parser.name == "span" -> { spanBegin = null; spanEnd = null }
|
||||
parser.name == "p" -> {
|
||||
inP = false
|
||||
val words = text.toString().replace(Regex("\\s+"), " ").trim()
|
||||
if (words.isNotEmpty()) lines.add(LyricLine(lineBegin ?: firstSpanBegin ?: -1, words))
|
||||
val timed = words.map { (b, e, w) -> LyricWord(b, e, w.toString()) }
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user