new: add playback speed control

This commit is contained in:
2026-07-03 10:37:28 +02:00
parent 9aaffdc363
commit 3847f46adb
20 changed files with 618 additions and 80 deletions
@@ -61,6 +61,10 @@ internal class AndroidAudioPlayer(
player.prepare()
}
override fun setSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun play() {
player.play()
}
@@ -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)
}
@@ -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> = _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
@@ -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> = _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() {
@@ -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> = _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()
}
@@ -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)
@@ -25,6 +25,8 @@ interface PlaybackRepository {
fun seekTo(position: Duration)
fun setSpeed(speed: Float)
fun seekToSentence(sentenceIndex: Int)
fun goToPreviousSentence()
@@ -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<SubtitlePreferences> = state.asStateFlow()
private val subtitleState = MutableStateFlow(SubtitlePreferences())
override val subtitlePreferences: StateFlow<SubtitlePreferences> = subtitleState.asStateFlow()
private val playbackState = MutableStateFlow(PlaybackPreferences())
override val playbackPreferences: StateFlow<PlaybackPreferences> = 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) }
}
}
@@ -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<SubtitlePreferences>
val playbackPreferences: StateFlow<PlaybackPreferences>
fun setShowTranscription(enabled: Boolean)
fun setShowTranslation(enabled: Boolean)
fun setPlaybackSpeed(speed: Float)
}
@@ -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
}
}
@@ -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)
}
}
@@ -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<Float?>(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,
)
}
}
@@ -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 },
)
}
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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<Float?>(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),
)
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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
}
@@ -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)
}