mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +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) }
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user