From 23eb83490fb7025edbb44e3e156b33987b89ead8 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 4 Aug 2026 18:19:57 +0200 Subject: [PATCH] feature: add auto-pause playback feature --- .../player/data/PlaybackRepositoryImpl.kt | 18 +++++++--- .../data/InMemoryPreferencesRepository.kt | 4 +++ .../domain/PreferencesRepository.kt | 2 ++ .../preferences/model/PlaybackPreferences.kt | 5 +++ .../data/DataStorePreferencesRepository.kt | 4 +++ .../composeResources/values-br/strings.xml | 2 ++ .../composeResources/values/strings.xml | 2 ++ .../player/ui/PlaybackSpeedBottomSheet.kt | 35 +++++++++++++++++-- .../ajaury/chombev/player/ui/PlayerScreen.kt | 4 +++ .../player/ui/components/LabeledSlider.kt | 23 +++++++++++- .../player/ui/viewmodel/PlayerUiState.kt | 5 +-- .../player/ui/viewmodel/PlayerViewModel.kt | 7 ++++ .../preferences/ui/PreferenceToggle.kt | 2 +- .../ui/SubtitlePreferencesBottomSheet.kt | 2 +- 14 files changed, 104 insertions(+), 11 deletions(-) diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt index 4666592..9e40849 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt @@ -186,11 +186,16 @@ internal class PlaybackRepositoryImpl( } /** - * Inserts the configured silent break between spoken phrases: when continuous playback reaches - * the next phrase, playback pauses just before that phrase starts, then resumes after the break. + * Reacts to reaching the next spoken phrase during continuous playback, just before that phrase + * starts. Depending on preferences it either: + * - pauses and waits for the user to resume, when auto-pause is enabled, or + * - pauses for the configured silent break and then resumes on its own. * - * Breaks only happen on automatic advancement — manual seeking and previous/next navigation are - * ignored via [suppressBreaks]. + * Auto-pause takes precedence: while it is on, the break between phrases is ignored. + * + * This only happens on automatic advancement — manual seeking and previous/next navigation are + * ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING] + * through this path. */ private suspend fun observePhraseBreaks() { phraseBreakJob?.cancel() @@ -203,6 +208,11 @@ internal class PlaybackRepositoryImpl( if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach val playbackPreferences = preferencesRepository.playbackPreferences.first() + if (playbackPreferences.autoPause) { + audioPlayer.pause() + return@onEach + } + val breakDuration = playbackPreferences.breakBetweenPhrases if (breakDuration <= Duration.ZERO) return@onEach diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt index 60c3af3..6064e3d 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/data/InMemoryPreferencesRepository.kt @@ -43,4 +43,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { override suspend fun setBreakBetweenPhrases(duration: Duration) { playbackState.update { it.copy(breakBetweenPhrases = duration) } } + + override suspend fun setAutoPause(enabled: Boolean) { + playbackState.update { it.copy(autoPause = enabled) } + } } diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt index a996b06..439bdc5 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/domain/PreferencesRepository.kt @@ -21,4 +21,6 @@ interface PreferencesRepository { suspend fun setPlaybackSpeed(speed: Float) suspend fun setBreakBetweenPhrases(duration: Duration) + + suspend fun setAutoPause(enabled: Boolean) } diff --git a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt index 2c2661d..cdbfdbb 100644 --- a/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt +++ b/data/preferences/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/model/PlaybackPreferences.kt @@ -10,10 +10,13 @@ import kotlin.time.Duration.Companion.seconds * [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed. * @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next, * between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps. + * @property autoPause when enabled, playback stops at the end of every phrase and waits for the + * user to resume. This takes precedence over [breakBetweenPhrases], which is then ignored. */ data class PlaybackPreferences( val speed: Float = DEFAULT_SPEED, val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES, + val autoPause: Boolean = DEFAULT_AUTO_PAUSE, ) { companion object { const val DEFAULT_SPEED = 1f @@ -24,5 +27,7 @@ data class PlaybackPreferences( val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds val BREAK_STEP: Duration = 1.seconds + + const val DEFAULT_AUTO_PAUSE = false } } diff --git a/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt b/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt index b415f52..37a8084 100644 --- a/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt +++ b/data/preferences/src/dataStoreMain/kotlin/bzh/ajaury/chombev/preferences/data/DataStorePreferencesRepository.kt @@ -44,6 +44,8 @@ internal class DataStorePreferencesRepository( override suspend fun setBreakBetweenPhrases(duration: Duration) = edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() } + override suspend fun setAutoPause(enabled: Boolean) = edit { it[AutoPauseKey] = enabled } + private suspend fun edit(transform: (MutablePreferences) -> Unit) { dataStore.edit(transform) } @@ -63,6 +65,7 @@ internal class DataStorePreferencesRepository( speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds ?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, + autoPause = this[AutoPauseKey] ?: PlaybackPreferences.DEFAULT_AUTO_PAUSE, ) private companion object { @@ -72,5 +75,6 @@ internal class DataStorePreferencesRepository( val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker") val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") + val AutoPauseKey = booleanPreferencesKey("auto_pause") } } diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index dabe1cf..b9000f3 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -25,6 +25,8 @@ Arventennoù al lenn Ehan etre ar frazennoù %1$d s + Ehan emgefreek + Ehanañ goude pep frazenn Distreiñ Frazenn a-raok diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 0f49516..24e15c9 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -25,6 +25,8 @@ Réglages de lecture Pause entre les phrases %1$d s + Pause automatique + Mettre en pause après chaque phrase Retour Phrase précédente diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt index 617ca5c..91078e8 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt @@ -15,7 +15,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.player.ui.components.LabeledSlider import bzh.ajaury.chombev.preferences.model.PlaybackPreferences +import bzh.ajaury.chombev.preferences.ui.PreferenceToggle import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description +import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title @@ -23,16 +26,19 @@ import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt /** - * Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent - * pause inserted between phrases. + * Bottom sheet letting the user adjust playback: the audio [speed], whether playback stops after + * each phrase ([autoPause]), and the [breakSeconds] silent pause inserted between phrases. When + * [autoPause] is on, the break setting is ignored and shown as disabled. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackSpeedBottomSheet( speed: Float, breakSeconds: Float, + autoPause: Boolean, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, onDismiss: () -> Unit = {}, sheetState: SheetState = rememberModalBottomSheetState(), ) { @@ -43,8 +49,10 @@ fun PlaybackSpeedBottomSheet( PlaybackSpeedContent( speed = speed, breakSeconds = breakSeconds, + autoPause = autoPause, onSpeedChange = onSpeedChange, onBreakChange = onBreakChange, + onAutoPauseChange = onAutoPauseChange, ) } } @@ -53,8 +61,10 @@ fun PlaybackSpeedBottomSheet( private fun PlaybackSpeedContent( speed: Float, breakSeconds: Float, + autoPause: Boolean, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, ) { // Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4). val speedSteps = with(PlaybackPreferences) { @@ -79,6 +89,12 @@ private fun PlaybackSpeedContent( steps = speedSteps, onValueChange = onSpeedChange, ) + PreferenceToggle( + title = stringResource(Res.string.auto_pause_title), + subtitle = stringResource(Res.string.auto_pause_description), + checked = autoPause, + onCheckedChange = onAutoPauseChange, + ) LabeledSlider( title = stringResource(Res.string.break_between_phrases_title), valueLabel = stringResource( @@ -89,6 +105,8 @@ private fun PlaybackSpeedContent( valueRange = 0f..maxBreakSeconds.toFloat(), steps = breakSteps, onValueChange = onBreakChange, + // Auto-pause supersedes the break between phrases, so disable it while on. + enabled = !autoPause, ) } } @@ -106,6 +124,19 @@ private fun PlaybackSpeedContentPreview() { PlaybackSpeedContent( speed = 0.7f, breakSeconds = 2f, + autoPause = false, + ) + } +} + +@Preview +@Composable +private fun PlaybackSpeedContentAutoPausePreview() { + MaterialTheme { + PlaybackSpeedContent( + speed = 0.7f, + breakSeconds = 2f, + autoPause = true, ) } } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index f2f70f6..d3b4a40 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -82,6 +82,7 @@ fun PlayerScreen( onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onSpeedChange = viewModel::setPlaybackSpeed, onBreakChange = viewModel::setBreakBetweenPhrases, + onAutoPauseChange = viewModel::setAutoPause, onBackClicked = onBackClicked, ) } @@ -102,6 +103,7 @@ fun PlayerScreen( onTextSizeIncrease: () -> Unit = {}, onSpeedChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {}, + onAutoPauseChange: (Boolean) -> Unit = {}, onBackClicked: () -> Unit = {}, ) { var showPreferences by remember { mutableStateOf(false) } @@ -166,8 +168,10 @@ fun PlayerScreen( PlaybackSpeedBottomSheet( speed = uiState.playbackSpeed, breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(), + autoPause = uiState.autoPause, onSpeedChange = onSpeedChange, onBreakChange = onBreakChange, + onAutoPauseChange = onAutoPauseChange, onDismiss = { showSpeed = false }, ) } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt index a064ba2..0a8bffd 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt @@ -21,7 +21,11 @@ fun LabeledSlider( valueRange: ClosedFloatingPointRange, steps: Int, onValueChange: (Float) -> Unit = {}, + enabled: Boolean = true, ) { + // Dim the labels to match the Slider's disabled appearance. + val contentAlpha = if (enabled) 1f else 0.38f + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -31,11 +35,12 @@ fun LabeledSlider( Text( text = title, style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), ) Text( text = valueLabel, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha), ) } Slider( @@ -43,6 +48,7 @@ fun LabeledSlider( onValueChange = onValueChange, valueRange = valueRange, steps = steps, + enabled = enabled, ) } } @@ -60,3 +66,18 @@ private fun LabeledSliderPreview() { ) } } + +@Preview +@Composable +private fun LabeledSliderDisabledPreview() { + MaterialTheme { + LabeledSlider( + title = "Playback speed", + valueLabel = "x0.7", + value = 0.7f, + valueRange = 0.5f..2f, + steps = 10, + enabled = false, + ) + } +} diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt index 6b122f1..c411806 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt @@ -14,6 +14,7 @@ data class PlayerUiState( val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, + val autoPause: Boolean = PlaybackPreferences.DEFAULT_AUTO_PAUSE, ) { val canGoToPreviousSentence: Boolean get() = playerState.playbackTiming.position > 2.seconds @@ -21,8 +22,8 @@ data class PlayerUiState( val canGoToNextSentence: Boolean get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex - /** True when either playback setting differs from its default, used to highlight the button. */ + /** True when any 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 + breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES || autoPause } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt index 59765d7..25b4a2d 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt @@ -46,6 +46,7 @@ class PlayerViewModel( subtitlePreferences = subtitlePreferences, playbackSpeed = playbackPreferences.speed, breakBetweenPhrases = playbackPreferences.breakBetweenPhrases, + autoPause = playbackPreferences.autoPause, ) }.stateIn( scope = viewModelScope, @@ -136,6 +137,12 @@ class PlayerViewModel( } } + fun setAutoPause(enabled: Boolean) { + viewModelScope.launch { + preferencesRepository.setAutoPause(enabled) + } + } + fun setShowTranscription(enabled: Boolean) { viewModelScope.launch { preferencesRepository.setShowTranscription(enabled) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt index 8a891c1..68219dc 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/PreferenceToggle.kt @@ -46,7 +46,7 @@ fun PreferenceToggle( Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { Text( text = title, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) Text( text = subtitle, diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt index dc4909a..95283ed 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt @@ -85,7 +85,7 @@ private fun SubtitlePreferencesContent( ) { Text( text = stringResource(Res.string.subtitle_preferences_title), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, ) PreferenceToggle( title = stringResource(Res.string.subtitle_transcription_label),