From 8f9e1ab87d4c17c6bd1d92957d99b930bc36eeb2 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 2 Oct 2026 16:01:35 +0200 Subject: [PATCH 01/36] fix: correct the way pause between phrases was handled --- CHANGELOG.md | 2 + data/playback/build.gradle.kts | 3 + .../player/data/PlaybackRepositoryImpl.kt | 244 +++++++++-------- .../chombev/player/data/SubtitleProgress.kt | 1 - .../player/data/PlaybackRepositoryImplTest.kt | 249 ++++++++++++++++++ 5 files changed, 392 insertions(+), 107 deletions(-) create mode 100644 data/playback/src/commonTest/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImplTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index aad4aff..73fe453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- correct the way pause between phrases was handled + ## [1.0.0-beta03] - 2026-09-30 ### Fixed diff --git a/data/playback/build.gradle.kts b/data/playback/build.gradle.kts index 4021fe4..0e6cf72 100644 --- a/data/playback/build.gradle.kts +++ b/data/playback/build.gradle.kts @@ -21,5 +21,8 @@ kotlin { implementation(projects.data.resources) implementation(projects.data.subtitle) } + commonTest.dependencies { + implementation(libs.kotlinx.coroutinesTest) + } } } diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt index 9e40849..38c3ac9 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt @@ -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 = 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 } } diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/SubtitleProgress.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/SubtitleProgress.kt index 44a280a..d125f4b 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/SubtitleProgress.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/SubtitleProgress.kt @@ -7,5 +7,4 @@ data class SubtitleProgress( val subtitle: Subtitle, val currentPosition: Duration, val currentSubtitleIndex: Int?, - val isGoingToPlayNextPhrase: Boolean, ) diff --git a/data/playback/src/commonTest/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImplTest.kt b/data/playback/src/commonTest/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImplTest.kt new file mode 100644 index 0000000..8b64d5c --- /dev/null +++ b/data/playback/src/commonTest/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImplTest.kt @@ -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, +) : PreferencesRepository { + override val subtitlePreferences: Flow = flowOf(SubtitlePreferences()) + override val onboardingCompleted: Flow = 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 +} From da9619b8fe1e00c7c49ab2f6e9c2c5fc2a5dfb5e Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 2 Oct 2026 16:02:41 +0200 Subject: [PATCH 02/36] chore: bump app version to 1.0.0-beta04 --- CHANGELOG.md | 4 ++++ androidApp/build.gradle.kts | 5 ++--- iosApp/Configuration/Config.xcconfig | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73fe453..6300898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [1.0.0-beta04] - 2026-10-02 + +### Fixed + - correct the way pause between phrases was handled ## [1.0.0-beta03] - 2026-09-30 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3942c96..1486c66 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.util.Properties plugins { alias(libs.plugins.androidApplication) @@ -50,8 +49,8 @@ android { libs.versions.androidTargetSdk .get() .toInt() - versionCode = 4 - versionName = "1.0.0-beta03" + versionCode = 5 + versionName = "1.0.0-beta04" } packaging { resources { diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 1b3b8ec..4cd84c1 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -5,5 +5,5 @@ 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=4 -MARKETING_VERSION=1.0.0.3 \ No newline at end of file +CURRENT_PROJECT_VERSION=5 +MARKETING_VERSION=1.0.0.4 \ No newline at end of file From 477cb445e30a320fd123b7875f51f8389b5ed30e Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 9 Oct 2026 15:19:05 +0200 Subject: [PATCH 03/36] refactor: improve record list accessibility --- .../composeResources/values/strings.xml | 3 ++ .../ajaury/chombev/records/ui/RecordView.kt | 43 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 310f12e..8f52320 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -44,6 +44,9 @@ Commencer Fermer + écouter + Chapitre %1$d + Retour Phrase précédente Phrase suivante diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt index 35007b9..12b2fb2 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt @@ -11,23 +11,42 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.core.model.Phrase import bzh.ajaury.chombev.core.model.Record +import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.records_chapter_action_description +import bzh.ajaury.chombev.resources.generated.resources.records_chapter_number +import org.jetbrains.compose.resources.stringResource @Composable fun RecordView( record: Record, onClick: () -> Unit = {}, ) { + val chapterNumber = stringResource(Res.string.records_chapter_number, record.index) + val rowDescription = + listOfNotNull(chapterNumber, record.title.transcription, record.title.translation) + .joinToString(separator = ", ") + Row( modifier = Modifier .fillMaxWidth() - .clickable { onClick() } - .padding(16.dp), + .clickable( + role = Role.Button, + onClickLabel = stringResource(Res.string.records_chapter_action_description), + ) { + onClick() + }.padding(16.dp) + .clearAndSetSemantics { + contentDescription = rowDescription + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -70,3 +89,23 @@ private fun RecordViewPreview() { onClick = {}, ) } + +@Preview +@Composable +private fun RecordViewWithoutTranslationPreview() { + RecordView( + record = + Record( + id = 1, + index = 1, + emoji = "🙏", + title = Phrase( + transcription = "Sample Record", + translation = null, + ), + audioResourcePath = "files/Chom_bev_01.ogg", + subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc", + ), + onClick = {}, + ) +} From 242d494c8692d88c5deb022286ac9084305e56fa Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 11:44:27 +0200 Subject: [PATCH 04/36] refactor: improve player accessibility --- core/accessibility/build.gradle.kts | 9 ++++ .../data/AndroidAccessibilityRepository.kt | 15 ++++++ .../di/AccessibilityModule.android.kt | 12 +++++ .../UndetectableAccessibilityRepository.kt | 7 +++ .../accessibility/di/AccessibilityModule.kt | 5 ++ .../domain/AccessibilityRepository.kt | 5 ++ .../data/IosAccessibilityRepository.kt | 8 ++++ .../di/AccessibilityModule.ios.kt | 12 +++++ .../di/AccessibilityModule.jvm.kt | 12 +++++ .../di/AccessibilityModule.web.kt | 12 +++++ .../player/data/PlaybackRepositoryImpl.kt | 16 +++++-- .../player/domain/PlaybackRepository.kt | 5 +- .../composeResources/values/strings.xml | 2 + feature/records/build.gradle.kts | 1 + .../ajaury/chombev/player/ui/PlayerScreen.kt | 48 ++++++++++++++++--- .../ajaury/chombev/player/ui/SubtitleList.kt | 5 +- .../player/ui/components/NextButton.kt | 3 +- .../player/ui/components/PlayButton.kt | 3 +- .../player/ui/components/PlaybackProgress.kt | 5 +- .../player/ui/components/PreviousButton.kt | 3 +- .../ui/components/SpeedPreferenceButton.kt | 14 +++++- .../player/ui/viewmodel/PlayerViewModel.kt | 9 +++- .../records/ui/di/RecordsFeatureModule.kt | 2 + settings.gradle.kts | 1 + 24 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 core/accessibility/build.gradle.kts create mode 100644 core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/AndroidAccessibilityRepository.kt create mode 100644 core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.android.kt create mode 100644 core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/UndetectableAccessibilityRepository.kt create mode 100644 core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.kt create mode 100644 core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/domain/AccessibilityRepository.kt create mode 100644 core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/IosAccessibilityRepository.kt create mode 100644 core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.ios.kt create mode 100644 core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.jvm.kt create mode 100644 core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.web.kt diff --git a/core/accessibility/build.gradle.kts b/core/accessibility/build.gradle.kts new file mode 100644 index 0000000..6f1670e --- /dev/null +++ b/core/accessibility/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("gwenedeg.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "bzh.ajaury.chombev.core.accessibility" + } +} diff --git a/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/AndroidAccessibilityRepository.kt b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/AndroidAccessibilityRepository.kt new file mode 100644 index 0000000..31ae11f --- /dev/null +++ b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/AndroidAccessibilityRepository.kt @@ -0,0 +1,15 @@ +package bzh.ajaury.chombev.core.accessibility.data + +import android.content.Context +import android.view.accessibility.AccessibilityManager +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository + +internal class AndroidAccessibilityRepository( + context: Context, +) : AccessibilityRepository { + private val accessibilityManager = context.getSystemService(AccessibilityManager::class.java) + + // Screen readers such as TalkBack turn on touch exploration. Other accessibility services + // (password managers, automation tools) do not, so `isEnabled` alone would misreport them. + override fun isScreenReaderEnabled(): Boolean = accessibilityManager?.isTouchExplorationEnabled == true +} diff --git a/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.android.kt b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.android.kt new file mode 100644 index 0000000..f5f4b4d --- /dev/null +++ b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.android.kt @@ -0,0 +1,12 @@ +package bzh.ajaury.chombev.core.accessibility.di + +import bzh.ajaury.chombev.core.accessibility.data.AndroidAccessibilityRepository +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +actual val accessibilityModule: Module = module { + singleOf(::AndroidAccessibilityRepository) { bind() } +} diff --git a/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/UndetectableAccessibilityRepository.kt b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/UndetectableAccessibilityRepository.kt new file mode 100644 index 0000000..63fdc89 --- /dev/null +++ b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/UndetectableAccessibilityRepository.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.core.accessibility.data + +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository + +internal class UndetectableAccessibilityRepository : AccessibilityRepository { + override fun isScreenReaderEnabled(): Boolean = false +} diff --git a/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.kt b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.kt new file mode 100644 index 0000000..1665e68 --- /dev/null +++ b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.kt @@ -0,0 +1,5 @@ +package bzh.ajaury.chombev.core.accessibility.di + +import org.koin.core.module.Module + +expect val accessibilityModule: Module diff --git a/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/domain/AccessibilityRepository.kt b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/domain/AccessibilityRepository.kt new file mode 100644 index 0000000..129e24d --- /dev/null +++ b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/domain/AccessibilityRepository.kt @@ -0,0 +1,5 @@ +package bzh.ajaury.chombev.core.accessibility.domain + +interface AccessibilityRepository { + fun isScreenReaderEnabled(): Boolean +} diff --git a/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/IosAccessibilityRepository.kt b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/IosAccessibilityRepository.kt new file mode 100644 index 0000000..d3367f7 --- /dev/null +++ b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/data/IosAccessibilityRepository.kt @@ -0,0 +1,8 @@ +package bzh.ajaury.chombev.core.accessibility.data + +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository +import platform.UIKit.UIAccessibilityIsVoiceOverRunning + +internal class IosAccessibilityRepository : AccessibilityRepository { + override fun isScreenReaderEnabled(): Boolean = UIAccessibilityIsVoiceOverRunning() +} diff --git a/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.ios.kt b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.ios.kt new file mode 100644 index 0000000..a668785 --- /dev/null +++ b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.ios.kt @@ -0,0 +1,12 @@ +package bzh.ajaury.chombev.core.accessibility.di + +import bzh.ajaury.chombev.core.accessibility.data.IosAccessibilityRepository +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +actual val accessibilityModule: Module = module { + singleOf(::IosAccessibilityRepository) { bind() } +} diff --git a/core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.jvm.kt b/core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.jvm.kt new file mode 100644 index 0000000..4b4a3d0 --- /dev/null +++ b/core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.jvm.kt @@ -0,0 +1,12 @@ +package bzh.ajaury.chombev.core.accessibility.di + +import bzh.ajaury.chombev.core.accessibility.data.UndetectableAccessibilityRepository +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +actual val accessibilityModule: Module = module { + singleOf(::UndetectableAccessibilityRepository) { bind() } +} diff --git a/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.web.kt b/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.web.kt new file mode 100644 index 0000000..4b4a3d0 --- /dev/null +++ b/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/di/AccessibilityModule.web.kt @@ -0,0 +1,12 @@ +package bzh.ajaury.chombev.core.accessibility.di + +import bzh.ajaury.chombev.core.accessibility.data.UndetectableAccessibilityRepository +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository +import org.koin.core.module.Module +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +actual val accessibilityModule: Module = module { + singleOf(::UndetectableAccessibilityRepository) { bind() } +} diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt index 38c3ac9..4273702 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/data/PlaybackRepositoryImpl.kt @@ -138,13 +138,16 @@ internal class PlaybackRepositoryImpl( ) } - override suspend fun load(record: Record) { + override suspend fun load( + record: Record, + autoPlay: Boolean, + ) { phraseBreakJob?.cancel() cancelRunningBreak() pendingPhraseStart = null pendingSeekTarget = null - playAudio(filePath = record.audioResourcePath) + loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay) subtitle.value = loadSubtitle(record = record) reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO) recordTitle.value = record.title @@ -264,12 +267,15 @@ internal class PlaybackRepositoryImpl( audioPlayer.seekTo(position) } - private fun playAudio(filePath: String) { + private fun loadAudio( + filePath: String, + autoPlay: Boolean, + ) { try { audioPlayer.load(uri = resourceReader.uri(filePath)) - audioPlayer.play() + if (autoPlay) audioPlayer.play() } catch (exception: Exception) { - logger.error(message = "Failed to play audio: $filePath", throwable = exception) + logger.error(message = "Failed to load audio: $filePath", throwable = exception) } } diff --git a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/domain/PlaybackRepository.kt b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/domain/PlaybackRepository.kt index be21d80..c75da92 100644 --- a/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/domain/PlaybackRepository.kt +++ b/data/playback/src/commonMain/kotlin/bzh/ajaury/chombev/player/domain/PlaybackRepository.kt @@ -15,7 +15,10 @@ import kotlin.time.Duration interface PlaybackRepository { val playerState: Flow - suspend fun load(record: Record) + suspend fun load( + record: Record, + autoPlay: Boolean = true, + ) suspend fun play() diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 8f52320..cc1df06 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -11,6 +11,7 @@ Sous-titres Préférences de sous-titrage + accéder aux paramètres Transcription (BZH) Afficher la transcription bretonne Traduction (FR) @@ -48,6 +49,7 @@ Chapitre %1$d Retour + revenir à la liste des contenus Phrase précédente Phrase suivante Lire diff --git a/feature/records/build.gradle.kts b/feature/records/build.gradle.kts index dedf59d..5e74eb2 100644 --- a/feature/records/build.gradle.kts +++ b/feature/records/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { } commonMain.dependencies { // Core modules + implementation(projects.core.accessibility) implementation(projects.core.model) implementation(projects.core.logging) implementation(projects.core.audioplayer) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index d3b4a40..90ec8a5 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -30,6 +30,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,6 +47,8 @@ import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.player_back +import bzh.ajaury.chombev.resources.generated.resources.player_back_action_label +import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action import bzh.ajaury.chombev.subtitle.model.SubtitleLine import org.jetbrains.compose.resources.stringResource @@ -198,6 +204,9 @@ private fun PlayerTopBar( label = "PlayerTitle", ) { (emoji, title) -> Row( + modifier = Modifier.clearAndSetSemantics { + contentDescription = title.translation ?: title.transcription + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -227,15 +236,20 @@ private fun PlayerTopBar( } }, navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.player_back), - ) - } + BackButton(onBackClicked = onBackClicked) }, actions = { - IconButton(onClick = showPreferences) { + val openSettingsLabel = stringResource(Res.string.settings_open_action_description) + IconButton( + // Names the action: "double-tap to open settings" rather than the generic "to activate". + modifier = Modifier.semantics { + onClick(label = openSettingsLabel) { + showPreferences() + true + } + }, + onClick = showPreferences, + ) { Icon( imageVector = Icons.Filled.Subtitles, contentDescription = stringResource(Res.string.subtitle_preferences_action), @@ -245,6 +259,26 @@ private fun PlayerTopBar( ) } +@Composable +private fun BackButton(onBackClicked: () -> Unit) { + val backActionLabel = stringResource(Res.string.player_back_action_label) + IconButton( + onClick = onBackClicked, + modifier = Modifier + .semantics { + onClick(label = backActionLabel) { + onBackClicked() + true + } + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.player_back), + ) + } +} + private val previewSubtitleLines = listOf( SubtitleLine( diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt index be11d2e..197cffa 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/SubtitleList.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -97,7 +98,9 @@ fun SubtitleList( } val density = LocalDensity.current - BoxWithConstraints(modifier = modifier) { + // Subtitles transcribe the audio being played: hide them from screen readers, which would + // otherwise read the same phrases a second time. + BoxWithConstraints(modifier = modifier.clearAndSetSemantics {}) { // Half-height padding so the first and last lines can reach the vertical center. Captured here // because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda. val verticalPadding = maxHeight / 2 diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt index 59e7e77..8da8681 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt @@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource @Composable fun NextButton( canGoToNextSentence: Boolean, + modifier: Modifier = Modifier, onNextSentenceClicked: () -> Unit = {}, ) { IconButton( - modifier = Modifier.size(60.dp), + modifier = modifier.size(60.dp), enabled = canGoToNextSentence, onClick = onNextSentenceClicked, ) { diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlayButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlayButton.kt index 3817864..5662f0a 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlayButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlayButton.kt @@ -38,6 +38,7 @@ private val BreakRingSize = 68.dp fun PlayButton( playbackState: PlaybackState, breakDuration: Duration = Duration.ZERO, + modifier: Modifier = Modifier, onMainPlayActionButtonClicked: () -> Unit = {}, ) { val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO @@ -61,7 +62,7 @@ fun PlayButton( // The ring reserves its footprint at all times so the transport row does not jump when a break // starts; the indicator itself is only drawn while breaking. Box( - modifier = Modifier.size(BreakRingSize), + modifier = modifier.size(BreakRingSize), contentAlignment = Alignment.Center, ) { if (isBreaking) { diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlaybackProgress.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlaybackProgress.kt index 295a28f..b6e31af 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlaybackProgress.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PlaybackProgress.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.tooling.preview.Preview import bzh.ajaury.chombev.player.model.PlaybackTiming import kotlin.time.Duration @@ -33,7 +34,9 @@ fun PlaybackProgress( } seekProgress = null }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { }, ) } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt index bcde2c3..bf54d90 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt @@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource @Composable fun PreviousButton( canGoToPreviousSentence: Boolean, + modifier: Modifier = Modifier, onPreviousSentenceClicked: () -> Unit = {}, ) { IconButton( - modifier = Modifier.size(60.dp), + modifier = modifier.size(60.dp), enabled = canGoToPreviousSentence, onClick = onPreviousSentenceClicked, ) { diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/SpeedPreferenceButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/SpeedPreferenceButton.kt index 44529b9..10221cc 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/SpeedPreferenceButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/SpeedPreferenceButton.kt @@ -9,10 +9,13 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action +import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description import org.jetbrains.compose.resources.stringResource /** @@ -22,10 +25,19 @@ import org.jetbrains.compose.resources.stringResource @Composable fun SpeedPreferenceButton( isActive: Boolean, + modifier: Modifier = Modifier, onSpeedClicked: () -> Unit = {}, ) { + val openSettingsLabel = stringResource(Res.string.settings_open_action_description) IconButton( - modifier = Modifier.size(60.dp), + modifier = modifier + .size(60.dp) + .semantics { + onClick(label = openSettingsLabel) { + onSpeedClicked() + true + } + }, onClick = onSpeedClicked, ) { Icon( diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt index 25b4a2d..bd2dddb 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt @@ -2,6 +2,7 @@ package bzh.ajaury.chombev.player.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository import bzh.ajaury.chombev.core.logging.domain.Logger import bzh.ajaury.chombev.core.model.Phrase import bzh.ajaury.chombev.core.model.Record @@ -29,6 +30,7 @@ class PlayerViewModel( private val playbackRepository: PlaybackRepository, private val preferencesRepository: PreferencesRepository, private val audioSessionManager: AudioSessionManager, + private val accessibilityRepository: AccessibilityRepository, private val logger: Logger, ) : ViewModel() { private val record = MutableStateFlow(null) @@ -62,11 +64,14 @@ class PlayerViewModel( viewModelScope.launch { playbackRepository.stop() + // A screen reader speaks over the audio: let its user start playback once oriented. + val autoPlay = !accessibilityRepository.isScreenReaderEnabled() + logger.debug("Loading record $recordId") val alreadyLoadedRecord = record.value if (alreadyLoadedRecord != null) { logger.debug("Record $recordId already loaded") - playbackRepository.load(record = alreadyLoadedRecord) + playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay) return@launch } @@ -76,7 +81,7 @@ class PlayerViewModel( return@launch } - playbackRepository.load(record = loadedRecord) + playbackRepository.load(record = loadedRecord, autoPlay = autoPlay) record.value = loadedRecord } } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/di/RecordsFeatureModule.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/di/RecordsFeatureModule.kt index 855dd83..2d12597 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/di/RecordsFeatureModule.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/di/RecordsFeatureModule.kt @@ -1,5 +1,6 @@ package bzh.ajaury.chombev.records.ui.di +import bzh.ajaury.chombev.core.accessibility.di.accessibilityModule import bzh.ajaury.chombev.player.di.audioPlayerModule import bzh.ajaury.chombev.player.di.playbackModule import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel @@ -23,6 +24,7 @@ val recordsFeatureModule: Module = module { recordsModule, resourcesModule, audioPlayerModule, + accessibilityModule, subtitleModule, ) diff --git a/settings.gradle.kts b/settings.gradle.kts index e25a9e3..f43a527 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include(":webApp") include(":shared") // Core modules +include(":core:accessibility") include(":core:audioplayer") include(":core:coroutines") include(":core:logging") From 1a23703911c1c23907d9687a5808293a6b825006 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 14:00:57 +0200 Subject: [PATCH 05/36] refactor: set traversal order for player --- .../ajaury/chombev/player/ui/PlayerControl.kt | 4 +++ .../ajaury/chombev/player/ui/PlayerScreen.kt | 33 +++++++++++++------ .../chombev/player/ui/PlayerTraversalOrder.kt | 30 +++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerTraversalOrder.kt diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt index e2742fc..8a30ca7 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt @@ -58,6 +58,7 @@ fun PlayerControl( contentAlignment = Alignment.Center, ) { SpeedPreferenceButton( + modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAYBACK_SETTINGS), isActive = isPlaybackModified, onSpeedClicked = onSpeedClicked, ) @@ -69,17 +70,20 @@ fun PlayerControl( verticalAlignment = Alignment.CenterVertically, ) { PreviousButton( + modifier = Modifier.traversalOrder(PlayerTraversalOrder.PREVIOUS), canGoToPreviousSentence = canGoToPreviousSentence, onPreviousSentenceClicked = onPreviousSentenceClicked, ) PlayButton( + modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAY), playbackState = playbackState, breakDuration = breakBetweenPhrases, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, ) NextButton( + modifier = Modifier.traversalOrder(PlayerTraversalOrder.NEXT), canGoToNextSentence = canGoToNextSentence, onNextSentenceClicked = onNextSentenceClicked, ) diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index 90ec8a5..46622c5 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -116,6 +117,9 @@ fun PlayerScreen( var showSpeed by remember { mutableStateOf(false) } Scaffold( + // Root of the screen reader order defined by PlayerTraversalOrder, which interleaves the + // top bar and the controls. + modifier = Modifier.semantics { isTraversalGroup = true }, topBar = { PlayerTopBar( uiState = uiState, @@ -191,7 +195,11 @@ private fun PlayerTopBar( showPreferences: () -> Unit, ) { TopAppBar( - modifier = Modifier.shadow(elevation = 2.dp), + modifier = Modifier + .shadow(elevation = 2.dp) + // TopAppBar makes itself a traversal group, which would keep its subtitle settings + // button next to the title. Flattened so PlayerTraversalOrder can move it last. + .semantics { isTraversalGroup = false }, title = { // Animate the title in as it loads, and between records: the record data isn't there on // the first frame, so fade + rise it into place once uiState carries it. @@ -204,9 +212,11 @@ private fun PlayerTopBar( label = "PlayerTitle", ) { (emoji, title) -> Row( - modifier = Modifier.clearAndSetSemantics { - contentDescription = title.translation ?: title.transcription - }, + modifier = Modifier + .traversalOrder(PlayerTraversalOrder.TITLE) + .clearAndSetSemantics { + contentDescription = title.translation ?: title.transcription + }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -242,12 +252,14 @@ private fun PlayerTopBar( val openSettingsLabel = stringResource(Res.string.settings_open_action_description) IconButton( // Names the action: "double-tap to open settings" rather than the generic "to activate". - modifier = Modifier.semantics { - onClick(label = openSettingsLabel) { - showPreferences() - true - } - }, + modifier = Modifier + .traversalOrder(PlayerTraversalOrder.SUBTITLE_SETTINGS) + .semantics { + onClick(label = openSettingsLabel) { + showPreferences() + true + } + }, onClick = showPreferences, ) { Icon( @@ -265,6 +277,7 @@ private fun BackButton(onBackClicked: () -> Unit) { IconButton( onClick = onBackClicked, modifier = Modifier + .traversalOrder(PlayerTraversalOrder.BACK) .semantics { onClick(label = backActionLabel) { onBackClicked() diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerTraversalOrder.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerTraversalOrder.kt new file mode 100644 index 0000000..725aa34 --- /dev/null +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerTraversalOrder.kt @@ -0,0 +1,30 @@ +package bzh.ajaury.chombev.player.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex + +/** + * Screen reader order on the player, which departs from the visual layout: the title and back + * button first, then the transport controls, the settings last. + */ +internal enum class PlayerTraversalOrder { + TITLE, + BACK, + PLAY, + PREVIOUS, + NEXT, + PLAYBACK_SETTINGS, + SUBTITLE_SETTINGS, +} + +/** + * Places this element at [order] in the player's screen reader order. Grouped so that an element + * made of several nodes (e.g. the play button and its break ring) moves as a whole. + */ +internal fun Modifier.traversalOrder(order: PlayerTraversalOrder): Modifier = + semantics { + isTraversalGroup = true + traversalIndex = order.ordinal.toFloat() + } From 8dc1e8e71e451e2bc839b6bdaca8013bd67b08d4 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 14:13:27 +0200 Subject: [PATCH 06/36] refactor: improve accessibility title for player --- .../bzh/ajaury/chombev/RowDescription.kt | 23 ++++++++ .../ajaury/chombev/player/ui/PlayerScreen.kt | 52 +++++++++++-------- .../player/ui/viewmodel/PlayerUiState.kt | 10 ++-- .../player/ui/viewmodel/PlayerViewModel.kt | 4 +- .../ajaury/chombev/records/ui/RecordView.kt | 7 +-- 5 files changed, 62 insertions(+), 34 deletions(-) create mode 100644 feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/RowDescription.kt diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/RowDescription.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/RowDescription.kt new file mode 100644 index 0000000..195223b --- /dev/null +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/RowDescription.kt @@ -0,0 +1,23 @@ +package bzh.ajaury.chombev + +import androidx.compose.runtime.Composable +import bzh.ajaury.chombev.core.model.Phrase +import bzh.ajaury.chombev.core.model.Record +import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.records_chapter_number +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun rowDescription(record: Record): String = rowDescription(index = record.index, title = record.title) + +@Composable +internal fun rowDescription( + index: Int, + title: Phrase, +): String { + val chapterNumber = stringResource(Res.string.records_chapter_number, index) + val rowDescription = + listOfNotNull(chapterNumber, title.translation) + .joinToString(separator = ", ") + return rowDescription +} diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt index 46622c5..19eaa4d 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import bzh.ajaury.chombev.core.model.Phrase +import bzh.ajaury.chombev.core.model.Record import bzh.ajaury.chombev.player.model.PlaybackState import bzh.ajaury.chombev.player.model.PlaybackTiming import bzh.ajaury.chombev.player.model.PlayerState @@ -51,6 +52,7 @@ import bzh.ajaury.chombev.resources.generated.resources.player_back import bzh.ajaury.chombev.resources.generated.resources.player_back_action_label import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action +import bzh.ajaury.chombev.rowDescription import bzh.ajaury.chombev.subtitle.model.SubtitleLine import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -204,35 +206,38 @@ private fun PlayerTopBar( // Animate the title in as it loads, and between records: the record data isn't there on // the first frame, so fade + rise it into place once uiState carries it. AnimatedContent( - targetState = uiState.recordEmoji to uiState.recordTitle, + targetState = uiState.record, transitionSpec = { fadeIn(tween()) togetherWith fadeOut(tween()) }, label = "PlayerTitle", - ) { (emoji, title) -> + ) { record -> + // Same description as the record's row in the list; none until the record is loaded. + val description = + record?.index?.let { rowDescription(index = it, title = record.title) } Row( modifier = Modifier .traversalOrder(PlayerTraversalOrder.TITLE) .clearAndSetSemantics { - contentDescription = title.translation ?: title.transcription + description?.let { contentDescription = it } }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = emoji, + text = record?.emoji.orEmpty(), style = MaterialTheme.typography.headlineSmall, ) Column { Text( - text = title.transcription, + text = record?.title?.transcription.orEmpty(), style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - title.translation?.let { translation -> + record?.title?.translation?.let { translation -> Text( text = translation, style = MaterialTheme.typography.titleSmall, @@ -317,17 +322,25 @@ private val previewSubtitleLines = ), ) +private val fakeRecord = Record( + id = 1, + index = 1, + emoji = "🙏", + title = Phrase( + transcription = "Demat", + translation = "Bonjour", + ), + audioResourcePath = "", + subtitleResourcePath = "", +) + @Preview @Composable private fun PlayerScreenPlayingPreview() { MaterialTheme { PlayerScreen( uiState = PlayerUiState( - recordEmoji = "🙏", - recordTitle = Phrase( - transcription = "Demat", - translation = "Bonjour", - ), + fakeRecord, playerState = PlayerState( playbackState = PlaybackState.PLAYING, playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), @@ -348,14 +361,13 @@ private fun PlayerScreenPausedPreview() { MaterialTheme { PlayerScreen( uiState = PlayerUiState( - recordEmoji = "🙏", - recordTitle = Phrase( - transcription = "Demat", - translation = "Bonjour", - ), + record = fakeRecord, playerState = PlayerState( playbackState = PlaybackState.PAUSED, - playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), + playbackTiming = PlaybackTiming( + position = 1.2.seconds, + duration = 5.seconds, + ), subtitleLines = previewSubtitleLines, currentSubtitleIndex = 1, ), @@ -373,11 +385,7 @@ private fun PlayerScreenEndedPreview() { MaterialTheme { PlayerScreen( uiState = PlayerUiState( - recordEmoji = "🙏", - recordTitle = Phrase( - transcription = "Demat", - translation = "Bonjour", - ), + record = fakeRecord, playerState = PlayerState( playbackState = PlaybackState.ENDED, playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt index c411806..a426419 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerUiState.kt @@ -1,6 +1,6 @@ package bzh.ajaury.chombev.player.ui.viewmodel -import bzh.ajaury.chombev.core.model.Phrase +import bzh.ajaury.chombev.core.model.Record import bzh.ajaury.chombev.player.model.PlayerState import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences @@ -8,8 +8,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds data class PlayerUiState( - val recordEmoji: String = "", - val recordTitle: Phrase = Phrase(transcription = ""), + val record: Record? = null, val playerState: PlayerState = PlayerState(), val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, @@ -20,7 +19,10 @@ data class PlayerUiState( get() = playerState.playbackTiming.position > 2.seconds val canGoToNextSentence: Boolean - get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex + get() = ( + playerState.currentSubtitleIndex + ?: Int.MAX_VALUE + ) < playerState.subtitleLines.lastIndex /** True when any playback setting differs from its default, used to highlight the button. */ val isPlaybackModified: Boolean diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt index bd2dddb..608e597 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository 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.AudioSessionManager import bzh.ajaury.chombev.player.domain.PlaybackRepository @@ -42,8 +41,7 @@ class PlayerViewModel( preferencesRepository.playbackPreferences, ) { record, playerState, subtitlePreferences, playbackPreferences -> PlayerUiState( - recordEmoji = record?.emoji ?: "", - recordTitle = record?.title ?: Phrase(transcription = ""), + record = record, playerState = playerState, subtitlePreferences = subtitlePreferences, playbackSpeed = playbackPreferences.speed, diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt index 12b2fb2..987b07a 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/ui/RecordView.kt @@ -21,7 +21,7 @@ import bzh.ajaury.chombev.core.model.Phrase import bzh.ajaury.chombev.core.model.Record import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.records_chapter_action_description -import bzh.ajaury.chombev.resources.generated.resources.records_chapter_number +import bzh.ajaury.chombev.rowDescription import org.jetbrains.compose.resources.stringResource @Composable @@ -29,10 +29,7 @@ fun RecordView( record: Record, onClick: () -> Unit = {}, ) { - val chapterNumber = stringResource(Res.string.records_chapter_number, record.index) - val rowDescription = - listOfNotNull(chapterNumber, record.title.transcription, record.title.translation) - .joinToString(separator = ", ") + val rowDescription = rowDescription(record) Row( modifier = From 8339ee128b36f9b95be0fbf84177e3a9d1de4e6c Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 14:29:59 +0200 Subject: [PATCH 07/36] refactor: improve accessibility for previous/next buttons in player --- .../composeResources/values/strings.xml | 2 ++ .../chombev/player/ui/components/NextButton.kt | 15 ++++++++++++++- .../player/ui/components/PreviousButton.kt | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index cc1df06..ccf7ab6 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -52,6 +52,8 @@ revenir à la liste des contenus Phrase précédente Phrase suivante + passer à la phrase précédente + passer à la phrase suivante Lire Pause Rejouer diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt index 8da8681..84d988c 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/NextButton.kt @@ -8,10 +8,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence +import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence_action_label import org.jetbrains.compose.resources.stringResource @Composable @@ -20,8 +23,18 @@ fun NextButton( modifier: Modifier = Modifier, onNextSentenceClicked: () -> Unit = {}, ) { + val actionLabel = stringResource(Res.string.player_next_sentence_action_label) IconButton( - modifier = modifier.size(60.dp), + modifier = modifier + .size(60.dp) + .semantics { + if (canGoToNextSentence) { + onClick(label = actionLabel) { + onNextSentenceClicked() + true + } + } + }, enabled = canGoToNextSentence, onClick = onNextSentenceClicked, ) { diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt index bf54d90..f46ce11 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/PreviousButton.kt @@ -8,10 +8,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence +import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence_action_label import org.jetbrains.compose.resources.stringResource @Composable @@ -20,8 +23,18 @@ fun PreviousButton( modifier: Modifier = Modifier, onPreviousSentenceClicked: () -> Unit = {}, ) { + val actionLabel = stringResource(Res.string.player_previous_sentence_action_label) IconButton( - modifier = modifier.size(60.dp), + modifier = modifier + .size(60.dp) + .semantics { + if (canGoToPreviousSentence) { + onClick(label = actionLabel) { + onPreviousSentenceClicked() + true + } + } + }, enabled = canGoToPreviousSentence, onClick = onPreviousSentenceClicked, ) { From 82a2ef3bdb3945eff9cc2858ba51ca601367211d Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 14:41:12 +0200 Subject: [PATCH 08/36] refactor: improve accessibility for subtitle size settings --- .../composeResources/values/strings.xml | 1 + .../ui/SubtitlePreferencesBottomSheet.kt | 51 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index ccf7ab6..42c5548 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -17,6 +17,7 @@ Traduction (FR) Afficher la traduction française Taille du texte + %1$d % Réduire la taille du texte Augmenter la taille du texte Marques de prononciation diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt index 0a3ff36..2994c74 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/preferences/ui/SubtitlePreferencesBottomSheet.kt @@ -20,6 +20,14 @@ import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import bzh.ajaury.chombev.preferences.model.SubtitlePreferences @@ -30,6 +38,7 @@ import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_tit import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_decrease import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_label +import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_value import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_description import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description @@ -120,10 +129,50 @@ private fun SubtitleTextSizeControl( onDecrease: () -> Unit = {}, onIncrease: () -> Unit = {}, ) { + val label = stringResource(Res.string.subtitle_text_size_label) + val value = stringResource(Res.string.subtitle_text_size_value, (textScale * 100).roundToInt()) + val decreaseLabel = stringResource(Res.string.subtitle_text_size_decrease) + val increaseLabel = stringResource(Res.string.subtitle_text_size_increase) + // The steps are uneven (0.1 then 0.2 apart), so screen readers adjust a step index instead. + val steps = SubtitlePreferences.TEXT_SCALE_STEPS + val stepIndex = steps.indexOfFirst { it >= textScale - 0.001f }.coerceAtLeast(0) + Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 8.dp) + // A single adjustable element for screen readers, like a slider: swiping up or down + // changes the size and announces the new value. The buttons only serve touch use. + .clearAndSetSemantics { + contentDescription = label + stateDescription = value + progressBarRangeInfo = ProgressBarRangeInfo( + current = stepIndex.toFloat(), + range = 0f..steps.lastIndex.toFloat(), + steps = steps.size - 2, + ) + setProgress { target -> + val targetIndex = target.roundToInt() + when { + targetIndex > stepIndex -> onIncrease() + targetIndex < stepIndex -> onDecrease() + else -> return@setProgress false + } + true + } + // Same adjustments from the screen reader's actions menu, for users unaware of the + // adjust gesture. + customActions = listOf( + CustomAccessibilityAction(label = increaseLabel) { + onIncrease() + true + }, + CustomAccessibilityAction(label = decreaseLabel) { + onDecrease() + true + }, + ) + }, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { From 5539bf8b0d4d9332a899988c5c4d9c1a40b91844 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 15:00:52 +0200 Subject: [PATCH 09/36] refactor: improve accessibility for audio settings --- .../composeResources/values/strings.xml | 5 +++ .../player/ui/PlaybackSpeedBottomSheet.kt | 11 +++++ .../player/ui/components/LabeledSlider.kt | 45 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index 42c5548..5c648fd 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -24,9 +24,14 @@ Afficher les consonnes muettes et les liaisons Vitesse de lecture + %1$d % Réglages de lecture Pause entre les phrases %1$d s + + %1$d seconde + %1$d secondes + Pause automatique Mettre en pause après chaque phrase diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt index 2f08c9c..f5fbef2 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt @@ -22,7 +22,10 @@ import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value +import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value_description import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title +import bzh.ajaury.chombev.resources.generated.resources.playback_speed_value +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt @@ -85,6 +88,8 @@ private fun PlaybackSpeedContent( LabeledSlider( title = stringResource(Res.string.playback_speed_title), valueLabel = formatPlaybackSpeed(speed), + // "x0.7" reads poorly aloud: screen readers get "70 %" instead. + valueDescription = stringResource(Res.string.playback_speed_value, (speed * 100).roundToInt()), value = speed, valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED, steps = speedSteps, @@ -102,6 +107,12 @@ private fun PlaybackSpeedContent( Res.string.break_between_phrases_value, breakSeconds.roundToInt().toString(), ), + // "2 s" may be spelled out letter by letter: screen readers get "2 secondes" instead. + valueDescription = pluralStringResource( + Res.plurals.break_between_phrases_value_description, + breakSeconds.roundToInt(), + breakSeconds.roundToInt(), + ), value = breakSeconds, valueRange = 0f..maxBreakSeconds.toFloat(), steps = breakSteps, diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt index 0a8bffd..873d6e7 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt @@ -10,8 +10,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt @Composable fun LabeledSlider( @@ -20,13 +28,37 @@ fun LabeledSlider( value: Float, valueRange: ClosedFloatingPointRange, steps: Int, + valueDescription: String = valueLabel, onValueChange: (Float) -> Unit = {}, enabled: Boolean = true, ) { // Dim the labels to match the Slider's disabled appearance. val contentAlpha = if (enabled) 1f else 0.38f - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + // The whole block (title, value and slider) is a single adjustable element for screen + // readers, named by the title: swiping up or down moves one step and reads the new value. + modifier = Modifier.clearAndSetSemantics { + contentDescription = title + stateDescription = valueDescription + progressBarRangeInfo = ProgressBarRangeInfo( + current = value, + range = valueRange, + steps = steps, + ) + if (enabled) { + setProgress { target -> + val newValue = target.snapToSteps(valueRange, steps) + if (newValue == value) return@setProgress false + onValueChange(newValue) + true + } + } else { + disabled() + } + }, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -53,6 +85,17 @@ fun LabeledSlider( } } +private fun Float.snapToSteps( + range: ClosedFloatingPointRange, + steps: Int, +): Float { + val clamped = coerceIn(range) + if (steps <= 0) return clamped + val stepSize = (range.endInclusive - range.start) / (steps + 1) + val stepIndex = ((clamped - range.start) / stepSize).roundToInt() + return (range.start + stepIndex * stepSize).coerceIn(range) +} + @Preview @Composable private fun LabeledSliderPreview() { From 032418613037dc507ad9847cd4073b0417ebf7e5 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Mon, 12 Oct 2026 15:47:40 +0200 Subject: [PATCH 10/36] refactor: update offline web pages for accessibility --- .../composeResources/files/offline_about.html | 274 +++++++++++------- .../files/offline_deepen.html | 228 ++++++++++----- 2 files changed, 322 insertions(+), 180 deletions(-) diff --git a/data/resources/src/commonMain/composeResources/files/offline_about.html b/data/resources/src/commonMain/composeResources/files/offline_about.html index d2e326d..300edf7 100644 --- a/data/resources/src/commonMain/composeResources/files/offline_about.html +++ b/data/resources/src/commonMain/composeResources/files/offline_about.html @@ -3,24 +3,33 @@ - + - App Donemat - Breton d'Auray – À propos – Kerlenn Sten Kidna - - - - - + ℹ️ À propos – Kerlenn Sten Kidna + + + + + - - + + @@ -54,28 +63,44 @@ .is-small-text{font-size:.875em}.is-regular-text{font-size:1em}.is-large-text{font-size:2.25em}.is-larger-text{font-size:3em}.has-drop-cap:not(:focus):first-letter{float:left;font-size:8.4em;font-style:normal;font-weight:100;line-height:.68;margin:.05em .1em 0 0;text-transform:uppercase}body.rtl .has-drop-cap:not(:focus):first-letter{float:none;margin-left:.1em}p.has-drop-cap.has-background{overflow:hidden}:root :where(p.has-background){padding:1.25em 2.375em}:where(p.has-text-color:not(.has-link-color)) a{color:inherit}p.has-text-align-left[style*="writing-mode:vertical-lr"],p.has-text-align-right[style*="writing-mode:vertical-rl"]{rotate:180deg} /*# sourceURL=https://ksk.bzh/wp-includes/blocks/paragraph/style.min.css */ - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + @@ -61,21 +70,41 @@ - - - - - - - - - - - + + + + + + + + + + + + + +