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(
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() },
)
}
@@ -12,5 +12,6 @@ sealed interface Route : NavKey {
data class Player(
val title: String,
val audioResourcePath: String,
val subtitleResourcePath: String,
) : Route
}
@@ -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<Float?>(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 = {},
@@ -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
@@ -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<PlayerUiState> = 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()
@@ -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",
)
}
@@ -4,4 +4,5 @@ data class Record(
val index: Int,
val title: String,
val audioResourcePath: String,
val subtitleResourcePath: String,
)
@@ -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 = {},
)
}