new: add adjustable break between phrases in playback settings

This commit is contained in:
2026-07-03 18:09:02 +02:00
parent b6e7a6a665
commit ea7dfe6470
18 changed files with 440 additions and 117 deletions
@@ -2,37 +2,37 @@ package fr.ajaury.gwenedeg.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.player.ui.components.LabeledSlider
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.resources.generated.resources.Res
import fr.ajaury.gwenedeg.resources.generated.resources.break_between_phrases_title
import fr.ajaury.gwenedeg.resources.generated.resources.break_between_phrases_value
import fr.ajaury.gwenedeg.resources.generated.resources.playback_speed_title
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
/**
* Bottom sheet letting the user pick the audio playback speed between
* [PlaybackPreferences.MIN_SPEED] and [PlaybackPreferences.MAX_SPEED].
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent
* pause inserted between phrases.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaybackSpeedBottomSheet(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
) {
@@ -42,7 +42,9 @@ fun PlaybackSpeedBottomSheet(
) {
PlaybackSpeedContent(
speed = speed,
breakSeconds = breakSeconds,
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
)
}
}
@@ -50,40 +52,43 @@ fun PlaybackSpeedBottomSheet(
@Composable
private fun PlaybackSpeedContent(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
) {
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
val innerSteps = with(PlaybackPreferences) {
val speedSteps = with(PlaybackPreferences) {
(((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0)
}
val maxBreakSeconds = PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds
val breakStepSeconds = PlaybackPreferences.BREAK_STEP.inWholeSeconds
val breakSteps = ((maxBreakSeconds / breakStepSeconds).toInt() - 1).coerceAtLeast(0)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.playback_speed_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = formatPlaybackSpeed(speed),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
LabeledSlider(
title = stringResource(Res.string.playback_speed_title),
valueLabel = formatPlaybackSpeed(speed),
value = speed,
onValueChange = onSpeedChange,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
steps = innerSteps,
steps = speedSteps,
onValueChange = onSpeedChange,
)
LabeledSlider(
title = stringResource(Res.string.break_between_phrases_title),
valueLabel = stringResource(
Res.string.break_between_phrases_value,
breakSeconds.roundToInt().toString(),
),
value = breakSeconds,
valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps,
onValueChange = onBreakChange,
)
}
}
@@ -98,6 +103,9 @@ internal fun formatPlaybackSpeed(speed: Float): String {
@Composable
private fun PlaybackSpeedContentPreview() {
ChomBevTheme {
PlaybackSpeedContent(speed = 0.7f)
PlaybackSpeedContent(
speed = 0.7f,
breakSeconds = 2f,
)
}
}
@@ -18,7 +18,6 @@ import fr.ajaury.gwenedeg.player.ui.components.PlayButton
import fr.ajaury.gwenedeg.player.ui.components.PlaybackProgress
import fr.ajaury.gwenedeg.player.ui.components.PreviousButton
import fr.ajaury.gwenedeg.player.ui.components.SpeedPreferenceButton
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -28,10 +27,10 @@ fun PlayerControl(
modifier: Modifier = Modifier,
playbackState: PlaybackState,
playbackTiming: PlaybackTiming,
breakBetweenPhrases: Duration,
canGoToPreviousSentence: Boolean,
canGoToNextSentence: Boolean,
isSpeedActive: Boolean,
playbackSpeed: Float,
isPlaybackModified: Boolean,
onSeek: (Duration) -> Unit = {},
onMainPlayActionButtonClicked: () -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
@@ -58,8 +57,7 @@ fun PlayerControl(
contentAlignment = Alignment.Center,
) {
SpeedPreferenceButton(
isSpeedActive = isSpeedActive,
playbackSpeed = playbackSpeed,
isActive = isPlaybackModified,
onSpeedClicked = onSpeedClicked,
)
}
@@ -76,6 +74,7 @@ fun PlayerControl(
PlayButton(
playbackState = playbackState,
breakDuration = breakBetweenPhrases,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
)
@@ -98,10 +97,10 @@ private fun PlayerControlPreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
isSpeedActive = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isPlaybackModified = false,
)
}
}
@@ -113,10 +112,10 @@ private fun PlayerControlFirstSentencePreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = false,
canGoToNextSentence = true,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isSpeedActive = false,
isPlaybackModified = false,
)
}
}
@@ -128,10 +127,10 @@ private fun PlayerControlActiveSpeedPreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
playbackSpeed = 0.7f,
isSpeedActive = true,
isPlaybackModified = true,
)
}
}
@@ -143,10 +142,10 @@ private fun PlayerControlLastSentencePreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isSpeedActive = false,
isPlaybackModified = false,
)
}
}
@@ -75,6 +75,7 @@ fun PlayerScreen(
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases,
onBackClicked = onBackClicked,
)
}
@@ -93,6 +94,7 @@ fun PlayerScreen(
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onBackClicked: () -> Unit = {},
) {
var showPreferences by remember { mutableStateOf(false) }
@@ -171,10 +173,10 @@ fun PlayerScreen(
modifier = Modifier.padding(horizontal = 8.dp),
playbackState = uiState.playerState.playbackState,
playbackTiming = uiState.playerState.playbackTiming,
breakBetweenPhrases = uiState.breakBetweenPhrases,
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
canGoToNextSentence = uiState.canGoToNextSentence,
playbackSpeed = uiState.playbackSpeed,
isSpeedActive = uiState.isSpeedActive,
isPlaybackModified = uiState.isPlaybackModified,
onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked,
@@ -197,7 +199,9 @@ fun PlayerScreen(
if (showSpeed) {
PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
onDismiss = { showSpeed = false },
)
}
@@ -0,0 +1,63 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.theme.ChomBevTheme
@Composable
fun LabeledSlider(
title: String,
valueLabel: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit = {},
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = valueLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
}
}
@Preview
@Composable
private fun LabeledSliderPreview() {
ChomBevTheme {
LabeledSlider(
title = "Playback speed",
valueLabel = "x0.7",
value = 0.7f,
valueRange = 0.5f..2f,
steps = 10,
)
}
}
@@ -1,5 +1,9 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
@@ -7,8 +11,12 @@ import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
@@ -20,27 +28,66 @@ import fr.ajaury.gwenedeg.resources.generated.resources.player_play
import fr.ajaury.gwenedeg.resources.generated.resources.player_replay
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private val ButtonSize = 60.dp
private val BreakRingSize = 68.dp
@Composable
fun PlayButton(
playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO,
onMainPlayActionButtonClicked: () -> Unit = {},
) {
Button(
modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked,
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
val breakProgress = remember { Animatable(0f) }
LaunchedEffect(isBreaking, breakDuration) {
if (isBreaking) {
breakProgress.snapTo(0f)
breakProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = breakDuration.inWholeMilliseconds.toInt(),
easing = LinearEasing,
),
)
} else {
breakProgress.snapTo(0f)
}
}
// The ring reserves its footprint at all times so the transport row does not jump when a break
// starts; the indicator itself is only drawn while breaking.
Box(
modifier = Modifier.size(BreakRingSize),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = playbackState.toActionIcon(),
contentDescription = playbackState.toActionContentDescription(),
)
if (isBreaking) {
CircularProgressIndicator(
progress = { breakProgress.value },
modifier = Modifier.size(BreakRingSize),
strokeWidth = 3.dp,
)
}
Button(
modifier = Modifier.size(ButtonSize),
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked,
) {
Icon(
imageVector = playbackState.toActionIcon(),
contentDescription = playbackState.toActionContentDescription(),
)
}
}
}
private fun PlaybackState.toActionIcon(): ImageVector =
when (this) {
PlaybackState.PLAYING -> Icons.Default.Pause
PlaybackState.PLAYING, PlaybackState.BREAKING -> Icons.Default.Pause
PlaybackState.ENDED -> Icons.Default.Replay
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
}
@@ -48,7 +95,7 @@ private fun PlaybackState.toActionIcon(): ImageVector =
@Composable
private fun PlaybackState.toActionContentDescription(): String =
when (this) {
PlaybackState.PLAYING -> stringResource(Res.string.player_pause)
PlaybackState.PLAYING, PlaybackState.BREAKING -> stringResource(Res.string.player_pause)
PlaybackState.ENDED -> stringResource(Res.string.player_replay)
PlaybackState.PAUSED, PlaybackState.IDLE -> stringResource(Res.string.player_play)
}
@@ -63,6 +110,17 @@ private fun PlayButtonPlayingPreview() {
}
}
@Preview
@Composable
private fun PlayButtonBreakingPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.BREAKING,
breakDuration = 3.seconds,
)
}
}
@Preview
@Composable
private fun PlayButtonPausedPreview() {
@@ -1,40 +1,38 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.player.ui.formatPlaybackSpeed
import fr.ajaury.gwenedeg.resources.generated.resources.Res
import fr.ajaury.gwenedeg.resources.generated.resources.playback_settings_action
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
/**
* Opens the playback settings sheet. Tinted with the primary color when a playback setting (speed or
* break between phrases) differs from its default.
*/
@Composable
fun SpeedPreferenceButton(
isSpeedActive: Boolean,
playbackSpeed: Float,
isActive: Boolean,
onSpeedClicked: () -> Unit = {},
) {
TextButton(
IconButton(
modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp),
onClick = onSpeedClicked,
colors = ButtonDefaults.textButtonColors(
contentColor = if (isSpeedActive) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
},
),
) {
Text(
text = formatPlaybackSpeed(playbackSpeed),
style = MaterialTheme.typography.labelLarge,
Icon(
imageVector = Icons.Default.Speed,
contentDescription = stringResource(Res.string.playback_settings_action),
tint = if (isActive) MaterialTheme.colorScheme.primary else LocalContentColor.current,
)
}
}
@@ -44,19 +42,17 @@ fun SpeedPreferenceButton(
private fun SpeedPreferenceButtonDefaultPreview() {
ChomBevTheme {
SpeedPreferenceButton(
isSpeedActive = false,
playbackSpeed = 1f,
isActive = false,
)
}
}
@Preview
@Composable
private fun SpeedPreferenceButtonPreview() {
private fun SpeedPreferenceButtonActivePreview() {
ChomBevTheme {
SpeedPreferenceButton(
isSpeedActive = true,
playbackSpeed = 0.7f,
isActive = true,
)
}
}
@@ -4,6 +4,7 @@ import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class PlayerUiState(
@@ -12,6 +13,7 @@ data class PlayerUiState(
val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
) {
val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds
@@ -19,6 +21,8 @@ data class PlayerUiState(
val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
val isSpeedActive: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED
/** True when either playback setting differs from its default, used to highlight the button. */
val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES
}
@@ -46,6 +46,7 @@ class PlayerViewModel(
playerState = playerState,
subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
)
}.stateIn(
scope = viewModelScope,
@@ -79,27 +80,37 @@ class PlayerViewModel(
}
fun performMainPlayAction() {
when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
viewModelScope.launch {
when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
}
}
}
fun seekToTimePart(progress: Duration) {
playbackRepository.seekTo(progress)
viewModelScope.launch {
playbackRepository.seekTo(progress)
}
}
fun seekToSentence(sentenceIndex: Int) {
playbackRepository.seekToSentence(sentenceIndex)
viewModelScope.launch {
playbackRepository.seekToSentence(sentenceIndex)
}
}
fun goToPreviousSentence() {
playbackRepository.goToPreviousSentence()
viewModelScope.launch {
playbackRepository.goToPreviousSentence()
}
}
fun goToNextSentence() {
playbackRepository.goToNextSentence()
viewModelScope.launch {
playbackRepository.goToNextSentence()
}
}
fun setPlaybackSpeed(speed: Float) {
@@ -114,6 +125,16 @@ class PlayerViewModel(
}
}
fun setBreakBetweenPhrases(seconds: Float) {
val clampedSeconds = seconds
.roundToInt()
.coerceIn(0, PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds.toInt())
viewModelScope.launch {
preferencesRepository.setBreakBetweenPhrases(clampedSeconds.seconds)
}
}
fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled)
@@ -143,7 +164,9 @@ class PlayerViewModel(
}
fun stop() {
playbackRepository.stop()
viewModelScope.launch {
playbackRepository.stop()
}
}
override fun onCleared() {