Compare commits
2
Commits
develop
...
da9619b8fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da9619b8fe | ||
|
|
8f9e1ab87d |
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
## [1.0.0-beta04] - 2026-10-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- correct the way pause between phrases was handled
|
||||||
|
|
||||||
## [1.0.0-beta03] - 2026-09-30
|
## [1.0.0-beta03] - 2026-09-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.androidApplication)
|
alias(libs.plugins.androidApplication)
|
||||||
@@ -50,8 +49,8 @@ android {
|
|||||||
libs.versions.androidTargetSdk
|
libs.versions.androidTargetSdk
|
||||||
.get()
|
.get()
|
||||||
.toInt()
|
.toInt()
|
||||||
versionCode = 4
|
versionCode = 5
|
||||||
versionName = "1.0.0-beta03"
|
versionName = "1.0.0-beta04"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -21,5 +21,8 @@ kotlin {
|
|||||||
implementation(projects.data.resources)
|
implementation(projects.data.resources)
|
||||||
implementation(projects.data.subtitle)
|
implementation(projects.data.subtitle)
|
||||||
}
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutinesTest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-106
@@ -10,6 +10,7 @@ import bzh.ajaury.chombev.player.model.PlaybackState
|
|||||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||||
import bzh.ajaury.chombev.player.model.PlayerState
|
import bzh.ajaury.chombev.player.model.PlayerState
|
||||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||||
|
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||||
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||||
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||||
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||||
@@ -18,23 +19,17 @@ 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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
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.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -53,13 +48,18 @@ internal class PlaybackRepositoryImpl(
|
|||||||
) : 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 preferencesObservationJob: Job? = null
|
||||||
private var phraseBreakJob: Job? = null
|
private var phraseBreakJob: Job? = null
|
||||||
|
private var playbackPreferences = PlaybackPreferences()
|
||||||
|
|
||||||
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
|
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
|
||||||
private var breakJob: Job? = null
|
private var breakJob: Job? = null
|
||||||
private val isBreaking = MutableStateFlow(false)
|
private val isBreaking = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private var reachedPhraseIndex = -1
|
||||||
|
private var pendingPhraseStart: Duration? = null
|
||||||
|
private var pendingSeekTarget: Duration? = null
|
||||||
|
|
||||||
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
|
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
|
||||||
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
|
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
|
||||||
|
|
||||||
@@ -113,45 +113,16 @@ internal class PlaybackRepositoryImpl(
|
|||||||
subtitle,
|
subtitle,
|
||||||
currentPosition,
|
currentPosition,
|
||||||
) { subtitle, position ->
|
) { subtitle, position ->
|
||||||
val currentSubtitleIndex = getCurrentSubtitleIndex(
|
|
||||||
subtitle = subtitle,
|
|
||||||
position = position,
|
|
||||||
)
|
|
||||||
|
|
||||||
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
|
|
||||||
currentSubtitleIndex = currentSubtitleIndex,
|
|
||||||
subtitle = subtitle,
|
|
||||||
position = position,
|
|
||||||
)
|
|
||||||
|
|
||||||
SubtitleProgress(
|
SubtitleProgress(
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
currentPosition = position,
|
currentPosition = position,
|
||||||
currentSubtitleIndex = currentSubtitleIndex,
|
currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||||
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase,
|
subtitle = subtitle,
|
||||||
|
position = position,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
playbackTiming,
|
playbackTiming,
|
||||||
@@ -168,19 +139,27 @@ internal class PlaybackRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun load(record: Record) {
|
override suspend fun load(record: Record) {
|
||||||
|
phraseBreakJob?.cancel()
|
||||||
|
cancelRunningBreak()
|
||||||
|
pendingPhraseStart = null
|
||||||
|
pendingSeekTarget = null
|
||||||
|
|
||||||
playAudio(filePath = record.audioResourcePath)
|
playAudio(filePath = record.audioResourcePath)
|
||||||
subtitle.value = loadSubtitle(record = record)
|
subtitle.value = loadSubtitle(record = record)
|
||||||
|
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
|
||||||
recordTitle.value = record.title
|
recordTitle.value = record.title
|
||||||
|
|
||||||
observePlaybackSpeed()
|
observePlaybackPreferences()
|
||||||
observePhraseBreaks()
|
observePhraseBreaks()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observePlaybackSpeed() {
|
private fun observePlaybackPreferences() {
|
||||||
speedObservationJob?.cancel()
|
preferencesObservationJob?.cancel()
|
||||||
speedObservationJob = scope.launch {
|
preferencesObservationJob = scope.launch {
|
||||||
preferencesRepository.playbackPreferences.collect { playbackPreferences ->
|
preferencesRepository.playbackPreferences.collect { preferences ->
|
||||||
setSpeed(speed = playbackPreferences.speed)
|
// Cached so that reaching a phrase reacts immediately, without a suspending read.
|
||||||
|
playbackPreferences = preferences
|
||||||
|
setSpeed(speed = preferences.speed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,51 +172,98 @@ internal class PlaybackRepositoryImpl(
|
|||||||
*
|
*
|
||||||
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
||||||
*
|
*
|
||||||
* This only happens on automatic advancement — manual seeking and previous/next navigation are
|
* A phrase is considered reached as soon as a polled position is past its start minus
|
||||||
* ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING]
|
* [PHRASE_LOOKAHEAD], even if the poll came late and the phrase already started: in that case
|
||||||
* through this path.
|
* playback is rewound to the phrase start when resuming. Each phrase is only handled once.
|
||||||
|
*
|
||||||
|
* Manual seeking and previous/next navigation never trigger a break: [seekAudio] marks the
|
||||||
|
* phrase at the target position as already reached.
|
||||||
*/
|
*/
|
||||||
private suspend fun observePhraseBreaks() {
|
private fun observePhraseBreaks() {
|
||||||
phraseBreakJob?.cancel()
|
phraseBreakJob?.cancel()
|
||||||
cancelRunningBreakJob()
|
phraseBreakJob = scope.launch {
|
||||||
phraseBreakJob = subtitleProgress
|
playbackState.collectLatest { state ->
|
||||||
.map { it.isGoingToPlayNextPhrase }
|
if (state != PlaybackState.PLAYING) return@collectLatest
|
||||||
.distinctUntilChanged()
|
while (true) {
|
||||||
.filter { it }
|
checkPhraseBoundary()
|
||||||
.onEach {
|
delay(POSITION_POLL_INTERVAL)
|
||||||
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
|
|
||||||
|
|
||||||
val playbackPreferences = preferencesRepository.playbackPreferences.first()
|
|
||||||
if (playbackPreferences.autoPause) {
|
|
||||||
audioPlayer.pause()
|
|
||||||
return@onEach
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 checkPhraseBoundary() {
|
||||||
|
if (isBreaking.value || audioPlayer.playbackState.value != PlaybackState.PLAYING) return
|
||||||
|
|
||||||
|
val position = audioPlayer.currentPosition
|
||||||
|
pendingSeekTarget?.let { target ->
|
||||||
|
// Some players (e.g. AVPlayer) seek asynchronously and still report the pre-seek
|
||||||
|
// position for a while: ignore it, or it could be taken for a phrase change.
|
||||||
|
if ((position - target).absoluteValue > SEEK_TOLERANCE) return
|
||||||
|
pendingSeekTarget = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val upcomingIndex = upcomingPhraseIndex(position = position)
|
||||||
|
if (upcomingIndex <= reachedPhraseIndex) return
|
||||||
|
reachedPhraseIndex = upcomingIndex
|
||||||
|
|
||||||
|
// There is no break before the very first phrase.
|
||||||
|
if (upcomingIndex == 0) return
|
||||||
|
val phraseStart = subtitle.value.lines[upcomingIndex].startTime
|
||||||
|
|
||||||
|
val preferences = playbackPreferences
|
||||||
|
when {
|
||||||
|
preferences.autoPause -> {
|
||||||
|
pendingPhraseStart = phraseStart
|
||||||
|
audioPlayer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences.breakBetweenPhrases > Duration.ZERO -> {
|
||||||
|
insertBreakBeforePhrase(
|
||||||
|
breakDuration = preferences.breakBetweenPhrases,
|
||||||
|
phraseStart = phraseStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertBreakBeforePhrase(
|
||||||
|
breakDuration: Duration,
|
||||||
|
phraseStart: Duration,
|
||||||
|
) {
|
||||||
|
breakJob?.cancel()
|
||||||
|
pendingPhraseStart = phraseStart
|
||||||
|
isBreaking.value = true
|
||||||
|
audioPlayer.pause()
|
||||||
|
breakJob = scope.launch {
|
||||||
|
delay(breakDuration)
|
||||||
|
isBreaking.value = false
|
||||||
|
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeAtPhraseStart(phraseStart: Duration) {
|
||||||
|
pendingPhraseStart = null
|
||||||
|
// The phrase may have been detected a little late: rewind so it is heard from its start.
|
||||||
|
if (audioPlayer.currentPosition > phraseStart) {
|
||||||
|
seekAudio(position = phraseStart)
|
||||||
|
}
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upcomingPhraseIndex(position: Duration): Int =
|
||||||
|
getCurrentSubtitleIndex(
|
||||||
|
subtitle = subtitle.value,
|
||||||
|
position = position + PHRASE_LOOKAHEAD,
|
||||||
|
) ?: -1
|
||||||
|
|
||||||
|
private fun seekAudio(position: Duration) {
|
||||||
|
pendingPhraseStart = null
|
||||||
|
reachedPhraseIndex = upcomingPhraseIndex(position = position)
|
||||||
|
pendingSeekTarget = position
|
||||||
|
audioPlayer.seekTo(position)
|
||||||
|
}
|
||||||
|
|
||||||
private fun playAudio(filePath: String) {
|
private fun playAudio(filePath: String) {
|
||||||
try {
|
try {
|
||||||
audioPlayer.load(uri = resourceReader.uri(filePath))
|
audioPlayer.load(uri = resourceReader.uri(filePath))
|
||||||
@@ -265,24 +291,30 @@ internal class PlaybackRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun play() {
|
override suspend fun play() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
audioPlayer.play()
|
val phraseStart = pendingPhraseStart
|
||||||
|
if (phraseStart != null) {
|
||||||
|
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun pause() {
|
override suspend fun pause() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun replay() {
|
override suspend fun replay() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
audioPlayer.seekTo(Duration.ZERO)
|
seekAudio(position = Duration.ZERO)
|
||||||
audioPlayer.play()
|
audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun seekTo(position: Duration) {
|
override suspend fun seekTo(position: Duration) {
|
||||||
cancelRunningBreakJob()
|
val wasBreaking = cancelRunningBreak()
|
||||||
audioPlayer.seekTo(position)
|
seekAudio(position = position)
|
||||||
|
if (wasBreaking) audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSpeed(speed: Float) {
|
private fun setSpeed(speed: Float) {
|
||||||
@@ -310,9 +342,8 @@ internal class PlaybackRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun seekToSentence(sentenceIndex: Int) {
|
override suspend fun seekToSentence(sentenceIndex: Int) {
|
||||||
cancelRunningBreakJob()
|
|
||||||
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
||||||
audioPlayer.seekTo(matchingSubtitleLine.startTime)
|
seekTo(position = matchingSubtitleLine.startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun currentSentenceIndex(): Int? =
|
private fun currentSentenceIndex(): Int? =
|
||||||
@@ -322,32 +353,33 @@ internal class PlaybackRepositoryImpl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun stop() {
|
override suspend fun stop() {
|
||||||
breakJob?.cancel()
|
phraseBreakJob?.cancel()
|
||||||
isBreaking.value = false
|
cancelRunningBreak()
|
||||||
|
pendingPhraseStart = null
|
||||||
subtitle.value = Subtitle(emptyList())
|
subtitle.value = Subtitle(emptyList())
|
||||||
audioPlayer.stop()
|
audioPlayer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun release() {
|
override fun release() {
|
||||||
breakJob?.cancel()
|
phraseBreakJob?.cancel()
|
||||||
isBreaking.value = false
|
cancelRunningBreak()
|
||||||
|
pendingPhraseStart = null
|
||||||
audioPlayer.release()
|
audioPlayer.release()
|
||||||
subtitle.value = Subtitle(emptyList())
|
subtitle.value = Subtitle(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun cancelRunningBreakJob() {
|
private fun cancelRunningBreak(): Boolean {
|
||||||
breakJob?.cancelAndJoin()
|
breakJob?.cancel()
|
||||||
|
breakJob = null
|
||||||
// Unlock if paused by a break.
|
val wasBreaking = isBreaking.value
|
||||||
if (playbackState.value == PlaybackState.BREAKING) {
|
|
||||||
audioPlayer.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
isBreaking.value = false
|
isBreaking.value = false
|
||||||
|
return wasBreaking
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val POSITION_POLL_INTERVAL = 50.milliseconds
|
private val POSITION_POLL_INTERVAL = 50.milliseconds
|
||||||
|
private val PHRASE_LOOKAHEAD = 2 * POSITION_POLL_INTERVAL
|
||||||
|
private val SEEK_TOLERANCE = 500.milliseconds
|
||||||
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
|
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,4 @@ data class SubtitleProgress(
|
|||||||
val subtitle: Subtitle,
|
val subtitle: Subtitle,
|
||||||
val currentPosition: Duration,
|
val currentPosition: Duration,
|
||||||
val currentSubtitleIndex: Int?,
|
val currentSubtitleIndex: Int?,
|
||||||
val isGoingToPlayNextPhrase: Boolean,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
package bzh.ajaury.chombev.player.data
|
||||||
|
|
||||||
|
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
|
||||||
|
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||||
|
import bzh.ajaury.chombev.core.model.Phrase
|
||||||
|
import bzh.ajaury.chombev.core.model.Record
|
||||||
|
import bzh.ajaury.chombev.player.domain.AudioPlayer
|
||||||
|
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||||
|
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||||
|
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||||
|
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||||
|
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||||
|
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||||
|
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||||
|
import bzh.ajaury.chombev.subtitle.model.Subtitle
|
||||||
|
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class PlaybackRepositoryImplTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val audioPlayer = FakeAudioPlayer()
|
||||||
|
private val preferences = MutableStateFlow(PlaybackPreferences(breakBetweenPhrases = 2.seconds))
|
||||||
|
|
||||||
|
private val repository = PlaybackRepositoryImpl(
|
||||||
|
audioPlayer = audioPlayer,
|
||||||
|
resourceReader = FakeResourceReader,
|
||||||
|
subtitleRepository = FakeSubtitleRepository,
|
||||||
|
preferencesRepository = FakePreferencesRepository(preferences),
|
||||||
|
getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase(),
|
||||||
|
logger = FakeLogger,
|
||||||
|
dispatcherProvider = FakeDispatcherProvider(dispatcher),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun breaks_before_the_next_phrase_when_the_poll_lands_just_before_it() = runTest(dispatcher) {
|
||||||
|
loadAndPlay()
|
||||||
|
|
||||||
|
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||||
|
|
||||||
|
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||||
|
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
assertEquals(SECOND_PHRASE_START - 60.milliseconds, audioPlayer.currentPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun breaks_and_rewinds_when_the_poll_lands_after_the_next_phrase_started() = runTest(dispatcher) {
|
||||||
|
loadAndPlay()
|
||||||
|
|
||||||
|
// A late poll (e.g. busy main thread) skips the window right before the phrase start.
|
||||||
|
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||||
|
|
||||||
|
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||||
|
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun breaks_only_once_per_phrase() = runTest(dispatcher) {
|
||||||
|
loadAndPlay()
|
||||||
|
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||||
|
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||||
|
|
||||||
|
playTo(SECOND_PHRASE_START + 500.milliseconds)
|
||||||
|
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun auto_pause_stops_and_resumes_from_the_phrase_start() = runTest(dispatcher) {
|
||||||
|
preferences.value = PlaybackPreferences(autoPause = true)
|
||||||
|
loadAndPlay()
|
||||||
|
|
||||||
|
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||||
|
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||||
|
|
||||||
|
advanceTimeBy(10.seconds)
|
||||||
|
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||||
|
|
||||||
|
repository.play()
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seeking_to_the_next_sentence_does_not_break() = runTest(dispatcher) {
|
||||||
|
loadAndPlay()
|
||||||
|
playTo(1.seconds)
|
||||||
|
|
||||||
|
repository.goToNextSentence()
|
||||||
|
runCurrent()
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a_stale_position_after_a_backward_seek_does_not_break() = runTest(dispatcher) {
|
||||||
|
loadAndPlay()
|
||||||
|
playTo(SECOND_PHRASE_START + 1.seconds)
|
||||||
|
advanceTimeBy(3.seconds)
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
|
||||||
|
// Like AVPlayer, the player still reports the pre-seek position for a while.
|
||||||
|
audioPlayer.asynchronousSeek = true
|
||||||
|
repository.seekToSentence(0)
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
|
||||||
|
audioPlayer.completeSeek()
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun TestScope.loadAndPlay() {
|
||||||
|
repository.load(record)
|
||||||
|
runCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.playTo(position: Duration) {
|
||||||
|
audioPlayer.position = position
|
||||||
|
advanceTimeBy(60.milliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val SECOND_PHRASE_START = 4310.milliseconds
|
||||||
|
|
||||||
|
val record = Record(
|
||||||
|
id = 1,
|
||||||
|
index = 0,
|
||||||
|
emoji = "",
|
||||||
|
title = Phrase(transcription = ""),
|
||||||
|
audioResourcePath = "audio",
|
||||||
|
subtitleResourcePath = "subtitle",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAudioPlayer : AudioPlayer {
|
||||||
|
override val playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||||
|
var position: Duration = Duration.ZERO
|
||||||
|
var asynchronousSeek = false
|
||||||
|
private var pendingSeek: Duration? = null
|
||||||
|
|
||||||
|
override val currentPosition: Duration get() = position
|
||||||
|
override val duration: Duration = 10.seconds
|
||||||
|
|
||||||
|
override fun seekTo(position: Duration) {
|
||||||
|
if (asynchronousSeek) pendingSeek = position else this.position = position
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeSeek() {
|
||||||
|
pendingSeek?.let { position = it }
|
||||||
|
pendingSeek = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun play() {
|
||||||
|
playbackState.value = PlaybackState.PLAYING
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
playbackState.value = PlaybackState.PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
playbackState.value = PlaybackState.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() = stop()
|
||||||
|
|
||||||
|
override fun load(uri: String) = Unit
|
||||||
|
|
||||||
|
override fun setSpeed(speed: Float) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private object FakeResourceReader : ResourceReader {
|
||||||
|
override suspend fun read(resourcePath: String): String = ""
|
||||||
|
|
||||||
|
override fun uri(resourcePath: String): String = resourcePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private object FakeSubtitleRepository : SubtitleRepository {
|
||||||
|
override suspend fun getSubtitle(
|
||||||
|
transcriptionContent: String,
|
||||||
|
translationContent: String?,
|
||||||
|
): Subtitle = Subtitle(
|
||||||
|
lines = listOf(
|
||||||
|
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
|
||||||
|
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
|
||||||
|
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakePreferencesRepository(
|
||||||
|
override val playbackPreferences: StateFlow<PlaybackPreferences>,
|
||||||
|
) : PreferencesRepository {
|
||||||
|
override val subtitlePreferences: Flow<SubtitlePreferences> = flowOf(SubtitlePreferences())
|
||||||
|
override val onboardingCompleted: Flow<Boolean> = flowOf(true)
|
||||||
|
|
||||||
|
override suspend fun setShowTranscription(enabled: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun setShowTranslation(enabled: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun setSubtitleTextScale(scale: Float) = Unit
|
||||||
|
|
||||||
|
override suspend fun setShowPhoneticMarkers(enabled: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun setPlaybackSpeed(speed: Float) = Unit
|
||||||
|
|
||||||
|
override suspend fun setBreakBetweenPhrases(duration: Duration) = Unit
|
||||||
|
|
||||||
|
override suspend fun setAutoPause(enabled: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun setOnboardingCompleted() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private object FakeLogger : Logger {
|
||||||
|
override fun debug(message: String, throwable: Throwable?) = Unit
|
||||||
|
|
||||||
|
override fun info(message: String, throwable: Throwable?) = Unit
|
||||||
|
|
||||||
|
override fun warning(message: String, throwable: Throwable?) = Unit
|
||||||
|
|
||||||
|
override fun error(message: String, throwable: Throwable?) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeDispatcherProvider(dispatcher: CoroutineDispatcher) : DispatcherProvider {
|
||||||
|
override val main = dispatcher
|
||||||
|
override val default = dispatcher
|
||||||
|
override val io = dispatcher
|
||||||
|
}
|
||||||
@@ -5,5 +5,5 @@ PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
|
|||||||
|
|
||||||
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
||||||
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
||||||
CURRENT_PROJECT_VERSION=4
|
CURRENT_PROJECT_VERSION=5
|
||||||
MARKETING_VERSION=1.0.0.3
|
MARKETING_VERSION=1.0.0.4
|
||||||
Reference in New Issue
Block a user