From 1928a7ba4d7887e766fd2a034c5a64f625de8cbd Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 19 Jun 2026 14:14:31 +0200 Subject: [PATCH] new: add subtitle parsing with LRC format integration --- .../kotlin/fr/ajaury/gwenedeg/di/Modules.kt | 7 +++ .../gwenedeg/subtitle/data/LrcParser.kt | 41 +++++++++++++++ .../subtitle/data/LrcSubtitleRepository.kt | 21 ++++++++ .../subtitle/domain/GetSubtitleTextUseCase.kt | 16 ++++++ .../gwenedeg/subtitle/domain/Subtitle.kt | 5 ++ .../gwenedeg/subtitle/domain/SubtitleLine.kt | 7 +++ .../subtitle/domain/SubtitleRepository.kt | 5 ++ .../model/CantGetSubtitleException.kt | 5 ++ .../subtitle/GetSubtitleTextUseCaseTest.kt | 45 ++++++++++++++++ .../ajaury/gwenedeg/subtitle/LrcParserTest.kt | 52 +++++++++++++++++++ 10 files changed, 204 insertions(+) create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/Subtitle.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/model/CantGetSubtitleException.kt create mode 100644 shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt create mode 100644 shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt index a6f88d4..a937a06 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/di/Modules.kt @@ -6,6 +6,10 @@ import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository import fr.ajaury.gwenedeg.records.domain.RecordRepository import fr.ajaury.gwenedeg.records.ui.viewmodel.RecordsViewModel +import fr.ajaury.gwenedeg.subtitle.data.LrcParser +import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository +import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleTextUseCase +import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.viewModelOf @@ -14,6 +18,9 @@ import org.koin.dsl.module val sharedModule = module { single { KermitLogger() } factoryOf(::InMemoryRecordRepository) { bind() } + factoryOf(::LrcParser) + factoryOf(::LrcSubtitleRepository) { bind() } + factoryOf(::GetSubtitleTextUseCase) viewModelOf(::RecordsViewModel) viewModelOf(::PlayerViewModel) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt new file mode 100644 index 0000000..5ea004c --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcParser.kt @@ -0,0 +1,41 @@ +package fr.ajaury.gwenedeg.subtitle.data + +import fr.ajaury.gwenedeg.subtitle.domain.Subtitle +import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine + +/** + * Parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) subtitle content. + * + * Only numeric timestamp tags `[mm:ss.xx]` / `[mm:ss.xxx]` are kept. A line may carry several + * leading timestamps that all share the same text. + */ +class LrcParser { + private val timeTag = Regex("""\[(\d{1,2}):(\d{2})[.:](\d{2,3})]""") + private val leadingTimeTags = Regex("""^(?:\[\d{1,2}:\d{2}[.:]\d{2,3}])+""") + + fun parse(content: String): Subtitle { + val lines = content + .lineSequence() + .flatMap { line -> parseLine(line) } + .sortedBy { it.startMs } + .toList() + return Subtitle(lines) + } + + private fun parseLine(line: String): List { + val tags = leadingTimeTags.find(line) ?: return emptyList() + val text = line.substring(tags.range.last + 1).trim() + return timeTag + .findAll(tags.value) + .mapNotNull { match -> + SubtitleLine(startMs = match.toMs(), text = text) + .takeIf { it.text.isNotBlank() } + }.toList() + } + + private fun MatchResult.toMs(): Long { + val (minutes, seconds, fraction) = destructured + val fractionMs = if (fraction.length == 2) fraction.toLong() * 10 else fraction.toLong() + return minutes.toLong() * 60_000 + seconds.toLong() * 1_000 + fractionMs + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt new file mode 100644 index 0000000..0d10e52 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/data/LrcSubtitleRepository.kt @@ -0,0 +1,21 @@ +package fr.ajaury.gwenedeg.subtitle.data + +import fr.ajaury.gwenedeg.core.logging.Logger +import fr.ajaury.gwenedeg.subtitle.domain.Subtitle +import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository +import fr.ajaury.gwenedeg.subtitle.model.CantGetSubtitleException +import gwenedeg.shared.generated.resources.Res + +class LrcSubtitleRepository( + private val parser: LrcParser, + private val logger: Logger, +) : SubtitleRepository { + override suspend fun getSubtitle(resourcePath: String): Subtitle = + try { + parser.parse(Res.readBytes(resourcePath).decodeToString()) + } catch (exception: Exception) { + val message = "Failed to load subtitle: $resourcePath" + logger.error(message = message, throwable = exception) + throw CantGetSubtitleException(message) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt new file mode 100644 index 0000000..762aced --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/GetSubtitleTextUseCase.kt @@ -0,0 +1,16 @@ +package fr.ajaury.gwenedeg.subtitle.domain + +/** + * Resolves the subtitle text to display for a given playback progress: the latest cue whose start + * is at or before [progressMs], or `null` when no cue has started yet. + */ +class GetSubtitleTextUseCase { + operator fun invoke( + subtitle: Subtitle, + progressMs: Long, + ): String = + subtitle.lines + .lastOrNull { it.startMs <= progressMs } + ?.text + .orEmpty() +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/Subtitle.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/Subtitle.kt new file mode 100644 index 0000000..d2f331c --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/Subtitle.kt @@ -0,0 +1,5 @@ +package fr.ajaury.gwenedeg.subtitle.domain + +data class Subtitle( + val lines: List, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt new file mode 100644 index 0000000..d66b340 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleLine.kt @@ -0,0 +1,7 @@ +package fr.ajaury.gwenedeg.subtitle.domain + +/** A single timed subtitle cue: [text] becomes active at [startMs]. */ +data class SubtitleLine( + val startMs: Long, + val text: String, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt new file mode 100644 index 0000000..397c832 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/domain/SubtitleRepository.kt @@ -0,0 +1,5 @@ +package fr.ajaury.gwenedeg.subtitle.domain + +interface SubtitleRepository { + suspend fun getSubtitle(resourcePath: String): Subtitle +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/model/CantGetSubtitleException.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/model/CantGetSubtitleException.kt new file mode 100644 index 0000000..18b3a43 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/subtitle/model/CantGetSubtitleException.kt @@ -0,0 +1,5 @@ +package fr.ajaury.gwenedeg.subtitle.model + +data class CantGetSubtitleException( + override val message: String, +) : Exception() diff --git a/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt new file mode 100644 index 0000000..7cadbca --- /dev/null +++ b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/GetSubtitleTextUseCaseTest.kt @@ -0,0 +1,45 @@ +package fr.ajaury.gwenedeg.subtitle + +import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleTextUseCase +import fr.ajaury.gwenedeg.subtitle.domain.Subtitle +import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GetSubtitleTextUseCaseTest { + private val getSubtitleText = GetSubtitleTextUseCase() + + private val subtitle = + Subtitle( + lines = + listOf( + SubtitleLine(startMs = 0, text = "Demat deoc'h !"), + SubtitleLine(startMs = 4310, text = "Kenavo !"), + SubtitleLine(startMs = 7350, text = "Kenavo emberr !"), + ), + ) + + @Test + fun returns_null_before_the_first_cue() { + assertEquals("", getSubtitleText(subtitle, progressMs = -1)) + } + + @Test + fun returns_the_active_cue_at_and_after_its_start() { + assertEquals("Demat deoc'h !", getSubtitleText(subtitle, progressMs = 0)) + assertEquals("Demat deoc'h !", getSubtitleText(subtitle, progressMs = 4309)) + assertEquals("Kenavo !", getSubtitleText(subtitle, progressMs = 4310)) + assertEquals("Kenavo !", getSubtitleText(subtitle, progressMs = 5000)) + } + + @Test + fun returns_the_last_cue_past_the_end() { + assertEquals("Kenavo emberr !", getSubtitleText(subtitle, progressMs = 50_000)) + } + + @Test + fun returns_null_for_an_empty_subtitle() { + assertEquals("", getSubtitleText(Subtitle(emptyList()), progressMs = 1_000)) + } +} diff --git a/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt new file mode 100644 index 0000000..224afc2 --- /dev/null +++ b/shared/src/commonTest/kotlin/fr/ajaury/gwenedeg/subtitle/LrcParserTest.kt @@ -0,0 +1,52 @@ +package fr.ajaury.gwenedeg.subtitle + +import fr.ajaury.gwenedeg.subtitle.data.LrcParser +import kotlin.test.Test +import kotlin.test.assertEquals + +class LrcParserTest { + private val parser = LrcParser() + + private val sample = + """ + [ti:Chom_bev_01_ss] + [re: Subtitle Edit] + [ve: 5.0.0.4] + + [00:00.00]Demat deoc'h ! + [00:04.31]Kenavo ! + [00:05.40] + [00:07.35]Kenavo emberr ! + [00:44.14] + """.trimIndent() + + @Test + fun ignores_metadata_tags_and_keeps_only_timed_cues() { + val subtitle = parser.parse(sample) + + assertEquals(3, subtitle.lines.size) + } + + @Test + fun converts_timestamp_to_milliseconds() { + val subtitle = parser.parse(sample) + + assertEquals(0, subtitle.lines[0].startMs) + assertEquals(4310, subtitle.lines[1].startMs) + assertEquals(7350, subtitle.lines[2].startMs) + } + + @Test + fun parses_three_digit_fraction_as_milliseconds() { + val subtitle = parser.parse("[00:01.250]Hello") + + assertEquals(1250, subtitle.lines.single().startMs) + } + + @Test + fun sorts_cues_ascending_by_start() { + val subtitle = parser.parse("[00:05.00]Later\n[00:01.00]Earlier") + + assertEquals(listOf("Earlier", "Later"), subtitle.lines.map { it.text }) + } +}