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.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),
)
}
}
@@ -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,
)
}
}
@@ -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
}
}