From ea7dfe6470d0d44b39afee412c5779d29a823a29 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 3 Jul 2026 18:09:02 +0200 Subject: [PATCH] new: add adjustable break between phrases in playback settings --- .../gwenedeg/player/model/PlaybackState.kt | 1 + .../player/data/PlaybackRepositoryImpl.kt | 165 +++++++++++++++--- .../gwenedeg/player/data/SubtitleProgress.kt | 11 ++ .../player/domain/PlaybackRepository.kt | 16 +- .../data/InMemoryPreferencesRepository.kt | 5 + .../domain/PreferencesRepository.kt | 3 + .../preferences/model/PlaybackPreferences.kt | 10 ++ .../data/DataStorePreferencesRepository.kt | 9 + .../composeResources/values-br/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../player/ui/PlaybackSpeedBottomSheet.kt | 64 ++++--- .../gwenedeg/player/ui/PlayerControl.kt | 25 ++- .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 8 +- .../player/ui/components/LabeledSlider.kt | 63 +++++++ .../player/ui/components/PlayButton.kt | 78 +++++++-- .../ui/components/SpeedPreferenceButton.kt | 44 +++-- .../player/ui/viewmodel/PlayerUiState.kt | 8 +- .../player/ui/viewmodel/PlayerViewModel.kt | 41 ++++- 18 files changed, 440 insertions(+), 117 deletions(-) create mode 100644 data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/SubtitleProgress.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/LabeledSlider.kt diff --git a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackState.kt b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackState.kt index 35843ed..2a484d9 100644 --- a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackState.kt +++ b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/model/PlaybackState.kt @@ -4,5 +4,6 @@ enum class PlaybackState { IDLE, PLAYING, PAUSED, + BREAKING, ENDED, } diff --git a/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt index 5af3821..3915de2 100644 --- a/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt +++ b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/PlaybackRepositoryImpl.kt @@ -18,19 +18,29 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.times internal class PlaybackRepositoryImpl( private val audioPlayer: AudioPlayer, @@ -38,17 +48,35 @@ internal class PlaybackRepositoryImpl( private val subtitleRepository: SubtitleRepository, private val preferencesRepository: PreferencesRepository, private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, - private val dispatcherProvider: DispatcherProvider, private val logger: Logger, + dispatcherProvider: DispatcherProvider, ) : PlaybackRepository { // Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it. private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main) private var speedObservationJob: Job? = null + private var phraseBreakJob: Job? = null + + // Pause-then-resume of an in-progress phrase break; cancelled when the user takes over. + private var breakJob: Job? = null + private val isBreaking = MutableStateFlow(false) private val recordTitle = MutableStateFlow(Phrase(transcription = "")) private val subtitle = MutableStateFlow(Subtitle(emptyList())) - private val playbackState: StateFlow = audioPlayer.playbackState + private val playbackState: StateFlow = combine( + audioPlayer.playbackState, + isBreaking, + ) { playbackState, isBreaking -> + if (isBreaking && playbackState == PlaybackState.PAUSED) { + PlaybackState.BREAKING + } else { + playbackState + } + }.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = PlaybackState.IDLE, + ) private val duration: Duration get() = audioPlayer.duration @@ -73,28 +101,60 @@ internal class PlaybackRepositoryImpl( ) } - private val currentSubtitleIndex: Flow = combine( + private val subtitleProgress: Flow = combine( subtitle, currentPosition, ) { subtitle, position -> - getCurrentSubtitleIndex( + val currentSubtitleIndex = getCurrentSubtitleIndex( subtitle = subtitle, position = position, ) + + val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase( + currentSubtitleIndex = currentSubtitleIndex, + subtitle = subtitle, + position = position, + ) + + SubtitleProgress( + subtitle = subtitle, + currentPosition = position, + currentSubtitleIndex = currentSubtitleIndex, + isGoingToPlayNextPhrase = isGoingToPlayNextPhrase, + ) + } + + private fun isGoingToPlayNextPhrase( + currentSubtitleIndex: Int?, + subtitle: Subtitle, + position: Duration, + ): Boolean { + if (currentSubtitleIndex == null) { + return false + } + + val nextSubtitleIndex = currentSubtitleIndex + 1 + if (nextSubtitleIndex <= 0) { + return false + } + + val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex) + ?: return false + + return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL } override val playerState: Flow = combine( playbackState, - subtitle, playbackTiming, - currentSubtitleIndex, + subtitleProgress, recordTitle, - ) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle -> + ) { playbackState, playbackTiming, subtitleProgress, recordTitle -> PlayerState( playbackState = playbackState, playbackTiming = playbackTiming, - subtitleLines = subtitle.lines, - currentSubtitleIndex = currentSubtitleIndex, + subtitleLines = subtitleProgress.subtitle.lines, + currentSubtitleIndex = subtitleProgress.currentSubtitleIndex, recordTitle = recordTitle, ) } @@ -105,6 +165,7 @@ internal class PlaybackRepositoryImpl( recordTitle.value = record.title observePlaybackSpeed() + observePhraseBreaks() } private fun observePlaybackSpeed() { @@ -116,6 +177,49 @@ 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. + * + * Breaks only happen on automatic advancement — manual seeking and previous/next navigation are + * ignored via [suppressBreaks]. + */ + private suspend fun observePhraseBreaks() { + phraseBreakJob?.cancel() + cancelRunningBreakJob() + phraseBreakJob = subtitleProgress + .map { it.isGoingToPlayNextPhrase } + .distinctUntilChanged() + .filter { it } + .onEach { + if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach + + val playbackPreferences = preferencesRepository.playbackPreferences.first() + val breakDuration = playbackPreferences.breakBetweenPhrases + if (breakDuration <= Duration.ZERO) return@onEach + + insertBreakBeforePhrase(breakDuration = breakDuration) + }.launchIn(scope) + } + + private suspend fun insertBreakBeforePhrase(breakDuration: Duration) { + breakJob?.cancelAndJoin() + breakJob = scope.launch { + try { + // Pause and rewind the polling overshoot so the next phrase resumes from its very start. + isBreaking.value = true + audioPlayer.pause() + delay(breakDuration) + } finally { + // Only resume if still paused by this break; the user may have acted during it. + if (playbackState.firstOrNull() == PlaybackState.BREAKING) { + audioPlayer.play() + } + isBreaking.value = false + } + } + } + private fun playAudio(filePath: String) { try { audioPlayer.load(uri = resourceReader.uri(filePath)) @@ -142,20 +246,24 @@ internal class PlaybackRepositoryImpl( Subtitle(emptyList()) } - override fun play() { + override suspend fun play() { + cancelRunningBreakJob() audioPlayer.play() } - override fun pause() { + override suspend fun pause() { + cancelRunningBreakJob() audioPlayer.pause() } - override fun replay() { + override suspend fun replay() { + cancelRunningBreakJob() audioPlayer.seekTo(Duration.ZERO) audioPlayer.play() } - override fun seekTo(position: Duration) { + override suspend fun seekTo(position: Duration) { + cancelRunningBreakJob() audioPlayer.seekTo(position) } @@ -163,12 +271,7 @@ internal class PlaybackRepositoryImpl( audioPlayer.setSpeed(speed) } - override fun seekToSentence(sentenceIndex: Int) { - val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return - audioPlayer.seekTo(matchingSubtitleLine.startTime) - } - - override fun goToPreviousSentence() { + override suspend fun goToPreviousSentence() { val lines = subtitle.value.lines val currentIndex = currentSentenceIndex() ?: return val currentLine = lines.getOrNull(currentIndex) ?: return @@ -182,26 +285,46 @@ internal class PlaybackRepositoryImpl( seekToSentence(targetIndex) } - override fun goToNextSentence() { + override suspend fun goToNextSentence() { val lines = subtitle.value.lines val currentIndex = currentSentenceIndex() ?: return seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) } + override suspend fun seekToSentence(sentenceIndex: Int) { + cancelRunningBreakJob() + val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return + audioPlayer.seekTo(matchingSubtitleLine.startTime) + } + private fun currentSentenceIndex(): Int? = getCurrentSubtitleIndex( subtitle = subtitle.value, position = audioPlayer.currentPosition, ) - override fun stop() { + override suspend fun stop() { + cancelRunningBreakJob() audioPlayer.stop() } override fun release() { + breakJob?.cancel() + isBreaking.value = false audioPlayer.release() } + private suspend fun cancelRunningBreakJob() { + breakJob?.cancelAndJoin() + + // Unlock if paused by a break. + if (playbackState.value == PlaybackState.BREAKING) { + audioPlayer.play() + } + + isBreaking.value = false + } + companion object { private val POSITION_POLL_INTERVAL = 50.milliseconds private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds diff --git a/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/SubtitleProgress.kt b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/SubtitleProgress.kt new file mode 100644 index 0000000..dca6056 --- /dev/null +++ b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/data/SubtitleProgress.kt @@ -0,0 +1,11 @@ +package fr.ajaury.gwenedeg.player.data + +import fr.ajaury.gwenedeg.subtitle.model.Subtitle +import kotlin.time.Duration + +data class SubtitleProgress( + val subtitle: Subtitle, + val currentPosition: Duration, + val currentSubtitleIndex: Int?, + val isGoingToPlayNextPhrase: Boolean, +) diff --git a/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt index 53f6711..9666273 100644 --- a/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt +++ b/data/playback/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/PlaybackRepository.kt @@ -17,21 +17,21 @@ interface PlaybackRepository { suspend fun load(record: Record) - fun play() + suspend fun play() - fun pause() + suspend fun pause() - fun replay() + suspend fun replay() - fun seekTo(position: Duration) + suspend fun seekTo(position: Duration) - fun seekToSentence(sentenceIndex: Int) + suspend fun seekToSentence(sentenceIndex: Int) - fun goToPreviousSentence() + suspend fun goToPreviousSentence() - fun goToNextSentence() + suspend fun goToNextSentence() - fun stop() + suspend fun stop() fun release() } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt index f52b08f..15d9830 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/InMemoryPreferencesRepository.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlin.time.Duration /** * In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do @@ -34,4 +35,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository { override suspend fun setPlaybackSpeed(speed: Float) { playbackState.update { it.copy(speed = speed) } } + + override suspend fun setBreakBetweenPhrases(duration: Duration) { + playbackState.update { it.copy(breakBetweenPhrases = duration) } + } } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt index 7bf8a01..1b537af 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/domain/PreferencesRepository.kt @@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.preferences.domain import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import kotlinx.coroutines.flow.Flow +import kotlin.time.Duration interface PreferencesRepository { val subtitlePreferences: Flow @@ -16,4 +17,6 @@ interface PreferencesRepository { suspend fun setSubtitleTextScale(scale: Float) suspend fun setPlaybackSpeed(speed: Float) + + suspend fun setBreakBetweenPhrases(duration: Duration) } diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt index b8cbb4b..de00823 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt @@ -1,18 +1,28 @@ package fr.ajaury.gwenedeg.preferences.model +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + /** * User preferences controlling audio playback. * * @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in * [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. */ data class PlaybackPreferences( val speed: Float = DEFAULT_SPEED, + val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES, ) { companion object { const val DEFAULT_SPEED = 1f const val MIN_SPEED = 0.5f const val MAX_SPEED = 1f const val SPEED_STEP = 0.1f + + val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO + val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds + val BREAK_STEP: Duration = 1.seconds } } diff --git a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt index 6b6a3c0..c805e99 100644 --- a/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt +++ b/data/preferences/src/dataStoreMain/kotlin/fr/ajaury/gwenedeg/preferences/data/DataStorePreferencesRepository.kt @@ -6,12 +6,15 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * [PreferencesRepository] persisting to a Preferences [DataStore]. Reads are exposed as hot @@ -36,6 +39,9 @@ internal class DataStorePreferencesRepository( override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed } + override suspend fun setBreakBetweenPhrases(duration: Duration) = + edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() } + private suspend fun edit(transform: (MutablePreferences) -> Unit) { dataStore.edit(transform) } @@ -52,6 +58,8 @@ internal class DataStorePreferencesRepository( private fun Preferences.toPlaybackPreferences(): PlaybackPreferences = PlaybackPreferences( speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, + breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds + ?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, ) private companion object { @@ -59,5 +67,6 @@ internal class DataStorePreferencesRepository( val ShowTranslationKey = booleanPreferencesKey("show_translation") val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale") val PlaybackSpeedKey = floatPreferencesKey("playback_speed") + val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") } } diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index 829c53e..acb42e8 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -13,6 +13,9 @@ Brasaat ment an destenn Tizh al lenn + Arventennoù al lenn + Ehan etre ar frazennoù + %1$d s 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 5da01d5..001ec5b 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -13,6 +13,9 @@ Augmenter la taille du texte Vitesse de lecture + Réglages de lecture + Pause entre les phrases + %1$d s Retour Phrase précédente diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt index 5c77e5b..5411fe2 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt @@ -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, + ) } } 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 75c9525..6203310 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 @@ -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, ) } } 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 a08586e..31ecd2a 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 @@ -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 }, ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/LabeledSlider.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/LabeledSlider.kt new file mode 100644 index 0000000..5c9e1c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/LabeledSlider.kt @@ -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, + 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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt index a6f16b2..41b27f4 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt @@ -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() { diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt index f5c5f6f..366d708 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt @@ -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, ) } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt index b69c56d..47f83cc 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/viewmodel/PlayerUiState.kt @@ -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 } 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 98151d7..617a5d4 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 @@ -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() {