new: add adjustable break between phrases in playback settings
This commit is contained in:
+1
@@ -4,5 +4,6 @@ enum class PlaybackState {
|
||||
IDLE,
|
||||
PLAYING,
|
||||
PAUSED,
|
||||
BREAKING,
|
||||
ENDED,
|
||||
}
|
||||
|
||||
+144
-21
@@ -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
|
||||
|
||||
+11
@@ -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,
|
||||
)
|
||||
+8
-8
@@ -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()
|
||||
}
|
||||
|
||||
+5
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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>
|
||||
|
||||
+36
-28
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+68
-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() {
|
||||
|
||||
+20
-24
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
|
||||
}
|
||||
|
||||
+32
-9
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user