diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt index 3c44e7d595..21df589aff 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/LyricsUtils.kt @@ -704,11 +704,11 @@ private fun String.capitalizeFirstLetter(): String { object LyricsUtils { - private val LRC_LINE_REGEX = Pattern.compile("^\\[(\\d{2}):(\\d{2})[.:](\\d{2,3})](.*)$") - private val LRC_WORD_REGEX = Pattern.compile("<(\\d{2}):(\\d{2})[.:](\\d{2,3})>([^<]*)") - private val LRC_WORD_TAG_REGEX = Regex("<\\d{2}:\\d{2}[.:]\\d{2,3}>") - private val LRC_WORD_SPLIT_REGEX = Regex("(?=<\\d{2}:\\d{2}[.:]\\d{2,3}>)") - private val LRC_TIMESTAMP_TAG_REGEX = Regex("\\[\\d{1,2}:\\d{2}(?:[.:]\\d{1,3})?]") + private val LRC_LINE_REGEX = Pattern.compile("^\\[(?:(\\d+):)?(\\d+):(\\d{2})[.:](\\d{2,3})](.*)$") + private val LRC_WORD_REGEX = Pattern.compile("<(?:(\\d+):)?(\\d+):(\\d{2})[.:](\\d{2,3})>([^<]*)") + private val LRC_WORD_TAG_REGEX = Regex("<(?:\\d+:)?\\d+:\\d{2}[.:]\\d{2,3}>") + private val LRC_WORD_SPLIT_REGEX = Regex("(?=<(?:\\d+:)?\\d+:\\d{2}[.:]\\d{2,3}>)") + private val LRC_TIMESTAMP_TAG_REGEX = Regex("\\[(?:\\d+:)?\\d+:\\d{2}(?:[.:]\\d{1,3})?]") private val TRANSLATION_CREDIT_REGEX = Regex("^\\s*by\\s*[::].+", RegexOption.IGNORE_CASE) private val LRC_METADATA_PATTERN = Pattern.compile("^\\[[a-zA-Z]+:.*]$") @@ -758,14 +758,15 @@ object LyricsUtils { val lineMatcher = LRC_LINE_REGEX.matcher(line) if (lineMatcher.matches()) { isSynced = true - val minutes = lineMatcher.group(1)?.toLong() ?: 0 - val seconds = lineMatcher.group(2)?.toLong() ?: 0 - val fraction = lineMatcher.group(3)?.toLong() ?: 0 - val textWithTags = stripFormatCharacters(lineMatcher.group(4)?.trim() ?: "") + val hours = lineMatcher.group(1)?.toLong() ?: 0 + val minutes = lineMatcher.group(2)?.toLong() ?: 0 + val seconds = lineMatcher.group(3)?.toLong() ?: 0 + val fraction = lineMatcher.group(4)?.toLong() ?: 0 + val textWithTags = stripFormatCharacters(lineMatcher.group(5)?.trim() ?: "") val text = stripLrcTimestamps(textWithTags) - val millis = if (lineMatcher.group(3)?.length == 2) fraction * 10 else fraction - val lineTimestamp = minutes * 60 * 1000 + seconds * 1000 + millis + val millis = if (lineMatcher.group(4)?.length == 2) fraction * 10 else fraction + val lineTimestamp = hours * 3600 * 1000 + minutes * 60 * 1000 + seconds * 1000 + millis // Enhanced word-by-word parsing if (text.contains(LRC_WORD_TAG_REGEX)) { @@ -778,10 +779,11 @@ object LyricsUtils { if (part.isEmpty()) continue val wordMatcher = LRC_WORD_REGEX.matcher(part) if (wordMatcher.find()) { - val wordMinutes = wordMatcher.group(1)?.toLong() ?: 0 - val wordSeconds = wordMatcher.group(2)?.toLong() ?: 0 - val wordFraction = wordMatcher.group(3)?.toLong() ?: 0 - val wordText = stripFormatCharacters(wordMatcher.group(4) ?: "") + val wordHours = wordMatcher.group(1)?.toLong() ?: 0 + val wordMinutes = wordMatcher.group(2)?.toLong() ?: 0 + val wordSeconds = wordMatcher.group(3)?.toLong() ?: 0 + val wordFraction = wordMatcher.group(4)?.toLong() ?: 0 + val wordText = stripFormatCharacters(wordMatcher.group(5) ?: "") val timedWordTextRaw = wordText .substringBefore('\n') .substringBefore('\r') @@ -792,8 +794,8 @@ object LyricsUtils { timedWordTextRaw.firstOrNull()?.isWhitespace() == true val timedWordText = timedWordTextRaw.trim() pendingWordBoundary = timedWordTextRaw.lastOrNull()?.isWhitespace() == true - val wordMillis = if (wordMatcher.group(3)?.length == 2) wordFraction * 10 else wordFraction - val wordTimestamp = wordMinutes * 60 * 1000 + wordSeconds * 1000 + wordMillis + val wordMillis = if (wordMatcher.group(4)?.length == 2) wordFraction * 10 else wordFraction + val wordTimestamp = wordHours * 3600 * 1000 + wordMinutes * 60 * 1000 + wordSeconds * 1000 + wordMillis if (timedWordText.isNotEmpty()) { words.add( SyncedWord( @@ -1053,10 +1055,15 @@ object LyricsUtils { fun syncedToLrcString(syncedLines: List): String { return syncedLines.sortedBy { it.time }.flatMap { line -> val totalMs = line.time - val minutes = totalMs / 60000 + val hours = totalMs / 3600000 + val minutes = (totalMs % 3600000) / 60000 val seconds = (totalMs % 60000) / 1000 val hundredths = (totalMs % 1000) / 10 - val timestamp = "[%02d:%02d.%02d]".format(minutes, seconds, hundredths) + val timestamp = if (hours > 0) { + "[%02d:%02d:%02d.%02d]".format(hours, minutes, seconds, hundredths) + } else { + "[%02d:%02d.%02d]".format(minutes, seconds, hundredths) + } buildList { add("$timestamp${line.line}") if (!line.translation.isNullOrBlank()) { diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/LyricsUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/LyricsUtilsTest.kt index e233f92b1f..0559855354 100644 --- a/app/src/test/java/com/theveloper/pixelplay/utils/LyricsUtilsTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/utils/LyricsUtilsTest.kt @@ -418,4 +418,54 @@ class LyricsUtilsTest { lrc ) } + + @Test + fun parseLyrics_supportsOverOneHour_minutesFormat() { + // 70 minutes = 70 * 60 * 1000 = 4,200,000 ms + val lrc = "[70:00.00]Long track line" + val lyrics = LyricsUtils.parseLyrics(lrc) + val synced = requireNotNull(lyrics.synced) + + assertEquals(1, synced.size) + assertEquals(4_200_000, synced[0].time) + assertEquals("Long track line", synced[0].line) + } + + @Test + fun parseLyrics_supportsOverOneHour_hoursFormat() { + // 01:10:00.00 = 1h 10m = 70 minutes = 4,200,000 ms + val lrc = "[01:10:00.00]Long track line with hours" + val lyrics = LyricsUtils.parseLyrics(lrc) + val synced = requireNotNull(lyrics.synced) + + assertEquals(1, synced.size) + assertEquals(4_200_000, synced[0].time) + assertEquals("Long track line with hours", synced[0].line) + } + + @Test + fun parseLyrics_wordByWord_supportsOverOneHour() { + // [01:00:00.00] = 3,600,000 ms + // <01:00:05.00> = 3,605,000 ms + val lrc = "[01:00:00.00]<01:00:00.00>Start <01:00:05.00>Later" + val lyrics = LyricsUtils.parseLyrics(lrc) + val synced = requireNotNull(lyrics.synced) + val words = requireNotNull(synced.single().words) + + assertEquals(3_600_000, synced[0].time) + assertEquals(3_600_000, words[0].time) + assertEquals(3_605_000, words[1].time) + } + + @Test + fun syncedToLrcString_usesHourFormatWhenNeeded() { + val synced = listOf( + com.theveloper.pixelplay.data.model.SyncedLine( + time = 4_200_000, // 70 minutes + line = "Over one hour" + ) + ) + val lrc = LyricsUtils.syncedToLrcString(synced) + assertEquals("[01:10:00.00]Over one hour", lrc) + } }