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