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
@@ -1,6 +1,8 @@
package fr.ajaury.gwenedeg.di
import fr.ajaury.gwenedeg.core.logging.di.loggingModule
import fr.ajaury.gwenedeg.player.data.PlaybackRepositoryImpl
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.records.data.InMemoryRecordRepository
import fr.ajaury.gwenedeg.records.domain.RecordRepository
@@ -9,12 +11,14 @@ import fr.ajaury.gwenedeg.resourcereader.data.ComposeResourceReader
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val sharedModule = module {
includes(loggingModule)
factoryOf(::InMemoryRecordRepository) { bind<RecordRepository>() }
singleOf(::PlaybackRepositoryImpl) { bind<PlaybackRepository>() }
viewModelOf(::RecordsViewModel)
viewModelOf(::PlayerViewModel)
factoryOf(::ComposeResourceReader) { bind<ResourceReader>() }
@@ -0,0 +1,165 @@
package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.player.domain.AudioPlayer
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
internal class PlaybackRepositoryImpl(
private val audioPlayer: AudioPlayer,
private val resourceReader: ResourceReader,
private val subtitleRepository: SubtitleRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val logger: Logger,
) : PlaybackRepository {
private val subtitleState = MutableStateFlow(Subtitle(emptyList()))
override val subtitle: StateFlow<Subtitle> = subtitleState.asStateFlow()
override val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
override val duration: Duration get() = audioPlayer.duration
@OptIn(ExperimentalCoroutinesApi::class)
override val currentPosition: Flow<Duration> = playbackState.flatMapLatest { state ->
if (state == PlaybackState.PLAYING) {
flow {
while (true) {
emit(audioPlayer.currentPosition)
delay(POSITION_POLL_INTERVAL)
}
}
} else {
flowOf(audioPlayer.currentPosition)
}
}
override val playbackTiming = currentPosition.map { currentPosition ->
PlaybackTiming(
positionMs = currentPosition.inWholeMilliseconds,
durationMs = duration.inWholeMilliseconds,
)
}
override val currentSubtitleIndex: Flow<Int?> = combine(
subtitleState,
currentPosition,
) { subtitle, position ->
getCurrentSubtitleIndex(
subtitle = subtitle,
position = position,
)
}
override suspend fun load(record: Record) {
playAudio(filePath = record.audioResourcePath)
subtitleState.value = loadSubtitle(record = record)
}
private fun playAudio(filePath: String) {
try {
audioPlayer.load(uri = Res.getUri(filePath))
audioPlayer.play()
} catch (exception: Exception) {
logger.error(message = "Failed to play audio: $filePath", throwable = exception)
}
}
private suspend fun loadSubtitle(record: Record): Subtitle =
try {
val content = resourceReader.read(resourcePath = record.subtitleResourcePath)
val translationContent = record.translationSubtitleResourcePath
?.let { resourceReader.read(resourcePath = it) }
subtitleRepository.getSubtitle(
content = content,
translationContent = translationContent,
)
} catch (exception: Exception) {
logger.error(
message = "Failed to load subtitle: ${record.subtitleResourcePath}",
throwable = exception,
)
Subtitle(emptyList())
}
override fun play() {
audioPlayer.play()
}
override fun pause() {
audioPlayer.pause()
}
override fun replay() {
audioPlayer.seekTo(Duration.ZERO)
audioPlayer.play()
}
override fun seekTo(position: Duration) {
audioPlayer.seekTo(position)
}
override fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitleState.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
override fun goToPreviousSentence() {
val lines = subtitleState.value.lines
val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return
val elapsedInSentence = audioPlayer.currentPosition - currentLine.startTime
val targetIndex = if (elapsedInSentence < PREVIOUS_SENTENCE_THRESHOLD) {
currentIndex - 1
} else {
currentIndex
}.coerceAtLeast(0)
seekToSentence(targetIndex)
}
override fun goToNextSentence() {
val lines = subtitleState.value.lines
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
}
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitleState.value,
position = audioPlayer.currentPosition,
)
override fun stop() {
audioPlayer.stop()
}
override fun release() {
audioPlayer.release()
}
companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
}
}
@@ -0,0 +1,50 @@
package fr.ajaury.gwenedeg.player.domain
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
/**
* Single source of truth for the current playback: owns the [AudioPlayer] together with the
* subtitle of the record being played, and exposes the combined state plus the operations to
* control it (play/pause, seeking, sentence navigation).
*
* Keeps audio and subtitle in sync.
*/
interface PlaybackRepository {
val playbackState: StateFlow<PlaybackState>
val subtitle: StateFlow<Subtitle>
val currentPosition: Flow<Duration>
val currentSubtitleIndex: Flow<Int?>
val duration: Duration
val playbackTiming: Flow<PlaybackTiming>
suspend fun load(record: Record)
fun play()
fun pause()
fun replay()
fun seekTo(position: Duration)
fun seekToSentence(sentenceIndex: Int)
fun goToPreviousSentence()
fun goToNextSentence()
fun stop()
fun release()
}
@@ -29,6 +29,7 @@ import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme
import org.koin.compose.viewmodel.koinViewModel
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun PlayerScreen(
@@ -119,9 +120,9 @@ fun PlayerScreen(
private val previewSubtitleLines =
listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"),
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
)
@Preview
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleLine
import fr.ajaury.gwenedeg.theme.GwenedegTheme
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun SubtitleList(
@@ -190,9 +191,9 @@ private fun TranslationLine(
private val previewSubtitleLines =
listOf(
SubtitleLine(startMs = 0, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startMs = 4310, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startMs = 7350, text = "Kenavo emberr !", translation = "À ce soir !"),
SubtitleLine(startTime = 0.milliseconds, text = "Demat deoc'h !", translation = "Bonjour !"),
SubtitleLine(startTime = 4310.milliseconds, text = "Kenavo !", translation = "Au revoir !"),
SubtitleLine(startTime = 7350.milliseconds, text = "Kenavo emberr !", translation = "À ce soir !"),
)
@Preview
@@ -3,27 +3,16 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.core.logging.domain.Logger
import fr.ajaury.gwenedeg.player.domain.AudioPlayer
import fr.ajaury.gwenedeg.player.domain.AudioSessionManager
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.records.domain.Record
import fr.ajaury.gwenedeg.records.domain.RecordRepository
import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.Subtitle
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
import gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -32,57 +21,18 @@ import kotlin.time.Duration.Companion.seconds
class PlayerViewModel(
private val recordRepository: RecordRepository,
private val resourceReader: ResourceReader,
private val audioPlayer: AudioPlayer,
private val playbackRepository: PlaybackRepository,
private val audioSessionManager: AudioSessionManager,
private val subtitleRepository: SubtitleRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val logger: Logger,
) : ViewModel() {
private val playbackState = audioPlayer.playbackState
private val recordTitle = MutableStateFlow("")
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
@OptIn(ExperimentalCoroutinesApi::class)
private val currentPosition = playbackState
.flatMapLatest { state ->
if (state == PlaybackState.PLAYING) {
flow {
while (true) {
emit(audioPlayer.currentPosition)
delay(POSITION_POLL_INTERVAL)
}
}
} else {
flowOf(audioPlayer.currentPosition)
}
}
private val currentSubtitleIndex = combine(
subtitle,
currentPosition,
) { subtitle, currentPosition ->
getCurrentSubtitleIndex(
subtitle = subtitle,
progressMs = currentPosition.inWholeMilliseconds,
)
}
private val playbackTiming = currentPosition.map { currentPosition ->
PlaybackTiming(
positionMs = currentPosition.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds,
)
}
val uiState: StateFlow<PlayerUiState> = combine(
recordTitle,
playbackState,
playbackTiming,
subtitle,
currentSubtitleIndex,
playbackRepository.playbackState,
playbackRepository.playbackTiming,
playbackRepository.subtitle,
playbackRepository.currentSubtitleIndex,
) { recordTitle, playbackState, playbackTiming, subtitle, currentSubtitleIndex ->
PlayerUiState(
recordTitle = recordTitle,
@@ -110,100 +60,42 @@ class PlayerViewModel(
}
recordTitle.value = record.title
play(filePath = record.audioResourcePath)
loadSubtitle(record = record)
}
}
private fun play(filePath: String) {
try {
audioPlayer.load(uri = Res.getUri(filePath))
audioPlayer.play()
} catch (exception: Exception) {
logger.error(message = "Failed to play audio: $filePath", throwable = exception)
}
}
private suspend fun loadSubtitle(record: Record) {
subtitle.value = try {
val content = resourceReader.read(resourcePath = record.subtitleResourcePath)
val translationContent = record.translationSubtitleResourcePath
?.let { resourceReader.read(resourcePath = it) }
subtitleRepository.getSubtitle(
content = content,
translationContent = translationContent,
)
} catch (exception: Exception) {
logger.error(
message = "Failed to load subtitle: ${record.subtitleResourcePath}",
throwable = exception,
)
Subtitle(emptyList())
playbackRepository.load(record = record)
}
}
fun performMainPlayAction() {
when (uiState.value.playbackState) {
PlaybackState.PLAYING -> audioPlayer.pause()
PlaybackState.ENDED -> replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> audioPlayer.play()
PlaybackState.PLAYING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
}
}
private fun replay() {
audioPlayer.seekTo(0.milliseconds)
audioPlayer.play()
}
fun seekToTimePart(progress: Float) {
val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds
audioPlayer.seekTo(position)
playbackRepository.seekTo(position)
}
fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds)
playbackRepository.seekToSentence(sentenceIndex)
}
fun goToPreviousSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return
val elapsedInSentence =
audioPlayer.currentPosition.inWholeMilliseconds - currentLine.startMs
val targetIndex = if (elapsedInSentence < PREVIOUS_SENTENCE_THRESHOLD.inWholeMilliseconds) {
(currentIndex - 1)
} else {
currentIndex
}.coerceAtLeast(0)
seekToSentence(targetIndex)
playbackRepository.goToPreviousSentence()
}
fun goToNextSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
playbackRepository.goToNextSentence()
}
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitle.value,
progressMs = audioPlayer.currentPosition.inWholeMilliseconds,
)
fun stop() {
audioPlayer.stop()
playbackRepository.stop()
}
override fun onCleared() {
super.onCleared()
audioSessionManager.deactivate()
audioPlayer.release()
}
companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
playbackRepository.release()
}
}