From c9b0b03ab9224ddac10a5e289dde82de98449597 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Wed, 1 Jul 2026 17:44:07 +0200 Subject: [PATCH] refactor: simplify player state --- core/audioplayer/build.gradle.kts | 3 + .../fr/ajaury/gwenedeg/core/model/Record.kt | 3 +- .../player/data/PlaybackRepositoryImpl.kt | 46 +++++++++----- .../player/domain/PlaybackRepository.kt | 13 +--- .../gwenedeg/player/model/PlayerState.kt | 12 ++++ .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 61 +++++++++++-------- .../player/ui/viewmodel/PlayerUiState.kt | 13 ++-- .../player/ui/viewmodel/PlayerViewModel.kt | 22 +++---- 8 files changed, 99 insertions(+), 74 deletions(-) create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlayerState.kt diff --git a/core/audioplayer/build.gradle.kts b/core/audioplayer/build.gradle.kts index 9973b9c..c35877e 100644 --- a/core/audioplayer/build.gradle.kts +++ b/core/audioplayer/build.gradle.kts @@ -48,6 +48,9 @@ kotlin { implementation(libs.vorbisspi) } commonMain.dependencies { + // Models (exposed through AudioPlayer's public API) +// api(projects.core.model) + // Coroutines implementation(libs.kotlinx.coroutinesCore) diff --git a/core/model/src/commonMain/kotlin/fr/ajaury/gwenedeg/core/model/Record.kt b/core/model/src/commonMain/kotlin/fr/ajaury/gwenedeg/core/model/Record.kt index 84313ae..a5d069f 100644 --- a/core/model/src/commonMain/kotlin/fr/ajaury/gwenedeg/core/model/Record.kt +++ b/core/model/src/commonMain/kotlin/fr/ajaury/gwenedeg/core/model/Record.kt @@ -3,8 +3,7 @@ package fr.ajaury.gwenedeg.core.model data class Record( val id: Int, val index: Int, - val title: String, - val titleTranslation: String? = null, + val title: Phrase, val audioResourcePath: String, val subtitleResourcePath: String, val translationSubtitleResourcePath: String? = null, diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt index 219491f..6bf1a79 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt @@ -1,11 +1,13 @@ package fr.ajaury.gwenedeg.player.data import fr.ajaury.gwenedeg.core.logging.domain.Logger +import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.core.model.Record 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.player.model.PlayerState import fr.ajaury.gwenedeg.resourcereader.data.domain.ResourceReader import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository @@ -16,7 +18,6 @@ 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 @@ -33,15 +34,15 @@ internal class PlaybackRepositoryImpl( private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, private val logger: Logger, ) : PlaybackRepository { - private val subtitleState = MutableStateFlow(Subtitle(emptyList())) - override val subtitle: StateFlow = subtitleState.asStateFlow() + private val recordTitle = MutableStateFlow(Phrase(transcription = "")) + private val subtitle = MutableStateFlow(Subtitle(emptyList())) - override val playbackState: StateFlow = audioPlayer.playbackState + private val playbackState: StateFlow = audioPlayer.playbackState - override val duration: Duration get() = audioPlayer.duration + private val duration: Duration get() = audioPlayer.duration @OptIn(ExperimentalCoroutinesApi::class) - override val currentPosition: Flow = playbackState.flatMapLatest { state -> + private val currentPosition: Flow = playbackState.flatMapLatest { state -> if (state == PlaybackState.PLAYING) { flow { while (true) { @@ -54,15 +55,15 @@ internal class PlaybackRepositoryImpl( } } - override val playbackTiming = currentPosition.map { currentPosition -> + private val playbackTiming = currentPosition.map { currentPosition -> PlaybackTiming( positionMs = currentPosition.inWholeMilliseconds, durationMs = duration.inWholeMilliseconds, ) } - override val currentSubtitleIndex: Flow = combine( - subtitleState, + private val currentSubtitleIndex: Flow = combine( + subtitle, currentPosition, ) { subtitle, position -> getCurrentSubtitleIndex( @@ -71,9 +72,26 @@ internal class PlaybackRepositoryImpl( ) } + override val playerState: Flow = combine( + playbackState, + subtitle, + playbackTiming, + currentSubtitleIndex, + recordTitle, + ) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle -> + PlayerState( + playbackState = playbackState, + playbackTiming = playbackTiming, + subtitleLines = subtitle.lines, + currentSubtitleIndex = currentSubtitleIndex, + recordTitle = recordTitle, + ) + } + override suspend fun load(record: Record) { playAudio(filePath = record.audioResourcePath) - subtitleState.value = loadSubtitle(record = record) + subtitle.value = loadSubtitle(record = record) + recordTitle.value = record.title } private fun playAudio(filePath: String) { @@ -120,12 +138,12 @@ internal class PlaybackRepositoryImpl( } override fun seekToSentence(sentenceIndex: Int) { - val matchingSubtitleLine = subtitleState.value.lines.getOrNull(sentenceIndex) ?: return + val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return audioPlayer.seekTo(matchingSubtitleLine.startTime) } override fun goToPreviousSentence() { - val lines = subtitleState.value.lines + val lines = subtitle.value.lines val currentIndex = currentSentenceIndex() ?: return val currentLine = lines.getOrNull(currentIndex) ?: return val elapsedInSentence = audioPlayer.currentPosition - currentLine.startTime @@ -139,14 +157,14 @@ internal class PlaybackRepositoryImpl( } override fun goToNextSentence() { - val lines = subtitleState.value.lines + val lines = subtitle.value.lines val currentIndex = currentSentenceIndex() ?: return seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) } private fun currentSentenceIndex(): Int? = getCurrentSubtitleIndex( - subtitle = subtitleState.value, + subtitle = subtitle.value, position = audioPlayer.currentPosition, ) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt index e5fa579..cbd762c 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt @@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.player.domain import fr.ajaury.gwenedeg.core.model.Record import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.player.model.PlayerState import fr.ajaury.gwenedeg.subtitle.model.Subtitle import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -16,17 +17,7 @@ import kotlin.time.Duration * Keeps audio and subtitle in sync. */ interface PlaybackRepository { - val playbackState: StateFlow - - val subtitle: StateFlow - - val currentPosition: Flow - - val currentSubtitleIndex: Flow - - val duration: Duration - - val playbackTiming: Flow + val playerState: Flow suspend fun load(record: Record) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlayerState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlayerState.kt new file mode 100644 index 0000000..5f28a73 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlayerState.kt @@ -0,0 +1,12 @@ +package fr.ajaury.gwenedeg.player.model + +import fr.ajaury.gwenedeg.core.model.Phrase +import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine + +data class PlayerState( + val recordTitle: Phrase = Phrase(transcription = ""), + val playbackState: PlaybackState = PlaybackState.IDLE, + val playbackTiming: PlaybackTiming = PlaybackTiming(), + val subtitleLines: List = emptyList(), + val currentSubtitleIndex: Int? = null, +) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt index fbbd83a..117e075 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerScreen.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.player.model.PlayerState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine @@ -77,12 +78,12 @@ fun PlayerScreen( title = { Column { Text( - text = uiState.recordTitle, + text = uiState.recordTitle.transcription, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - uiState.recordTitleTranslation?.let { translation -> + uiState.recordTitle.translation?.let { translation -> Text( text = translation, style = MaterialTheme.typography.titleSmall, @@ -112,8 +113,8 @@ fun PlayerScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { SubtitleList( - lines = uiState.subtitleLines, - currentIndex = uiState.currentSubtitleIndex, + lines = uiState.playerState.subtitleLines, + currentIndex = uiState.playerState.currentSubtitleIndex, onSeek = onSeekToSentence, modifier = Modifier .weight(1f) @@ -121,8 +122,8 @@ fun PlayerScreen( ) PlayerControl( - playbackState = uiState.playbackState, - playbackTiming = uiState.playbackTiming, + playbackState = uiState.playerState.playbackState, + playbackTiming = uiState.playerState.playbackTiming, onSeek = onSeekToTimePart, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onPreviousSentenceClicked = onPreviousSentenceClicked, @@ -163,12 +164,16 @@ private fun PlayerScreenPlayingPreview() { GwenedegTheme { PlayerScreen( uiState = PlayerUiState( - recordTitle = "Demat", - recordTitleTranslation = "Bonjour", - playbackState = PlaybackState.PLAYING, - playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), - subtitleLines = previewSubtitleLines, - currentSubtitleIndex = 1, + recordTitle = Phrase( + transcription = "Demat", + translation = "Bonjour", + ), + playerState = PlayerState( + playbackState = PlaybackState.PLAYING, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + subtitleLines = previewSubtitleLines, + currentSubtitleIndex = 1, + ), ), onMainPlayActionButtonClicked = {}, onSeekToSentence = {}, @@ -183,12 +188,16 @@ private fun PlayerScreenPausedPreview() { GwenedegTheme { PlayerScreen( uiState = PlayerUiState( - recordTitle = "Demat", - recordTitleTranslation = "Bonjour", - playbackState = PlaybackState.PAUSED, - playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), - subtitleLines = previewSubtitleLines, - currentSubtitleIndex = 1, + recordTitle = Phrase( + transcription = "Demat", + translation = "Bonjour", + ), + playerState = PlayerState( + playbackState = PlaybackState.PAUSED, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + subtitleLines = previewSubtitleLines, + currentSubtitleIndex = 1, + ), ), onMainPlayActionButtonClicked = {}, onSeekToSentence = {}, @@ -203,12 +212,16 @@ private fun PlayerScreenEndedPreview() { GwenedegTheme { PlayerScreen( uiState = PlayerUiState( - recordTitle = "Demat", - recordTitleTranslation = "Bonjour", - playbackState = PlaybackState.ENDED, - playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), - subtitleLines = previewSubtitleLines, - currentSubtitleIndex = 2, + recordTitle = Phrase( + transcription = "Demat", + translation = "Bonjour", + ), + playerState = PlayerState( + playbackState = PlaybackState.ENDED, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + subtitleLines = previewSubtitleLines, + currentSubtitleIndex = 2, + ), ), onMainPlayActionButtonClicked = {}, onSeekToSentence = {}, diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt index e86a7ed..c6fdf37 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt @@ -1,14 +1,9 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel -import fr.ajaury.gwenedeg.player.model.PlaybackState -import fr.ajaury.gwenedeg.player.model.PlaybackTiming -import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine +import fr.ajaury.gwenedeg.core.model.Phrase +import fr.ajaury.gwenedeg.player.model.PlayerState data class PlayerUiState( - val recordTitle: String = "", - val recordTitleTranslation: String? = null, - val playbackState: PlaybackState = PlaybackState.IDLE, - val playbackTiming: PlaybackTiming = PlaybackTiming(), - val subtitleLines: List = emptyList(), - val currentSubtitleIndex: Int? = null, + val recordTitle: Phrase = Phrase(transcription = ""), + val playerState: PlayerState = PlayerState(), ) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt index 2f54d9d..049caa5 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerViewModel.kt @@ -3,6 +3,7 @@ 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.core.model.Phrase import fr.ajaury.gwenedeg.core.model.Record import fr.ajaury.gwenedeg.player.domain.AudioSessionManager import fr.ajaury.gwenedeg.player.domain.PlaybackRepository @@ -13,7 +14,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @@ -30,18 +30,11 @@ class PlayerViewModel( val uiState: StateFlow = combine( record, - playbackRepository.playbackState, - playbackRepository.playbackTiming, - playbackRepository.subtitle, - playbackRepository.currentSubtitleIndex, - ) { record, playbackState, playbackTiming, subtitle, currentSubtitleIndex -> + playbackRepository.playerState, + ) { record, playerState -> PlayerUiState( - recordTitle = record?.title.orEmpty(), - recordTitleTranslation = record?.titleTranslation, - playbackState = playbackState, - playbackTiming = playbackTiming, - subtitleLines = subtitle.lines, - currentSubtitleIndex = currentSubtitleIndex, + recordTitle = record?.title ?: Phrase(transcription = ""), + playerState = playerState, ) }.stateIn( scope = viewModelScope, @@ -75,7 +68,7 @@ class PlayerViewModel( } fun performMainPlayAction() { - when (uiState.value.playbackState) { + when (uiState.value.playerState.playbackState) { PlaybackState.PLAYING -> playbackRepository.pause() PlaybackState.ENDED -> playbackRepository.replay() PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play() @@ -83,7 +76,8 @@ class PlayerViewModel( } fun seekToTimePart(progress: Float) { - val position = (progress.toDouble() * uiState.value.playbackTiming.durationMs).milliseconds + val playbackTiming = uiState.value.playerState.playbackTiming + val position = (progress.toDouble() * playbackTiming.durationMs).milliseconds playbackRepository.seekTo(position) }