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) }
// 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))
}
}
}