diff --git a/CHANGELOG.md b/CHANGELOG.md index aad4aff..35f7592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [1.0.0, 1.0.0-beta06] - 2026-10-19 + +### Added + +- add iOS privacy manifest and export compliance declaration +- declare French as the iOS app language + +### Changed + +- link iOS SwiftPM dependencies through the Kotlin Multiplatform linkage package + +### Fixed + +- fix Sentry version conflict on iOS +- fix iOS marketing version and app icon for App Store submission + +## [1.0.0-beta05] - 2026-10-16 + +### Changed + +- improve accessibility globally in the app +- add scrollable support for subtitle preferences bottom sheet +- improve color contrast handling +- remove wrong breton translation for interface + +## [1.0.0-beta04] - 2026-10-02 + +### Fixed + +- correct the way pause between phrases was handled + ## [1.0.0-beta03] - 2026-09-30 ### Fixed diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3942c96..36a60c8 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) @@ -35,10 +34,17 @@ dependencies { android { namespace = "bzh.ajaury.chombev" - compileSdk = - libs.versions.androidCompileSdk - .get() - .toInt() + compileSdk { + version = release( + libs.versions.androidCompileSdk + .get() + .toInt(), + ) { + minorApiLevel = libs.versions.androidCompileSdkMinor + .get() + .toInt() + } + } defaultConfig { applicationId = "bzh.ksk.donemat" @@ -50,8 +56,8 @@ android { libs.versions.androidTargetSdk .get() .toInt() - versionCode = 4 - versionName = "1.0.0-beta03" + versionCode = 8 + versionName = "1.0.0" } packaging { resources { diff --git a/build-logic/src/main/kotlin/gwenedeg.kmp.library.gradle.kts b/build-logic/src/main/kotlin/gwenedeg.kmp.library.gradle.kts index 81dab8f..bb906c2 100644 --- a/build-logic/src/main/kotlin/gwenedeg.kmp.library.gradle.kts +++ b/build-logic/src/main/kotlin/gwenedeg.kmp.library.gradle.kts @@ -26,10 +26,11 @@ kotlin { androidLibrary { // namespace is set per module in the consuming build file - compileSdk = - libs.versions.androidCompileSdk - .get() - .toInt() + compileSdk { + version = release(libs.versions.androidCompileSdk.get().toInt()) { + minorApiLevel = libs.versions.androidCompileSdkMinor.get().toInt() + } + } minSdk = libs.versions.androidMinSdk .get() diff --git a/core/accessibility/build.gradle.kts b/core/accessibility/build.gradle.kts new file mode 100644 index 0000000..9b84f3c --- /dev/null +++ b/core/accessibility/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("gwenedeg.kmp.library") + id("gwenedeg.compose") +} + +kotlin { + androidLibrary { + namespace = "bzh.ajaury.chombev.core.accessibility" + } + + sourceSets { + commonMain.dependencies { + // UI - Compose + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + } + } +} 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/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.android.kt b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.android.kt new file mode 100644 index 0000000..b9f8c8b --- /dev/null +++ b/core/accessibility/src/androidMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.android.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.core.accessibility.ui + +import androidx.compose.runtime.Composable + +/** No-op: the font scale already follows the system setting. */ +@Composable +actual fun ProvideDynamicTypeFontScale(content: @Composable () -> Unit) = content() 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/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.kt b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.kt new file mode 100644 index 0000000..e67176a --- /dev/null +++ b/core/accessibility/src/commonMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.kt @@ -0,0 +1,13 @@ +package bzh.ajaury.chombev.core.accessibility.ui + +import androidx.compose.runtime.Composable + +/** + * Applies the system text size to [content] as soon as the user changes it. + * + * Only needed on iOS, see its implementation. Every Compose scene must be wrapped: the root one + * and each dialog or bottom sheet, which iOS renders in a scene of its own that does not inherit + * the font scale provided by the root one. + */ +@Composable +expect fun ProvideDynamicTypeFontScale(content: @Composable () -> Unit) 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/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.ios.kt b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.ios.kt new file mode 100644 index 0000000..371a464 --- /dev/null +++ b/core/accessibility/src/iosMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.ios.kt @@ -0,0 +1,80 @@ +package bzh.ajaury.chombev.core.accessibility.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.UIKit.UIApplication +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityExtraLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityLarge +import platform.UIKit.UIContentSizeCategoryAccessibilityMedium +import platform.UIKit.UIContentSizeCategoryDidChangeNotification +import platform.UIKit.UIContentSizeCategoryExtraExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraLarge +import platform.UIKit.UIContentSizeCategoryExtraSmall +import platform.UIKit.UIContentSizeCategoryLarge +import platform.UIKit.UIContentSizeCategoryMedium +import platform.UIKit.UIContentSizeCategorySmall + +/** + * Applies the system text size (Dynamic Type) to [content] as soon as the user changes it. + * + * Compose Multiplatform reads the font scale once, when the Compose scene is created, so a change + * made in the iOS settings only shows once the scene is created again: after the app restarts for + * the root scene, or when it is opened again for a dialog or bottom sheet. Workaround for CMP-10365 + * (https://youtrack.jetbrains.com/issue/CMP-10365), fixed in Compose Multiplatform 1.13.0: remove + * it once the project is upgraded. + */ +@Composable +actual fun ProvideDynamicTypeFontScale(content: @Composable () -> Unit) { + var contentSizeCategory by remember { + mutableStateOf(UIApplication.sharedApplication.preferredContentSizeCategory) + } + + DisposableEffect(Unit) { + val notificationCenter = NSNotificationCenter.defaultCenter + val observer = notificationCenter.addObserverForName( + name = UIContentSizeCategoryDidChangeNotification, + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { _ -> + contentSizeCategory = UIApplication.sharedApplication.preferredContentSizeCategory + } + onDispose { notificationCenter.removeObserver(observer) } + } + + val density = LocalDensity.current + // Only the font scale is overridden: the pixel density stays the one Compose computed. + val fontScale = contentSizeCategoryToFontScale[contentSizeCategory] ?: density.fontScale + CompositionLocalProvider( + LocalDensity provides Density(density = density.density, fontScale = fontScale), + content = content, + ) +} + +// Same mapping as Compose Multiplatform (`uiContentSizeCategoryToFontScaleMap`, which is private), +// so that the font scale matches the one Compose computes when the scene is created. +private val contentSizeCategoryToFontScale = mapOf( + UIContentSizeCategoryExtraSmall to 0.8f, + UIContentSizeCategorySmall to 0.85f, + UIContentSizeCategoryMedium to 0.9f, + UIContentSizeCategoryLarge to 1f, + UIContentSizeCategoryExtraLarge to 1.1f, + UIContentSizeCategoryExtraExtraLarge to 1.2f, + UIContentSizeCategoryExtraExtraExtraLarge to 1.3f, + UIContentSizeCategoryAccessibilityMedium to 1.4f, + UIContentSizeCategoryAccessibilityLarge to 1.5f, + UIContentSizeCategoryAccessibilityExtraLarge to 1.6f, + UIContentSizeCategoryAccessibilityExtraExtraLarge to 1.7f, + UIContentSizeCategoryAccessibilityExtraExtraExtraLarge to 1.8f, +) 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/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.jvm.kt b/core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.jvm.kt new file mode 100644 index 0000000..b9f8c8b --- /dev/null +++ b/core/accessibility/src/jvmMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.jvm.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.core.accessibility.ui + +import androidx.compose.runtime.Composable + +/** No-op: the font scale already follows the system setting. */ +@Composable +actual fun ProvideDynamicTypeFontScale(content: @Composable () -> Unit) = content() 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/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.web.kt b/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.web.kt new file mode 100644 index 0000000..b9f8c8b --- /dev/null +++ b/core/accessibility/src/webMain/kotlin/bzh/ajaury/chombev/core/accessibility/ui/DynamicTypeFontScale.web.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.core.accessibility.ui + +import androidx.compose.runtime.Composable + +/** No-op: the font scale already follows the system setting. */ +@Composable +actual fun ProvideDynamicTypeFontScale(content: @Composable () -> Unit) = content() diff --git a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt index f0dc816..7f96886 100644 --- a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt +++ b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt @@ -55,8 +55,11 @@ internal class IosAudioPlayer : AudioPlayer { @OptIn(ExperimentalForeignApi::class) override fun seekTo(position: Duration) { val seconds = position.inWholeMilliseconds / 1000.0 + // Resume after seeking (e.g. back from the end of the record), unless playback was paused + // on purpose: the caller may be holding it, for instance while a screen reader speaks. + val resume = _playbackState.value != PlaybackState.PAUSED player?.seekToTime(CMTimeMakeWithSeconds(seconds, preferredTimescale = 1000)) { - play() + if (resume) play() } } 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..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 @@ -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, @@ -167,20 +138,31 @@ internal class PlaybackRepositoryImpl( ) } - override suspend fun load(record: Record) { - playAudio(filePath = record.audioResourcePath) + override suspend fun load( + record: Record, + autoPlay: Boolean, + ) { + phraseBreakJob?.cancel() + cancelRunningBreak() + pendingPhraseStart = null + pendingSeekTarget = null + + loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay) 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,57 +175,107 @@ 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 playAudio(filePath: String) { + 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 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) } } @@ -265,24 +297,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 +348,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 +359,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/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/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 +} 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 @@ - - - - - - - - - - - + + + + + + + + + + + + + +