11 changed files with 435 additions and 125 deletions
+26
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
### Changed
### Fixed
## [1.0.0-beta04] - 2026-10-02
### Fixed
- correct the way pause between phrases was handled
## [1.0.0-beta03] - 2026-09-30
### Fixed
- correct transcriptions and translations for all records
## [1.0.0-beta02] - 2026-09-17
### Changed
- rename app to "Donemat - Breton d'Auray"
## [1.0.0-beta01] - 2026-09-07
### Changed ### Changed
- show on boarding as a modal on large screens - show on boarding as a modal on large screens
+2 -3
View File
@@ -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 = 3 versionCode = 5
versionName = "1.0.0-beta02" versionName = "1.0.0-beta04"
} }
packaging { packaging {
resources { resources {
+3
View File
@@ -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)
}
} }
} }
@@ -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,
) )
@@ -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
}
@@ -8,7 +8,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -40,7 +41,7 @@ fun PlaybackSpeedBottomSheet(
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {}, onAutoPauseChange: (Boolean) -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -14,8 +14,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,7 +51,7 @@ fun SubtitlePreferencesBottomSheet(
onTextSizeDecrease: () -> Unit = {}, onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {}, onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
+8 -8
View File
@@ -1,27 +1,27 @@
[versions] [versions]
agp = "9.3.2" # https://developer.android.com/build/releases/gradle-plugin agp = "9.4.1" # https://developer.android.com/build/releases/gradle-plugin
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
androidxWebkit = "1.17.0" # https://developer.android.com/jetpack/androidx/releases/webkit androidxWebkit = "1.17.1" # https://developer.android.com/jetpack/androidx/releases/webkit
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
composeMultiplatform = "1.12.0" # https://github.com/JetBrains/compose-multiplatform/releases composeMultiplatform = "1.12.1" # https://github.com/JetBrains/compose-multiplatform/releases
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
kermit = "2.1.0" # https://github.com/touchlab/Kermit/releases kermit = "2.1.0" # https://github.com/touchlab/Kermit/releases
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
kotlin = "2.4.10" # https://github.com/JetBrains/kotlin/releases kotlin = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinSerialization = "2.4.10" # https://github.com/JetBrains/kotlin/releases kotlinSerialization = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinWrappers = "2026.9.1" # https://github.com/JetBrains/kotlin-wrappers/releases kotlinWrappers = "2026.9.2" # https://github.com/JetBrains/kotlin-wrappers/releases
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases
material3 = "1.11.0-alpha07" # https://developer.android.com/jetpack/androidx/releases/compose-material3 material3 = "1.12.0-alpha03" # https://developer.android.com/jetpack/androidx/releases/compose-material3
media3 = "1.11.0" # https://developer.android.com/jetpack/androidx/releases/media3 media3 = "1.11.0" # https://developer.android.com/jetpack/androidx/releases/media3
navigation3 = "1.1.1" # https://developer.android.com/jetpack/androidx/releases/navigation3 navigation3 = "1.1.2" # https://developer.android.com/jetpack/androidx/releases/navigation3
okio = "3.18.1" # https://square.github.io/okio/changelog/ okio = "3.18.1" # https://square.github.io/okio/changelog/
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+2 -2
View File
@@ -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=3 CURRENT_PROJECT_VERSION=5
MARKETING_VERSION=1.0.0.2 MARKETING_VERSION=1.0.0.4