Author SHA1 Message Date
Antoine Jaury da9619b8fe chore: bump app version to 1.0.0-beta04 2026-10-02 16:02:41 +02:00
Antoine Jaury 8f9e1ab87d fix: correct the way pause between phrases was handled 2026-10-02 16:01:35 +02:00
Antoine_Jaury_KSK e12b7f1bfd Merge pull request 'Create version 1.0.0-beta03' (#4) from release/1.0.0 into develop
Reviewed-on: #4
2026-09-30 11:53:01 +02:00
Antoine Jaury 59116a8248 chore: bump app version to 1.0.0-beta03 2026-09-30 11:52:23 +02:00
Antoine_Jaury_KSK 77f76a22ea Merge pull request 'Create version beta 1 and 2' (#3) from release/1.0.0 into develop
Reviewed-on: #3
2026-09-30 11:21:36 +02:00
Antoine Jaury a143ac1a68 chore: bump dependency versions 2026-09-30 11:18:54 +02:00
Antoine Jaury 1700e2ea72 refactor: replace deprecated rememberModalBottomSheetState method 2026-09-30 11:18:54 +02:00
Antoine Jaury 021f1e773e chore: bump AGP to 9.4.1 and Gradle to 9.7.1 2026-09-30 11:18:54 +02:00
Antoine Jaury a979389fb5 chore: bump app version to 1.0.0-beta02 2026-09-30 11:18:54 +02:00
Antoine Jaury 894cca26a5 chore: update launcher and round icons in all density folders 2026-09-30 11:18:54 +02:00
Antoine Jaury 6a1acb7505 chore: rename app to "Donemat - Breton d'Auray" 2026-09-30 11:18:54 +02:00
Antoine Jaury b7eb12cd82 chore: update applicationId in androidApp build configuration 2026-09-30 11:18:54 +02:00
Antoine Jaury b37d83c9a1 chore: bump dependency versions in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 6897118e1c chore: add release notes links for dependencies in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 4e77185b3c chore: bump AGP version to 9.3.2 2026-09-30 11:18:54 +02:00
33 changed files with 476 additions and 166 deletions
+28 -2
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
- show on boarding as a modal on large screens
@@ -45,13 +71,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0-alpha01] - 2026-07-08
First public release of **Chom Bev e Brezhoneg**.
First public release of **Donemat - Breton d'Auray**.
### Added
#### Listening
- Record list of the *Chom Bev* audio course, with an emoji, the Breton title and
- Record list of the *Donemat - Breton d'Auray* audio course, with an emoji, the Breton title and
its French translation for each record.
- Audio player with play/pause/replay, a seekable progress bar, and
previous/next-sentence navigation.
+1 -1
View File
@@ -1,4 +1,4 @@
# Chom Bev e Brezhoneg - Survive in Breton - Survive en Breton
# "Donemat - Breton d'Auray" - Survive in Breton
This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio
player adapted for each platform and synchronised subtitles.
+3 -4
View File
@@ -1,5 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
alias(libs.plugins.androidApplication)
@@ -41,7 +40,7 @@ android {
.toInt()
defaultConfig {
applicationId = "bzh.ajaury.chombev"
applicationId = "bzh.ksk.donemat"
minSdk =
libs.versions.androidMinSdk
.get()
@@ -50,8 +49,8 @@ android {
libs.versions.androidTargetSdk
.get()
.toInt()
versionCode = 2
versionName = "1.0.0-beta01"
versionCode = 5
versionName = "1.0.0-beta04"
}
packaging {
resources {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">Chom Bev</string>
<string name="app_name">Donemat - Breton d\'Auray</string>
</resources>
@@ -3,7 +3,7 @@ package bzh.ajaury.chombev.core.logging.data
import bzh.ajaury.chombev.core.logging.domain.Logger
internal class KermitLogger : Logger {
private val tag: String = "Chom Bev"
private val tag: String = "Donemat"
override fun debug(
message: String,
+3
View File
@@ -21,5 +21,8 @@ kotlin {
implementation(projects.data.resources)
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.PlayerState
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.subtitle.domain.GetCurrentSubtitleIndexUseCase
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
@@ -18,23 +19,17 @@ 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.collectLatest
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
@@ -53,13 +48,18 @@ internal class PlaybackRepositoryImpl(
) : 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 preferencesObservationJob: 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.
private var breakJob: Job? = null
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 subtitle = MutableStateFlow(Subtitle(emptyList()))
@@ -113,45 +113,16 @@ internal class PlaybackRepositoryImpl(
subtitle,
currentPosition,
) { subtitle, position ->
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,
currentSubtitleIndex = getCurrentSubtitleIndex(
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(
playbackState,
playbackTiming,
@@ -168,19 +139,27 @@ internal class PlaybackRepositoryImpl(
}
override suspend fun load(record: Record) {
phraseBreakJob?.cancel()
cancelRunningBreak()
pendingPhraseStart = null
pendingSeekTarget = null
playAudio(filePath = record.audioResourcePath)
subtitle.value = loadSubtitle(record = record)
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
recordTitle.value = record.title
observePlaybackSpeed()
observePlaybackPreferences()
observePhraseBreaks()
}
private fun observePlaybackSpeed() {
speedObservationJob?.cancel()
speedObservationJob = scope.launch {
preferencesRepository.playbackPreferences.collect { playbackPreferences ->
setSpeed(speed = playbackPreferences.speed)
private fun observePlaybackPreferences() {
preferencesObservationJob?.cancel()
preferencesObservationJob = scope.launch {
preferencesRepository.playbackPreferences.collect { preferences ->
// 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.
*
* This only happens on automatic advancement — manual seeking and previous/next navigation are
* ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING]
* through this path.
* A phrase is considered reached as soon as a polled position is past its start minus
* [PHRASE_LOOKAHEAD], even if the poll came late and the phrase already started: in that case
* 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()
cancelRunningBreakJob()
phraseBreakJob = subtitleProgress
.map { it.isGoingToPlayNextPhrase }
.distinctUntilChanged()
.filter { it }
.onEach {
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
val playbackPreferences = preferencesRepository.playbackPreferences.first()
if (playbackPreferences.autoPause) {
audioPlayer.pause()
return@onEach
phraseBreakJob = scope.launch {
playbackState.collectLatest { state ->
if (state != PlaybackState.PLAYING) return@collectLatest
while (true) {
checkPhraseBoundary()
delay(POSITION_POLL_INTERVAL)
}
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) {
try {
audioPlayer.load(uri = resourceReader.uri(filePath))
@@ -265,24 +291,30 @@ internal class PlaybackRepositoryImpl(
}
override suspend fun play() {
cancelRunningBreakJob()
audioPlayer.play()
cancelRunningBreak()
val phraseStart = pendingPhraseStart
if (phraseStart != null) {
resumeAtPhraseStart(phraseStart = phraseStart)
} else {
audioPlayer.play()
}
}
override suspend fun pause() {
cancelRunningBreakJob()
cancelRunningBreak()
audioPlayer.pause()
}
override suspend fun replay() {
cancelRunningBreakJob()
audioPlayer.seekTo(Duration.ZERO)
cancelRunningBreak()
seekAudio(position = Duration.ZERO)
audioPlayer.play()
}
override suspend fun seekTo(position: Duration) {
cancelRunningBreakJob()
audioPlayer.seekTo(position)
val wasBreaking = cancelRunningBreak()
seekAudio(position = position)
if (wasBreaking) audioPlayer.play()
}
private fun setSpeed(speed: Float) {
@@ -310,9 +342,8 @@ internal class PlaybackRepositoryImpl(
}
override suspend fun seekToSentence(sentenceIndex: Int) {
cancelRunningBreakJob()
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime)
seekTo(position = matchingSubtitleLine.startTime)
}
private fun currentSentenceIndex(): Int? =
@@ -322,32 +353,33 @@ internal class PlaybackRepositoryImpl(
)
override suspend fun stop() {
breakJob?.cancel()
isBreaking.value = false
phraseBreakJob?.cancel()
cancelRunningBreak()
pendingPhraseStart = null
subtitle.value = Subtitle(emptyList())
audioPlayer.stop()
}
override fun release() {
breakJob?.cancel()
isBreaking.value = false
phraseBreakJob?.cancel()
cancelRunningBreak()
pendingPhraseStart = null
audioPlayer.release()
subtitle.value = Subtitle(emptyList())
}
private suspend fun cancelRunningBreakJob() {
breakJob?.cancelAndJoin()
// Unlock if paused by a break.
if (playbackState.value == PlaybackState.BREAKING) {
audioPlayer.play()
}
private fun cancelRunningBreak(): Boolean {
breakJob?.cancel()
breakJob = null
val wasBreaking = isBreaking.value
isBreaking.value = false
return wasBreaking
}
companion object {
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
}
}
@@ -7,5 +7,4 @@ data class SubtitleProgress(
val subtitle: Subtitle,
val currentPosition: Duration,
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
}
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev &#8211; À propos &#8211; Kerlenn Sten Kidna</title>
<title>App Donemat - Breton d'Auray &#8211; À propos &#8211; Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
@@ -164,7 +164,7 @@
<pre class="wp-block-preformatted">"<strong>Chom Bev e Brezhoneg</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
<pre class="wp-block-preformatted">"<strong>Donemat - Breton d'Auray</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
@@ -176,7 +176,7 @@
<p class="wp-block-paragraph">Cette application vous est proposée par l&rsquo;association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. « Chom Bev e Brezhoneg » est une oeuvre créée par KSK, initialement sous la forme d&rsquo;un livret imprimé et accompagné d&rsquo;un CD audio. Il est adapté ici au format numérique. L&rsquo;association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d&rsquo;Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, &#8230;) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur &#8230;) et des cours hebdomadaires de breton (en journée &amp; en soirée, en présentiel &amp; à distance). </p>
<p class="wp-block-paragraph">Cette application vous est proposée par l&rsquo;association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. «Donemat - Breton d'Auray» est une oeuvre créée par KSK, initialement sous la forme d&rsquo;un livret imprimé et accompagné d&rsquo;un CD audio ("Chom Bev e Brezhoneg"). Il est adapté ici au format numérique. L&rsquo;association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d&rsquo;Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, &#8230;) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur &#8230;) et des cours hebdomadaires de breton (en journée &amp; en soirée, en présentiel &amp; à distance). </p>
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev -​ Approfondir &#8211; Kerlenn Sten Kidna</title>
<title>App Donemat - Breton d'Auray -​ Approfondir &#8211; Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" />
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
@@ -1,6 +1,6 @@
<resources>
<!-- Breton (br) -->
<string name="app_name">Chom bev</string>
<string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Dastumoù</string>
<string name="nav_deepen">Donaat</string>
@@ -28,7 +28,7 @@
<string name="auto_pause_title">Ehan emgefreek</string>
<string name="auto_pause_description">Ehanañ goude pep frazenn</string>
<string name="onboarding_goal_title">Degemer mat e Chom bev</string>
<string name="onboarding_goal_title">Degemer mat e "Donemat - Breton d'Auray"</string>
<string name="onboarding_goal_description">Deskit brezhoneg en ur selaou enrolladennoù, frazenn ha frazenn, evit gwellaat ho komprenidigezh hag ho tistagadur.</string>
<string name="onboarding_subtitles_title">Istitloù diouzh ho tro</string>
<string name="onboarding_subtitles_description">Adalek al lenner, diskouezit pe kuzhit an treuzskrivadur brezhonek hag an droidigezh c\'hallek, cheñchit ment an destenn hag ar merkoù distagañ.</string>
@@ -1,6 +1,6 @@
<resources>
<!-- Default locale: French -->
<string name="app_name">Chom bev</string>
<string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Enregistrements</string>
<string name="nav_deepen">Approfondir</string>
@@ -28,7 +28,7 @@
<string name="auto_pause_title">Pause automatique</string>
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
<string name="onboarding_goal_title">Bienvenue dans\n Chom bev e Brezhoneg</string>
<string name="onboarding_goal_title">Bienvenue dans\n Donemat - Breton d'Auray</string>
<string name="onboarding_goal_description">Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais.</string>
<string name="onboarding_subtitles_title">Des sous-titres à votre rythme</string>
<string name="onboarding_subtitles_description">Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation.</string>
@@ -7,7 +7,7 @@ fun main() {
application {
Window(
onCloseRequest = ::exitApplication,
title = "Chom Bev",
title = "Donemat - Breton d'Auray",
) {
App()
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Crash reporting — Sentry SDK on GlitchTip
Chom Bev reports crashes and handled errors with the
"Donemat - Breton d'Auray" reports crashes and handled errors with the
[Sentry Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/),
but sends them to [GlitchTip](https://glitchtip.com) instead of Sentry. GlitchTip is an
open-source, Sentry-API-compatible backend, so the Sentry SDKs and `glitchtip-cli` work against
@@ -42,7 +42,7 @@ Add the DSN to `local.properties` at the repository root (this file is git-ignor
glitchtip.dsn=https://<key>@eu.glitchtip.com/<project-id>
```
Find the value in GlitchTip under **Settings → Projects → _Chom Bev e Brezhoneg_ → Client Keys (DSN)**.
Find the value in GlitchTip under **Settings → Projects → _Donemat_ → Client Keys (DSN)**.
On CI, set the `GLITCHTIP_DSN` environment variable instead — it takes precedence over
`local.properties`.
@@ -58,7 +58,7 @@ private fun OnboardingInfoPagePreview() {
MaterialTheme {
OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = "Bienvenue dans Chom bev",
title = "Bienvenue dans Donemat - Breton d'Auray",
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
)
}
@@ -8,7 +8,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@@ -40,7 +41,7 @@ fun PlaybackSpeedBottomSheet(
onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -14,8 +14,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -50,7 +51,7 @@ fun SubtitlePreferencesBottomSheet(
onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(),
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
+25 -25
View File
@@ -1,29 +1,29 @@
[versions]
agp = "9.3.1"
androidCompileSdk = "37"
androidMinSdk = "24"
androidTargetSdk = "37"
androidxActivity = "1.13.0"
androidxLifecycle = "2.11.0"
androidxWebkit = "1.16.0"
composeIconsExtended = "1.7.3"
composeMultiplatform = "1.11.1"
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
googleServices = "4.5.0"
jaad = "0.9.4"
kermit = "2.1.0"
koin = "4.2.2"
kotlin = "2.4.10"
kotlinSerialization = "2.4.10"
kotlinWrappers = "2026.8.0"
kotlinxBrowser = "0.5.0"
kotlinxCoroutines = "1.11.0"
kotlinxSerializationJson = "1.11.0"
material3 = "1.11.0-alpha07"
media3 = "1.11.0"
navigation3 = "1.1.1"
okio = "3.18.1"
sentry = "0.27.0"
agp = "9.4.1" # https://developer.android.com/build/releases/gradle-plugin
androidCompileSdk = "37" # 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
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
androidxWebkit = "1.17.1" # https://developer.android.com/jetpack/androidx/releases/webkit
composeIconsExtended = "1.7.3" # 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
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
kermit = "2.1.0" # https://github.com/touchlab/Kermit/releases
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
kotlin = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinSerialization = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinWrappers = "2026.9.2" # https://github.com/JetBrains/kotlin-wrappers/releases
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases
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
navigation3 = "1.1.2" # https://developer.android.com/jetpack/androidx/releases/navigation3
okio = "3.18.1" # https://square.github.io/okio/changelog/
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
[libraries]
# Gradle plugin artifacts, used by build-logic convention plugins only
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4 -4
View File
@@ -1,9 +1,9 @@
TEAM_ID=
PRODUCT_NAME=Chom Bev
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID)
PRODUCT_NAME=Donemat - Breton d'Auray
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
// 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.
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0.0
CURRENT_PROJECT_VERSION=5
MARKETING_VERSION=1.0.0.4
+3 -3
View File
@@ -11,7 +11,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
9DA43AABF87DF46F5E794E72 /* Chom Bev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -55,7 +55,7 @@
47B0C8770C82BEADFE13EF77 /* Products */ = {
isa = PBXGroup;
children = (
9DA43AABF87DF46F5E794E72 /* Chom Bev.app */,
9DA43AABF87DF46F5E794E72 /* Donemat.app */,
);
name = Products;
sourceTree = "<group>";
@@ -93,7 +93,7 @@
F36B7318302CAEC900B3E490 /* Sentry */,
);
productName = iosApp;
productReference = 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */;
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chom Bev</title>
<title>Donemat - Breton d'Auray</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">