new: add adjustable break between phrases in playback settings

This commit is contained in:
2026-07-03 18:09:02 +02:00
parent b6e7a6a665
commit ea7dfe6470
18 changed files with 440 additions and 117 deletions
@@ -4,5 +4,6 @@ enum class PlaybackState {
IDLE,
PLAYING,
PAUSED,
BREAKING,
ENDED,
}
@@ -18,19 +18,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.times
internal class PlaybackRepositoryImpl(
private val audioPlayer: AudioPlayer,
@@ -38,17 +48,35 @@ internal class PlaybackRepositoryImpl(
private val subtitleRepository: SubtitleRepository,
private val preferencesRepository: PreferencesRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger,
dispatcherProvider: DispatcherProvider,
) : PlaybackRepository {
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it.
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
private var speedObservationJob: Job? = null
private var phraseBreakJob: Job? = null
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
private var breakJob: Job? = null
private val isBreaking = MutableStateFlow(false)
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
private val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
private val playbackState: StateFlow<PlaybackState> = combine(
audioPlayer.playbackState,
isBreaking,
) { playbackState, isBreaking ->
if (isBreaking && playbackState == PlaybackState.PAUSED) {
PlaybackState.BREAKING
} else {
playbackState
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = PlaybackState.IDLE,
)
private val duration: Duration get() = audioPlayer.duration
@@ -73,28 +101,60 @@ internal class PlaybackRepositoryImpl(
)
}
private val currentSubtitleIndex: Flow<Int?> = combine(
private val subtitleProgress: Flow<SubtitleProgress> = combine(
subtitle,
currentPosition,
) { subtitle, position ->
getCurrentSubtitleIndex(
val currentSubtitleIndex = getCurrentSubtitleIndex(
subtitle = subtitle,
position = position,
)
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
currentSubtitleIndex = currentSubtitleIndex,
subtitle = subtitle,
position = position,
)
SubtitleProgress(
subtitle = subtitle,
currentPosition = position,
currentSubtitleIndex = currentSubtitleIndex,
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase,
)
}
private fun isGoingToPlayNextPhrase(
currentSubtitleIndex: Int?,
subtitle: Subtitle,
position: Duration,
): Boolean {
if (currentSubtitleIndex == null) {
return false
}
val nextSubtitleIndex = currentSubtitleIndex + 1
if (nextSubtitleIndex <= 0) {
return false
}
val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex)
?: return false
return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL
}
override val playerState: Flow<PlayerState> = combine(
playbackState,
subtitle,
playbackTiming,
currentSubtitleIndex,
subtitleProgress,
recordTitle,
) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle ->
) { playbackState, playbackTiming, subtitleProgress, recordTitle ->
PlayerState(
playbackState = playbackState,
playbackTiming = playbackTiming,
subtitleLines = subtitle.lines,
currentSubtitleIndex = currentSubtitleIndex,
subtitleLines = subtitleProgress.subtitle.lines,
currentSubtitleIndex = subtitleProgress.currentSubtitleIndex,
recordTitle = recordTitle,
)
}
@@ -105,6 +165,7 @@ internal class PlaybackRepositoryImpl(
recordTitle.value = record.title
observePlaybackSpeed()
observePhraseBreaks()
}
private fun observePlaybackSpeed() {
@@ -116,6 +177,49 @@ internal class PlaybackRepositoryImpl(
}
}
/**
* Inserts the configured silent break between spoken phrases: when continuous playback reaches
* the next phrase, playback pauses just before that phrase starts, then resumes after the break.
*
* Breaks only happen on automatic advancement — manual seeking and previous/next navigation are
* ignored via [suppressBreaks].
*/
private suspend fun observePhraseBreaks() {
phraseBreakJob?.cancel()
cancelRunningBreakJob()
phraseBreakJob = subtitleProgress
.map { it.isGoingToPlayNextPhrase }
.distinctUntilChanged()
.filter { it }
.onEach {
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
val playbackPreferences = preferencesRepository.playbackPreferences.first()
val breakDuration = playbackPreferences.breakBetweenPhrases
if (breakDuration <= Duration.ZERO) return@onEach
insertBreakBeforePhrase(breakDuration = breakDuration)
}.launchIn(scope)
}
private suspend fun insertBreakBeforePhrase(breakDuration: Duration) {
breakJob?.cancelAndJoin()
breakJob = scope.launch {
try {
// Pause and rewind the polling overshoot so the next phrase resumes from its very start.
isBreaking.value = true
audioPlayer.pause()
delay(breakDuration)
} finally {
// Only resume if still paused by this break; the user may have acted during it.
if (playbackState.firstOrNull() == PlaybackState.BREAKING) {
audioPlayer.play()
}
isBreaking.value = false
}
}
}
private fun playAudio(filePath: String) {
try {
audioPlayer.load(uri = resourceReader.uri(filePath))
@@ -142,20 +246,24 @@ internal class PlaybackRepositoryImpl(
Subtitle(emptyList())
}
override fun play() {
override suspend fun play() {
cancelRunningBreakJob()
audioPlayer.play()
}
override fun pause() {
override suspend fun pause() {
cancelRunningBreakJob()
audioPlayer.pause()
}
override fun replay() {
override suspend fun replay() {
cancelRunningBreakJob()
audioPlayer.seekTo(Duration.ZERO)
audioPlayer.play()
}
override fun seekTo(position: Duration) {
override suspend fun seekTo(position: Duration) {
cancelRunningBreakJob()
audioPlayer.seekTo(position)
}
@@ -163,12 +271,7 @@ internal class PlaybackRepositoryImpl(
audioPlayer.setSpeed(speed)
}
override fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
override fun goToPreviousSentence() {
override suspend fun goToPreviousSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return
@@ -182,26 +285,46 @@ internal class PlaybackRepositoryImpl(
seekToSentence(targetIndex)
}
override fun goToNextSentence() {
override suspend fun goToNextSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
}
override suspend fun seekToSentence(sentenceIndex: Int) {
cancelRunningBreakJob()
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitle.value,
position = audioPlayer.currentPosition,
)
override fun stop() {
override suspend fun stop() {
cancelRunningBreakJob()
audioPlayer.stop()
}
override fun release() {
breakJob?.cancel()
isBreaking.value = false
audioPlayer.release()
}
private suspend fun cancelRunningBreakJob() {
breakJob?.cancelAndJoin()
// Unlock if paused by a break.
if (playbackState.value == PlaybackState.BREAKING) {
audioPlayer.play()
}
isBreaking.value = false
}
companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
@@ -0,0 +1,11 @@
package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlin.time.Duration
data class SubtitleProgress(
val subtitle: Subtitle,
val currentPosition: Duration,
val currentSubtitleIndex: Int?,
val isGoingToPlayNextPhrase: Boolean,
)
@@ -17,21 +17,21 @@ interface PlaybackRepository {
suspend fun load(record: Record)
fun play()
suspend fun play()
fun pause()
suspend fun pause()
fun replay()
suspend fun replay()
fun seekTo(position: Duration)
suspend fun seekTo(position: Duration)
fun seekToSentence(sentenceIndex: Int)
suspend fun seekToSentence(sentenceIndex: Int)
fun goToPreviousSentence()
suspend fun goToPreviousSentence()
fun goToNextSentence()
suspend fun goToNextSentence()
fun stop()
suspend fun stop()
fun release()
}
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.time.Duration
/**
* In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do
@@ -34,4 +35,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) }
}
override suspend fun setBreakBetweenPhrases(duration: Duration) {
playbackState.update { it.copy(breakBetweenPhrases = duration) }
}
}
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.preferences.domain
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
interface PreferencesRepository {
val subtitlePreferences: Flow<SubtitlePreferences>
@@ -16,4 +17,6 @@ interface PreferencesRepository {
suspend fun setSubtitleTextScale(scale: Float)
suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration)
}
@@ -1,18 +1,28 @@
package fr.ajaury.gwenedeg.preferences.model
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* User preferences controlling audio playback.
*
* @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed.
* @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next,
* between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps.
*/
data class PlaybackPreferences(
val speed: Float = DEFAULT_SPEED,
val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES,
) {
companion object {
const val DEFAULT_SPEED = 1f
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 1f
const val SPEED_STEP = 0.1f
val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO
val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds
val BREAK_STEP: Duration = 1.seconds
}
}
@@ -6,12 +6,15 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* [PreferencesRepository] persisting to a Preferences [DataStore]. Reads are exposed as hot
@@ -36,6 +39,9 @@ internal class DataStorePreferencesRepository(
override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
override suspend fun setBreakBetweenPhrases(duration: Duration) =
edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() }
private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform)
}
@@ -52,6 +58,8 @@ internal class DataStorePreferencesRepository(
private fun Preferences.toPlaybackPreferences(): PlaybackPreferences =
PlaybackPreferences(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds
?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
)
private companion object {
@@ -59,5 +67,6 @@ internal class DataStorePreferencesRepository(
val ShowTranslationKey = booleanPreferencesKey("show_translation")
val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds")
}
}
@@ -13,6 +13,9 @@
<string name="subtitle_text_size_increase">Brasaat ment an destenn</string>
<string name="playback_speed_title">Tizh al lenn</string>
<string name="playback_settings_action">Arventennoù al lenn</string>
<string name="break_between_phrases_title">Ehan etre ar frazennoù</string>
<string name="break_between_phrases_value">%1$d s</string>
<string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string>
@@ -13,6 +13,9 @@
<string name="subtitle_text_size_increase">Augmenter la taille du texte</string>
<string name="playback_speed_title">Vitesse de lecture</string>
<string name="playback_settings_action">Réglages de lecture</string>
<string name="break_between_phrases_title">Pause entre les phrases</string>
<string name="break_between_phrases_value">%1$d s</string>
<string name="player_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</string>
@@ -2,37 +2,37 @@ package fr.ajaury.gwenedeg.player.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.player.ui.components.LabeledSlider
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.resources.generated.resources.Res
import fr.ajaury.gwenedeg.resources.generated.resources.break_between_phrases_title
import fr.ajaury.gwenedeg.resources.generated.resources.break_between_phrases_value
import fr.ajaury.gwenedeg.resources.generated.resources.playback_speed_title
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
/**
* Bottom sheet letting the user pick the audio playback speed between
* [PlaybackPreferences.MIN_SPEED] and [PlaybackPreferences.MAX_SPEED].
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent
* pause inserted between phrases.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaybackSpeedBottomSheet(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
) {
@@ -42,7 +42,9 @@ fun PlaybackSpeedBottomSheet(
) {
PlaybackSpeedContent(
speed = speed,
breakSeconds = breakSeconds,
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
)
}
}
@@ -50,40 +52,43 @@ fun PlaybackSpeedBottomSheet(
@Composable
private fun PlaybackSpeedContent(
speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
) {
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
val innerSteps = with(PlaybackPreferences) {
val speedSteps = with(PlaybackPreferences) {
(((MAX_SPEED - MIN_SPEED) / SPEED_STEP).roundToInt() - 1).coerceAtLeast(0)
}
val maxBreakSeconds = PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds
val breakStepSeconds = PlaybackPreferences.BREAK_STEP.inWholeSeconds
val breakSteps = ((maxBreakSeconds / breakStepSeconds).toInt() - 1).coerceAtLeast(0)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.playback_speed_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = formatPlaybackSpeed(speed),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
LabeledSlider(
title = stringResource(Res.string.playback_speed_title),
valueLabel = formatPlaybackSpeed(speed),
value = speed,
onValueChange = onSpeedChange,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
steps = innerSteps,
steps = speedSteps,
onValueChange = onSpeedChange,
)
LabeledSlider(
title = stringResource(Res.string.break_between_phrases_title),
valueLabel = stringResource(
Res.string.break_between_phrases_value,
breakSeconds.roundToInt().toString(),
),
value = breakSeconds,
valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps,
onValueChange = onBreakChange,
)
}
}
@@ -98,6 +103,9 @@ internal fun formatPlaybackSpeed(speed: Float): String {
@Composable
private fun PlaybackSpeedContentPreview() {
ChomBevTheme {
PlaybackSpeedContent(speed = 0.7f)
PlaybackSpeedContent(
speed = 0.7f,
breakSeconds = 2f,
)
}
}
@@ -18,7 +18,6 @@ import fr.ajaury.gwenedeg.player.ui.components.PlayButton
import fr.ajaury.gwenedeg.player.ui.components.PlaybackProgress
import fr.ajaury.gwenedeg.player.ui.components.PreviousButton
import fr.ajaury.gwenedeg.player.ui.components.SpeedPreferenceButton
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -28,10 +27,10 @@ fun PlayerControl(
modifier: Modifier = Modifier,
playbackState: PlaybackState,
playbackTiming: PlaybackTiming,
breakBetweenPhrases: Duration,
canGoToPreviousSentence: Boolean,
canGoToNextSentence: Boolean,
isSpeedActive: Boolean,
playbackSpeed: Float,
isPlaybackModified: Boolean,
onSeek: (Duration) -> Unit = {},
onMainPlayActionButtonClicked: () -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {},
@@ -58,8 +57,7 @@ fun PlayerControl(
contentAlignment = Alignment.Center,
) {
SpeedPreferenceButton(
isSpeedActive = isSpeedActive,
playbackSpeed = playbackSpeed,
isActive = isPlaybackModified,
onSpeedClicked = onSpeedClicked,
)
}
@@ -76,6 +74,7 @@ fun PlayerControl(
PlayButton(
playbackState = playbackState,
breakDuration = breakBetweenPhrases,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
)
@@ -98,10 +97,10 @@ private fun PlayerControlPreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
isSpeedActive = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isPlaybackModified = false,
)
}
}
@@ -113,10 +112,10 @@ private fun PlayerControlFirstSentencePreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = false,
canGoToNextSentence = true,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isSpeedActive = false,
isPlaybackModified = false,
)
}
}
@@ -128,10 +127,10 @@ private fun PlayerControlActiveSpeedPreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = true,
playbackSpeed = 0.7f,
isSpeedActive = true,
isPlaybackModified = true,
)
}
}
@@ -143,10 +142,10 @@ private fun PlayerControlLastSentencePreview() {
PlayerControl(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true,
canGoToNextSentence = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
isSpeedActive = false,
isPlaybackModified = false,
)
}
}
@@ -75,6 +75,7 @@ fun PlayerScreen(
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases,
onBackClicked = onBackClicked,
)
}
@@ -93,6 +94,7 @@ fun PlayerScreen(
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onBackClicked: () -> Unit = {},
) {
var showPreferences by remember { mutableStateOf(false) }
@@ -171,10 +173,10 @@ fun PlayerScreen(
modifier = Modifier.padding(horizontal = 8.dp),
playbackState = uiState.playerState.playbackState,
playbackTiming = uiState.playerState.playbackTiming,
breakBetweenPhrases = uiState.breakBetweenPhrases,
canGoToPreviousSentence = uiState.canGoToPreviousSentence,
canGoToNextSentence = uiState.canGoToNextSentence,
playbackSpeed = uiState.playbackSpeed,
isSpeedActive = uiState.isSpeedActive,
isPlaybackModified = uiState.isPlaybackModified,
onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked,
@@ -197,7 +199,9 @@ fun PlayerScreen(
if (showSpeed) {
PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
onDismiss = { showSpeed = false },
)
}
@@ -0,0 +1,63 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.theme.ChomBevTheme
@Composable
fun LabeledSlider(
title: String,
valueLabel: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit = {},
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = valueLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
}
}
@Preview
@Composable
private fun LabeledSliderPreview() {
ChomBevTheme {
LabeledSlider(
title = "Playback speed",
valueLabel = "x0.7",
value = 0.7f,
valueRange = 0.5f..2f,
steps = 10,
)
}
}
@@ -1,5 +1,9 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
@@ -7,8 +11,12 @@ import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
@@ -20,27 +28,66 @@ import fr.ajaury.gwenedeg.resources.generated.resources.player_play
import fr.ajaury.gwenedeg.resources.generated.resources.player_replay
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private val ButtonSize = 60.dp
private val BreakRingSize = 68.dp
@Composable
fun PlayButton(
playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO,
onMainPlayActionButtonClicked: () -> Unit = {},
) {
Button(
modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked,
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
val breakProgress = remember { Animatable(0f) }
LaunchedEffect(isBreaking, breakDuration) {
if (isBreaking) {
breakProgress.snapTo(0f)
breakProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = breakDuration.inWholeMilliseconds.toInt(),
easing = LinearEasing,
),
)
} else {
breakProgress.snapTo(0f)
}
}
// The ring reserves its footprint at all times so the transport row does not jump when a break
// starts; the indicator itself is only drawn while breaking.
Box(
modifier = Modifier.size(BreakRingSize),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = playbackState.toActionIcon(),
contentDescription = playbackState.toActionContentDescription(),
)
if (isBreaking) {
CircularProgressIndicator(
progress = { breakProgress.value },
modifier = Modifier.size(BreakRingSize),
strokeWidth = 3.dp,
)
}
Button(
modifier = Modifier.size(ButtonSize),
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked,
) {
Icon(
imageVector = playbackState.toActionIcon(),
contentDescription = playbackState.toActionContentDescription(),
)
}
}
}
private fun PlaybackState.toActionIcon(): ImageVector =
when (this) {
PlaybackState.PLAYING -> Icons.Default.Pause
PlaybackState.PLAYING, PlaybackState.BREAKING -> Icons.Default.Pause
PlaybackState.ENDED -> Icons.Default.Replay
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
}
@@ -48,7 +95,7 @@ private fun PlaybackState.toActionIcon(): ImageVector =
@Composable
private fun PlaybackState.toActionContentDescription(): String =
when (this) {
PlaybackState.PLAYING -> stringResource(Res.string.player_pause)
PlaybackState.PLAYING, PlaybackState.BREAKING -> stringResource(Res.string.player_pause)
PlaybackState.ENDED -> stringResource(Res.string.player_replay)
PlaybackState.PAUSED, PlaybackState.IDLE -> stringResource(Res.string.player_play)
}
@@ -63,6 +110,17 @@ private fun PlayButtonPlayingPreview() {
}
}
@Preview
@Composable
private fun PlayButtonBreakingPreview() {
ChomBevTheme {
PlayButton(
playbackState = PlaybackState.BREAKING,
breakDuration = 3.seconds,
)
}
}
@Preview
@Composable
private fun PlayButtonPausedPreview() {
@@ -1,40 +1,38 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import fr.ajaury.gwenedeg.player.ui.formatPlaybackSpeed
import fr.ajaury.gwenedeg.resources.generated.resources.Res
import fr.ajaury.gwenedeg.resources.generated.resources.playback_settings_action
import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource
/**
* Opens the playback settings sheet. Tinted with the primary color when a playback setting (speed or
* break between phrases) differs from its default.
*/
@Composable
fun SpeedPreferenceButton(
isSpeedActive: Boolean,
playbackSpeed: Float,
isActive: Boolean,
onSpeedClicked: () -> Unit = {},
) {
TextButton(
IconButton(
modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp),
onClick = onSpeedClicked,
colors = ButtonDefaults.textButtonColors(
contentColor = if (isSpeedActive) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
},
),
) {
Text(
text = formatPlaybackSpeed(playbackSpeed),
style = MaterialTheme.typography.labelLarge,
Icon(
imageVector = Icons.Default.Speed,
contentDescription = stringResource(Res.string.playback_settings_action),
tint = if (isActive) MaterialTheme.colorScheme.primary else LocalContentColor.current,
)
}
}
@@ -44,19 +42,17 @@ fun SpeedPreferenceButton(
private fun SpeedPreferenceButtonDefaultPreview() {
ChomBevTheme {
SpeedPreferenceButton(
isSpeedActive = false,
playbackSpeed = 1f,
isActive = false,
)
}
}
@Preview
@Composable
private fun SpeedPreferenceButtonPreview() {
private fun SpeedPreferenceButtonActivePreview() {
ChomBevTheme {
SpeedPreferenceButton(
isSpeedActive = true,
playbackSpeed = 0.7f,
isActive = true,
)
}
}
@@ -4,6 +4,7 @@ import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class PlayerUiState(
@@ -12,6 +13,7 @@ data class PlayerUiState(
val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
) {
val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds
@@ -19,6 +21,8 @@ data class PlayerUiState(
val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
val isSpeedActive: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED
/** True when either playback setting differs from its default, used to highlight the button. */
val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES
}
@@ -46,6 +46,7 @@ class PlayerViewModel(
playerState = playerState,
subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
)
}.stateIn(
scope = viewModelScope,
@@ -79,27 +80,37 @@ class PlayerViewModel(
}
fun performMainPlayAction() {
when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
viewModelScope.launch {
when (uiState.value.playerState.playbackState) {
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
}
}
}
fun seekToTimePart(progress: Duration) {
playbackRepository.seekTo(progress)
viewModelScope.launch {
playbackRepository.seekTo(progress)
}
}
fun seekToSentence(sentenceIndex: Int) {
playbackRepository.seekToSentence(sentenceIndex)
viewModelScope.launch {
playbackRepository.seekToSentence(sentenceIndex)
}
}
fun goToPreviousSentence() {
playbackRepository.goToPreviousSentence()
viewModelScope.launch {
playbackRepository.goToPreviousSentence()
}
}
fun goToNextSentence() {
playbackRepository.goToNextSentence()
viewModelScope.launch {
playbackRepository.goToNextSentence()
}
}
fun setPlaybackSpeed(speed: Float) {
@@ -114,6 +125,16 @@ class PlayerViewModel(
}
}
fun setBreakBetweenPhrases(seconds: Float) {
val clampedSeconds = seconds
.roundToInt()
.coerceIn(0, PlaybackPreferences.MAX_BREAK_BETWEEN_PHRASES.inWholeSeconds.toInt())
viewModelScope.launch {
preferencesRepository.setBreakBetweenPhrases(clampedSeconds.seconds)
}
}
fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled)
@@ -143,7 +164,9 @@ class PlayerViewModel(
}
fun stop() {
playbackRepository.stop()
viewModelScope.launch {
playbackRepository.stop()
}
}
override fun onCleared() {