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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user