From d86a18fcea4346c2071fd5fda66b5e6e0948d126 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 19 Jun 2026 14:34:12 +0200 Subject: [PATCH] new: integrate subtitles into playback, update models, UI, and navigation --- .../kotlin/fr/ajaury/gwenedeg/App.kt | 2 ++ .../fr/ajaury/gwenedeg/navigation/Route.kt | 1 + .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 18 ++++++++++--- .../player/ui/viewmodel/PlayerUiState.kt | 1 + .../player/ui/viewmodel/PlayerViewModel.kt | 27 +++++++++++++++++-- .../records/data/InMemoryRecordRepository.kt | 4 ++- .../ajaury/gwenedeg/records/domain/Record.kt | 1 + .../ajaury/gwenedeg/records/ui/RecordView.kt | 8 +++++- 8 files changed, 54 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt index 40e390c..a2b7cfb 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/App.kt @@ -45,6 +45,7 @@ fun App() { Route.Player( title = record.title, audioResourcePath = record.audioResourcePath, + subtitleResourcePath = record.subtitleResourcePath, ), ) }, @@ -55,6 +56,7 @@ fun App() { PlayerScreen( recordTitle = key.title, audioResourcePath = key.audioResourcePath, + subtitleResourcePath = key.subtitleResourcePath, onBackClicked = { backStack.removeLastOrNull() }, ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt index 04536b3..84f1ffc 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/navigation/Route.kt @@ -12,5 +12,6 @@ sealed interface Route : NavKey { data class Player( val title: String, val audioResourcePath: String, + val subtitleResourcePath: String, ) : Route } 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 db21512..6407f7d 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,13 +46,15 @@ import org.koin.compose.viewmodel.koinViewModel fun PlayerScreen( recordTitle: String, audioResourcePath: String, + subtitleResourcePath: String, onBackClicked: () -> Unit, viewModel: PlayerViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - DisposableEffect(audioResourcePath) { + DisposableEffect(audioResourcePath, subtitleResourcePath) { viewModel.play(filePath = audioResourcePath) + viewModel.loadSubtitle(resourcePath = subtitleResourcePath) onDispose { viewModel.stop() } @@ -108,6 +111,13 @@ fun PlayerScreen( Spacer(modifier = Modifier.weight(1f)) + Text( + text = uiState.subtitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + var seekProgress by remember { mutableStateOf(null) } Slider( value = seekProgress ?: uiState.progress, @@ -158,7 +168,7 @@ private fun PlayerScreenPlayingPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000), + uiState = PlayerUiState(playbackState = PlaybackState.PLAYING, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"), onMainPlayActionButtonClicked = {}, onSeek = {}, onBackClicked = {}, @@ -172,7 +182,7 @@ private fun PlayerScreenPausedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000), + uiState = PlayerUiState(playbackState = PlaybackState.PAUSED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"), onMainPlayActionButtonClicked = {}, onSeek = {}, onBackClicked = {}, @@ -186,7 +196,7 @@ private fun PlayerScreenEndedPreview() { GwenedegTheme { PlayerScreen( recordTitle = "Demat", - uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000), + uiState = PlayerUiState(playbackState = PlaybackState.ENDED, positionMs = 1234, durationMs = 5000, subtitle = "Demat deoc'h !"), onMainPlayActionButtonClicked = {}, onSeek = {}, onBackClicked = {}, 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 1eef80f..c2f420f 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 @@ -6,6 +6,7 @@ data class PlayerUiState( val playbackState: PlaybackState = PlaybackState.IDLE, val positionMs: Long = 0L, val durationMs: Long = 0L, + val subtitle: String = "", ) { val progress: Float get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f 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 29ae095..e1c707f 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 @@ -6,9 +6,13 @@ import fr.ajaury.gwenedeg.core.logging.Logger import fr.ajaury.gwenedeg.player.AudioPlayer import fr.ajaury.gwenedeg.player.AudioSessionManager import fr.ajaury.gwenedeg.player.PlaybackState +import fr.ajaury.gwenedeg.subtitle.domain.GetSubtitleTextUseCase +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 @@ -17,18 +21,23 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class PlayerViewModel( private val audioPlayer: AudioPlayer, private val audioSessionManager: AudioSessionManager, + private val subtitleRepository: SubtitleRepository, + private val getSubtitleText: GetSubtitleTextUseCase, private val logger: Logger, ) : ViewModel() { private var filePath: String? = null private val playbackState = audioPlayer.playbackState + private val subtitle = MutableStateFlow(Subtitle(emptyList())) + @OptIn(ExperimentalCoroutinesApi::class) private val currentPosition = playbackState .flatMapLatest { state -> @@ -47,11 +56,13 @@ class PlayerViewModel( val uiState: StateFlow = combine( playbackState, currentPosition, - ) { playbackState, currentPositionMs -> + subtitle, + ) { playbackState, currentPosition, subtitle -> PlayerUiState( playbackState = playbackState, - positionMs = currentPositionMs.inWholeMilliseconds, + positionMs = currentPosition.inWholeMilliseconds, durationMs = audioPlayer.duration.inWholeMilliseconds, + subtitle = getSubtitleText(subtitle, currentPosition.inWholeMilliseconds), ) }.stateIn( scope = viewModelScope, @@ -73,6 +84,18 @@ class PlayerViewModel( } } + fun loadSubtitle(resourcePath: String) { + viewModelScope.launch { + subtitle.value = + try { + subtitleRepository.getSubtitle(resourcePath) + } catch (exception: Exception) { + logger.error(message = "Failed to load subtitle: $resourcePath", throwable = exception) + Subtitle(emptyList()) + } + } + } + fun performMainPlayAction() { when (uiState.value.playbackState) { PlaybackState.PLAYING -> audioPlayer.pause() diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt index e30e54a..1dcfc36 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/data/InMemoryRecordRepository.kt @@ -33,10 +33,12 @@ class InMemoryRecordRepository : RecordRepository { "Lec'hiiƱ", ).mapIndexed { index, title -> val number = index + 1 + val paddedNumber = number.toString().padStart(2, '0') Record( index = number, title = title, - audioResourcePath = "files/Chom_bev_${number.toString().padStart(2, '0')}.m4a", + audioResourcePath = "files/Chom_bev_$paddedNumber.m4a", + subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc", ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt index 061740b..2fafc9f 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/domain/Record.kt @@ -4,4 +4,5 @@ data class Record( val index: Int, val title: String, val audioResourcePath: String, + val subtitleResourcePath: String, ) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordView.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordView.kt index 61f5853..261ed22 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordView.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/records/ui/RecordView.kt @@ -40,7 +40,13 @@ fun RecordView( @Composable private fun RecordViewPreview() { RecordView( - record = Record(index = 1, title = "Sample Record", audioResourcePath = "files/Chom_bev_01.ogg"), + record = + Record( + index = 1, + title = "Sample Record", + audioResourcePath = "files/Chom_bev_01.ogg", + subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc", + ), onClick = {}, ) }