new: integrate subtitles into playback, update models, UI, and navigation

This commit is contained in:
2026-06-19 14:34:12 +02:00
parent 1928a7ba4d
commit d86a18fcea
8 changed files with 54 additions and 8 deletions
@@ -45,6 +45,7 @@ fun App() {
Route.Player( Route.Player(
title = record.title, title = record.title,
audioResourcePath = record.audioResourcePath, audioResourcePath = record.audioResourcePath,
subtitleResourcePath = record.subtitleResourcePath,
), ),
) )
}, },
@@ -55,6 +56,7 @@ fun App() {
PlayerScreen( PlayerScreen(
recordTitle = key.title, recordTitle = key.title,
audioResourcePath = key.audioResourcePath, audioResourcePath = key.audioResourcePath,
subtitleResourcePath = key.subtitleResourcePath,
onBackClicked = { backStack.removeLastOrNull() }, onBackClicked = { backStack.removeLastOrNull() },
) )
} }
@@ -12,5 +12,6 @@ sealed interface Route : NavKey {
data class Player( data class Player(
val title: String, val title: String,
val audioResourcePath: String, val audioResourcePath: String,
val subtitleResourcePath: String,
) : Route ) : Route
} }
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -45,13 +46,15 @@ import org.koin.compose.viewmodel.koinViewModel
fun PlayerScreen( fun PlayerScreen(
recordTitle: String, recordTitle: String,
audioResourcePath: String, audioResourcePath: String,
subtitleResourcePath: String,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
viewModel: PlayerViewModel = koinViewModel(), viewModel: PlayerViewModel = koinViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(audioResourcePath) { DisposableEffect(audioResourcePath, subtitleResourcePath) {
viewModel.play(filePath = audioResourcePath) viewModel.play(filePath = audioResourcePath)
viewModel.loadSubtitle(resourcePath = subtitleResourcePath)
onDispose { onDispose {
viewModel.stop() viewModel.stop()
} }
@@ -108,6 +111,13 @@ fun PlayerScreen(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text(
text = uiState.subtitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
var seekProgress by remember { mutableStateOf<Float?>(null) } var seekProgress by remember { mutableStateOf<Float?>(null) }
Slider( Slider(
value = seekProgress ?: uiState.progress, value = seekProgress ?: uiState.progress,
@@ -158,7 +168,7 @@ private fun PlayerScreenPlayingPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -172,7 +182,7 @@ private fun PlayerScreenPausedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -186,7 +196,7 @@ private fun PlayerScreenEndedPreview() {
GwenedegTheme { GwenedegTheme {
PlayerScreen( PlayerScreen(
recordTitle = "Demat", 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 = {}, onMainPlayActionButtonClicked = {},
onSeek = {}, onSeek = {},
onBackClicked = {}, onBackClicked = {},
@@ -6,6 +6,7 @@ data class PlayerUiState(
val playbackState: PlaybackState = PlaybackState.IDLE, val playbackState: PlaybackState = PlaybackState.IDLE,
val positionMs: Long = 0L, val positionMs: Long = 0L,
val durationMs: Long = 0L, val durationMs: Long = 0L,
val subtitle: String = "",
) { ) {
val progress: Float val progress: Float
get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f get() = if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
@@ -6,9 +6,13 @@ import fr.ajaury.gwenedeg.core.logging.Logger
import fr.ajaury.gwenedeg.player.AudioPlayer import fr.ajaury.gwenedeg.player.AudioPlayer
import fr.ajaury.gwenedeg.player.AudioSessionManager import fr.ajaury.gwenedeg.player.AudioSessionManager
import fr.ajaury.gwenedeg.player.PlaybackState 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 gwenedeg.shared.generated.resources.Res
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted 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
@@ -17,18 +21,23 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class PlayerViewModel( class PlayerViewModel(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
private val audioSessionManager: AudioSessionManager, private val audioSessionManager: AudioSessionManager,
private val subtitleRepository: SubtitleRepository,
private val getSubtitleText: GetSubtitleTextUseCase,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private var filePath: String? = null private var filePath: String? = null
private val playbackState = audioPlayer.playbackState private val playbackState = audioPlayer.playbackState
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private val currentPosition = playbackState private val currentPosition = playbackState
.flatMapLatest { state -> .flatMapLatest { state ->
@@ -47,11 +56,13 @@ class PlayerViewModel(
val uiState: StateFlow<PlayerUiState> = combine( val uiState: StateFlow<PlayerUiState> = combine(
playbackState, playbackState,
currentPosition, currentPosition,
) { playbackState, currentPositionMs -> subtitle,
) { playbackState, currentPosition, subtitle ->
PlayerUiState( PlayerUiState(
playbackState = playbackState, playbackState = playbackState,
positionMs = currentPositionMs.inWholeMilliseconds, positionMs = currentPosition.inWholeMilliseconds,
durationMs = audioPlayer.duration.inWholeMilliseconds, durationMs = audioPlayer.duration.inWholeMilliseconds,
subtitle = getSubtitleText(subtitle, currentPosition.inWholeMilliseconds),
) )
}.stateIn( }.stateIn(
scope = viewModelScope, 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() { fun performMainPlayAction() {
when (uiState.value.playbackState) { when (uiState.value.playbackState) {
PlaybackState.PLAYING -> audioPlayer.pause() PlaybackState.PLAYING -> audioPlayer.pause()
@@ -33,10 +33,12 @@ class InMemoryRecordRepository : RecordRepository {
"Lec'hiiñ", "Lec'hiiñ",
).mapIndexed { index, title -> ).mapIndexed { index, title ->
val number = index + 1 val number = index + 1
val paddedNumber = number.toString().padStart(2, '0')
Record( Record(
index = number, index = number,
title = title, 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",
) )
} }
@@ -4,4 +4,5 @@ data class Record(
val index: Int, val index: Int,
val title: String, val title: String,
val audioResourcePath: String, val audioResourcePath: String,
val subtitleResourcePath: String,
) )
@@ -40,7 +40,13 @@ fun RecordView(
@Composable @Composable
private fun RecordViewPreview() { private fun RecordViewPreview() {
RecordView( 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 = {}, onClick = {},
) )
} }