diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index ecfb7ab..33df319 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -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 } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index a2a7943..70a3ede 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -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) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 0e06f3d..b6aac31 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt index 4e67d79..4745844 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -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 { 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, ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 3afb133..600c465 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -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) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt index 45d80cc..aea80c4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt @@ -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 = 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 `

` per line, its words in ``s with spaces between. - * Background vocals, translations and romanisations are left out, so a line reads as sung. + * Apple-style TTML: one `

` per line, its words in ``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>() 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)) } } }