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
@@ -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()
}
}