refactor: move subtitle package to standalone data module, update dependencies

This commit is contained in:
2026-06-19 18:12:16 +02:00
parent fdeeee9bf8
commit 1028b748a9
18 changed files with 108 additions and 16 deletions
+53
View File
@@ -0,0 +1,53 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
iosArm64()
iosSimulatorArm64()
jvm()
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
androidLibrary {
namespace = "fr.ajaury.gwenedeg.subtitle"
compileSdk =
libs.versions.androidCompileSdk
.get()
.toInt()
minSdk =
libs.versions.androidMinSdk
.get()
.toInt()
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
commonMain.dependencies {
// Logging
implementation(projects.core.logging)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
@@ -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.
*/
internal 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<SubtitleLine> {
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
}
}
@@ -0,0 +1,20 @@
package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import fr.ajaury.gwenedeg.subtitle.model.CantGetSubtitleException
internal class LrcSubtitleRepository(
private val parser: LrcParser,
private val logger: Logger,
) : SubtitleRepository {
override suspend fun getSubtitle(content: String): Subtitle =
try {
parser.parse(content = content)
} catch (exception: Exception) {
val message = "Failed to load subtitle: $content"
logger.error(message = message, throwable = exception)
throw CantGetSubtitleException(message)
}
}
@@ -0,0 +1,16 @@
package fr.ajaury.gwenedeg.subtitle.di
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
import fr.ajaury.gwenedeg.subtitle.data.LrcSubtitleRepository
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val subtitleModule: Module = module {
factoryOf(::LrcParser)
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
factoryOf(::GetCurrentSubtitleIndexUseCase)
}
@@ -0,0 +1,8 @@
package fr.ajaury.gwenedeg.subtitle.domain
class GetCurrentSubtitleIndexUseCase {
operator fun invoke(
subtitle: Subtitle,
progressMs: Long,
): Int = subtitle.lines.indexOfLast { it.startMs <= progressMs }
}
@@ -0,0 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.domain
data class Subtitle(
val lines: List<SubtitleLine>,
)
@@ -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,
)
@@ -0,0 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.domain
interface SubtitleRepository {
suspend fun getSubtitle(content: String): Subtitle
}
@@ -0,0 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.model
data class CantGetSubtitleException(
override val message: String,
) : Exception()
@@ -0,0 +1,44 @@
package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
class GetCurrentSubtitleIndexUseCaseTest {
private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase()
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_minus_one_before_the_first_cue() {
assertEquals(-1, getCurrentSubtitleIndex(subtitle, progressMs = -1))
}
@Test
fun returns_the_index_at_and_after_each_cue_start() {
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 0))
assertEquals(0, getCurrentSubtitleIndex(subtitle, progressMs = 4309))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 4310))
assertEquals(1, getCurrentSubtitleIndex(subtitle, progressMs = 5000))
}
@Test
fun returns_the_last_index_past_the_end() {
assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000))
}
@Test
fun returns_minus_one_for_an_empty_subtitle() {
assertEquals(-1, getCurrentSubtitleIndex(Subtitle(emptyList()), progressMs = 1_000))
}
}
@@ -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 })
}
}