new: add previous and next sentence navigation in PlayerControl and PlayerViewModel

This commit is contained in:
2026-06-26 12:12:11 +02:00
parent 82c7a16473
commit e8bab1ef88
3 changed files with 86 additions and 8 deletions
@@ -3,14 +3,18 @@ package fr.ajaury.gwenedeg.player.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay 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.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -20,9 +24,11 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.player.model.PlaybackState
import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.model.PlaybackTiming
import fr.ajaury.gwenedeg.theme.GwenedegTheme
@Composable @Composable
fun PlayerControl( fun PlayerControl(
@@ -30,6 +36,8 @@ fun PlayerControl(
playbackTiming: PlaybackTiming, playbackTiming: PlaybackTiming,
onSeek: (Float) -> Unit = {}, onSeek: (Float) -> Unit = {},
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
onNextSentenceClicked: () -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -51,6 +59,20 @@ fun PlayerControl(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
IconButton(
modifier = Modifier.size(60.dp),
onClick = onPreviousSentenceClicked,
) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous sentence",
)
}
Button( Button(
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
@@ -61,6 +83,17 @@ fun PlayerControl(
contentDescription = playbackState.toActionContentDescription(), 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.ENDED -> "Replay"
PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" PlaybackState.PAUSED, PlaybackState.IDLE -> "Play"
} }
@Preview
@Composable
private fun PlayerControlPreview() {
GwenedegTheme {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000),
)
}
}
@@ -50,6 +50,8 @@ fun PlayerScreen(
onMainPlayActionButtonClicked = viewModel::performMainPlayAction, onMainPlayActionButtonClicked = viewModel::performMainPlayAction,
onSeekToTimePart = viewModel::seekToTimePart, onSeekToTimePart = viewModel::seekToTimePart,
onSeekToSentence = viewModel::seekToSentence, onSeekToSentence = viewModel::seekToSentence,
onPreviousSentenceClicked = viewModel::goToPreviousSentence,
onNextSentenceClicked = viewModel::goToNextSentence,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
} }
@@ -61,6 +63,8 @@ fun PlayerScreen(
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
onSeekToTimePart: (progress: Float) -> Unit = {}, onSeekToTimePart: (progress: Float) -> Unit = {},
onSeekToSentence: (position: Int) -> Unit = {}, onSeekToSentence: (position: Int) -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
onNextSentenceClicked: () -> Unit = {},
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},
) { ) {
Scaffold( Scaffold(
@@ -106,6 +110,8 @@ fun PlayerScreen(
playbackTiming = uiState.playbackTiming, playbackTiming = uiState.playbackTiming,
onSeek = onSeekToTimePart, onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked,
onNextSentenceClicked = onNextSentenceClicked,
) )
} }
} }
@@ -165,6 +165,33 @@ class PlayerViewModel(
audioPlayer.seekTo(matchingSubtitleLine.startMs.milliseconds) 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() { fun stop() {
audioPlayer.stop() audioPlayer.stop()
} }
@@ -177,5 +204,6 @@ class PlayerViewModel(
companion object { companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
} }
} }