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, IDLE,
PLAYING, PLAYING,
PAUSED, PAUSED,
BREAKING,
ENDED, ENDED,
} }
@@ -18,19 +18,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine 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.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.times
internal class PlaybackRepositoryImpl( internal class PlaybackRepositoryImpl(
private val audioPlayer: AudioPlayer, private val audioPlayer: AudioPlayer,
@@ -38,17 +48,35 @@ internal class PlaybackRepositoryImpl(
private val subtitleRepository: SubtitleRepository, private val subtitleRepository: SubtitleRepository,
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase, private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger, private val logger: Logger,
dispatcherProvider: DispatcherProvider,
) : PlaybackRepository { ) : PlaybackRepository {
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it. // 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 val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
private var speedObservationJob: Job? = null 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 recordTitle = MutableStateFlow(Phrase(transcription = ""))
private val subtitle = MutableStateFlow(Subtitle(emptyList())) 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 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, subtitle,
currentPosition, currentPosition,
) { subtitle, position -> ) { subtitle, position ->
getCurrentSubtitleIndex( val currentSubtitleIndex = getCurrentSubtitleIndex(
subtitle = subtitle, subtitle = subtitle,
position = position, 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( override val playerState: Flow<PlayerState> = combine(
playbackState, playbackState,
subtitle,
playbackTiming, playbackTiming,
currentSubtitleIndex, subtitleProgress,
recordTitle, recordTitle,
) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle -> ) { playbackState, playbackTiming, subtitleProgress, recordTitle ->
PlayerState( PlayerState(
playbackState = playbackState, playbackState = playbackState,
playbackTiming = playbackTiming, playbackTiming = playbackTiming,
subtitleLines = subtitle.lines, subtitleLines = subtitleProgress.subtitle.lines,
currentSubtitleIndex = currentSubtitleIndex, currentSubtitleIndex = subtitleProgress.currentSubtitleIndex,
recordTitle = recordTitle, recordTitle = recordTitle,
) )
} }
@@ -105,6 +165,7 @@ internal class PlaybackRepositoryImpl(
recordTitle.value = record.title recordTitle.value = record.title
observePlaybackSpeed() observePlaybackSpeed()
observePhraseBreaks()
} }
private fun observePlaybackSpeed() { 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) { private fun playAudio(filePath: String) {
try { try {
audioPlayer.load(uri = resourceReader.uri(filePath)) audioPlayer.load(uri = resourceReader.uri(filePath))
@@ -142,20 +246,24 @@ internal class PlaybackRepositoryImpl(
Subtitle(emptyList()) Subtitle(emptyList())
} }
override fun play() { override suspend fun play() {
cancelRunningBreakJob()
audioPlayer.play() audioPlayer.play()
} }
override fun pause() { override suspend fun pause() {
cancelRunningBreakJob()
audioPlayer.pause() audioPlayer.pause()
} }
override fun replay() { override suspend fun replay() {
cancelRunningBreakJob()
audioPlayer.seekTo(Duration.ZERO) audioPlayer.seekTo(Duration.ZERO)
audioPlayer.play() audioPlayer.play()
} }
override fun seekTo(position: Duration) { override suspend fun seekTo(position: Duration) {
cancelRunningBreakJob()
audioPlayer.seekTo(position) audioPlayer.seekTo(position)
} }
@@ -163,12 +271,7 @@ internal class PlaybackRepositoryImpl(
audioPlayer.setSpeed(speed) audioPlayer.setSpeed(speed)
} }
override fun seekToSentence(sentenceIndex: Int) { override suspend fun goToPreviousSentence() {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
override fun goToPreviousSentence() {
val lines = subtitle.value.lines val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return val currentLine = lines.getOrNull(currentIndex) ?: return
@@ -182,26 +285,46 @@ internal class PlaybackRepositoryImpl(
seekToSentence(targetIndex) seekToSentence(targetIndex)
} }
override fun goToNextSentence() { override suspend fun goToNextSentence() {
val lines = subtitle.value.lines val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex)) 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? = private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex( getCurrentSubtitleIndex(
subtitle = subtitle.value, subtitle = subtitle.value,
position = audioPlayer.currentPosition, position = audioPlayer.currentPosition,
) )
override fun stop() { override suspend fun stop() {
cancelRunningBreakJob()
audioPlayer.stop() audioPlayer.stop()
} }
override fun release() { override fun release() {
breakJob?.cancel()
isBreaking.value = false
audioPlayer.release() 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 { companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds 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) 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() fun release()
} }
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlin.time.Duration
/** /**
* In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do * 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) { override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) } 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.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
interface PreferencesRepository { interface PreferencesRepository {
val subtitlePreferences: Flow<SubtitlePreferences> val subtitlePreferences: Flow<SubtitlePreferences>
@@ -16,4 +17,6 @@ interface PreferencesRepository {
suspend fun setSubtitleTextScale(scale: Float) suspend fun setSubtitleTextScale(scale: Float)
suspend fun setPlaybackSpeed(speed: Float) suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration)
} }
@@ -1,18 +1,28 @@
package fr.ajaury.gwenedeg.preferences.model package fr.ajaury.gwenedeg.preferences.model
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** /**
* User preferences controlling audio playback. * User preferences controlling audio playback.
* *
* @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in * @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed. * [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( data class PlaybackPreferences(
val speed: Float = DEFAULT_SPEED, val speed: Float = DEFAULT_SPEED,
val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES,
) { ) {
companion object { companion object {
const val DEFAULT_SPEED = 1f const val DEFAULT_SPEED = 1f
const val MIN_SPEED = 0.5f const val MIN_SPEED = 0.5f
const val MAX_SPEED = 1f const val MAX_SPEED = 1f
const val SPEED_STEP = 0.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.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map 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 * [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 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) { private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform) dataStore.edit(transform)
} }
@@ -52,6 +58,8 @@ internal class DataStorePreferencesRepository(
private fun Preferences.toPlaybackPreferences(): PlaybackPreferences = private fun Preferences.toPlaybackPreferences(): PlaybackPreferences =
PlaybackPreferences( PlaybackPreferences(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds
?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
) )
private companion object { private companion object {
@@ -59,5 +67,6 @@ internal class DataStorePreferencesRepository(
val ShowTranslationKey = booleanPreferencesKey("show_translation") val ShowTranslationKey = booleanPreferencesKey("show_translation")
val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale") val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed") 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="subtitle_text_size_increase">Brasaat ment an destenn</string>
<string name="playback_speed_title">Tizh al lenn</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_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</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="subtitle_text_size_increase">Augmenter la taille du texte</string>
<string name="playback_speed_title">Vitesse de lecture</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_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.resources.generated.resources.Res 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.resources.generated.resources.playback_speed_title
import fr.ajaury.gwenedeg.theme.ChomBevTheme import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Bottom sheet letting the user pick the audio playback speed between * Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent
* [PlaybackPreferences.MIN_SPEED] and [PlaybackPreferences.MAX_SPEED]. * pause inserted between phrases.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PlaybackSpeedBottomSheet( fun PlaybackSpeedBottomSheet(
speed: Float, speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
) { ) {
@@ -42,7 +42,9 @@ fun PlaybackSpeedBottomSheet(
) { ) {
PlaybackSpeedContent( PlaybackSpeedContent(
speed = speed, speed = speed,
breakSeconds = breakSeconds,
onSpeedChange = onSpeedChange, onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
) )
} }
} }
@@ -50,40 +52,43 @@ fun PlaybackSpeedBottomSheet(
@Composable @Composable
private fun PlaybackSpeedContent( private fun PlaybackSpeedContent(
speed: Float, speed: Float,
breakSeconds: Float,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
) { ) {
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4). // 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) (((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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row( LabeledSlider(
modifier = Modifier.fillMaxWidth(), title = stringResource(Res.string.playback_speed_title),
horizontalArrangement = Arrangement.SpaceBetween, valueLabel = formatPlaybackSpeed(speed),
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(
value = speed, value = speed,
onValueChange = onSpeedChange,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED, 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 @Composable
private fun PlaybackSpeedContentPreview() { private fun PlaybackSpeedContentPreview() {
ChomBevTheme { 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.PlaybackProgress
import fr.ajaury.gwenedeg.player.ui.components.PreviousButton import fr.ajaury.gwenedeg.player.ui.components.PreviousButton
import fr.ajaury.gwenedeg.player.ui.components.SpeedPreferenceButton import fr.ajaury.gwenedeg.player.ui.components.SpeedPreferenceButton
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.theme.ChomBevTheme import fr.ajaury.gwenedeg.theme.ChomBevTheme
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -28,10 +27,10 @@ fun PlayerControl(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playbackState: PlaybackState, playbackState: PlaybackState,
playbackTiming: PlaybackTiming, playbackTiming: PlaybackTiming,
breakBetweenPhrases: Duration,
canGoToPreviousSentence: Boolean, canGoToPreviousSentence: Boolean,
canGoToNextSentence: Boolean, canGoToNextSentence: Boolean,
isSpeedActive: Boolean, isPlaybackModified: Boolean,
playbackSpeed: Float,
onSeek: (Duration) -> Unit = {}, onSeek: (Duration) -> Unit = {},
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
onPreviousSentenceClicked: () -> Unit = {}, onPreviousSentenceClicked: () -> Unit = {},
@@ -58,8 +57,7 @@ fun PlayerControl(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
SpeedPreferenceButton( SpeedPreferenceButton(
isSpeedActive = isSpeedActive, isActive = isPlaybackModified,
playbackSpeed = playbackSpeed,
onSpeedClicked = onSpeedClicked, onSpeedClicked = onSpeedClicked,
) )
} }
@@ -76,6 +74,7 @@ fun PlayerControl(
PlayButton( PlayButton(
playbackState = playbackState, playbackState = playbackState,
breakDuration = breakBetweenPhrases,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
) )
@@ -98,10 +97,10 @@ private fun PlayerControlPreview() {
PlayerControl( PlayerControl(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true, canGoToPreviousSentence = true,
canGoToNextSentence = true, canGoToNextSentence = true,
isSpeedActive = false, isPlaybackModified = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
) )
} }
} }
@@ -113,10 +112,10 @@ private fun PlayerControlFirstSentencePreview() {
PlayerControl( PlayerControl(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = false, canGoToPreviousSentence = false,
canGoToNextSentence = true, canGoToNextSentence = true,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED, isPlaybackModified = false,
isSpeedActive = false,
) )
} }
} }
@@ -128,10 +127,10 @@ private fun PlayerControlActiveSpeedPreview() {
PlayerControl( PlayerControl(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true, canGoToPreviousSentence = true,
canGoToNextSentence = true, canGoToNextSentence = true,
playbackSpeed = 0.7f, isPlaybackModified = true,
isSpeedActive = true,
) )
} }
} }
@@ -143,10 +142,10 @@ private fun PlayerControlLastSentencePreview() {
PlayerControl( PlayerControl(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
breakBetweenPhrases = 3.seconds,
canGoToPreviousSentence = true, canGoToPreviousSentence = true,
canGoToNextSentence = false, canGoToNextSentence = false,
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED, isPlaybackModified = false,
isSpeedActive = false,
) )
} }
} }
@@ -75,6 +75,7 @@ fun PlayerScreen(
onTextSizeDecrease = viewModel::decreaseSubtitleTextSize, onTextSizeDecrease = viewModel::decreaseSubtitleTextSize,
onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed, onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
} }
@@ -93,6 +94,7 @@ fun PlayerScreen(
onTextSizeDecrease: () -> Unit = {}, onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {}, onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {},
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},
) { ) {
var showPreferences by remember { mutableStateOf(false) } var showPreferences by remember { mutableStateOf(false) }
@@ -171,10 +173,10 @@ fun PlayerScreen(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
playbackState = uiState.playerState.playbackState, playbackState = uiState.playerState.playbackState,
playbackTiming = uiState.playerState.playbackTiming, playbackTiming = uiState.playerState.playbackTiming,
breakBetweenPhrases = uiState.breakBetweenPhrases,
canGoToPreviousSentence = uiState.canGoToPreviousSentence, canGoToPreviousSentence = uiState.canGoToPreviousSentence,
canGoToNextSentence = uiState.canGoToNextSentence, canGoToNextSentence = uiState.canGoToNextSentence,
playbackSpeed = uiState.playbackSpeed, isPlaybackModified = uiState.isPlaybackModified,
isSpeedActive = uiState.isSpeedActive,
onSeek = onSeekToTimePart, onSeek = onSeekToTimePart,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
onPreviousSentenceClicked = onPreviousSentenceClicked, onPreviousSentenceClicked = onPreviousSentenceClicked,
@@ -197,7 +199,9 @@ fun PlayerScreen(
if (showSpeed) { if (showSpeed) {
PlaybackSpeedBottomSheet( PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed, speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
onSpeedChange = onSpeedChange, onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
onDismiss = { showSpeed = false }, 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 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.PaddingValues
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons 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.PlayArrow
import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview 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.resources.generated.resources.player_replay
import fr.ajaury.gwenedeg.theme.ChomBevTheme import fr.ajaury.gwenedeg.theme.ChomBevTheme
import org.jetbrains.compose.resources.stringResource 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 @Composable
fun PlayButton( fun PlayButton(
playbackState: PlaybackState, playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO,
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
) { ) {
Button( val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
modifier = Modifier.size(60.dp), val breakProgress = remember { Animatable(0f) }
contentPadding = PaddingValues(0.dp),
onClick = onMainPlayActionButtonClicked, 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( if (isBreaking) {
imageVector = playbackState.toActionIcon(), CircularProgressIndicator(
contentDescription = playbackState.toActionContentDescription(), 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 = private fun PlaybackState.toActionIcon(): ImageVector =
when (this) { when (this) {
PlaybackState.PLAYING -> Icons.Default.Pause PlaybackState.PLAYING, PlaybackState.BREAKING -> Icons.Default.Pause
PlaybackState.ENDED -> Icons.Default.Replay PlaybackState.ENDED -> Icons.Default.Replay
PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow PlaybackState.PAUSED, PlaybackState.IDLE -> Icons.Default.PlayArrow
} }
@@ -48,7 +95,7 @@ private fun PlaybackState.toActionIcon(): ImageVector =
@Composable @Composable
private fun PlaybackState.toActionContentDescription(): String = private fun PlaybackState.toActionContentDescription(): String =
when (this) { 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.ENDED -> stringResource(Res.string.player_replay)
PlaybackState.PAUSED, PlaybackState.IDLE -> stringResource(Res.string.player_play) 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 @Preview
@Composable @Composable
private fun PlayButtonPausedPreview() { private fun PlayButtonPausedPreview() {
@@ -1,40 +1,38 @@
package fr.ajaury.gwenedeg.player.ui.components package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size 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.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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 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 @Composable
fun SpeedPreferenceButton( fun SpeedPreferenceButton(
isSpeedActive: Boolean, isActive: Boolean,
playbackSpeed: Float,
onSpeedClicked: () -> Unit = {}, onSpeedClicked: () -> Unit = {},
) { ) {
TextButton( IconButton(
modifier = Modifier.size(60.dp), modifier = Modifier.size(60.dp),
contentPadding = PaddingValues(0.dp),
onClick = onSpeedClicked, onClick = onSpeedClicked,
colors = ButtonDefaults.textButtonColors(
contentColor = if (isSpeedActive) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
},
),
) { ) {
Text( Icon(
text = formatPlaybackSpeed(playbackSpeed), imageVector = Icons.Default.Speed,
style = MaterialTheme.typography.labelLarge, 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() { private fun SpeedPreferenceButtonDefaultPreview() {
ChomBevTheme { ChomBevTheme {
SpeedPreferenceButton( SpeedPreferenceButton(
isSpeedActive = false, isActive = false,
playbackSpeed = 1f,
) )
} }
} }
@Preview @Preview
@Composable @Composable
private fun SpeedPreferenceButtonPreview() { private fun SpeedPreferenceButtonActivePreview() {
ChomBevTheme { ChomBevTheme {
SpeedPreferenceButton( SpeedPreferenceButton(
isSpeedActive = true, isActive = true,
playbackSpeed = 0.7f,
) )
} }
} }
@@ -4,6 +4,7 @@ import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.model.PlayerState import fr.ajaury.gwenedeg.player.model.PlayerState
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
data class PlayerUiState( data class PlayerUiState(
@@ -12,6 +13,7 @@ data class PlayerUiState(
val playerState: PlayerState = PlayerState(), val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
) { ) {
val canGoToPreviousSentence: Boolean val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds get() = playerState.playbackTiming.position > 2.seconds
@@ -19,6 +21,8 @@ data class PlayerUiState(
val canGoToNextSentence: Boolean val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
val isSpeedActive: Boolean /** True when either playback setting differs from its default, used to highlight the button. */
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES
} }
@@ -46,6 +46,7 @@ class PlayerViewModel(
playerState = playerState, playerState = playerState,
subtitlePreferences = subtitlePreferences, subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed, playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -79,27 +80,37 @@ class PlayerViewModel(
} }
fun performMainPlayAction() { fun performMainPlayAction() {
when (uiState.value.playerState.playbackState) { viewModelScope.launch {
PlaybackState.PLAYING -> playbackRepository.pause() when (uiState.value.playerState.playbackState) {
PlaybackState.ENDED -> playbackRepository.replay() PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play() PlaybackState.ENDED -> playbackRepository.replay()
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
}
} }
} }
fun seekToTimePart(progress: Duration) { fun seekToTimePart(progress: Duration) {
playbackRepository.seekTo(progress) viewModelScope.launch {
playbackRepository.seekTo(progress)
}
} }
fun seekToSentence(sentenceIndex: Int) { fun seekToSentence(sentenceIndex: Int) {
playbackRepository.seekToSentence(sentenceIndex) viewModelScope.launch {
playbackRepository.seekToSentence(sentenceIndex)
}
} }
fun goToPreviousSentence() { fun goToPreviousSentence() {
playbackRepository.goToPreviousSentence() viewModelScope.launch {
playbackRepository.goToPreviousSentence()
}
} }
fun goToNextSentence() { fun goToNextSentence() {
playbackRepository.goToNextSentence() viewModelScope.launch {
playbackRepository.goToNextSentence()
}
} }
fun setPlaybackSpeed(speed: Float) { 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) { fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled) preferencesRepository.setShowTranscription(enabled)
@@ -143,7 +164,9 @@ class PlayerViewModel(
} }
fun stop() { fun stop() {
playbackRepository.stop() viewModelScope.launch {
playbackRepository.stop()
}
} }
override fun onCleared() { override fun onCleared() {