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)
}
commonMain.dependencies {
// Models (exposed through AudioPlayer's public API)
// api(projects.core.model)
// Coroutines
implementation(libs.kotlinx.coroutinesCore)
@@ -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,
@@ -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<Subtitle> = subtitleState.asStateFlow()
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
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)
override val currentPosition: Flow<Duration> = playbackState.flatMapLatest { state ->
private val currentPosition: Flow<Duration> = 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<Int?> = combine(
subtitleState,
private val currentSubtitleIndex: Flow<Int?> = combine(
subtitle,
currentPosition,
) { subtitle, position ->
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) {
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,
)
@@ -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<PlaybackState>
val subtitle: StateFlow<Subtitle>
val currentPosition: Flow<Duration>
val currentSubtitleIndex: Flow<Int?>
val duration: Duration
val playbackTiming: Flow<PlaybackTiming>
val playerState: Flow<PlayerState>
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.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 = {},
@@ -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<SubtitleLine> = emptyList(),
val currentSubtitleIndex: Int? = null,
val recordTitle: Phrase = Phrase(transcription = ""),
val playerState: PlayerState = PlayerState(),
)
@@ -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<PlayerUiState> = 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)
}