new: implement PlaybackRepository to centralize playback and subtitle management across app

This commit is contained in:
2026-06-26 14:21:25 +02:00
parent e8bab1ef88
commit a9d3402d79
12 changed files with 275 additions and 152 deletions
@@ -2,6 +2,7 @@ package fr.ajaury.gwenedeg.subtitle.data
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.time.Duration.Companion.milliseconds
/**
* Parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) subtitle content.
@@ -17,7 +18,7 @@ internal class LrcParser {
val lines = content
.lineSequence()
.flatMap { line -> parseLine(line) }
.sortedBy { it.startMs }
.sortedBy { it.startTime }
.toList()
return Subtitle(lines)
}
@@ -28,7 +29,7 @@ internal class LrcParser {
return timeTag
.findAll(tags.value)
.mapNotNull { match ->
SubtitleLine(startMs = match.toMs(), text = text)
SubtitleLine(startTime = match.toMs().milliseconds, text = text)
.takeIf { it.text.isNotBlank() }
}.toList()
}
@@ -31,10 +31,10 @@ internal class LrcSubtitleRepository(
/** Attaches each [translation] line's text to the matching original line, paired by timestamp. */
private fun Subtitle.mergeTranslation(translation: Subtitle?): Subtitle {
if (translation == null) return this
val translationByStart = translation.lines.associate { it.startMs to it.text }
val translationByStart = translation.lines.associate { it.startTime to it.text }
return Subtitle(
lines = lines.map { line ->
line.copy(translation = translationByStart[line.startMs])
line.copy(translation = translationByStart[line.startTime])
},
)
}
@@ -1,8 +1,10 @@
package fr.ajaury.gwenedeg.subtitle.domain
import kotlin.time.Duration
class GetCurrentSubtitleIndexUseCase {
operator fun invoke(
subtitle: Subtitle,
progressMs: Long,
): Int? = subtitle.lines.indexOfLast { it.startMs <= progressMs }.takeIf { it >= 0 }
position: Duration,
): Int? = subtitle.lines.indexOfLast { it.startTime <= position }.takeIf { it >= 0 }
}
@@ -1,11 +1,13 @@
package fr.ajaury.gwenedeg.subtitle.domain
import kotlin.time.Duration
/**
* A single timed subtitle cue: [text] becomes active at [startMs]. [translation] is the optional
* A single timed subtitle cue: [text] becomes active at [startTime]. [translation] is the optional
* translation of [text] in the user's language (e.g. French), shown alongside the original.
*/
data class SubtitleLine(
val startMs: Long,
val startTime: Duration,
val text: String,
val translation: String? = null,
)
@@ -5,6 +5,7 @@ import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
class GetCurrentSubtitleIndexUseCaseTest {
private val getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase()
@@ -13,32 +14,35 @@ class GetCurrentSubtitleIndexUseCaseTest {
Subtitle(
lines =
listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !"),
SubtitleLine(startMs = 4310, text = "Kenavo !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !"),
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !"),
),
)
@Test
fun returns_minus_one_before_the_first_cue() {
assertEquals(null, getCurrentSubtitleIndex(subtitle, progressMs = -1))
assertEquals(null, getCurrentSubtitleIndex(subtitle, position = (-1).milliseconds))
}
@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))
assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 0.milliseconds))
assertEquals(0, getCurrentSubtitleIndex(subtitle, position = 4309.milliseconds))
assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 4310.milliseconds))
assertEquals(1, getCurrentSubtitleIndex(subtitle, position = 5000.milliseconds))
}
@Test
fun returns_the_last_index_past_the_end() {
assertEquals(2, getCurrentSubtitleIndex(subtitle, progressMs = 50_000))
assertEquals(2, getCurrentSubtitleIndex(subtitle, position = 50_000.milliseconds))
}
@Test
fun returns_minus_one_for_an_empty_subtitle() {
assertEquals(null, getCurrentSubtitleIndex(Subtitle(emptyList()), progressMs = 1_000))
assertEquals(
null,
getCurrentSubtitleIndex(Subtitle(emptyList()), position = 1_000.milliseconds),
)
}
}
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.subtitle
import fr.ajaury.gwenedeg.subtitle.data.LrcParser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
class LrcParserTest {
private val parser = LrcParser()
@@ -31,16 +32,16 @@ class LrcParserTest {
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)
assertEquals(0.milliseconds, subtitle.lines[0].startTime)
assertEquals(4310.milliseconds, subtitle.lines[1].startTime)
assertEquals(7350.milliseconds, subtitle.lines[2].startTime)
}
@Test
fun parses_three_digit_fraction_as_milliseconds() {
val subtitle = parser.parse("[00:01.250]Hello")
assertEquals(1250, subtitle.lines.single().startMs)
assertEquals(1250.milliseconds, subtitle.lines.single().startTime)
}
@Test