From e8bab1ef887ad9fa150fe1f37f70e828eea1a6ce Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 26 Jun 2026 12:12:11 +0200 Subject: [PATCH] new: add previous and next sentence navigation in PlayerControl and PlayerViewModel --- .../gwenedeg/player/ui/PlayerControl.kt | 60 ++++++++++++++++--- .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 6 ++ .../player/ui/viewmodel/PlayerViewModel.kt | 28 +++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt index c8d3f5f..c3c4f40 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlayerControl.kt @@ -3,14 +3,18 @@ package fr.ajaury.gwenedeg.player.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material3.Button import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Slider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -20,9 +24,11 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.theme.GwenedegTheme @Composable fun PlayerControl( @@ -30,6 +36,8 @@ fun PlayerControl( playbackTiming: PlaybackTiming, onSeek: (Float) -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {}, + onPreviousSentenceClicked: () -> Unit = {}, + onNextSentenceClicked: () -> Unit = {}, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -51,15 +59,40 @@ fun PlayerControl( modifier = Modifier.fillMaxWidth(), ) - Button( - modifier = Modifier.size(60.dp), - contentPadding = PaddingValues(0.dp), - onClick = onMainPlayActionButtonClicked, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Icon( - imageVector = playbackState.toActionIcon(), - contentDescription = playbackState.toActionContentDescription(), - ) + IconButton( + modifier = Modifier.size(60.dp), + onClick = onPreviousSentenceClicked, + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = "Previous sentence", + ) + } + + Button( + modifier = Modifier.size(60.dp), + contentPadding = PaddingValues(0.dp), + onClick = onMainPlayActionButtonClicked, + ) { + Icon( + imageVector = playbackState.toActionIcon(), + contentDescription = playbackState.toActionContentDescription(), + ) + } + + IconButton( + modifier = Modifier.size(60.dp), + onClick = onNextSentenceClicked, + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = "Next sentence", + ) + } } } } @@ -77,3 +110,14 @@ private fun PlaybackState.toActionContentDescription(): String = PlaybackState.ENDED -> "Replay" PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" } + +@Preview +@Composable +private fun PlayerControlPreview() { + GwenedegTheme { + PlayerControl( + playbackState = PlaybackState.PLAYING, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + ) + } +} 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 85fb5e4..0853a4e 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 @@ -50,6 +50,8 @@ fun PlayerScreen( onMainPlayActionButtonClicked = viewModel::performMainPlayAction, onSeekToTimePart = viewModel::seekToTimePart, onSeekToSentence = viewModel::seekToSentence, + onPreviousSentenceClicked = viewModel::goToPreviousSentence, + onNextSentenceClicked = viewModel::goToNextSentence, onBackClicked = onBackClicked, ) } @@ -61,6 +63,8 @@ fun PlayerScreen( onMainPlayActionButtonClicked: () -> Unit = {}, onSeekToTimePart: (progress: Float) -> Unit = {}, onSeekToSentence: (position: Int) -> Unit = {}, + onPreviousSentenceClicked: () -> Unit = {}, + onNextSentenceClicked: () -> Unit = {}, onBackClicked: () -> Unit = {}, ) { Scaffold( @@ -106,6 +110,8 @@ fun PlayerScreen( playbackTiming = uiState.playbackTiming, onSeek = onSeekToTimePart, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, + onPreviousSentenceClicked = onPreviousSentenceClicked, + onNextSentenceClicked = onNextSentenceClicked, ) } } 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 1525e45..c1d6581 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 @@ -165,6 +165,33 @@ class PlayerViewModel( audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds) } + fun goToPreviousSentence() { + val lines = subtitle.value.lines + val currentIndex = currentSentenceIndex() ?: return + val currentLine = lines.getOrNull(currentIndex) ?: return + val elapsedInSentence = + audioPlayer.currentPosition.inWholeMilliseconds - currentLine.startMs + val targetIndex = if (elapsedInSentence < PREVIOUS_SENTENCE_THRESHOLD.inWholeMilliseconds) { + (currentIndex - 1) + } else { + currentIndex + }.coerceAtLeast(0) + + seekToSentence(targetIndex) + } + + fun goToNextSentence() { + val lines = subtitle.value.lines + val currentIndex = currentSentenceIndex() ?: return + seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) + } + + private fun currentSentenceIndex(): Int? = + getCurrentSubtitleIndex( + subtitle = subtitle.value, + progressMs = audioPlayer.currentPosition.inWholeMilliseconds, + ) + fun stop() { audioPlayer.stop() } @@ -177,5 +204,6 @@ class PlayerViewModel( companion object { private val POSITION_POLL_INTERVAL = 50.milliseconds + private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds } }