refactor: simplify player state

This commit is contained in:
2026-07-01 17:44:07 +02:00
parent 6a590344de
commit c9b0b03ab9
8 changed files with 99 additions and 74 deletions
+3
View File
@@ -48,6 +48,9 @@ kotlin {
implementation(libs.vorbisspi) implementation(libs.vorbisspi)
} }
commonMain.dependencies { commonMain.dependencies {
// Models (exposed through AudioPlayer's public API)
// api(projects.core.model)
// Coroutines // Coroutines
implementation(libs.kotlinx.coroutinesCore) implementation(libs.kotlinx.coroutinesCore)
@@ -3,8 +3,7 @@ package fr.ajaury.gwenedeg.core.model
data class Record( data class Record(
val id: Int, val id: Int,
val index: Int, val index: Int,
val title: String, val title: Phrase,
val titleTranslation: String? = null,
val audioResourcePath: String, val audioResourcePath: String,
val subtitleResourcePath: String, val subtitleResourcePath: String,
val translationSubtitleResourcePath: String? = null, val translationSubtitleResourcePath: String? = null,
@@ -1,11 +1,13 @@
package fr.ajaury.gwenedeg.player.data package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.core.logging.domain.Logger 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.core.model.Record
import fr.ajaury.gwenedeg.player.domain.AudioPlayer import fr.ajaury.gwenedeg.player.domain.AudioPlayer
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.domain.PlaybackRepository
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming 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.resourcereader.data.domain.ResourceReader
import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase import fr.ajaury.gwenedeg.subtitle.domain.GetCurrentSubtitleIndexUseCase
import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository import fr.ajaury.gwenedeg.subtitle.domain.SubtitleRepository
@@ -16,7 +18,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -33,15 +34,15 @@ internal class PlaybackRepositoryImpl(
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val logger: Logger, private val logger: Logger,
) : PlaybackRepository { ) : PlaybackRepository {
private val subtitleState = MutableStateFlow(Subtitle(emptyList())) private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
override val subtitle: StateFlow<Subtitle> = subtitleState.asStateFlow() private val subtitle = MutableStateFlow(Subtitle(emptyList()))
override val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState private val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
override val duration: Duration get() = audioPlayer.duration private val duration: Duration get() = audioPlayer.duration
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override val currentPosition: Flow<Duration> = playbackState.flatMapLatest { state -> private val currentPosition: Flow<Duration> = playbackState.flatMapLatest { state ->
if (state == PlaybackState.PLAYING) { if (state == PlaybackState.PLAYING) {
flow { flow {
while (true) { while (true) {
@@ -54,15 +55,15 @@ internal class PlaybackRepositoryImpl(
} }
} }
override val playbackTiming = currentPosition.map { currentPosition -> private val playbackTiming = currentPosition.map { currentPosition ->
PlaybackTiming( PlaybackTiming(
positionMs = currentPosition.inWholeMilliseconds, positionMs = currentPosition.inWholeMilliseconds,
durationMs = duration.inWholeMilliseconds, durationMs = duration.inWholeMilliseconds,
) )
} }
override val currentSubtitleIndex: Flow<Int?> = combine( private val currentSubtitleIndex: Flow<Int?> = combine(
subtitleState, subtitle,
currentPosition, currentPosition,
) { subtitle, position -> ) { subtitle, position ->
getCurrentSubtitleIndex( getCurrentSubtitleIndex(
@@ -71,9 +72,26 @@ internal class PlaybackRepositoryImpl(
) )
} }
override val playerState: Flow<PlayerState> = 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) { override suspend fun load(record: Record) {
playAudio(filePath = record.audioResourcePath) playAudio(filePath = record.audioResourcePath)
subtitleState.value = loadSubtitle(record = record) subtitle.value = loadSubtitle(record = record)
recordTitle.value = record.title
} }
private fun playAudio(filePath: String) { private fun playAudio(filePath: String) {
@@ -120,12 +138,12 @@ internal class PlaybackRepositoryImpl(
} }
override fun seekToSentence(sentenceIndex: Int) { 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) audioPlayer.seekTo(matchingSubtitleLine.startTime)
} }
override fun goToPreviousSentence() { override fun goToPreviousSentence() {
val lines = subtitleState.value.lines val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return val currentLine = lines.getOrNull(currentIndex) ?: return
val elapsedInSentence = audioPlayer.currentPosition - currentLine.startTime val elapsedInSentence = audioPlayer.currentPosition - currentLine.startTime
@@ -139,14 +157,14 @@ internal class PlaybackRepositoryImpl(
} }
override fun goToNextSentence() { override fun goToNextSentence() {
val lines = subtitleState.value.lines val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
} }
private fun currentSentenceIndex(): Int? = private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex( getCurrentSubtitleIndex(
subtitle = subtitleState.value, subtitle = subtitle.value,
position = audioPlayer.currentPosition, position = audioPlayer.currentPosition,
) )
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.player.domain
import fr.ajaury.gwenedeg.core.model.Record import fr.ajaury.gwenedeg.core.model.Record
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.subtitle.model.Subtitle import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -16,17 +17,7 @@ import kotlin.time.Duration
* Keeps audio and subtitle in sync. * Keeps audio and subtitle in sync.
*/ */
interface PlaybackRepository { interface PlaybackRepository {
val playbackState: StateFlow<PlaybackState> val playerState: Flow<PlayerState>
val subtitle: StateFlow<Subtitle>
val currentPosition: Flow<Duration>
val currentSubtitleIndex: Flow<Int?>
val duration: Duration
val playbackTiming: Flow<PlaybackTiming>
suspend fun load(record: Record) suspend fun load(record: Record)
@@ -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<SubtitleLine> = emptyList(),
val currentSubtitleIndex: Int? = null,
)
@@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.ajaury.gwenedeg.core.model.Phrase import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming 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.PlayerUiState
import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
@@ -77,12 +78,12 @@ fun PlayerScreen(
title = { title = {
Column { Column {
Text( Text(
text = uiState.recordTitle, text = uiState.recordTitle.transcription,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
uiState.recordTitleTranslation?.let { translation -> uiState.recordTitle.translation?.let { translation ->
Text( Text(
text = translation, text = translation,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -112,8 +113,8 @@ fun PlayerScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
SubtitleList( SubtitleList(
lines = uiState.subtitleLines, lines = uiState.playerState.subtitleLines,
currentIndex = uiState.currentSubtitleIndex, currentIndex = uiState.playerState.currentSubtitleIndex,
onSeek = onSeekToSentence, onSeek = onSeekToSentence,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -121,8 +122,8 @@ fun PlayerScreen(
) )
PlayerControl( PlayerControl(
playbackState = uiState.playbackState, playbackState = uiState.playerState.playbackState,
playbackTiming = uiState.playbackTiming, playbackTiming = uiState.playerState.playbackTiming,
onSeek = onSeekToTimePart, onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked, onPreviousSentenceClicked = onPreviousSentenceClicked,
@@ -163,13 +164,17 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordTitle = "Demat", recordTitle = Phrase(
recordTitleTranslation = "Bonjour", transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
subtitleLines = previewSubtitleLines, subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1, currentSubtitleIndex = 1,
), ),
),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onSeekToSentence = {}, onSeekToSentence = {},
onBackClicked = {}, onBackClicked = {},
@@ -183,13 +188,17 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordTitle = "Demat", recordTitle = Phrase(
recordTitleTranslation = "Bonjour", transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.PAUSED, playbackState = PlaybackState.PAUSED,
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
subtitleLines = previewSubtitleLines, subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1, currentSubtitleIndex = 1,
), ),
),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onSeekToSentence = {}, onSeekToSentence = {},
onBackClicked = {}, onBackClicked = {},
@@ -203,13 +212,17 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordTitle = "Demat", recordTitle = Phrase(
recordTitleTranslation = "Bonjour", transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState(
playbackState = PlaybackState.ENDED, playbackState = PlaybackState.ENDED,
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
subtitleLines = previewSubtitleLines, subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 2, currentSubtitleIndex = 2,
), ),
),
onMainPlayActionButtonClicked = {}, onMainPlayActionButtonClicked = {},
onSeekToSentence = {}, onSeekToSentence = {},
onBackClicked = {}, onBackClicked = {},
@@ -1,14 +1,9 @@
package fr.ajaury.gwenedeg.player.ui.viewmodel package fr.ajaury.gwenedeg.player.ui.viewmodel
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
data class PlayerUiState( data class PlayerUiState(
val recordTitle: String = "", val recordTitle: Phrase = Phrase(transcription = ""),
val recordTitleTranslation: String? = null, val playerState: PlayerState = PlayerState(),
val playbackState: PlaybackState = PlaybackState.IDLE,
val playbackTiming: PlaybackTiming = PlaybackTiming(),
val subtitleLines: List<SubtitleLine> = emptyList(),
val currentSubtitleIndex: Int? = null,
) )
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import fr.ajaury.gwenedeg.core.logging.domain.Logger 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.core.model.Record
import fr.ajaury.gwenedeg.player.domain.AudioSessionManager import fr.ajaury.gwenedeg.player.domain.AudioSessionManager
import fr.ajaury.gwenedeg.player.domain.PlaybackRepository 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.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -30,18 +30,11 @@ class PlayerViewModel(
val uiState: StateFlow<PlayerUiState> = combine( val uiState: StateFlow<PlayerUiState> = combine(
record, record,
playbackRepository.playbackState, playbackRepository.playerState,
playbackRepository.playbackTiming, ) { record, playerState ->
playbackRepository.subtitle,
playbackRepository.currentSubtitleIndex,
) { record, playbackState, playbackTiming, subtitle, currentSubtitleIndex ->
PlayerUiState( PlayerUiState(
recordTitle = record?.title.orEmpty(), recordTitle = record?.title ?: Phrase(transcription = ""),
recordTitleTranslation = record?.titleTranslation, playerState = playerState,
playbackState = playbackState,
playbackTiming = playbackTiming,
subtitleLines = subtitle.lines,
currentSubtitleIndex = currentSubtitleIndex,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -75,7 +68,7 @@ class PlayerViewModel(
} }
fun performMainPlayAction() { fun performMainPlayAction() {
when (uiState.value.playbackState) { when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING -> playbackRepository.pause() PlaybackState.PLAYING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay() PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play() PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
@@ -83,7 +76,8 @@ class PlayerViewModel(
} }
fun seekToTimePart(progress: Float) { 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) playbackRepository.seekTo(position)
} }