From 3847f46adbe65cad24cb60cf5f2d06516bd4fc93 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 3 Jul 2026 10:37:28 +0200 Subject: [PATCH] new: add playback speed control --- .../player/data/AndroidAudioPlayer.kt | 4 + .../gwenedeg/player/domain/AudioPlayer.kt | 3 + .../gwenedeg/player/data/IosAudioPlayer.kt | 17 ++- .../gwenedeg/player/data/JvmAudioPlayer.kt | 21 +++ .../gwenedeg/player/data/WebAudioPlayer.kt | 8 + .../player/data/PlaybackRepositoryImpl.kt | 4 + .../player/domain/PlaybackRepository.kt | 2 + .../data/PreferencesRepositoryImpl.kt | 16 +- .../domain/PreferencesRepository.kt | 5 + .../preferences/model/PlaybackPreferences.kt | 18 +++ .../player/ui/PlaybackSpeedBottomSheet.kt | 100 +++++++++++++ .../gwenedeg/player/ui/PlayerControl.kt | 140 +++++++++--------- .../ajaury/gwenedeg/player/ui/PlayerScreen.kt | 19 ++- .../player/ui/components/NextButton.kt | 49 ++++++ .../player/ui/components/PlayButton.kt | 88 +++++++++++ .../player/ui/components/PlaybackProgress.kt | 66 +++++++++ .../player/ui/components/PreviousButton.kt | 49 ++++++ .../ui/components/SpeedPreferenceButton.kt | 62 ++++++++ .../player/ui/viewmodel/PlayerUiState.kt | 5 + .../player/ui/viewmodel/PlayerViewModel.kt | 22 ++- 20 files changed, 618 insertions(+), 80 deletions(-) create mode 100644 data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/NextButton.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlaybackProgress.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PreviousButton.kt create mode 100644 shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt diff --git a/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/data/AndroidAudioPlayer.kt b/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/data/AndroidAudioPlayer.kt index 4239b30..d172692 100644 --- a/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/data/AndroidAudioPlayer.kt +++ b/core/audioplayer/src/androidMain/kotlin/fr/ajaury/gwenedeg/player/data/AndroidAudioPlayer.kt @@ -61,6 +61,10 @@ internal class AndroidAudioPlayer( player.prepare() } + override fun setSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + } + override fun play() { player.play() } diff --git a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/AudioPlayer.kt b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/AudioPlayer.kt index 1c4ce42..84f565c 100644 --- a/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/AudioPlayer.kt +++ b/core/audioplayer/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/domain/AudioPlayer.kt @@ -22,4 +22,7 @@ interface AudioPlayer { fun release() fun load(uri: String) + + /** Sets the playback speed multiplier (e.g. 1.0 for normal speed, 0.5 for half speed). */ + fun setSpeed(speed: Float) } diff --git a/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/data/IosAudioPlayer.kt b/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/data/IosAudioPlayer.kt index db12066..0270ed8 100644 --- a/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/data/IosAudioPlayer.kt +++ b/core/audioplayer/src/iosMain/kotlin/fr/ajaury/gwenedeg/player/data/IosAudioPlayer.kt @@ -6,14 +6,16 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import platform.AVFoundation.AVAudioTimePitchAlgorithmTimeDomain import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItem import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification +import platform.AVFoundation.audioTimePitchAlgorithm import platform.AVFoundation.currentItem import platform.AVFoundation.currentTime import platform.AVFoundation.duration import platform.AVFoundation.pause -import platform.AVFoundation.play +import platform.AVFoundation.rate import platform.AVFoundation.replaceCurrentItemWithPlayerItem import platform.AVFoundation.seekToTime import platform.CoreMedia.CMTimeGetSeconds @@ -29,6 +31,7 @@ import kotlin.time.Duration.Companion.seconds internal class IosAudioPlayer : AudioPlayer { private var player: AVPlayer? = null private var endObserver: NSObjectProtocol? = null + private var speed: Float = 1f private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() @@ -63,6 +66,8 @@ internal class IosAudioPlayer : AudioPlayer { val nsUrl = NSURL.URLWithString(uri) ?: error("Invalid audio URI: $uri") val item = AVPlayerItem(uRL = nsUrl) + // Time-domain stretching preserves the pitch when the rate is below 1.0. + item.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithmTimeDomain player = AVPlayer(playerItem = item) endObserver = NSNotificationCenter.defaultCenter.addObserverForName( @@ -75,10 +80,18 @@ internal class IosAudioPlayer : AudioPlayer { } override fun play() { - player?.play() + // Setting a positive rate resumes playback; using the stored speed keeps the chosen tempo. + player?.rate = speed _playbackState.value = PlaybackState.PLAYING } + override fun setSpeed(speed: Float) { + this.speed = speed + if (_playbackState.value == PlaybackState.PLAYING) { + player?.rate = speed + } + } + override fun pause() { player?.pause() _playbackState.value = PlaybackState.PAUSED diff --git a/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/data/JvmAudioPlayer.kt b/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/data/JvmAudioPlayer.kt index ade088b..ef8bbd5 100644 --- a/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/data/JvmAudioPlayer.kt +++ b/core/audioplayer/src/jvmMain/kotlin/fr/ajaury/gwenedeg/player/data/JvmAudioPlayer.kt @@ -10,6 +10,7 @@ import java.net.URI import javax.sound.sampled.AudioFormat import javax.sound.sampled.AudioSystem import javax.sound.sampled.Clip +import javax.sound.sampled.FloatControl import javax.sound.sampled.LineEvent import kotlin.time.Duration import kotlin.time.Duration.Companion.microseconds @@ -26,6 +27,9 @@ internal class JvmAudioPlayer : AudioPlayer { private var stoppedByUser = false + private var speed: Float = 1f + private var baseSampleRate: Float = 0f + private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() @@ -77,6 +81,23 @@ internal class JvmAudioPlayer : AudioPlayer { } open(decodedInput) } + baseSampleRate = pcmFormat.sampleRate + applySpeed() + } + + override fun setSpeed(speed: Float) { + this.speed = speed + applySpeed() + } + + /** + * Adjusts the tempo through the clip's sample-rate control. Unlike the other platforms this does + * not preserve pitch, and it is a no-op when the mixer does not expose the control. + */ + private fun applySpeed() { + val clip = clip ?: return + if (baseSampleRate <= 0f || !clip.isControlSupported(FloatControl.Type.SAMPLE_RATE)) return + (clip.getControl(FloatControl.Type.SAMPLE_RATE) as FloatControl).value = baseSampleRate * speed } override fun play() { diff --git a/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/data/WebAudioPlayer.kt b/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/data/WebAudioPlayer.kt index 04d67f6..23173cb 100644 --- a/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/data/WebAudioPlayer.kt +++ b/core/audioplayer/src/webMain/kotlin/fr/ajaury/gwenedeg/player/data/WebAudioPlayer.kt @@ -12,6 +12,7 @@ import kotlin.time.Duration.Companion.seconds internal class WebAudioPlayer : AudioPlayer { private var audio: HTMLAudioElement? = null + private var speed: Float = 1f private val _playbackState = MutableStateFlow(PlaybackState.IDLE) override val playbackState: StateFlow = _playbackState.asStateFlow() @@ -31,12 +32,19 @@ internal class WebAudioPlayer : AudioPlayer { audio = (document.createElement("audio") as HTMLAudioElement).apply { src = uri + playbackRate = speed.toDouble() addEventListener("play") { _playbackState.value = PlaybackState.PLAYING } addEventListener("ended") { _playbackState.value = PlaybackState.ENDED } addEventListener("pause") { _playbackState.value = PlaybackState.PAUSED } } } + override fun setSpeed(speed: Float) { + // Browsers preserve pitch (preservesPitch defaults to true) when changing playbackRate. + this.speed = speed + audio?.playbackRate = speed.toDouble() + } + override fun play() { audio?.play() } 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 313f439..fc1ae70 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 @@ -136,6 +136,10 @@ internal class PlaybackRepositoryImpl( audioPlayer.seekTo(position) } + override fun setSpeed(speed: Float) { + audioPlayer.setSpeed(speed) + } + override fun seekToSentence(sentenceIndex: Int) { val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return audioPlayer.seekTo(matchingSubtitleLine.startTime) 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..ea39365 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 @@ -25,6 +25,8 @@ interface PlaybackRepository { fun seekTo(position: Duration) + fun setSpeed(speed: Float) + fun seekToSentence(sentenceIndex: Int) fun goToPreviousSentence() diff --git a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt index 3c7a48e..0f593c6 100644 --- a/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/data/PreferencesRepositoryImpl.kt @@ -1,6 +1,7 @@ package fr.ajaury.gwenedeg.preferences.data 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.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -8,14 +9,21 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update internal class PreferencesRepositoryImpl : PreferencesRepository { - private val state = MutableStateFlow(SubtitlePreferences()) - override val subtitlePreferences: StateFlow = state.asStateFlow() + private val subtitleState = MutableStateFlow(SubtitlePreferences()) + override val subtitlePreferences: StateFlow = subtitleState.asStateFlow() + + private val playbackState = MutableStateFlow(PlaybackPreferences()) + override val playbackPreferences: StateFlow = playbackState.asStateFlow() override fun setShowTranscription(enabled: Boolean) { - state.update { it.copy(showTranscription = enabled) } + subtitleState.update { it.copy(showTranscription = enabled) } } override fun setShowTranslation(enabled: Boolean) { - state.update { it.copy(showTranslation = enabled) } + subtitleState.update { it.copy(showTranslation = enabled) } + } + + override fun setPlaybackSpeed(speed: Float) { + playbackState.update { it.copy(speed = speed) } } } 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 52ad041..2fdf95b 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 @@ -1,12 +1,17 @@ package fr.ajaury.gwenedeg.preferences.domain +import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import kotlinx.coroutines.flow.StateFlow interface PreferencesRepository { val subtitlePreferences: StateFlow + val playbackPreferences: StateFlow + fun setShowTranscription(enabled: Boolean) fun setShowTranslation(enabled: Boolean) + + fun setPlaybackSpeed(speed: Float) } 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 new file mode 100644 index 0000000..b8cbb4b --- /dev/null +++ b/data/preferences/src/commonMain/kotlin/fr/ajaury/gwenedeg/preferences/model/PlaybackPreferences.kt @@ -0,0 +1,18 @@ +package fr.ajaury.gwenedeg.preferences.model + +/** + * 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. + */ +data class PlaybackPreferences( + val speed: Float = DEFAULT_SPEED, +) { + companion object { + const val DEFAULT_SPEED = 1f + const val MIN_SPEED = 0.5f + const val MAX_SPEED = 1f + const val SPEED_STEP = 0.1f + } +} 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 new file mode 100644 index 0000000..0a7a047 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/PlaybackSpeedBottomSheet.kt @@ -0,0 +1,100 @@ +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.preferences.model.PlaybackPreferences +import fr.ajaury.gwenedeg.theme.GwenedegTheme +import kotlin.math.roundToInt + +/** + * Bottom sheet letting the user pick the audio playback speed between + * [PlaybackPreferences.MIN_SPEED] and [PlaybackPreferences.MAX_SPEED]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaybackSpeedBottomSheet( + speed: Float, + onSpeedChange: (Float) -> Unit = {}, + onDismiss: () -> Unit = {}, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + PlaybackSpeedContent( + speed = speed, + onSpeedChange = onSpeedChange, + ) + } +} + +@Composable +private fun PlaybackSpeedContent( + speed: Float, + onSpeedChange: (Float) -> Unit = {}, +) { + // Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4). + val innerSteps = with(PlaybackPreferences) { + (((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Vitesse de lecture", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = formatPlaybackSpeed(speed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + Slider( + value = speed, + onValueChange = onSpeedChange, + valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED, + steps = innerSteps, + ) + } +} + +/** Formats a speed multiplier as a short label, e.g. 1.0 → "x1", 0.7 → "x0.7". */ +internal fun formatPlaybackSpeed(speed: Float): String { + val tenths = (speed * 10).roundToInt() + return if (tenths % 10 == 0) "x${tenths / 10}" else "x${tenths / 10}.${tenths % 10}" +} + +@Preview +@Composable +private fun PlaybackSpeedContentPreview() { + GwenedegTheme { + PlaybackSpeedContent(speed = 0.7f) + } +} 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 72f18d1..2bc1de5 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 @@ -1,33 +1,24 @@ package fr.ajaury.gwenedeg.player.ui import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer 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 -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.player.ui.components.NextButton +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.GwenedegTheme @Composable @@ -35,87 +26,69 @@ fun PlayerControl( modifier: Modifier = Modifier, playbackState: PlaybackState, playbackTiming: PlaybackTiming, + canGoToPreviousSentence: Boolean, + canGoToNextSentence: Boolean, + isSpeedActive: Boolean, + playbackSpeed: Float, onSeek: (Float) -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {}, onPreviousSentenceClicked: () -> Unit = {}, onNextSentenceClicked: () -> Unit = {}, - canGoToPreviousSentence: Boolean = true, - canGoToNextSentence: Boolean = true, + onSpeedClicked: () -> Unit = {}, ) { Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - var seekProgress by remember { mutableStateOf(null) } - Slider( - value = seekProgress ?: playbackTiming.progress, - onValueChange = { seekProgress = it }, - onValueChangeFinished = { - seekProgress?.let { fraction -> - if (playbackTiming.durationMs > 0) { - onSeek(fraction) - } - } - seekProgress = null - }, - modifier = Modifier.fillMaxWidth(), + PlaybackProgress( + playbackTiming = playbackTiming, + onSeek = onSeek, ) Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - IconButton( - modifier = Modifier.size(60.dp), - enabled = canGoToPreviousSentence, - onClick = onPreviousSentenceClicked, + // Left region: the speed button is centered in the space left of the transport group. + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Default.SkipPrevious, - contentDescription = "Previous sentence", + SpeedPreferenceButton( + isSpeedActive = isSpeedActive, + playbackSpeed = playbackSpeed, + onSpeedClicked = onSpeedClicked, ) } - Button( - modifier = Modifier.size(60.dp), - contentPadding = PaddingValues(0.dp), - onClick = onMainPlayActionButtonClicked, + // Transport group: centered in the full width by the equal-weight cells on either side. + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = playbackState.toActionIcon(), - contentDescription = playbackState.toActionContentDescription(), + PreviousButton( + canGoToPreviousSentence = canGoToPreviousSentence, + onPreviousSentenceClicked = onPreviousSentenceClicked, + ) + + PlayButton( + playbackState = playbackState, + onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, + ) + + NextButton( + canGoToNextSentence = canGoToNextSentence, + onNextSentenceClicked = onNextSentenceClicked, ) } - IconButton( - modifier = Modifier.size(60.dp), - enabled = canGoToNextSentence, - onClick = onNextSentenceClicked, - ) { - Icon( - imageVector = Icons.Default.SkipNext, - contentDescription = "Next sentence", - ) - } + // Right region: mirrors the left weight so the transport group stays centered. + Spacer(modifier = Modifier.weight(1f)) } } } -private fun PlaybackState.toActionIcon(): ImageVector = - when (this) { - PlaybackState.PLAYING -> Icons.Default.Pause - PlaybackState.ENDED -> Icons.Default.Replay - PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow - } - -private fun PlaybackState.toActionContentDescription(): String = - when (this) { - PlaybackState.PLAYING -> "Pause" - PlaybackState.ENDED -> "Replay" - PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" - } - @Preview @Composable private fun PlayerControlPreview() { @@ -123,6 +96,10 @@ private fun PlayerControlPreview() { PlayerControl( playbackState = PlaybackState.PLAYING, playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + canGoToPreviousSentence = true, + canGoToNextSentence = true, + isSpeedActive = false, + playbackSpeed = PlaybackPreferences.DEFAULT_SPEED, ) } } @@ -135,6 +112,24 @@ private fun PlayerControlFirstSentencePreview() { playbackState = PlaybackState.PLAYING, playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), canGoToPreviousSentence = false, + canGoToNextSentence = true, + playbackSpeed = PlaybackPreferences.DEFAULT_SPEED, + isSpeedActive = false, + ) + } +} + +@Preview +@Composable +private fun PlayerControlActiveSpeedPreview() { + GwenedegTheme { + PlayerControl( + playbackState = PlaybackState.PLAYING, + playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + canGoToPreviousSentence = true, + canGoToNextSentence = true, + playbackSpeed = 0.7f, + isSpeedActive = true, ) } } @@ -146,7 +141,10 @@ private fun PlayerControlLastSentencePreview() { PlayerControl( playbackState = PlaybackState.PLAYING, playbackTiming = PlaybackTiming(positionMs = 1234, durationMs = 5000), + canGoToPreviousSentence = true, canGoToNextSentence = false, + playbackSpeed = PlaybackPreferences.DEFAULT_SPEED, + isSpeedActive = 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 3b6a865..1e1246a 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 @@ -32,6 +32,7 @@ import fr.ajaury.gwenedeg.player.model.PlaybackTiming import fr.ajaury.gwenedeg.player.model.PlayerState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerUiState import fr.ajaury.gwenedeg.player.ui.viewmodel.PlayerViewModel +import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.ui.SubtitlePreferencesBottomSheet import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine import fr.ajaury.gwenedeg.theme.GwenedegTheme @@ -64,6 +65,7 @@ fun PlayerScreen( onNextSentenceClicked = viewModel::goToNextSentence, onShowTranscriptionChange = viewModel::setShowTranscription, onShowTranslationChange = viewModel::setShowTranslation, + onSpeedChange = viewModel::setPlaybackSpeed, onBackClicked = onBackClicked, ) } @@ -79,9 +81,11 @@ fun PlayerScreen( onNextSentenceClicked: () -> Unit = {}, onShowTranscriptionChange: (Boolean) -> Unit = {}, onShowTranslationChange: (Boolean) -> Unit = {}, + onSpeedChange: (Float) -> Unit = {}, onBackClicked: () -> Unit = {}, ) { var showPreferences by remember { mutableStateOf(false) } + var showSpeed by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -146,12 +150,15 @@ fun PlayerScreen( modifier = Modifier.padding(horizontal = 8.dp), playbackState = uiState.playerState.playbackState, playbackTiming = uiState.playerState.playbackTiming, + canGoToPreviousSentence = uiState.canGoToPreviousSentence, + canGoToNextSentence = uiState.canGoToNextSentence, + playbackSpeed = uiState.playbackSpeed, + isSpeedActive = uiState.isSpeedActive, onSeek = onSeekToTimePart, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onPreviousSentenceClicked = onPreviousSentenceClicked, onNextSentenceClicked = onNextSentenceClicked, - canGoToPreviousSentence = uiState.canGoToPreviousSentence, - canGoToNextSentence = uiState.canGoToNextSentence, + onSpeedClicked = { showSpeed = true }, ) } @@ -163,6 +170,14 @@ fun PlayerScreen( onDismiss = { showPreferences = false }, ) } + + if (showSpeed) { + PlaybackSpeedBottomSheet( + speed = uiState.playbackSpeed, + onSpeedChange = onSpeedChange, + onDismiss = { showSpeed = false }, + ) + } } } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/NextButton.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/NextButton.kt new file mode 100644 index 0000000..3cc769e --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/NextButton.kt @@ -0,0 +1,49 @@ +package fr.ajaury.gwenedeg.player.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.theme.GwenedegTheme + +@Composable +fun NextButton( + canGoToNextSentence: Boolean, + onNextSentenceClicked: () -> Unit = {}, +) { + IconButton( + modifier = Modifier.size(60.dp), + enabled = canGoToNextSentence, + onClick = onNextSentenceClicked, + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = "Next sentence", + ) + } +} + +@Preview +@Composable +private fun NextButtonCanGoToNextSentencePreview() { + GwenedegTheme { + NextButton( + canGoToNextSentence = true, + ) + } +} + +@Preview +@Composable +private fun NextButtonCanNotGoToNextSentencePreview() { + GwenedegTheme { + NextButton( + canGoToNextSentence = false, + ) + } +} 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 new file mode 100644 index 0000000..c8d17cd --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlayButton.kt @@ -0,0 +1,88 @@ +package fr.ajaury.gwenedeg.player.ui.components + +import androidx.compose.foundation.layout.PaddingValues +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.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +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.theme.GwenedegTheme + +@Composable +fun PlayButton( + playbackState: PlaybackState, + onMainPlayActionButtonClicked: () -> Unit = {}, +) { + Button( + modifier = Modifier.size(60.dp), + 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.ENDED -> Icons.Default.Replay + PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow + } + +private fun PlaybackState.toActionContentDescription(): String = + when (this) { + PlaybackState.PLAYING -> "Pause" + PlaybackState.ENDED -> "Replay" + PlaybackState.PAUSED, PlaybackState.IDLE -> "Play" + } + +@Preview +@Composable +private fun PlayButtonPlayingPreview() { + GwenedegTheme { + PlayButton( + playbackState = PlaybackState.PLAYING, + ) + } +} + +@Preview +@Composable +private fun PlayButtonPausedPreview() { + GwenedegTheme { + PlayButton( + playbackState = PlaybackState.PAUSED, + ) + } +} + +@Preview +@Composable +private fun PlayButtonEndedPreview() { + GwenedegTheme { + PlayButton( + playbackState = PlaybackState.ENDED, + ) + } +} + +@Preview +@Composable +private fun PlayButtonIdlePreview() { + GwenedegTheme { + PlayButton( + playbackState = PlaybackState.IDLE, + ) + } +} diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlaybackProgress.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlaybackProgress.kt new file mode 100644 index 0000000..331c025 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PlaybackProgress.kt @@ -0,0 +1,66 @@ +package fr.ajaury.gwenedeg.player.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Slider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import fr.ajaury.gwenedeg.player.model.PlaybackTiming +import fr.ajaury.gwenedeg.theme.GwenedegTheme + + +@Composable +fun PlaybackProgress( + playbackTiming: PlaybackTiming, + onSeek: (Float) -> Unit = {}, +) { + var seekProgress by remember { mutableStateOf(null) } + Slider( + value = seekProgress ?: playbackTiming.progress, + onValueChange = { seekProgress = it }, + onValueChangeFinished = { + seekProgress?.let { fraction -> + if (playbackTiming.durationMs > 0) { + onSeek(fraction) + } + } + seekProgress = null + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Preview +@Composable +private fun PlaybackProgressPreview() { + GwenedegTheme { + PlaybackProgress( + playbackTiming = PlaybackTiming(0, 0), + ) + } +} + +@Preview +@Composable +private fun PlaybackProgressInProgressPreview() { + GwenedegTheme { + PlaybackProgress( + playbackTiming = PlaybackTiming(7000, 10000), + ) + } +} + +@Preview +@Composable +private fun PlaybackProgressEndedPreview() { + GwenedegTheme { + PlaybackProgress( + playbackTiming = PlaybackTiming(10000, 10000), + ) + } +} + diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PreviousButton.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PreviousButton.kt new file mode 100644 index 0000000..6c18811 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/PreviousButton.kt @@ -0,0 +1,49 @@ +package fr.ajaury.gwenedeg.player.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.theme.GwenedegTheme + +@Composable +fun PreviousButton( + canGoToPreviousSentence: Boolean, + onPreviousSentenceClicked: () -> Unit = {}, +) { + IconButton( + modifier = Modifier.size(60.dp), + enabled = canGoToPreviousSentence, + onClick = onPreviousSentenceClicked, + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = "Previous sentence", + ) + } +} + +@Preview +@Composable +private fun PreviousButtonCanGoToPreviousSentencePreview() { + GwenedegTheme { + PreviousButton( + canGoToPreviousSentence = true, + ) + } +} + +@Preview +@Composable +private fun PreviousButtonCanNotGoToPreviousSentencePreview() { + GwenedegTheme { + PreviousButton( + canGoToPreviousSentence = false, + ) + } +} 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 new file mode 100644 index 0000000..29fbd90 --- /dev/null +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/SpeedPreferenceButton.kt @@ -0,0 +1,62 @@ +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.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.theme.GwenedegTheme + +@Composable +fun SpeedPreferenceButton( + isSpeedActive: Boolean, + playbackSpeed: Float, + onSpeedClicked: () -> Unit = {}, +) { + TextButton( + 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, + ) + } +} + +@Preview +@Composable +private fun SpeedPreferenceButtonDefaultPreview() { + GwenedegTheme { + SpeedPreferenceButton( + isSpeedActive = false, + playbackSpeed = 1f, + ) + } +} + +@Preview +@Composable +private fun SpeedPreferenceButtonPreview() { + GwenedegTheme { + SpeedPreferenceButton( + isSpeedActive = true, + playbackSpeed = 0.7f, + ) + } +} 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 72408ec..b63ec64 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 @@ -2,16 +2,21 @@ package fr.ajaury.gwenedeg.player.ui.viewmodel 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 data class PlayerUiState( val recordTitle: Phrase = Phrase(transcription = ""), val playerState: PlayerState = PlayerState(), val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), + val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, ) { val canGoToPreviousSentence: Boolean get() = playerState.playbackTiming.positionMs > 2000 val canGoToNextSentence: Boolean get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex + + val isSpeedActive: Boolean + get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED } 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 4de3ad8..244518a 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 @@ -8,6 +8,7 @@ import fr.ajaury.gwenedeg.player.domain.AudioSessionManager import fr.ajaury.gwenedeg.player.domain.PlaybackRepository import fr.ajaury.gwenedeg.player.model.PlaybackState import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository +import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.records.domain.RecordRepository import fr.ajaury.gwenedeg.records.model.Record import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -34,11 +36,13 @@ class PlayerViewModel( record, playbackRepository.playerState, preferencesRepository.subtitlePreferences, - ) { record, playerState, subtitlePreferences -> + preferencesRepository.playbackPreferences, + ) { record, playerState, subtitlePreferences, playbackPreferences -> PlayerUiState( recordTitle = record?.title ?: Phrase(transcription = ""), playerState = playerState, subtitlePreferences = subtitlePreferences, + playbackSpeed = playbackPreferences.speed, ) }.stateIn( scope = viewModelScope, @@ -57,6 +61,7 @@ class PlayerViewModel( if (alreadyLoadedRecord != null) { logger.debug("Record $recordId already loaded") playbackRepository.load(record = alreadyLoadedRecord) + applyCurrentSpeed() return@launch } @@ -67,10 +72,15 @@ class PlayerViewModel( } playbackRepository.load(record = loadedRecord) + applyCurrentSpeed() record.value = loadedRecord } } + private fun applyCurrentSpeed() { + playbackRepository.setSpeed(preferencesRepository.playbackPreferences.value.speed) + } + fun performMainPlayAction() { when (uiState.value.playerState.playbackState) { PlaybackState.PLAYING -> playbackRepository.pause() @@ -97,6 +107,16 @@ class PlayerViewModel( playbackRepository.goToNextSentence() } + fun setPlaybackSpeed(speed: Float) { + val steppedSpeed = (speed * 10).roundToInt() / 10f + val clampedSpeed = steppedSpeed.coerceIn( + PlaybackPreferences.MIN_SPEED, + PlaybackPreferences.MAX_SPEED, + ) + preferencesRepository.setPlaybackSpeed(clampedSpeed) + playbackRepository.setSpeed(clampedSpeed) + } + fun setShowTranscription(enabled: Boolean) { preferencesRepository.setShowTranscription(enabled) }