new: add adjustable break between phrases in playback settings

This commit is contained in:
2026-07-03 18:09:02 +02:00
parent b6e7a6a665
commit ea7dfe6470
18 changed files with 440 additions and 117 deletions
@@ -18,19 +18,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.times
internal class PlaybackRepositoryImpl(
private val audioPlayer: AudioPlayer,
@@ -38,17 +48,35 @@ internal class PlaybackRepositoryImpl(
private val subtitleRepository: SubtitleRepository,
private val preferencesRepository: PreferencesRepository,
private val getCurrentSubtitleIndex: GetCurrentSubtitleIndexUseCase,
private val dispatcherProvider: DispatcherProvider,
private val logger: Logger,
dispatcherProvider: DispatcherProvider,
) : PlaybackRepository {
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it.
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
private var speedObservationJob: Job? = null
private var phraseBreakJob: Job? = null
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
private var breakJob: Job? = null
private val isBreaking = MutableStateFlow(false)
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
private val playbackState: StateFlow<PlaybackState> = audioPlayer.playbackState
private val playbackState: StateFlow<PlaybackState> = combine(
audioPlayer.playbackState,
isBreaking,
) { playbackState, isBreaking ->
if (isBreaking && playbackState == PlaybackState.PAUSED) {
PlaybackState.BREAKING
} else {
playbackState
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = PlaybackState.IDLE,
)
private val duration: Duration get() = audioPlayer.duration
@@ -73,28 +101,60 @@ internal class PlaybackRepositoryImpl(
)
}
private val currentSubtitleIndex: Flow<Int?> = combine(
private val subtitleProgress: Flow<SubtitleProgress> = combine(
subtitle,
currentPosition,
) { subtitle, position ->
getCurrentSubtitleIndex(
val currentSubtitleIndex = getCurrentSubtitleIndex(
subtitle = subtitle,
position = position,
)
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
currentSubtitleIndex = currentSubtitleIndex,
subtitle = subtitle,
position = position,
)
SubtitleProgress(
subtitle = subtitle,
currentPosition = position,
currentSubtitleIndex = currentSubtitleIndex,
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase,
)
}
private fun isGoingToPlayNextPhrase(
currentSubtitleIndex: Int?,
subtitle: Subtitle,
position: Duration,
): Boolean {
if (currentSubtitleIndex == null) {
return false
}
val nextSubtitleIndex = currentSubtitleIndex + 1
if (nextSubtitleIndex <= 0) {
return false
}
val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex)
?: return false
return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL
}
override val playerState: Flow<PlayerState> = combine(
playbackState,
subtitle,
playbackTiming,
currentSubtitleIndex,
subtitleProgress,
recordTitle,
) { playbackState, subtitle, playbackTiming, currentSubtitleIndex, recordTitle ->
) { playbackState, playbackTiming, subtitleProgress, recordTitle ->
PlayerState(
playbackState = playbackState,
playbackTiming = playbackTiming,
subtitleLines = subtitle.lines,
currentSubtitleIndex = currentSubtitleIndex,
subtitleLines = subtitleProgress.subtitle.lines,
currentSubtitleIndex = subtitleProgress.currentSubtitleIndex,
recordTitle = recordTitle,
)
}
@@ -105,6 +165,7 @@ internal class PlaybackRepositoryImpl(
recordTitle.value = record.title
observePlaybackSpeed()
observePhraseBreaks()
}
private fun observePlaybackSpeed() {
@@ -116,6 +177,49 @@ internal class PlaybackRepositoryImpl(
}
}
/**
* Inserts the configured silent break between spoken phrases: when continuous playback reaches
* the next phrase, playback pauses just before that phrase starts, then resumes after the break.
*
* Breaks only happen on automatic advancement — manual seeking and previous/next navigation are
* ignored via [suppressBreaks].
*/
private suspend fun observePhraseBreaks() {
phraseBreakJob?.cancel()
cancelRunningBreakJob()
phraseBreakJob = subtitleProgress
.map { it.isGoingToPlayNextPhrase }
.distinctUntilChanged()
.filter { it }
.onEach {
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
val playbackPreferences = preferencesRepository.playbackPreferences.first()
val breakDuration = playbackPreferences.breakBetweenPhrases
if (breakDuration <= Duration.ZERO) return@onEach
insertBreakBeforePhrase(breakDuration = breakDuration)
}.launchIn(scope)
}
private suspend fun insertBreakBeforePhrase(breakDuration: Duration) {
breakJob?.cancelAndJoin()
breakJob = scope.launch {
try {
// Pause and rewind the polling overshoot so the next phrase resumes from its very start.
isBreaking.value = true
audioPlayer.pause()
delay(breakDuration)
} finally {
// Only resume if still paused by this break; the user may have acted during it.
if (playbackState.firstOrNull() == PlaybackState.BREAKING) {
audioPlayer.play()
}
isBreaking.value = false
}
}
}
private fun playAudio(filePath: String) {
try {
audioPlayer.load(uri = resourceReader.uri(filePath))
@@ -142,20 +246,24 @@ internal class PlaybackRepositoryImpl(
Subtitle(emptyList())
}
override fun play() {
override suspend fun play() {
cancelRunningBreakJob()
audioPlayer.play()
}
override fun pause() {
override suspend fun pause() {
cancelRunningBreakJob()
audioPlayer.pause()
}
override fun replay() {
override suspend fun replay() {
cancelRunningBreakJob()
audioPlayer.seekTo(Duration.ZERO)
audioPlayer.play()
}
override fun seekTo(position: Duration) {
override suspend fun seekTo(position: Duration) {
cancelRunningBreakJob()
audioPlayer.seekTo(position)
}
@@ -163,12 +271,7 @@ internal class PlaybackRepositoryImpl(
audioPlayer.setSpeed(speed)
}
override fun seekToSentence(sentenceIndex: Int) {
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
override fun goToPreviousSentence() {
override suspend fun goToPreviousSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
val currentLine = lines.getOrNull(currentIndex) ?: return
@@ -182,26 +285,46 @@ internal class PlaybackRepositoryImpl(
seekToSentence(targetIndex)
}
override fun goToNextSentence() {
override suspend fun goToNextSentence() {
val lines = subtitle.value.lines
val currentIndex = currentSentenceIndex() ?: return
seekToSentence((currentIndex + 1).coerceAtMost(lines.lastIndex))
}
override suspend fun seekToSentence(sentenceIndex: Int) {
cancelRunningBreakJob()
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
}
private fun currentSentenceIndex(): Int? =
getCurrentSubtitleIndex(
subtitle = subtitle.value,
position = audioPlayer.currentPosition,
)
override fun stop() {
override suspend fun stop() {
cancelRunningBreakJob()
audioPlayer.stop()
}
override fun release() {
breakJob?.cancel()
isBreaking.value = false
audioPlayer.release()
}
private suspend fun cancelRunningBreakJob() {
breakJob?.cancelAndJoin()
// Unlock if paused by a break.
if (playbackState.value == PlaybackState.BREAKING) {
audioPlayer.play()
}
isBreaking.value = false
}
companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
@@ -0,0 +1,11 @@
package fr.ajaury.gwenedeg.player.data
import fr.ajaury.gwenedeg.subtitle.model.Subtitle
import kotlin.time.Duration
data class SubtitleProgress(
val subtitle: Subtitle,
val currentPosition: Duration,
val currentSubtitleIndex: Int?,
val isGoingToPlayNextPhrase: Boolean,
)
@@ -17,21 +17,21 @@ interface PlaybackRepository {
suspend fun load(record: Record)
fun play()
suspend fun play()
fun pause()
suspend fun pause()
fun replay()
suspend fun replay()
fun seekTo(position: Duration)
suspend fun seekTo(position: Duration)
fun seekToSentence(sentenceIndex: Int)
suspend fun seekToSentence(sentenceIndex: Int)
fun goToPreviousSentence()
suspend fun goToPreviousSentence()
fun goToNextSentence()
suspend fun goToNextSentence()
fun stop()
suspend fun stop()
fun release()
}
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.time.Duration
/**
* In-memory [PreferencesRepository] used on platforms without a DataStore artifact (web). Values do
@@ -34,4 +35,8 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
override suspend fun setPlaybackSpeed(speed: Float) {
playbackState.update { it.copy(speed = speed) }
}
override suspend fun setBreakBetweenPhrases(duration: Duration) {
playbackState.update { it.copy(breakBetweenPhrases = duration) }
}
}
@@ -3,6 +3,7 @@ package fr.ajaury.gwenedeg.preferences.domain
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
interface PreferencesRepository {
val subtitlePreferences: Flow<SubtitlePreferences>
@@ -16,4 +17,6 @@ interface PreferencesRepository {
suspend fun setSubtitleTextScale(scale: Float)
suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration)
}
@@ -1,18 +1,28 @@
package fr.ajaury.gwenedeg.preferences.model
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* User preferences controlling audio playback.
*
* @property speed playback speed multiplier, clamped between [MIN_SPEED] and [MAX_SPEED] in
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed.
* @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next,
* between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps.
*/
data class PlaybackPreferences(
val speed: Float = DEFAULT_SPEED,
val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES,
) {
companion object {
const val DEFAULT_SPEED = 1f
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 1f
const val SPEED_STEP = 0.1f
val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO
val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds
val BREAK_STEP: Duration = 1.seconds
}
}
@@ -6,12 +6,15 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import fr.ajaury.gwenedeg.preferences.domain.PreferencesRepository
import fr.ajaury.gwenedeg.preferences.model.PlaybackPreferences
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* [PreferencesRepository] persisting to a Preferences [DataStore]. Reads are exposed as hot
@@ -36,6 +39,9 @@ internal class DataStorePreferencesRepository(
override suspend fun setPlaybackSpeed(speed: Float) = edit { it[PlaybackSpeedKey] = speed }
override suspend fun setBreakBetweenPhrases(duration: Duration) =
edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() }
private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform)
}
@@ -52,6 +58,8 @@ internal class DataStorePreferencesRepository(
private fun Preferences.toPlaybackPreferences(): PlaybackPreferences =
PlaybackPreferences(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds
?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
)
private companion object {
@@ -59,5 +67,6 @@ internal class DataStorePreferencesRepository(
val ShowTranslationKey = booleanPreferencesKey("show_translation")
val SubtitleTextScaleKey = floatPreferencesKey("subtitle_text_scale")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds")
}
}
@@ -13,6 +13,9 @@
<string name="subtitle_text_size_increase">Brasaat ment an destenn</string>
<string name="playback_speed_title">Tizh al lenn</string>
<string name="playback_settings_action">Arventennoù al lenn</string>
<string name="break_between_phrases_title">Ehan etre ar frazennoù</string>
<string name="break_between_phrases_value">%1$d s</string>
<string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string>
@@ -13,6 +13,9 @@
<string name="subtitle_text_size_increase">Augmenter la taille du texte</string>
<string name="playback_speed_title">Vitesse de lecture</string>
<string name="playback_settings_action">Réglages de lecture</string>
<string name="break_between_phrases_title">Pause entre les phrases</string>
<string name="break_between_phrases_value">%1$d s</string>
<string name="player_back">Retour</string>
<string name="player_previous_sentence">Phrase précédente</string>