diff --git a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsEntry.kt b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsEntry.kt index 14e45f5225..8c26b087e5 100644 --- a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsEntry.kt +++ b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsEntry.kt @@ -6,6 +6,7 @@ package com.metrolist.music.lyrics import kotlinx.coroutines.flow.MutableStateFlow +import com.metrolist.music.ui.screens.settings.LyricsPosition data class WordTimestamp( val text: String, @@ -21,7 +22,9 @@ data class LyricsEntry( val romanizedTextFlow: MutableStateFlow = MutableStateFlow(null), val translatedTextFlow: MutableStateFlow = MutableStateFlow(null), val agent: String? = null, - val isBackground: Boolean = false + val isBackground: Boolean = false, + val endTime: Long? = null, + var linePosition: LyricsPosition? = null ) : Comparable { override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() diff --git a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsPlusProvider.kt b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsPlusProvider.kt index aed4a9f3df..159159ee25 100644 --- a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsPlusProvider.kt +++ b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsPlusProvider.kt @@ -270,11 +270,12 @@ object LyricsPlusProvider : LyricsProvider { } } ?: return null - val parsedLines = runCatching { TTMLParser.parseTTML(ttml) } + val agentsMap = mutableMapOf() + val parsedLines = runCatching { TTMLParser.parseTTML(ttml, agentsMap) } .onFailure { Timber.tag("LyricsPlus").w(it, "Failed parsing binimum TTML") } .getOrNull() ?.takeIf { it.isNotEmpty() } ?: return null - val lrc = runCatching { TTMLParser.toLRC(parsedLines).trim() } + val lrc = runCatching { TTMLParser.toLRC(parsedLines, agentsMap).trim() } .getOrNull() ?.takeIf { it.isNotBlank() } ?: return null @@ -296,6 +297,11 @@ object LyricsPlusProvider : LyricsProvider { */ private fun convertToLrc(response: LyricsPlusResponse?): String? { val lyrics = response?.lyrics?.takeIf { it.isNotEmpty() } ?: return null + + if (response.type.equals("None", ignoreCase = true)) { + return lyrics.joinToString("\n") { it.text.trim() }.ifBlank { null } + } + val isWordSync = response.type.equals("Word", ignoreCase = true) // Agent mapping @@ -305,19 +311,17 @@ object LyricsPlusProvider : LyricsProvider { lyrics.forEach { line -> val raw = line.element?.singer?.lowercase() ?: return@forEach if (raw !in agentMap) { - agentMap[raw] = when { - raw == "v1" || raw == "v2" || raw == "v1000" -> raw - else -> { - val taken = agentMap.values.toSet() - listOf("v1", "v2").firstOrNull { it !in taken } ?: "v1" - } - } + agentMap[raw] = raw } } - val isMultiAgent = agentMap.size > 1 || - (agentMap.size == 1 && !agentMap.containsKey("v1")) - val sb = StringBuilder(lyrics.size * 128) + + // Agent metadata header + response.metadata?.agents?.forEach { (_, info) -> + val alias = info.alias?.takeIf { it.isNotBlank() } ?: return@forEach + sb.append("[agent:$alias:${info.type ?: "person"}:${info.name ?: ""}]\n") + } + var lastWasBg = false for (line in lyrics) { @@ -337,8 +341,8 @@ object LyricsPlusProvider : LyricsProvider { if (mainText.isNotBlank()) { lastWasBg = false val agentId = agentMap[line.element?.singer?.lowercase()] - val agentTag = if (isMultiAgent && agentId != null) "{agent:$agentId}" else "" - sb.appendLrcLine(line.time, agentTag, mainText) + val agentTag = if (!agentId.isNullOrBlank()) "{agent:$agentId}" else "" + sb.appendLrcLine(line.time, line.duration, agentTag, mainText) if (isWordSync && mainWords.isNotEmpty()) sb.appendWordBlock(mainWords) } @@ -352,7 +356,12 @@ object LyricsPlusProvider : LyricsProvider { if (bgText.isNotBlank()) { val bgTime = bgToEmit.minOf { it.time } val bgTag = if (lastWasBg) "" else "{bg}" - sb.appendLrcLine(bgTime, bgTag, bgText) + val bgDuration = bgToEmit + .maxOf { it.time + it.duration } + .minus(bgTime) + .takeIf { it > 0 } + ?: line.duration + sb.appendLrcLine(bgTime, bgDuration, bgTag, bgText) lastWasBg = true if (isWordSync) sb.appendWordBlock(bgToEmit) } @@ -367,10 +376,13 @@ object LyricsPlusProvider : LyricsProvider { words.joinToString("") { it.text }.trim() /** Appends `[mm:ss.cc]text\n` */ - private fun StringBuilder.appendLrcLine(timeMs: Long, tag: String, text: String) { + private fun StringBuilder.appendLrcLine(timeMs: Long, durationMs: Long, tag: String, text: String) { append(formatLrcTime(timeMs)) append(tag) append(text) + if (durationMs > 0) { + append(formatLrcTime(timeMs + durationMs)) + } append('\n') } diff --git a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt index 8eef37e6e0..c12da48e27 100644 --- a/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt +++ b/app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt @@ -11,6 +11,7 @@ import com.github.promeg.pinyinhelper.Pinyin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.Locale +import com.metrolist.music.ui.screens.settings.LyricsPosition val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\] ?)+)(.*)".toRegex() val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() @@ -29,8 +30,18 @@ private val PAXSENIX_BG_LINE_REGEX = "^\\[bg:\\s*(.*)\\]$".toRegex() private val AGENT_REGEX = "\\{agent:([^}]+)\\}".toRegex() private val BACKGROUND_REGEX = "^\\{bg\\}".toRegex() +// Regex for trailing line-level end time or duration (supports [mm:ss.cc] and ) +private val TRAILING_TIME_REGEX = "[<\\[](\\d{1,2}):(\\d{2})\\.(\\d{2,3})[>\\]]\\s*$".toRegex() + +private val AGENT_HEADER_REGEX = "\\[agent:([^:]+):([^:]*):?([^]]*)\\]".toRegex() + @Suppress("RegExpRedundantEscape") object LyricsUtils { + private data class LyricsAgentMetadata( + val id: String, + val type: String = "person", + val name: String = "" + ) fun cleanTitleForSearch(title: String): String { return title.replace(Regex("\\s*[(\\[].*?[)\\]]"), "").trim() } @@ -436,11 +447,21 @@ object LyricsUtils { val decodedLyrics = decodeHtmlEntities(unescapedLyrics) - val lines = decodedLyrics.lines() - .filter { - it.isNotBlank() || it.trim().startsWith("[") || it.trim().startsWith("<") + val rawLines = decodedLyrics.lines() + val agents = mutableMapOf() + val lines = rawLines.filter { line -> + val trimmed = line.trim() + val agentMatch = AGENT_HEADER_REGEX.find(trimmed) + if (agentMatch != null) { + val alias = agentMatch.groupValues[1] + val type = agentMatch.groupValues[2].ifEmpty { "person" } + val name = agentMatch.groupValues[3] + agents[alias] = LyricsAgentMetadata(alias, type, name) + false + } else { + trimmed.isNotBlank() || trimmed.startsWith("[") || trimmed.startsWith("<") } - .filter { !it.trim().startsWith("[offset:") } + }.filter { !it.trim().startsWith("[offset:") } // Check if this is rich sync format (contains patterns) val isRichSync = lines.any { line -> @@ -448,11 +469,70 @@ object LyricsUtils { RICH_SYNC_WORD_REGEX.containsMatchIn(line) } - return if (isRichSync) { + val parsed = if (isRichSync) { parseRichSyncLyrics(lines) } else { parseStandardLyrics(lines) } + + applyAgentPositioning(parsed, agents) + return parsed + } + + private fun applyAgentPositioning(entries: List, agentMetadata: Map) { + if (entries.isEmpty()) return + + var currentSideIsLeft = true + var lastPersonSingerId: String? = null + var rightCount = 0 + var totalCount = 0 + + entries.forEach { entry -> + val singerId = entry.agent + if (singerId != null) { + val agentData = agentMetadata[singerId] + val type = agentData?.type ?: when (singerId) { + "v1000" -> "group" + "v2000" -> "other" + else -> "person" + } + + if (type == "group") { + entry.linePosition = LyricsPosition.CENTER + } else { + if (lastPersonSingerId == null) { + currentSideIsLeft = type != "other" + } else if (singerId != lastPersonSingerId) { + currentSideIsLeft = !currentSideIsLeft + } + + entry.linePosition = if (currentSideIsLeft) { + LyricsPosition.LEFT + } else { + LyricsPosition.RIGHT + } + + if (entry.linePosition == LyricsPosition.RIGHT) { + rightCount++ + } + totalCount++ + lastPersonSingerId = singerId + } + } + } + + // 85% flip logic: if >= 85% of lines are on the right, flip all sides + if (totalCount > 0 && (rightCount.toDouble() / totalCount.toDouble()) >= 0.85) { + entries.forEach { entry -> + when (entry.linePosition) { + LyricsPosition.LEFT -> + entry.linePosition = LyricsPosition.RIGHT + LyricsPosition.RIGHT -> + entry.linePosition = LyricsPosition.LEFT + else -> {} + } + } + } } /** @@ -480,11 +560,22 @@ object LyricsUtils { } else null } + val endTimeMatch = TRAILING_TIME_REGEX.find(content) + val lineEndTimeMs = endTimeMatch?.let { match -> + val min = match.groupValues[1].toLong() + val sec = match.groupValues[2].toLong() + val milString = match.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) mil *= 10 + min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + } + val contentToClean = if (endTimeMatch != null) content.replaceFirst(TRAILING_TIME_REGEX, "") else content + // Extract plain text (remove all tags) - val plainText = content.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() + val plainText = contentToClean.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() val lineTimeMs = wordTimings?.firstOrNull()?.startTime?.let { (it * 1000).toLong() } ?: 0L - result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = lastNonBgAgent ?: "bg", isBackground = true)) + result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = lastNonBgAgent ?: "bg", isBackground = true, endTime = lineEndTimeMs)) return@forEachIndexed } @@ -509,13 +600,24 @@ object LyricsUtils { } else null } + val endTimeMatch = TRAILING_TIME_REGEX.find(content) + val lineEndTimeMs = endTimeMatch?.let { match -> + val min = match.groupValues[1].toLong() + val sec = match.groupValues[2].toLong() + val milString = match.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) mil *= 10 + min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + } + val contentToClean = if (endTimeMatch != null) content.replaceFirst(TRAILING_TIME_REGEX, "") else content + // Extract plain text (remove all tags) - val plainText = content.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() + val plainText = contentToClean.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() if (!agent.isNullOrBlank()) { lastNonBgAgent = agent } - result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = agent, isBackground = false)) + result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = agent, isBackground = false, endTime = lineEndTimeMs)) return@forEachIndexed } @@ -554,13 +656,24 @@ object LyricsUtils { } else null } + val endTimeMatch = TRAILING_TIME_REGEX.find(content) + val lineEndTimeMs = endTimeMatch?.let { match -> + val min = match.groupValues[1].toLong() + val sec = match.groupValues[2].toLong() + val milString = match.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) mil *= 10 + min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + } + val contentToClean = if (endTimeMatch != null) content.replaceFirst(TRAILING_TIME_REGEX, "") else content + // Extract plain text (remove all tags) - val plainText = content.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() + val plainText = contentToClean.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() if (!isBackground && !agent.isNullOrBlank()) { lastNonBgAgent = agent } - result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = if (isBackground) lastNonBgAgent ?: "bg" else agent, isBackground = isBackground)) + result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = if (isBackground) lastNonBgAgent ?: "bg" else agent, isBackground = isBackground, endTime = lineEndTimeMs)) } } @@ -769,6 +882,19 @@ object LyricsUtils { text = text.replaceFirst(BACKGROUND_REGEX, "") } + val endTimeMatch = TRAILING_TIME_REGEX.find(text) + var parsedEndTime: Long? = endTimeMatch?.let { match -> + val min = match.groupValues[1].toLong() + val sec = match.groupValues[2].toLong() + val milString = match.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) mil *= 10 + min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + } + if (endTimeMatch != null) { + text = text.replaceFirst(TRAILING_TIME_REGEX, "") + } + return timeMatchResults .map { timeMatchResult -> val min = timeMatchResult.groupValues[1].toLong() @@ -779,7 +905,7 @@ object LyricsUtils { mil *= 10 } val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil - LyricsEntry(time, text, words, agent = agent, isBackground = isBackground) + LyricsEntry(time, text, words, agent = agent, isBackground = isBackground, endTime = parsedEndTime) }.toList() } @@ -814,7 +940,9 @@ object LyricsUtils { if (line.time > position) break // Past current position, stop early // Determine this line's end time - val lineEndMs: Long = if (!line.words.isNullOrEmpty()) { + val lineEndMs: Long = if (line.endTime != null && line.endTime > line.time) { + line.endTime + } else if (!line.words.isNullOrEmpty()) { // Use last word's endTime converted to ms (line.words.last().endTime * 1000).toLong() } else { diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/ExperimentalLyrics.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/ExperimentalLyrics.kt index d6613dc8ca..b4b4c338fe 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/component/ExperimentalLyrics.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/ExperimentalLyrics.kt @@ -73,6 +73,7 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.stringResource import android.text.Layout import androidx.compose.ui.unit.Constraints @@ -138,6 +139,7 @@ private val LYRICS_FADE_BOTTOM_DP = 160.dp private const val LYRICS_STAGGER_DELAY_PER_DISTANCE = 20 private const val LYRICS_STAGGER_DELAY_MAX_MS = 200 private const val LYRICS_PREVIEW_TIME = 8000L +private const val LYRICS_SCROLL_DURATION = 750 @OptIn( ExperimentalMaterial3Api::class, @@ -353,7 +355,8 @@ fun ExperimentalLyrics( sliderPosition!! } else { val playerPos = playerConnection.player.currentPosition - if (playerPos != lastPlayerPos) { + val timeSinceUpdate = now - lastUpdateTime + if (playerPos != lastPlayerPos || timeSinceUpdate > 500L) { lastPlayerPos = playerPos lastUpdateTime = now } @@ -368,7 +371,7 @@ fun ExperimentalLyrics( val effectivePosition = position + lyricsOffset val initialActiveIndices = findActiveLineIndices(lines, effectivePosition) - val scrollActiveIndicesRaw = findActiveLineIndices(lines, effectivePosition + (if (hasWordTimings) 0L else 250L)) + val scrollActiveIndicesRaw = findActiveLineIndices(lines, effectivePosition + (LYRICS_SCROLL_DURATION / 2)) val scrollActiveIndices = scrollActiveIndicesRaw.toMutableSet() for (i in scrollActiveIndicesRaw) { @@ -497,60 +500,67 @@ fun ExperimentalLyrics( ) { val maxHeightPx = constraints.maxHeight.toFloat() val anchorY = maxHeightPx * LYRICS_ANCHOR_RATIO + val viewConfiguration = LocalViewConfiguration.current val lineHeightPx = with(density) { LYRICS_ITEM_FALLBACK_HEIGHT_DP.toPx() } val indicatorHeightPx = with(density) { 72.dp.toPx() } // Use a more permissive fallback for constraints to prevent "locks" if items are not measured yet val constraintLineHeightPx = with(density) { 120.dp.toPx() } - val positions = remember(itemHeights.toMap(), activeListIndex, mergedLyricsList) { - val map = mutableMapOf() - if (activeListIndex == -1 || mergedLyricsList.isEmpty()) return@remember map - - map[activeListIndex] = 0f - var currentY = 0f - for (i in activeListIndex - 1 downTo 0) { - val item = mergedLyricsList[i] - val height = itemHeights[i]?.toFloat() ?: (if (item is LyricsListItem.Indicator) indicatorHeightPx else lineHeightPx) - val noGap = (item as? LyricsListItem.Line)?.entry?.isBackground == true || item is LyricsListItem.Indicator - currentY -= (height + if (noGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }) - map[i] = currentY - } - currentY = 0f - for (i in activeListIndex until mergedLyricsList.size - 1) { - val currentItem = mergedLyricsList[i] - val nextItem = mergedLyricsList[i + 1] - val height = itemHeights[i]?.toFloat() ?: (if (currentItem is LyricsListItem.Indicator) indicatorHeightPx else lineHeightPx) - val nextNoGap = (nextItem as? LyricsListItem.Line)?.entry?.isBackground == true || nextItem is LyricsListItem.Indicator - currentY += (height + if (nextNoGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }) - map[i + 1] = currentY + val positions by remember(activeListIndex, mergedLyricsList) { + derivedStateOf { + val map = mutableMapOf() + if (activeListIndex == -1 || mergedLyricsList.isEmpty()) return@derivedStateOf map + + map[activeListIndex] = 0f + var currentY = 0f + for (i in activeListIndex - 1 downTo 0) { + val item = mergedLyricsList[i] + val height = itemHeights[i]?.toFloat() ?: (if (item is LyricsListItem.Indicator) indicatorHeightPx else lineHeightPx) + val noGap = (item as? LyricsListItem.Line)?.entry?.isBackground == true || item is LyricsListItem.Indicator + currentY -= (height + if (noGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }) + map[i] = currentY + } + currentY = 0f + for (i in activeListIndex until mergedLyricsList.size - 1) { + val currentItem = mergedLyricsList[i] + val nextItem = mergedLyricsList[i + 1] + val height = itemHeights[i]?.toFloat() ?: (if (currentItem is LyricsListItem.Indicator) indicatorHeightPx else lineHeightPx) + val nextNoGap = (nextItem as? LyricsListItem.Line)?.entry?.isBackground == true || nextItem is LyricsListItem.Indicator + currentY += (height + if (nextNoGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }) + map[i + 1] = currentY + } + map } - map } - val minOffset = remember(itemHeights.toMap(), mergedLyricsList, activeListIndex, anchorY) { - if (mergedLyricsList.isEmpty() || activeListIndex == -1) return@remember 0f - val totalBelow = (activeListIndex until mergedLyricsList.size - 1).sumOf { i -> - val currentItem = mergedLyricsList[i] - val nextItem = mergedLyricsList[i + 1] - val height = itemHeights[i]?.toFloat() ?: (if (currentItem is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) - val nextNoGap = (nextItem as? LyricsListItem.Line)?.entry?.isBackground == true || nextItem is LyricsListItem.Indicator - (height + if (nextNoGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }).toDouble() - }.toFloat() - val lastItem = mergedLyricsList.last() - val lastHeight = itemHeights[mergedLyricsList.size - 1]?.toFloat() ?: (if (lastItem is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) - with(density) { 100.dp.toPx() } - anchorY - totalBelow - lastHeight + val minOffset by remember(mergedLyricsList, activeListIndex, anchorY) { + derivedStateOf { + if (mergedLyricsList.isEmpty() || activeListIndex == -1) return@derivedStateOf 0f + val totalBelow = (activeListIndex until mergedLyricsList.size - 1).sumOf { i -> + val currentItem = mergedLyricsList[i] + val nextItem = mergedLyricsList[i + 1] + val height = itemHeights[i]?.toFloat() ?: (if (currentItem is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) + val nextNoGap = (nextItem as? LyricsListItem.Line)?.entry?.isBackground == true || nextItem is LyricsListItem.Indicator + (height + if (nextNoGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }).toDouble() + }.toFloat() + val lastItem = mergedLyricsList.last() + val lastHeight = itemHeights[mergedLyricsList.size - 1]?.toFloat() ?: (if (lastItem is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) + with(density) { 100.dp.toPx() } - anchorY - totalBelow - lastHeight + } } - val maxOffset = remember(itemHeights.toMap(), mergedLyricsList, activeListIndex, maxHeightPx, anchorY) { - if (mergedLyricsList.isEmpty() || activeListIndex == -1) return@remember 0f - val totalAbove = (0 until activeListIndex).sumOf { i -> - val item = mergedLyricsList[i] - val height = itemHeights[i]?.toFloat() ?: (if (item is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) - val noGap = (item as? LyricsListItem.Line)?.entry?.isBackground == true || item is LyricsListItem.Indicator - (height + if (noGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }).toDouble() - }.toFloat() - maxHeightPx - with(density) { 150.dp.toPx() } - anchorY + totalAbove + val maxOffset by remember(mergedLyricsList, activeListIndex, maxHeightPx, anchorY) { + derivedStateOf { + if (mergedLyricsList.isEmpty() || activeListIndex == -1) return@derivedStateOf 0f + val totalAbove = (0 until activeListIndex).sumOf { i -> + val item = mergedLyricsList[i] + val height = itemHeights[i]?.toFloat() ?: (if (item is LyricsListItem.Indicator) indicatorHeightPx else constraintLineHeightPx) + val noGap = (item as? LyricsListItem.Line)?.entry?.isBackground == true || item is LyricsListItem.Indicator + (height + if (noGap) 0f else with(density) { LYRICS_ITEM_GAP_DP.toPx() }).toDouble() + }.toFloat() + maxHeightPx - with(density) { 150.dp.toPx() } - anchorY + totalAbove + } } // Clamp to real content bounds only. minOffset/maxOffset already use conservative height @@ -586,10 +596,12 @@ fun ExperimentalLyrics( if (showLyrics && mergedLyricsList.isNotEmpty()) { isInitialLayout = true snapshotFlow { - val h = itemHeights.toMap() - val windowStart = (activeListIndex - 8).coerceAtLeast(0) - val windowEnd = (activeListIndex + 12).coerceAtMost(mergedLyricsList.size - 1) - (windowStart..windowEnd).all { h.containsKey(it) } + val visibleIndices = mergedLyricsList.indices.filter { i -> + val pos = positions[i] ?: return@filter false + val top = anchorY + pos + userManualOffset + top > -200f && top < maxHeightPx + 200f + } + visibleIndices.isNotEmpty() && visibleIndices.all { itemHeights.containsKey(it) } }.first { it } isInitialLayout = false } @@ -605,8 +617,12 @@ fun ExperimentalLyrics( if (needsMeasurement) { isInitialLayout = true snapshotFlow { - val hh = itemHeights.toMap() - (windowStart..windowEnd).all { hh.containsKey(it) } + val visibleIndices = mergedLyricsList.indices.filter { i -> + val pos = positions[i] ?: return@filter false + val top = anchorY + pos + userManualOffset + top > -200f && top < maxHeightPx + 200f + } + visibleIndices.isNotEmpty() && visibleIndices.all { itemHeights.containsKey(it) } }.first { it } isInitialLayout = false } @@ -688,20 +704,37 @@ fun ExperimentalLyrics( if (isInitialLayout) continue flingJob?.cancel() velocityTracker.resetTracking() - isAutoScrollEnabled = false - lastPreviewTime = System.currentTimeMillis() velocityTracker.addPosition(down.uptimeMillis, down.position) + + var didDrag = false + val downTime = System.currentTimeMillis() verticalDrag(down.id) { change -> - userManualOffset = (userManualOffset + change.positionChange().y).coerceIn(scrollClampMin, scrollClampMax) + val dy = change.positionChange().y + + // Commit to drag only if finger moved past slop and + // with sumn enough time passed to rule out a tap/long-press gesture. + // This handles the natural micro-movement fingers make even on a "still" press. + if (!didDrag && abs(dy) > viewConfiguration.touchSlop * 2f + && (System.currentTimeMillis() - downTime) > 80L) { + didDrag = true + isAutoScrollEnabled = false + lastPreviewTime = System.currentTimeMillis() + } + if (didDrag) { + userManualOffset = (userManualOffset + dy).coerceIn(scrollClampMin, scrollClampMax) + } velocityTracker.addPosition(change.uptimeMillis, change.position) change.consume() } - val velocity = velocityTracker.calculateVelocity().y - flingJob = scope.launch { - AnimationState(initialValue = userManualOffset, initialVelocity = velocity).animateDecay(decayAnimSpec) { - val clamped = value.coerceIn(scrollClampMin, scrollClampMax) - userManualOffset = clamped - if (value != clamped) cancelAnimation() + + if (didDrag) { + val velocity = velocityTracker.calculateVelocity().y + flingJob = scope.launch { + AnimationState(initialValue = userManualOffset, initialVelocity = velocity).animateDecay(decayAnimSpec) { + val clamped = value.coerceIn(scrollClampMin, scrollClampMax) + userManualOffset = clamped + if (value != clamped) cancelAnimation() + } } } } @@ -717,7 +750,7 @@ fun ExperimentalLyrics( targetValue = targetProviderBase, animationSpec = if (isInitialLayout || !isAutoScrollEnabled) snap() else { - tween(750, 0, FastOutSlowInEasing) + tween(LYRICS_SCROLL_DURATION, 0, FastOutSlowInEasing) }, label = "lyricsProviderOffset" ) @@ -738,79 +771,121 @@ fun ExperimentalLyrics( } val animatedOffset by animateFloatAsState( targetValue = if (isAutoScrollEnabled) targetOffset else frozenOffset.floatValue, - animationSpec = if (isInitialLayout || !isAutoScrollEnabled) snap() - else { - tween(750, (distance * LYRICS_STAGGER_DELAY_PER_DISTANCE).coerceAtMost(LYRICS_STAGGER_DELAY_MAX_MS), FastOutSlowInEasing) - }, + animationSpec = if (isInitialLayout || !isAutoScrollEnabled) snap() + else tween(LYRICS_SCROLL_DURATION, (distance * LYRICS_STAGGER_DELAY_PER_DISTANCE).coerceAtMost(LYRICS_STAGGER_DELAY_MAX_MS), FastOutSlowInEasing), label = "lyricStaggeredOffset_$listIndex" ) - + Box( - modifier = Modifier.fillMaxWidth().layout { m, c -> + modifier = Modifier.fillMaxWidth().layout { m, c -> val p = m.measure(c.copy(maxHeight = Constraints.Infinity)) layout(p.width, 0) { p.place(0, 0) } }.offset { IntOffset(0, (animatedOffset + userManualOffset).roundToInt()) } ) { - when (listItem) { - is LyricsListItem.Indicator -> { - val visible = - isAutoScrollEnabled && - currentPositionState >= listItem.gapStartMs && - currentPositionState <= listItem.gapEndMs - 650L - IntervalIndicator(listItem.gapStartMs, listItem.gapEndMs - 650L, currentPositionState, visible, expressiveAccent, - Modifier.fillMaxWidth().onSizeChanged { itemHeights[listIndex] = it.height }.padding(horizontal = 24.dp).wrapContentWidth(Alignment.CenterHorizontally)) + val isInViewport by remember(maxHeightPx) { + derivedStateOf { + val top = animatedOffset + userManualOffset + top > -2500f && top < maxHeightPx + 2500f } - is LyricsListItem.Line -> { - val index = listItem.index - val item = listItem.entry - val isActiveLine = activeLineIndices.contains(index) - val pairedMainLineIndex = if (item.isBackground) (index - 1 downTo 0).firstOrNull { lines.getOrNull(it)?.isBackground == false } ?: -1 else -1 - - val isInGapWithMain = if (item.isBackground && pairedMainLineIndex != -1) { - val pairedMainLine = lines[pairedMainLineIndex] - currentEffectivePosition >= pairedMainLine.time && currentEffectivePosition <= item.time - } else false - - val bgVisible = item.isBackground && (activeLineIndices.contains(pairedMainLineIndex) || activeLineIndices.contains(index) || isInGapWithMain) - - LyricsLine( - index = index, item = item, isSynced = isSynced, - isActiveLine = isActiveLine, - bgVisible = bgVisible, isSelected = selectedIndices.contains(index), - isSelectionModeActive = isSelectionModeActive, currentPositionState = currentPositionState, - lyricsOffset = (currentSong?.song?.lyricsOffset ?: 0).toLong(), - playerConnection = playerConnection, lyricsTextSize = 36f, lyricsLineSpacing = 1.3f, - expressiveAccent = expressiveAccent, lyricsTextPosition = lyricsTextPosition, - respectAgentPositioning = respectAgentPositioning, isAutoScrollEnabled = isAutoScrollEnabled, - displayedCurrentLineIndex = deferredCurrentLineIndex, romanizeAsMain = romanizeAsMain, - enabledLanguages = enabledLanguages, romanizeLyrics = currentSong?.romanizeLyrics == true, - onSizeChanged = { itemHeights[listIndex] = it }, - onClick = { - if (isSelectionModeActive) { - if (selectedIndices.contains(index)) { - selectedIndices.remove(index) - if (selectedIndices.isEmpty()) isSelectionModeActive = false - } else if (selectedIndices.size < maxSelectionLimit) selectedIndices.add(index) - else showMaxSelectionToast = true - } else if (changeLyrics && !isGuest) { - if (item.time < playerConnection.player.duration + 30000L) { - playerConnection.seekTo((item.time - (currentSong?.song?.lyricsOffset ?: 0)).coerceAtLeast(0)) - } else { - scrollTargetIndex = index - deferredCurrentLineIndex = index + } + + // Items near the active index that haven't been measured yet need + // an invisible pre-render so positions are accurate before they scroll in. + val hasMeasurement = itemHeights.containsKey(listIndex) + val needsPreRender = !hasMeasurement && distance < 30 + + when { + isInViewport -> { + // Normal visible render + when (listItem) { + is LyricsListItem.Indicator -> { + val visible = + isAutoScrollEnabled && + currentPositionState >= listItem.gapStartMs && + currentPositionState <= listItem.gapEndMs - 650L + IntervalIndicator(listItem.gapStartMs, listItem.gapEndMs - 650L, currentPositionState, visible, expressiveAccent, + Modifier.fillMaxWidth().onSizeChanged { itemHeights[listIndex] = it.height }.padding(horizontal = 24.dp).wrapContentWidth(Alignment.CenterHorizontally)) + } + is LyricsListItem.Line -> { + val index = listItem.index + val item = listItem.entry + val isActiveLine = activeLineIndices.contains(index) + val pairedMainLineIndex = if (item.isBackground) (index - 1 downTo 0).firstOrNull { lines.getOrNull(it)?.isBackground == false } ?: -1 else -1 + val isInGapWithMain = if (item.isBackground && pairedMainLineIndex != -1) { + val pairedMainLine = lines[pairedMainLineIndex] + currentEffectivePosition >= pairedMainLine.time && currentEffectivePosition <= item.time + } else false + val bgVisible = item.isBackground && (activeLineIndices.contains(pairedMainLineIndex) || activeLineIndices.contains(index) || isInGapWithMain) + + LyricsLine( + index = index, item = item, isSynced = isSynced, + isActiveLine = isActiveLine, + bgVisible = bgVisible, isSelected = selectedIndices.contains(index), + isSelectionModeActive = isSelectionModeActive, currentPositionState = currentPositionState, + lyricsOffset = (currentSong?.song?.lyricsOffset ?: 0).toLong(), + playerConnection = playerConnection, lyricsTextSize = 36f, lyricsLineSpacing = 1.3f, + expressiveAccent = expressiveAccent, lyricsTextPosition = lyricsTextPosition, + respectAgentPositioning = respectAgentPositioning, isAutoScrollEnabled = isAutoScrollEnabled, + displayedCurrentLineIndex = deferredCurrentLineIndex, romanizeAsMain = romanizeAsMain, + enabledLanguages = enabledLanguages, romanizeLyrics = currentSong?.romanizeLyrics == true, + onSizeChanged = { itemHeights[listIndex] = it }, + onClick = { + if (isSelectionModeActive) { + if (selectedIndices.contains(index)) { + selectedIndices.remove(index) + if (selectedIndices.isEmpty()) isSelectionModeActive = false + } else if (selectedIndices.size < maxSelectionLimit) selectedIndices.add(index) + else showMaxSelectionToast = true + } else if (changeLyrics && !isGuest) { + if (item.time < playerConnection.player.duration + 30000L) { + playerConnection.seekTo((item.time - (currentSong?.song?.lyricsOffset ?: 0)).coerceAtLeast(0)) + } else { + scrollTargetIndex = index + deferredCurrentLineIndex = index + } + isAutoScrollEnabled = true + lastPreviewTime = 0L + } + }, + onLongClick = { + if (!isGuest) { + isSelectionModeActive = true + selectedIndices.add(index) + } } - isAutoScrollEnabled = true - lastPreviewTime = 0L + ) + } + } + } + + needsPreRender -> { + // Invisible render purely for measurement, there is no interactions, no visuals. + // Once itemHeights[listIndex] is set this branch is skipped entirely. + Box(modifier = Modifier.alpha(0f)) { + when (listItem) { + is LyricsListItem.Indicator -> { + IntervalIndicator(listItem.gapStartMs, listItem.gapEndMs - 650L, 0L, false, expressiveAccent, + Modifier.fillMaxWidth().onSizeChanged { itemHeights[listIndex] = it.height }.padding(horizontal = 24.dp).wrapContentWidth(Alignment.CenterHorizontally)) } - }, - onLongClick = { - if (!isSelectionModeActive) { - isSelectionModeActive = true - selectedIndices.add(index) + is LyricsListItem.Line -> { + LyricsLine( + index = listItem.index, item = listItem.entry, isSynced = isSynced, + isActiveLine = false, bgVisible = false, isSelected = false, + isSelectionModeActive = false, currentPositionState = 0L, + lyricsOffset = 0L, playerConnection = playerConnection, + lyricsTextSize = 36f, lyricsLineSpacing = 1.3f, + expressiveAccent = expressiveAccent, lyricsTextPosition = lyricsTextPosition, + respectAgentPositioning = respectAgentPositioning, isAutoScrollEnabled = false, + displayedCurrentLineIndex = -1, romanizeAsMain = romanizeAsMain, + enabledLanguages = enabledLanguages, romanizeLyrics = currentSong?.romanizeLyrics == true, + onSizeChanged = { itemHeights[listIndex] = it }, + onClick = {}, onLongClick = {} + ) } } - ) + } } + // else: far off-screen and already measured,ig skip composition entirely } } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/LyricsLine.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/LyricsLine.kt index 72478c1a37..90a22a4b46 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/component/LyricsLine.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/LyricsLine.kt @@ -6,6 +6,7 @@ package com.metrolist.music.ui.component import android.graphics.BlurMaskFilter +import java.text.BreakIterator import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -138,6 +139,12 @@ internal fun LyricsLine( ) val agentAlignment = when { + respectAgentPositioning && item.linePosition != null -> when (item.linePosition) { + LyricsPosition.LEFT -> Alignment.Start + LyricsPosition.RIGHT -> Alignment.End + LyricsPosition.CENTER -> Alignment.CenterHorizontally + else -> Alignment.CenterHorizontally + } respectAgentPositioning && item.agent == "v1" -> Alignment.Start respectAgentPositioning && item.agent == "v2" -> Alignment.End respectAgentPositioning && item.agent == "v1000" -> Alignment.CenterHorizontally @@ -150,6 +157,12 @@ internal fun LyricsLine( } val agentTextAlign = when { + respectAgentPositioning && item.linePosition != null -> when (item.linePosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.RIGHT -> TextAlign.Right + LyricsPosition.CENTER -> TextAlign.Center + else -> TextAlign.Center + } respectAgentPositioning && item.agent == "v1" -> TextAlign.Left respectAgentPositioning && item.agent == "v2" -> TextAlign.Right respectAgentPositioning && item.agent == "v1000" -> TextAlign.Center @@ -213,21 +226,6 @@ internal fun LyricsLine( val effectiveWords = if (item.words?.isNotEmpty() == true) { item.words - } else if (mainText != null) { - remember(mainText, item.time) { - val words = mainText.split(Regex("\\s+")).filter { it.isNotBlank() } - val wordDurationSec = 0.18 - val wordStaggerSec = 0.03 - val startTimeSec = item.time / 1000.0 - words.mapIndexed { idx, wordText -> - WordTimestamp( - text = wordText, - startTime = startTimeSec + (idx * wordStaggerSec), - endTime = startTimeSec + (idx * wordStaggerSec) + wordDurationSec, - hasTrailingSpace = idx < words.size - 1 - ) - } - } } else null if (isSynced && effectiveWords != null && (isActiveLine || abs(index - displayedCurrentLineIndex) <= 3) && mainText != null) { @@ -442,8 +440,22 @@ private fun WordLevelLyrics( ) } - val letterLayouts = remember(mainText, lyricStyle) { - mainText.map { textMeasurer.measure(it.toString(), lyricStyle) } + val clusters = remember(mainText) { + val list = mutableListOf>() + val it = BreakIterator.getCharacterInstance() + it.setText(mainText) + var s = it.first() + var e = it.next() + while (e != BreakIterator.DONE) { + list.add(s until e to mainText.substring(s, e)) + s = e + e = it.next() + } + list + } + + val clusterLayouts = remember(clusters, lyricStyle) { + clusters.map { (_, text) -> textMeasurer.measure(text, lyricStyle) } } val isRtlText = remember(mainText) { mainText.containsRtl() } @@ -481,13 +493,16 @@ private fun WordLevelLyrics( var bottom = Float.MIN_VALUE var found = false - for (i in mainText.indices) { + clusters.forEach { (range, _) -> + val i = range.first if (wordIdxMap[i] == wIdx) { - val bounds = layoutResult.getBoundingBox(i) - left = minOf(left, bounds.left) - right = maxOf(right, bounds.right) - top = minOf(top, bounds.top) - bottom = maxOf(bottom, bounds.bottom) + for (idx in range) { + val bounds = layoutResult.getBoundingBox(idx) + left = minOf(left, bounds.left) + right = maxOf(right, bounds.right) + top = minOf(top, bounds.top) + bottom = maxOf(bottom, bounds.bottom) + } found = true } } @@ -533,13 +548,14 @@ private fun WordLevelLyrics( val lineCurrentPushes = FloatArray(layoutResult.lineCount) val lineTotalPushes = FloatArray(layoutResult.lineCount) - // Pre-calculate total pushes per line to handle alignment correctly - for (i in mainText.indices) { + // Pre-calculate total pushes per cluster to handle alignment correctly + clusters.forEachIndexed { clusterIdx, (range, _) -> + val i = range.first val lineIdx = layoutResult.getLineForOffset(i) val wordIdx = wordIdxMap[i] val originalWordIdx = if (wordIdx != -1) effectiveToOriginalIdx[wordIdx] else -1 - val (sungFactor, wordItem, isWordSung) = if (wordIdx != -1) wordFactors[wordIdx] else Triple(0f, null, false) + val (sungFactor, wordItem, _) = if (wordIdx != -1) wordFactors[wordIdx] else Triple(0f, null, false) val wobble = if (originalWordIdx != -1) wordWobbles[originalWordIdx] else 0f var crescendoDeltaX = 0f @@ -576,18 +592,41 @@ private fun WordLevelLyrics( ((wProg - cInW / wLen) * wLen).coerceIn(0.0, 1.0).toFloat() } else 0f - val nudgeScale = if (wordItem != null && !isWordSung && sungFactor > 0f) { + val nudgeScale = if (wordItem != null && !wordFactors[wordIdx].third && sungFactor > 0f) { 0.038f * sin(charLp * PI.toFloat()) * exp(-3f * charLp) } else 0f val charScaleX = 1f + (wobble * 0.025f) + crescendoDeltaX + (nudgeScale * 0.3f) - val charBounds = layoutResult.getBoundingBox(i) - lineTotalPushes[lineIdx] += charBounds.width * (charScaleX - 1f) + + var clusterLeft = Float.MAX_VALUE + var clusterRight = Float.MIN_VALUE + for (idx in range) { + val bounds = layoutResult.getBoundingBox(idx) + clusterLeft = minOf(clusterLeft, bounds.left) + clusterRight = maxOf(clusterRight, bounds.right) + } + val clusterWidth = clusterRight - clusterLeft + lineTotalPushes[lineIdx] += clusterWidth * (charScaleX - 1f) } - for (i in mainText.indices) { + clusters.forEachIndexed { clusterIdx, (range, clusterText) -> + val i = range.first val lineIdx = layoutResult.getLineForOffset(i) - val charBounds = layoutResult.getBoundingBox(i) + + var clusterLeft = Float.MAX_VALUE + var clusterRight = Float.MIN_VALUE + var clusterTop = Float.MAX_VALUE + var clusterBottom = Float.MIN_VALUE + for (idx in range) { + val bounds = layoutResult.getBoundingBox(idx) + clusterLeft = minOf(clusterLeft, bounds.left) + clusterRight = maxOf(clusterRight, bounds.right) + clusterTop = minOf(clusterTop, bounds.top) + clusterBottom = maxOf(clusterBottom, bounds.bottom) + } + val clusterWidth = clusterRight - clusterLeft + val clusterHeight = clusterBottom - clusterTop + val wordIdx = wordIdxMap[i] val originalWordIdx = if (wordIdx != -1) effectiveToOriginalIdx[wordIdx] else -1 @@ -663,17 +702,17 @@ private fun WordLevelLyrics( if (waveFade > 0.01f) { val waveSpeed = 0.006f val waveHeight = 3.24f - val phaseOffset = i * 0.4f + val phaseOffset = clusterIdx * 0.4f waveOffset = sin(wallTime * waveSpeed + phaseOffset) * waveHeight * waveFade } } - translate(left = alignShift + lineCurrentPushes[lineIdx] + charBounds.left, top = charBounds.top + waveOffset) + translate(left = alignShift + lineCurrentPushes[lineIdx] + clusterLeft, top = clusterTop + waveOffset) if (wordIdx != -1) { scale( charScaleX, charScaleY, - pivot = Offset(charBounds.width / 2f, charBounds.height) + pivot = Offset(clusterWidth / 2f, clusterHeight) ) } }) { @@ -693,29 +732,29 @@ private fun WordLevelLyrics( glowPaint.color = expressiveAccent.copy(alpha = glowAlpha).toArgb() glowPaint.textSize = lyricStyle.fontSize.toPx() glowPaint.typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) - canvas.nativeCanvas.drawText(letterLayouts[i].layoutInput.text.text, 0f, letterLayouts[i].firstBaseline, glowPaint) + canvas.nativeCanvas.drawText(clusterLayouts[clusterIdx].layoutInput.text.text, 0f, clusterLayouts[clusterIdx].firstBaseline, glowPaint) } } } val baseAlpha = if (isWordSung || charLp > 0.99f) 1f else (focusedAlpha + (1f - focusedAlpha) * sungFactor) - drawText(letterLayouts[i], color = expressiveAccent.copy(alpha = if (wordIdx == -1) focusedAlpha else baseAlpha)) + drawText(clusterLayouts[clusterIdx], color = expressiveAccent.copy(alpha = if (wordIdx == -1) focusedAlpha else baseAlpha)) if (!isWordSung && charLp > 0f && charLp < 1f) { - val fXL = charBounds.width * charLp - val eW = (charBounds.width * 0.45f).coerceAtLeast(1f) + val fXL = clusterWidth * charLp + val eW = (clusterWidth * 0.45f).coerceAtLeast(1f) val sWL = (fXL - eW).coerceAtLeast(0f) if (sWL > 0f) { - clipRect(left = 0f, top = 0f, right = sWL, bottom = charBounds.height) { drawText(letterLayouts[i], color = expressiveAccent) } + clipRect(left = 0f, top = 0f, right = sWL, bottom = clusterHeight) { drawText(clusterLayouts[clusterIdx], color = expressiveAccent) } } for (j in 0 until 12) { val start = sWL + (j * eW / 12f) val end = (sWL + ((j + 1) * eW / 12f) + 0.5f).coerceAtMost(fXL) if (end > start) { - clipRect(left = start, top = 0f, right = end, bottom = charBounds.height) { drawText(letterLayouts[i], color = expressiveAccent.copy(alpha = 1f - (j + 0.5f) / 12f)) } + clipRect(left = start, top = 0f, right = end, bottom = clusterHeight) { drawText(clusterLayouts[clusterIdx], color = expressiveAccent.copy(alpha = 1f - (j + 0.5f) / 12f)) } } } } } - lineCurrentPushes[lineIdx] += charBounds.width * (charScaleX - 1f) + lineCurrentPushes[lineIdx] += clusterWidth * (charScaleX - 1f) } } } diff --git a/app/src/main/kotlin/com/metrolist/music/viewmodels/LyricsViewModel.kt b/app/src/main/kotlin/com/metrolist/music/viewmodels/LyricsViewModel.kt index b601fa0684..787781eff2 100644 --- a/app/src/main/kotlin/com/metrolist/music/viewmodels/LyricsViewModel.kt +++ b/app/src/main/kotlin/com/metrolist/music/viewmodels/LyricsViewModel.kt @@ -94,6 +94,8 @@ class LyricsViewModel @Inject constructor() : ViewModel() { val nextStart = lines[i + 1].time val currentEnd = if (!entry.words.isNullOrEmpty()) { (entry.words.last().endTime * 1000).toLong() + } else if (entry.endTime != null) { + entry.endTime } else if (entry.text.isBlank()) { entry.time } else { diff --git a/betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/TTMLParser.kt b/betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/TTMLParser.kt index 63b0075378..c852699aef 100644 --- a/betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/TTMLParser.kt +++ b/betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/TTMLParser.kt @@ -13,11 +13,18 @@ object TTMLParser { data class ParsedLine( val text: String, val startTime: Double, + val endTime: Double? = null, val words: List, val agent: String? = null, val isBackground: Boolean = false, val backgroundLines: List = emptyList() ) + + data class TTMLAgent( + val id: String, + val type: String? = null, + val name: String? = null + ) data class ParsedWord( val text: String, @@ -73,7 +80,7 @@ object TTMLParser { return best } - fun parseTTML(ttml: String): List { + fun parseTTML(ttml: String, agentsOut: MutableMap? = null): List { val lines = mutableListOf() try { val factory = DocumentBuilderFactory.newInstance() @@ -92,6 +99,22 @@ object TTMLParser { if (head != null) { val meta = findChild(head, "metadata") if (meta != null) { + var child = meta.firstChild + while (child != null) { + if (child is Element) { + val name = child.localName ?: child.nodeName.substringAfterLast(':') + if (name == "agent") { + val id = child.getAttribute("xml:id").ifEmpty { child.getAttribute("id") } + val type = child.getAttribute("type") + val agentName = findChild(child, "name")?.textContent + if (id.isNotEmpty()) { + agentsOut?.put(id, TTMLAgent(id, type, agentName)) + } + } + } + child = child.nextSibling + } + val audio = findChild(meta, "audio") if (audio != null) { globalOffset = audio.getAttribute("lyricOffset").toDoubleOrNull() ?: 0.0 @@ -154,6 +177,8 @@ object TTMLParser { } val startTime = parseTime(begin) + offset + val end = timingAttr(p, "end") + val endTime = parseTime(end) + offset val spanInfos = mutableListOf() val backgroundLines = mutableListOf() @@ -187,15 +212,17 @@ object TTMLParser { listOf(ParsedLine( text = backgroundLines.joinToString(" ") { it.text }, startTime = backgroundLines.minOf { it.startTime }, + endTime = null, words = backgroundLines.flatMap { it.words }, isBackground = true )) } else emptyList() - lines.add(ParsedLine(lineText, startTime, words, agent, isPBackground, bgLines)) + lines.add(ParsedLine(lineText, startTime, endTime, words, agent, isPBackground, bgLines)) } else if (backgroundLines.isNotEmpty()) { lines.add(ParsedLine( text = backgroundLines.joinToString(" ") { it.text }, startTime = backgroundLines.minOf { it.startTime }, + endTime = null, words = backgroundLines.flatMap { it.words }, isBackground = true )) @@ -235,12 +262,12 @@ object TTMLParser { if (!hasSpans) { val text = span.textContent?.trim() ?: "" - return ParsedLine(text, start, emptyList(), isBackground = true) + return ParsedLine(text, start, null, emptyList(), isBackground = true) } val words = mergeSpansIntoWords(spanInfos) val text = if (words.isEmpty()) getDirectText(span).trim() else buildLineText(words) - return ParsedLine(text, start, words, isBackground = true) + return ParsedLine(text, start, null, words, isBackground = true) } private fun getDirectText(el: Element): String { @@ -291,43 +318,24 @@ object TTMLParser { return words.map { it.copy(text = it.text.trim()) }.filter { it.text.isNotEmpty() } } - fun toLRC(lines: List): String { + fun toLRC(lines: List, agents: Map = emptyMap()): String { val agentMap = mutableMapOf() - // Phase 1: Preserve explicit v1, v2, v1000 - lines.forEach { line -> - line.agent?.lowercase()?.let { raw -> - if (raw == "v1" || raw == "v2" || raw == "v1000") { - agentMap[raw] = raw - } - } - } - - // Phase 2: Map other agents to v1/v2 if available - var nextNum = 1 lines.forEach { line -> line.agent?.lowercase()?.let { raw -> if (!agentMap.containsKey(raw)) { - while (nextNum <= 2 && (agentMap.containsKey("v$nextNum") || agentMap.values.contains("v$nextNum"))) { - nextNum++ - } - agentMap[raw] = if (nextNum <= 2) "v$nextNum" else "v1" + agentMap[raw] = raw } } } - // v1000 (group) shares display slot with v2 when a primary v1 vocalist exists - if (agentMap.containsKey("v1000") && agentMap.containsKey("v1")) { - agentMap["v1000"] = "v2" + val sb = StringBuilder(lines.size * 128) + + // Append agent headers + agents.forEach { (id, info) -> + sb.append("[agent:$id:${info.type ?: "person"}:${info.name ?: ""}]\n") } - val hasBackgroundLine = lines.any { it.isBackground } - val multi = - agentMap.size > 1 || - (agentMap.size == 1 && !agentMap.containsKey("v1")) || - (hasBackgroundLine && agentMap.size == 1 && agentMap.containsKey("v1")) - - val sb = StringBuilder(lines.size * 128) var lastBg = false lines.forEach { line -> val time = formatLrcTime(line.startTime) @@ -337,12 +345,16 @@ object TTMLParser { val agentId = agentMap[line.agent?.lowercase()] val tag = when { isBg -> if (lastBg) "" else "{bg}" - multi && agentId != null -> "{agent:$agentId}" + !agentId.isNullOrBlank() -> "{agent:$agentId}" else -> "" } if (isBg) lastBg = true - sb.append(time).append(tag).append(line.text).append('\n') + val trailing = if (line.words.isEmpty() && line.endTime != null && line.endTime > line.startTime) { + formatLrcTime(line.endTime) + } else "" + + sb.append(time).append(tag).append(line.text).append(trailing).append('\n') if (line.words.isNotEmpty()) { sb.append('<') line.words.forEachIndexed { i, w -> diff --git a/paxsenix/src/main/kotlin/com/metrolist/paxsenix/Paxsenix.kt b/paxsenix/src/main/kotlin/com/metrolist/paxsenix/Paxsenix.kt index f1b5be4e85..585277fad7 100644 --- a/paxsenix/src/main/kotlin/com/metrolist/paxsenix/Paxsenix.kt +++ b/paxsenix/src/main/kotlin/com/metrolist/paxsenix/Paxsenix.kt @@ -310,17 +310,21 @@ object Paxsenix { } val lrc = buildString { + // Agent headers + appendLine("[agent:v1:person:]") + appendLine("[agent:v2:person:]") + response.content.forEach { line -> val timeMs = line.timestamp val minutes = timeMs / 1000 / 60 val seconds = (timeMs / 1000) % 60 val centiseconds = (timeMs % 1000) / 10 - val agent = when { - line.background -> "{bg}" - line.oppositeTurn -> "{agent:v2}" - else -> "{agent:v1}" + val agentAlias = when { + line.oppositeTurn -> "v2" + else -> "v1" } + val agent = if (line.background) "{bg}" else "{agent:$agentAlias}" val lineText = line.text.joinToString(" ") { it.text } @@ -399,8 +403,9 @@ object Paxsenix { */ private fun convertTTMLToAppFormat(ttml: String): String { return try { - val parsedLines = TTMLParser.parseTTML(ttml) - TTMLParser.toLRC(parsedLines) + val agents = mutableMapOf() + val parsedLines = TTMLParser.parseTTML(ttml, agents) + TTMLParser.toLRC(parsedLines, agents) } catch (e: Exception) { Timber.e(e, "TTML conversion failed: ${e.message}") ""