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()
}