new: add adjustable break between phrases in playback settings
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user