new: add subtitle parsing with LRC format integration

This commit is contained in:
2026-06-19 14:14:31 +02:00
parent 7720a71825
commit 1928a7ba4d
10 changed files with 204 additions and 0 deletions
@@ -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<Logger> { KermitLogger() }
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
factoryOf(::LrcParser)
factoryOf(::LrcSubtitleRepository) { bind<SubtitleRepository>() }
factoryOf(::GetSubtitleTextUseCase)
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
}
@@ -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<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,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)
}
}
@@ -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()
}
@@ -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(resourcePath: String): Subtitle
}
@@ -0,0 +1,5 @@
package fr.ajaury.gwenedeg.subtitle.model
data class CantGetSubtitleException(
override val message: String,
) : Exception()