new: add subtitle parsing with LRC format integration
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package fr.ajaury.gwenedeg.subtitle.model
|
||||
|
||||
data class CantGetSubtitleException(
|
||||
override val message: String,
|
||||
) : Exception()
|
||||
Reference in New Issue
Block a user