new: add playback speed control
This commit is contained in:
+100
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+66
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
|
||||
+21
-1
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user