78 Commits
Author SHA1 Message Date
Antoine_Jaury_KSK 8276112a2a Merge pull request 'Create first production version (1.0.0)' (#5) from release/1.0.0 into develop
Reviewed-on: Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg#5
2026-11-10 17:50:19 +01:00
Antoine Jaury 524bedf663 chore: update dependencies 2026-11-02 14:09:39 +01:00
Antoine Jaury dbd899cde7 chore: bump app version to 1.0.0 2026-11-02 10:51:53 +01:00
Antoine Jaury 36a23d88c0 chore: update compose multiplatform to improve web accessibility 2026-10-23 16:15:26 +02:00
Antoine Jaury 471be37fe4 chore: update dependencies in libs.versions.toml 2026-10-23 15:36:23 +02:00
Antoine Jaury 2c1de5cf37 test: add accessibility tests for RecordsScreen and PlayerScreen 2026-10-23 15:04:57 +02:00
Antoine Jaury abeb484258 refactor: add dynamic font scaling support and integrate ProvideDynamicTypeFontScale across modules 2026-10-22 14:27:06 +02:00
Antoine Jaury df38936893 refactor: enhance playback behavior with screen reader support and delayed actions 2026-10-22 13:58:30 +02:00
Antoine Jaury 38c5d2678a chore: add PrivacyInfo.xcprivacy file for iOS app tracking and diagnostics declarations 2026-10-19 23:13:50 +02:00
Antoine Jaury 399b7964cf chore: add French localization for iOS app name and bundle display name 2026-10-19 23:13:18 +02:00
Antoine Jaury 70be7bf332 chore: bump app version to 1.0.0-beta06 2026-10-19 22:39:08 +02:00
Antoine Jaury e90d37c8e0 chore: update iOS app localization to French 2026-10-19 22:28:48 +02:00
Antoine Jaury b3fe80aad9 chore: update iOS app name, bundle display name, and app category 2026-10-19 19:36:37 +02:00
Antoine Jaury b7076a8b07 refactor: add KotlinMultiplatformLinkedPackage in iOS project dependencies 2026-10-19 19:35:29 +02:00
Antoine Jaury 6bdbc0467f chore: update iOS marketing version to 1.0.0 2026-10-19 19:33:13 +02:00
Antoine Jaury 82a2b9683d chore: disable non-exempt encryption in iOS app settings 2026-10-19 19:32:58 +02:00
Antoine Jaury 7a8b535a17 refactor: replace iOS app icon with new logo image 2026-10-19 19:32:58 +02:00
Antoine Jaury 073a1a6de0 chore: bump app version to 1.0.0-beta05 2026-10-19 15:55:38 +02:00
Antoine Jaury 34cb8d24a4 refactor: remove redundant color attributes and improve color contrast handling 2026-10-19 15:53:25 +02:00
Antoine Jaury 0add1176db refactor: improve layout for speed preferences 2026-10-19 14:40:03 +02:00
Antoine Jaury 0d42413136 refactor: add scrollable support for subtitle preferences bottom sheet 2026-10-19 14:36:19 +02:00
Antoine Jaury f2e35fc8f2 refactor: update navigation label for "Records" in French localization 2026-10-19 14:34:54 +02:00
Antoine Jaury 6d31dcda93 refactor: update Gradle to 9.8.1 2026-10-19 14:18:15 +02:00
Antoine Jaury 032ba458f7 refactor: remove unverified Breton language resources 2026-10-19 14:17:32 +02:00
Antoine Jaury d51223bb19 refactor: update dependencies 2026-10-12 16:15:24 +02:00
Antoine Jaury d721b0475d refactor: update gradle 2026-10-12 16:02:29 +02:00
Antoine Jaury 9cbb4181b3 refactor: update web page links 2026-10-12 16:02:16 +02:00
Antoine Jaury 0324186130 refactor: update offline web pages for accessibility 2026-10-12 15:51:41 +02:00
Antoine Jaury 5539bf8b0d refactor: improve accessibility for audio settings 2026-10-12 15:02:41 +02:00
Antoine Jaury 82a2ef3bdb refactor: improve accessibility for subtitle size settings 2026-10-12 14:41:12 +02:00
Antoine Jaury 8339ee128b refactor: improve accessibility for previous/next buttons in player 2026-10-12 14:29:59 +02:00
Antoine Jaury 8dc1e8e71e refactor: improve accessibility title for player 2026-10-12 14:13:27 +02:00
Antoine Jaury 1a23703911 refactor: set traversal order for player 2026-10-12 14:03:21 +02:00
Antoine Jaury 242d494c86 refactor: improve player accessibility 2026-10-12 14:03:21 +02:00
Antoine Jaury 477cb445e3 refactor: improve record list accessibility 2026-10-09 15:27:19 +02:00
Antoine Jaury da9619b8fe chore: bump app version to 1.0.0-beta04 2026-10-02 16:02:41 +02:00
Antoine Jaury 8f9e1ab87d fix: correct the way pause between phrases was handled 2026-10-02 16:01:35 +02:00
Antoine_Jaury_KSK e12b7f1bfd Merge pull request 'Create version 1.0.0-beta03' (#4) from release/1.0.0 into develop
Reviewed-on: Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg#4
2026-09-30 11:53:01 +02:00
Antoine Jaury 59116a8248 chore: bump app version to 1.0.0-beta03 2026-09-30 11:52:23 +02:00
Antoine_Jaury_KSK 77f76a22ea Merge pull request 'Create version beta 1 and 2' (#3) from release/1.0.0 into develop
Reviewed-on: Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg#3
2026-09-30 11:21:36 +02:00
Antoine Jaury a143ac1a68 chore: bump dependency versions 2026-09-30 11:18:54 +02:00
Antoine Jaury 1700e2ea72 refactor: replace deprecated rememberModalBottomSheetState method 2026-09-30 11:18:54 +02:00
Antoine Jaury 021f1e773e chore: bump AGP to 9.4.1 and Gradle to 9.7.1 2026-09-30 11:18:54 +02:00
Antoine Jaury a979389fb5 chore: bump app version to 1.0.0-beta02 2026-09-30 11:18:54 +02:00
Antoine Jaury 894cca26a5 chore: update launcher and round icons in all density folders 2026-09-30 11:18:54 +02:00
Antoine Jaury 6a1acb7505 chore: rename app to "Donemat - Breton d'Auray" 2026-09-30 11:18:54 +02:00
Antoine Jaury b7eb12cd82 chore: update applicationId in androidApp build configuration 2026-09-30 11:18:54 +02:00
Antoine Jaury b37d83c9a1 chore: bump dependency versions in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 6897118e1c chore: add release notes links for dependencies in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 4e77185b3c chore: bump AGP version to 9.3.2 2026-09-30 11:18:54 +02:00
Antoine Jaury 255eedff37 chore: bump version to 1.0.0-beta01 2026-09-30 11:18:54 +02:00
Antoine Jaury bb95c587b1 chore: remove yarn for Kotlin JS 2026-09-30 11:18:54 +02:00
Antoine Jaury d3a0e09f9c fix: correct seeking to a sentence on web 2026-09-30 11:18:54 +02:00
Antoine Jaury 880aba06a2 feature: embed web pages inside the app using <iframe> for web platforms 2026-09-30 11:18:54 +02:00
Antoine Jaury fe0ed2391b chore: limit player controller width to improve UI consistency 2026-09-30 11:18:54 +02:00
Antoine Jaury 46da355819 refactor: improve contrast between surfaces 2026-09-30 11:18:54 +02:00
Antoine Jaury 4ec151dda5 feature: show onboarding as a modal on large screens, add close functionality 2026-09-30 11:18:54 +02:00
Antoine Jaury 46dfe64493 chore: add emoji fallback font support for web 2026-09-30 11:18:54 +02:00
Antoine Jaury 3acba87728 chore: add jaad dependency to fix .m4a playback on Desktop 2026-09-30 11:18:54 +02:00
Antoine Jaury ac07be5634 chore: fix typography in string resource 2026-09-30 11:18:54 +02:00
Antoine Jaury 8de3ee0a1f chore: add Gradle convention plugin for injecting build-time secrets 2026-09-30 11:18:54 +02:00
Antoine Jaury eb94d55d69 chore: remove VorbisSPI dependency 2026-09-30 11:18:54 +02:00
Antoine Jaury 705e9d32f7 chore: remove unused loggingModule dependency from iOS AudioPlayerModule 2026-09-30 11:18:54 +02:00
Antoine Jaury 48a78e5743 feature: integrate Sentry SDK for crash reporting on Android and iOS 2026-09-30 11:18:54 +02:00
Antoine Jaury 87d4f3ce5d chore: remove unused compose-multiplatform drawable file 2026-09-30 11:18:54 +02:00
Antoine Jaury 1f19bd7e7d chore: update library versions in libs.versions.toml 2026-09-30 11:18:54 +02:00
Antoine Jaury 9abb1b9e4b chore: update Gradle toolchain vendor to AZUL 2026-09-30 11:18:54 +02:00
Antoine Jaury 06714cd0c7 chore: remove redundant Koin core dependency 2026-09-30 11:18:54 +02:00
Antoine Jaury 34cf1e0bdb chore: update AGP to 9.3.1 2026-09-30 11:18:54 +02:00
Antoine Jaury a06081e2f6 chore: update iOS app name 2026-09-30 11:18:54 +02:00
Antoine Jaury 85cc6df527 chore: bump version to 1.0.0-alpha02 2026-09-30 11:18:54 +02:00
Antoine_Jaury_KSK 2823ac29eb Merge pull request 'Create a new version v1.0.0-alpha02' (#2) from release/1.0.0 into develop
Reviewed-on: Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg#2
2026-08-04 21:30:10 +02:00
Antoine Jaury 2bbd595c23 docs: update CHANGELOG for 1.0.0-alpha02 release 2026-08-04 21:29:27 +02:00
Antoine Jaury 7d32f06965 feature: implement onboarding flow for first-time users 2026-08-04 21:29:27 +02:00
Antoine Jaury 23eb83490f feature: add auto-pause playback feature 2026-08-04 21:29:27 +02:00
Antoine Jaury ff4ef71bfd chore: align iOS versioning with Android 2026-08-04 21:29:27 +02:00
Antoine Jaury 98900aa6dd chore: upgrade Gradle wrapper to 9.6.1 2026-08-04 21:29:27 +02:00
Antoine_Jaury_KSK 7e7680cdc3 Merge pull request 'new: add documentation for first release' (#1) from release/1.0.0 into develop
Reviewed-on: Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg#1
2026-07-08 17:32:07 +02:00
126 changed files with 3546 additions and 4047 deletions

No files matched your search

+3
View File
@@ -32,3 +32,6 @@ data/resources/src/commonMain/composeResources/files/records.json
keystore.properties keystore.properties
*.jks *.jks
*.keystore *.keystore
# Secrets — see docs/sentry-glitchtip.md (the .example template stays versioned)
.sentryclirc
+17
View File
@@ -0,0 +1,17 @@
; Template for the local `.sentryclirc` used by sentry-cli to upload debug symbols
; to GlitchTip. Copy it to `.sentryclirc` at the repository root and fill in the token:
;
; cp .sentryclirc.example .sentryclirc
; chmod 600 .sentryclirc
;
; `.sentryclirc` is git-ignored and must never be committed — it holds a credential.
; See docs/sentry-glitchtip.md for the full procedure.
[auth]
; Created in GlitchTip under your user menu > Profile > Auth Tokens.
token=<your-glitchtip-auth-token>
[defaults]
url=https://eu.glitchtip.com/
org=kerlenn-sten-kidna
project=chom-bev-e-brezhoneg
+92 -4
View File
@@ -7,15 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
### Changed
### 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
- correct transcriptions and translations for all records
## [1.0.0-beta02] - 2026-09-17
### Changed
- rename app to "Donemat - Breton d'Auray"
## [1.0.0-beta01] - 2026-09-07
### Changed
- show on boarding as a modal on large screens
- limit player controller size
- browse the web pages inside the app on web, instead of only linking to them
- embed web pages inside the app on web
### Fixed
- Desktop: audio playback failed with *Failed to play audio* on every record,
because the JDK cannot decode the `.m4a` (AAC) recordings on its own.
- add emoji fallback for web
- correct seeking to a sentence on web
## [1.0.0-alpha02] - 2026-08-04
### Added
#### Onboarding
- First-launch onboarding introducing the app: its goal, how to manage
subtitles, and how to adjust playback speed and the break between phrases.
- A final step to pick a starting configuration — *beginner* (slower x0.9
playback with a 3-second break between phrases) or *normal/advanced* (the
default speed with no break) — with *beginner* selected by default.
#### Listening
- Auto-pause option: playback stops at the end of every phrase and waits for you
to resume. While enabled, it takes precedence over the break between phrases,
which is shown as disabled.
### Changed
- Smoother seeking when dragging through the subtitle list.
## [1.0.0-alpha01] - 2026-07-08 ## [1.0.0-alpha01] - 2026-07-08
First public release of **Chom Bev e Brezhoneg**. First public release of **Donemat - Breton d'Auray**.
### Added ### Added
#### Listening #### Listening
- Record list of the *Chom Bev* audio course, with an emoji, the Breton title and - Record list of the *Donemat - Breton d'Auray* audio course, with an emoji, the Breton title and
its French translation for each record. its French translation for each record.
- Audio player with play/pause/replay, a seekable progress bar, and - Audio player with play/pause/replay, a seekable progress bar, and
previous/next-sentence navigation. previous/next-sentence navigation.
@@ -42,7 +129,7 @@ First public release of **Chom Bev e Brezhoneg**.
- Targets Android, iOS, Desktop (JVM) and Web (JS + Wasm), all sharing the same - Targets Android, iOS, Desktop (JVM) and Web (JS + Wasm), all sharing the same
Compose Multiplatform UI. Only Android has been deeply tested for this version. Compose Multiplatform UI. Only Android has been deeply tested for this version.
- Audio playback backed by ExoPlayer (Android), AVFoundation (iOS), the browser - Audio playback backed by ExoPlayer (Android), AVFoundation (iOS), the browser
audio APIs (Web) and VorbisSPI (Desktop). audio APIs (Web) and `javax.sound.sampled` (Desktop).
- Light and dark themes; interface localised in French and Breton. - Light and dark themes; interface localised in French and Breton.
- Modular architecture: `core`, `data` and `feature` modules glued by a thin - Modular architecture: `core`, `data` and `feature` modules glued by a thin
`shared` module, with Koin dependency injection validated at build time. `shared` module, with Koin dependency injection validated at build time.
@@ -50,5 +137,6 @@ First public release of **Chom Bev e Brezhoneg**.
otherwise). otherwise).
- Released under the Apache 2.0 license. - Released under the Apache 2.0 license.
[Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...main [Unreleased]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha02...main
[1.0.0-alpha02]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/compare/v1.0.0-alpha01...v1.0.0-alpha02
[1.0.0-alpha01]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/tags/v1.0.0-alpha01 [1.0.0-alpha01]: https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg/-/tags/v1.0.0-alpha01
+17 -1
View File
@@ -1,4 +1,4 @@
# Chom Bev e Brezhoneg - Survive in Breton - Survive en Breton # "Donemat - Breton d'Auray" - Survive in Breton
This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio
player adapted for each platform and synchronised subtitles. player adapted for each platform and synchronised subtitles.
@@ -93,8 +93,24 @@ See [Resource's README.md](data/resources/README.md) to handle the resource file
audio files, audio files,
and subtitles). and subtitles).
## Documentation
- [Crash reporting — Sentry SDK on GlitchTip](./docs/sentry-glitchtip.md): DSN configuration,
`.sentryclirc` setup, and the iOS Archive procedure for uploading dSYM files.
## Crash reporting
Crashes are reported with the Sentry Kotlin Multiplatform SDK to a [GlitchTip](https://glitchtip.com)
instance. The DSN is read from `local.properties` and never committed, so a fresh clone builds and
runs with crash reporting disabled until you configure it. See
[docs/sentry-glitchtip.md](./docs/sentry-glitchtip.md).R
## License ## License
The web build bundles a subset of [Noto Color Emoji](https://github.com/googlefonts/noto-emoji),
licensed under the SIL Open Font License 1.1
([data/resources/licenses/NotoColorEmoji-OFL.txt](./data/resources/licenses/NotoColorEmoji-OFL.txt)).
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
``` ```
-5
View File
@@ -1,10 +1,5 @@
# Android app # Android app
## Crash logging
GlitchTip is configured in the project. It uses the open source solution Senstry to log crashes
generated by the app and send them to GlitchTip on a European server.
## StrictMode (debug builds) ## StrictMode (debug builds)
The app enables Android [StrictMode](https://developer.android.com/reference/android/os/StrictMode) The app enables Android [StrictMode](https://developer.android.com/reference/android/os/StrictMode)
+19 -25
View File
@@ -1,5 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins { plugins {
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
@@ -12,7 +11,11 @@ plugins {
// Apply Google Services only when the Firebase config is present: google-services.json is not // Apply Google Services only when the Firebase config is present: google-services.json is not
// tracked in git (see google-services.example.json), so builds must work without it. // tracked in git (see google-services.example.json), so builds must work without it.
if (file("google-services.json").exists()) { if (file("google-services.json").exists()) {
apply(plugin = libs.plugins.googleServices.get().pluginId) apply(
plugin = libs.plugins.googleServices
.get()
.pluginId,
)
} }
kotlin { kotlin {
@@ -27,20 +30,24 @@ dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
// Crash logging
implementation(libs.glitchtips)
} }
android { android {
namespace = "bzh.ajaury.chombev" namespace = "bzh.ajaury.chombev"
compileSdk = compileSdk {
libs.versions.androidCompileSdk version = release(
.get() libs.versions.androidCompileSdk
.toInt() .get()
.toInt(),
) {
minorApiLevel = libs.versions.androidCompileSdkMinor
.get()
.toInt()
}
}
defaultConfig { defaultConfig {
applicationId = "bzh.ajaury.chombev" applicationId = "bzh.ksk.donemat"
minSdk = minSdk =
libs.versions.androidMinSdk libs.versions.androidMinSdk
.get() .get()
@@ -49,12 +56,8 @@ android {
libs.versions.androidTargetSdk libs.versions.androidTargetSdk
.get() .get()
.toInt() .toInt()
versionCode = 1 versionCode = 8
versionName = "1.0.0-alpha01" versionName = "1.0.0"
// Crash-reporting DSN, kept out of the public repo: read from local.properties
// (glitchtip.dsn=...). Empty when absent, which disables the Sentry SDK.
manifestPlaceholders["glitchtipDsn"] = localProperty("glitchtip.dsn") ?: ""
} }
packaging { packaging {
resources { resources {
@@ -72,12 +75,3 @@ android {
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
} }
/** Reads a key from the root local.properties, or null when the file or key is absent. */
fun localProperty(key: String): String? {
val file = rootProject.file("local.properties")
if (!file.exists()) return null
return Properties()
.apply { file.inputStream().use { load(it) } }
.getProperty(key)
}
-5
View File
@@ -20,11 +20,6 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Injected from local.properties (glitchtip.dsn); an empty DSN disables the SDK. -->
<meta-data android:name="io.sentry.dsn" android:value="${glitchtipDsn}" />
<meta-data android:name="io.sentry.traces.sample-rate" android:value="0.01" />
</application> </application>
</manifest> </manifest>
@@ -10,10 +10,11 @@ class ChomBevApp : Application() {
if (isDebuggable()) { if (isDebuggable()) {
enableStrictMode() enableStrictMode()
} }
SentryHelper.initializeSentry()
} }
private fun isDebuggable(): Boolean = private fun isDebuggable(): Boolean = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
/** /**
* Enables StrictMode for debug builds only. All violations are logged * Enables StrictMode for debug builds only. All violations are logged
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">Chom Bev</string> <string name="app_name">Donemat - Breton d\'Auray</string>
</resources> </resources>
@@ -26,10 +26,11 @@ kotlin {
androidLibrary { androidLibrary {
// namespace is set per module in the consuming build file // namespace is set per module in the consuming build file
compileSdk = compileSdk {
libs.versions.androidCompileSdk version = release(libs.versions.androidCompileSdk.get().toInt()) {
.get() minorApiLevel = libs.versions.androidCompileSdkMinor.get().toInt()
.toInt() }
}
minSdk = minSdk =
libs.versions.androidMinSdk libs.versions.androidMinSdk
.get() .get()
@@ -0,0 +1,84 @@
import java.util.Properties
/**
* Injects build-time secrets into `commonMain` so they stay out of version control.
*
* Each secret is read from the environment first (CI), then from `local.properties`
* (local development), and defaults to an empty string when neither provides it —
* consumers are expected to degrade gracefully rather than fail.
*
* The values are emitted as a generated `BuildSecrets` object, available to every
* Kotlin target of the module.
*/
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val secretsPackage = "bzh.ajaury.chombev"
private val localProperties: Provider<Properties> =
providers
.fileContents(layout.settingsDirectory.file("local.properties"))
.asText
.map { text -> Properties().apply { load(text.reader()) } }
/** Resolves a secret from [environmentVariable], falling back to [localPropertyKey], then to an empty string. */
fun secret(
environmentVariable: String,
localPropertyKey: String,
): Provider<String> =
providers
.environmentVariable(environmentVariable)
.orElse(localProperties.map { properties -> properties.getProperty(localPropertyKey).orEmpty() })
.orElse("")
// GlitchTip DSN, consumed by initializeSentry() in the shared module.
val glitchtipDsn: Provider<String> = secret(environmentVariable = "GLITCHTIP_DSN", localPropertyKey = "glitchtip.dsn")
val generateBuildSecrets =
tasks.register("generateBuildSecrets") {
description = "Generates the BuildSecrets object from local.properties and environment variables."
// Escaped here so the value can be inlined as-is in a Kotlin string literal.
val dsn =
glitchtipDsn.map { value ->
value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\${'$'}")
}
val outputDir = layout.buildDirectory.dir("generated/secrets/commonMain/kotlin")
// Kept in locals so the execution-time action never captures the script instance.
val packageName = secretsPackage
val packagePath = packageName.replace('.', '/')
inputs.property("glitchtipDsn", dsn)
outputs.dir(outputDir)
doLast {
val file =
outputDir
.get()
.asFile
.resolve("$packagePath/BuildSecrets.kt")
file.parentFile.mkdirs()
file.writeText(
"""
package $packageName
// Generated by the `gwenedeg.secrets` convention plugin. Do not edit.
internal object BuildSecrets {
const val GLITCHTIP_DSN: String = "${dsn.get()}"
}
""".trimIndent(),
)
}
}
kotlin {
sourceSets.commonMain {
kotlin.srcDir(generateBuildSecrets)
}
}
+18
View File
@@ -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)
}
}
}
@@ -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
}
@@ -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<AccessibilityRepository>() }
}
@@ -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()
@@ -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
}
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.di
import org.koin.core.module.Module
expect val accessibilityModule: Module
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.domain
interface AccessibilityRepository {
fun isScreenReaderEnabled(): Boolean
}
@@ -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)
@@ -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()
}
@@ -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<AccessibilityRepository>() }
}
@@ -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,
)
@@ -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<AccessibilityRepository>() }
}
@@ -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()
@@ -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<AccessibilityRepository>() }
}
@@ -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()
+2 -2
View File
@@ -14,8 +14,8 @@ kotlin {
implementation(libs.media3.common) implementation(libs.media3.common)
} }
jvmMain.dependencies { jvmMain.dependencies {
// Desktop audio: Ogg Vorbis SPI so javax.sound.sampled can decode .ogg // Desktop audio: AAC/MP4 SPI so javax.sound.sampled can decode .m4a
implementation(libs.vorbisspi) implementation(libs.jaad)
} }
// webMain is the js + wasmJs intermediate source set from the default hierarchy template. // webMain is the js + wasmJs intermediate source set from the default hierarchy template.
webMain.dependencies { webMain.dependencies {
@@ -55,8 +55,11 @@ internal class IosAudioPlayer : AudioPlayer {
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
override fun seekTo(position: Duration) { override fun seekTo(position: Duration) {
val seconds = position.inWholeMilliseconds / 1000.0 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)) { player?.seekToTime(CMTimeMakeWithSeconds(seconds, preferredTimescale = 1000)) {
play() if (resume) play()
} }
} }
@@ -1,6 +1,5 @@
package bzh.ajaury.chombev.player.di package bzh.ajaury.chombev.player.di
import bzh.ajaury.chombev.core.logging.di.loggingModule
import bzh.ajaury.chombev.player.data.IosAudioPlayer import bzh.ajaury.chombev.player.data.IosAudioPlayer
import bzh.ajaury.chombev.player.data.IosAudioSessionManager import bzh.ajaury.chombev.player.data.IosAudioSessionManager
import bzh.ajaury.chombev.player.domain.AudioPlayer import bzh.ajaury.chombev.player.domain.AudioPlayer
@@ -11,7 +10,6 @@ import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
actual val audioPlayerModule: Module = module { actual val audioPlayerModule: Module = module {
includes(loggingModule)
singleOf(::IosAudioPlayer) { bind<AudioPlayer>() } singleOf(::IosAudioPlayer) { bind<AudioPlayer>() }
singleOf(::IosAudioSessionManager) { bind<AudioSessionManager>() } singleOf(::IosAudioSessionManager) { bind<AudioSessionManager>() }
} }
@@ -18,9 +18,9 @@ import kotlin.time.Duration.Companion.microseconds
/** /**
* Desktop [AudioPlayer] backed by [javax.sound.sampled]. * Desktop [AudioPlayer] backed by [javax.sound.sampled].
* *
* The Ogg Vorbis recordings are decoded to PCM by the VorbisSPI providers on the classpath, which * The JDK decodes neither AAC nor its MP4 container, so the `.m4a` recordings are decoded to PCM by
* the JDK [AudioSystem] discovers automatically, so loading works the same as any natively * the jaad AAC provider on the classpath, which [AudioSystem] discovers automatically. Loading then
* supported format. * works the same as for any natively supported format.
*/ */
internal class JvmAudioPlayer : AudioPlayer { internal class JvmAudioPlayer : AudioPlayer {
private var clip: Clip? = null private var clip: Clip? = null
@@ -24,7 +24,8 @@ internal class WebAudioPlayer : AudioPlayer {
get() = (audio?.duration?.takeIf { it.isFinite() } ?: 0.0).seconds get() = (audio?.duration?.takeIf { it.isFinite() } ?: 0.0).seconds
override fun seekTo(position: Duration) { override fun seekTo(position: Duration) {
audio?.currentTime = position.inWholeMicroseconds.toDouble() audio?.currentTime = position.inWholeMilliseconds.toDouble() / 1000
audio?.play()
} }
override fun load(uri: String) { override fun load(uri: String) {
@@ -3,7 +3,7 @@ package bzh.ajaury.chombev.core.logging.data
import bzh.ajaury.chombev.core.logging.domain.Logger import bzh.ajaury.chombev.core.logging.domain.Logger
internal class KermitLogger : Logger { internal class KermitLogger : Logger {
private val tag: String = "Chom Bev" private val tag: String = "Donemat"
override fun debug( override fun debug(
message: String, message: String,
+3
View File
@@ -21,5 +21,8 @@ kotlin {
implementation(projects.data.resources) implementation(projects.data.resources)
implementation(projects.data.subtitle) implementation(projects.data.subtitle)
} }
commonTest.dependencies {
implementation(libs.kotlinx.coroutinesTest)
}
} }
} }
@@ -10,6 +10,7 @@ import bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.player.model.PlaybackTiming import bzh.ajaury.chombev.player.model.PlaybackTiming
import bzh.ajaury.chombev.player.model.PlayerState import bzh.ajaury.chombev.player.model.PlayerState
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.resources.domain.ResourceReader import bzh.ajaury.chombev.resources.domain.ResourceReader
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
@@ -18,23 +19,17 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration import kotlin.time.Duration
@@ -53,13 +48,18 @@ internal class PlaybackRepositoryImpl(
) : PlaybackRepository { ) : PlaybackRepository {
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it. // Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it.
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main) private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
private var speedObservationJob: Job? = null private var preferencesObservationJob: Job? = null
private var phraseBreakJob: Job? = null private var phraseBreakJob: Job? = null
private var playbackPreferences = PlaybackPreferences()
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over. // Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
private var breakJob: Job? = null private var breakJob: Job? = null
private val isBreaking = MutableStateFlow(false) private val isBreaking = MutableStateFlow(false)
private var reachedPhraseIndex = -1
private var pendingPhraseStart: Duration? = null
private var pendingSeekTarget: Duration? = null
private val recordTitle = MutableStateFlow(Phrase(transcription = "")) private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
private val subtitle = MutableStateFlow(Subtitle(emptyList())) private val subtitle = MutableStateFlow(Subtitle(emptyList()))
@@ -113,45 +113,16 @@ internal class PlaybackRepositoryImpl(
subtitle, subtitle,
currentPosition, currentPosition,
) { subtitle, position -> ) { subtitle, position ->
val currentSubtitleIndex = getCurrentSubtitleIndex(
subtitle = subtitle,
position = position,
)
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
currentSubtitleIndex = currentSubtitleIndex,
subtitle = subtitle,
position = position,
)
SubtitleProgress( SubtitleProgress(
subtitle = subtitle, subtitle = subtitle,
currentPosition = position, currentPosition = position,
currentSubtitleIndex = currentSubtitleIndex, currentSubtitleIndex = getCurrentSubtitleIndex(
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase, subtitle = subtitle,
position = position,
),
) )
} }
private fun isGoingToPlayNextPhrase(
currentSubtitleIndex: Int?,
subtitle: Subtitle,
position: Duration,
): Boolean {
if (currentSubtitleIndex == null) {
return false
}
val nextSubtitleIndex = currentSubtitleIndex + 1
if (nextSubtitleIndex <= 0) {
return false
}
val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex)
?: return false
return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL
}
override val playerState: Flow<PlayerState> = combine( override val playerState: Flow<PlayerState> = combine(
playbackState, playbackState,
playbackTiming, playbackTiming,
@@ -167,73 +138,144 @@ internal class PlaybackRepositoryImpl(
) )
} }
override suspend fun load(record: Record) { override suspend fun load(
playAudio(filePath = record.audioResourcePath) record: Record,
autoPlay: Boolean,
) {
phraseBreakJob?.cancel()
cancelRunningBreak()
pendingPhraseStart = null
pendingSeekTarget = null
loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
subtitle.value = loadSubtitle(record = record) subtitle.value = loadSubtitle(record = record)
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
recordTitle.value = record.title recordTitle.value = record.title
observePlaybackSpeed() observePlaybackPreferences()
observePhraseBreaks() observePhraseBreaks()
} }
private fun observePlaybackSpeed() { private fun observePlaybackPreferences() {
speedObservationJob?.cancel() preferencesObservationJob?.cancel()
speedObservationJob = scope.launch { preferencesObservationJob = scope.launch {
preferencesRepository.playbackPreferences.collect { playbackPreferences -> preferencesRepository.playbackPreferences.collect { preferences ->
setSpeed(speed = playbackPreferences.speed) // Cached so that reaching a phrase reacts immediately, without a suspending read.
playbackPreferences = preferences
setSpeed(speed = preferences.speed)
} }
} }
} }
/** /**
* Inserts the configured silent break between spoken phrases: when continuous playback reaches * Reacts to reaching the next spoken phrase during continuous playback, just before that phrase
* the next phrase, playback pauses just before that phrase starts, then resumes after the break. * starts. Depending on preferences it either:
* - pauses and waits for the user to resume, when auto-pause is enabled, or
* - pauses for the configured silent break and then resumes on its own.
* *
* Breaks only happen on automatic advancement — manual seeking and previous/next navigation are * Auto-pause takes precedence: while it is on, the break between phrases is ignored.
* ignored via [suppressBreaks]. *
* 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() phraseBreakJob?.cancel()
cancelRunningBreakJob() phraseBreakJob = scope.launch {
phraseBreakJob = subtitleProgress playbackState.collectLatest { state ->
.map { it.isGoingToPlayNextPhrase } if (state != PlaybackState.PLAYING) return@collectLatest
.distinctUntilChanged() while (true) {
.filter { it } checkPhraseBoundary()
.onEach { delay(POSITION_POLL_INTERVAL)
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
val playbackPreferences = preferencesRepository.playbackPreferences.first()
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 { try {
audioPlayer.load(uri = resourceReader.uri(filePath)) audioPlayer.load(uri = resourceReader.uri(filePath))
audioPlayer.play() if (autoPlay) audioPlayer.play()
} catch (exception: Exception) { } catch (exception: Exception) {
logger.error(message = "Failed to play audio: $filePath", throwable = exception) logger.error(message = "Failed to load audio: $filePath", throwable = exception)
} }
} }
@@ -255,24 +297,30 @@ internal class PlaybackRepositoryImpl(
} }
override suspend fun play() { override suspend fun play() {
cancelRunningBreakJob() cancelRunningBreak()
audioPlayer.play() val phraseStart = pendingPhraseStart
if (phraseStart != null) {
resumeAtPhraseStart(phraseStart = phraseStart)
} else {
audioPlayer.play()
}
} }
override suspend fun pause() { override suspend fun pause() {
cancelRunningBreakJob() cancelRunningBreak()
audioPlayer.pause() audioPlayer.pause()
} }
override suspend fun replay() { override suspend fun replay() {
cancelRunningBreakJob() cancelRunningBreak()
audioPlayer.seekTo(Duration.ZERO) seekAudio(position = Duration.ZERO)
audioPlayer.play() audioPlayer.play()
} }
override suspend fun seekTo(position: Duration) { override suspend fun seekTo(position: Duration) {
cancelRunningBreakJob() val wasBreaking = cancelRunningBreak()
audioPlayer.seekTo(position) seekAudio(position = position)
if (wasBreaking) audioPlayer.play()
} }
private fun setSpeed(speed: Float) { private fun setSpeed(speed: Float) {
@@ -300,9 +348,8 @@ internal class PlaybackRepositoryImpl(
} }
override suspend fun seekToSentence(sentenceIndex: Int) { override suspend fun seekToSentence(sentenceIndex: Int) {
cancelRunningBreakJob()
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
audioPlayer.seekTo(matchingSubtitleLine.startTime) seekTo(position = matchingSubtitleLine.startTime)
} }
private fun currentSentenceIndex(): Int? = private fun currentSentenceIndex(): Int? =
@@ -312,32 +359,33 @@ internal class PlaybackRepositoryImpl(
) )
override suspend fun stop() { override suspend fun stop() {
breakJob?.cancel() phraseBreakJob?.cancel()
isBreaking.value = false cancelRunningBreak()
pendingPhraseStart = null
subtitle.value = Subtitle(emptyList()) subtitle.value = Subtitle(emptyList())
audioPlayer.stop() audioPlayer.stop()
} }
override fun release() { override fun release() {
breakJob?.cancel() phraseBreakJob?.cancel()
isBreaking.value = false cancelRunningBreak()
pendingPhraseStart = null
audioPlayer.release() audioPlayer.release()
subtitle.value = Subtitle(emptyList()) subtitle.value = Subtitle(emptyList())
} }
private suspend fun cancelRunningBreakJob() { private fun cancelRunningBreak(): Boolean {
breakJob?.cancelAndJoin() breakJob?.cancel()
breakJob = null
// Unlock if paused by a break. val wasBreaking = isBreaking.value
if (playbackState.value == PlaybackState.BREAKING) {
audioPlayer.play()
}
isBreaking.value = false isBreaking.value = false
return wasBreaking
} }
companion object { companion object {
private val POSITION_POLL_INTERVAL = 50.milliseconds private val POSITION_POLL_INTERVAL = 50.milliseconds
private val PHRASE_LOOKAHEAD = 2 * POSITION_POLL_INTERVAL
private val SEEK_TOLERANCE = 500.milliseconds
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
} }
} }
@@ -7,5 +7,4 @@ data class SubtitleProgress(
val subtitle: Subtitle, val subtitle: Subtitle,
val currentPosition: Duration, val currentPosition: Duration,
val currentSubtitleIndex: Int?, val currentSubtitleIndex: Int?,
val isGoingToPlayNextPhrase: Boolean,
) )
@@ -15,7 +15,10 @@ import kotlin.time.Duration
interface PlaybackRepository { interface PlaybackRepository {
val playerState: Flow<PlayerState> val playerState: Flow<PlayerState>
suspend fun load(record: Record) suspend fun load(
record: Record,
autoPlay: Boolean = true,
)
suspend fun play() suspend fun play()
@@ -0,0 +1,249 @@
package bzh.ajaury.chombev.player.data
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record
import bzh.ajaury.chombev.player.domain.AudioPlayer
import bzh.ajaury.chombev.player.model.PlaybackState
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.resources.domain.ResourceReader
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
import bzh.ajaury.chombev.subtitle.model.Subtitle
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class PlaybackRepositoryImplTest {
private val dispatcher = StandardTestDispatcher()
private val audioPlayer = FakeAudioPlayer()
private val preferences = MutableStateFlow(PlaybackPreferences(breakBetweenPhrases = 2.seconds))
private val repository = PlaybackRepositoryImpl(
audioPlayer = audioPlayer,
resourceReader = FakeResourceReader,
subtitleRepository = FakeSubtitleRepository,
preferencesRepository = FakePreferencesRepository(preferences),
getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase(),
logger = FakeLogger,
dispatcherProvider = FakeDispatcherProvider(dispatcher),
)
@Test
fun breaks_before_the_next_phrase_when_the_poll_lands_just_before_it() = runTest(dispatcher) {
loadAndPlay()
playTo(SECOND_PHRASE_START - 60.milliseconds)
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
advanceTimeBy(2.seconds + 1.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
assertEquals(SECOND_PHRASE_START - 60.milliseconds, audioPlayer.currentPosition)
}
@Test
fun breaks_and_rewinds_when_the_poll_lands_after_the_next_phrase_started() = runTest(dispatcher) {
loadAndPlay()
// A late poll (e.g. busy main thread) skips the window right before the phrase start.
playTo(SECOND_PHRASE_START + 80.milliseconds)
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
advanceTimeBy(2.seconds + 1.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
}
@Test
fun breaks_only_once_per_phrase() = runTest(dispatcher) {
loadAndPlay()
playTo(SECOND_PHRASE_START - 60.milliseconds)
advanceTimeBy(2.seconds + 1.milliseconds)
playTo(SECOND_PHRASE_START + 500.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
}
@Test
fun auto_pause_stops_and_resumes_from_the_phrase_start() = runTest(dispatcher) {
preferences.value = PlaybackPreferences(autoPause = true)
loadAndPlay()
playTo(SECOND_PHRASE_START + 80.milliseconds)
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
advanceTimeBy(10.seconds)
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
repository.play()
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
}
@Test
fun seeking_to_the_next_sentence_does_not_break() = runTest(dispatcher) {
loadAndPlay()
playTo(1.seconds)
repository.goToNextSentence()
runCurrent()
advanceTimeBy(200.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
}
@Test
fun a_stale_position_after_a_backward_seek_does_not_break() = runTest(dispatcher) {
loadAndPlay()
playTo(SECOND_PHRASE_START + 1.seconds)
advanceTimeBy(3.seconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
// Like AVPlayer, the player still reports the pre-seek position for a while.
audioPlayer.asynchronousSeek = true
repository.seekToSentence(0)
advanceTimeBy(200.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
audioPlayer.completeSeek()
advanceTimeBy(200.milliseconds)
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
}
private suspend fun TestScope.loadAndPlay() {
repository.load(record)
runCurrent()
}
private fun TestScope.playTo(position: Duration) {
audioPlayer.position = position
advanceTimeBy(60.milliseconds)
}
private companion object {
val SECOND_PHRASE_START = 4310.milliseconds
val record = Record(
id = 1,
index = 0,
emoji = "",
title = Phrase(transcription = ""),
audioResourcePath = "audio",
subtitleResourcePath = "subtitle",
)
}
}
private class FakeAudioPlayer : AudioPlayer {
override val playbackState = MutableStateFlow(PlaybackState.IDLE)
var position: Duration = Duration.ZERO
var asynchronousSeek = false
private var pendingSeek: Duration? = null
override val currentPosition: Duration get() = position
override val duration: Duration = 10.seconds
override fun seekTo(position: Duration) {
if (asynchronousSeek) pendingSeek = position else this.position = position
}
fun completeSeek() {
pendingSeek?.let { position = it }
pendingSeek = null
}
override fun play() {
playbackState.value = PlaybackState.PLAYING
}
override fun pause() {
playbackState.value = PlaybackState.PAUSED
}
override fun stop() {
playbackState.value = PlaybackState.IDLE
}
override fun release() = stop()
override fun load(uri: String) = Unit
override fun setSpeed(speed: Float) = Unit
}
private object FakeResourceReader : ResourceReader {
override suspend fun read(resourcePath: String): String = ""
override fun uri(resourcePath: String): String = resourcePath
}
private object FakeSubtitleRepository : SubtitleRepository {
override suspend fun getSubtitle(
transcriptionContent: String,
translationContent: String?,
): Subtitle = Subtitle(
lines = listOf(
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
),
)
}
private class FakePreferencesRepository(
override val playbackPreferences: StateFlow<PlaybackPreferences>,
) : PreferencesRepository {
override val subtitlePreferences: Flow<SubtitlePreferences> = flowOf(SubtitlePreferences())
override val onboardingCompleted: Flow<Boolean> = flowOf(true)
override suspend fun setShowTranscription(enabled: Boolean) = Unit
override suspend fun setShowTranslation(enabled: Boolean) = Unit
override suspend fun setSubtitleTextScale(scale: Float) = Unit
override suspend fun setShowPhoneticMarkers(enabled: Boolean) = Unit
override suspend fun setPlaybackSpeed(speed: Float) = Unit
override suspend fun setBreakBetweenPhrases(duration: Duration) = Unit
override suspend fun setAutoPause(enabled: Boolean) = Unit
override suspend fun setOnboardingCompleted() = Unit
}
private object FakeLogger : Logger {
override fun debug(message: String, throwable: Throwable?) = Unit
override fun info(message: String, throwable: Throwable?) = Unit
override fun warning(message: String, throwable: Throwable?) = Unit
override fun error(message: String, throwable: Throwable?) = Unit
}
private class FakeDispatcherProvider(dispatcher: CoroutineDispatcher) : DispatcherProvider {
override val main = dispatcher
override val default = dispatcher
override val io = dispatcher
}
@@ -20,6 +20,9 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
private val playbackState = MutableStateFlow(PlaybackPreferences()) private val playbackState = MutableStateFlow(PlaybackPreferences())
override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow() override val playbackPreferences: StateFlow<PlaybackPreferences> = playbackState.asStateFlow()
private val onboardingCompletedState = MutableStateFlow(false)
override val onboardingCompleted: StateFlow<Boolean> = onboardingCompletedState.asStateFlow()
override suspend fun setShowTranscription(enabled: Boolean) { override suspend fun setShowTranscription(enabled: Boolean) {
subtitleState.update { it.copy(showTranscription = enabled) } subtitleState.update { it.copy(showTranscription = enabled) }
} }
@@ -43,4 +46,12 @@ internal class InMemoryPreferencesRepository : PreferencesRepository {
override suspend fun setBreakBetweenPhrases(duration: Duration) { override suspend fun setBreakBetweenPhrases(duration: Duration) {
playbackState.update { it.copy(breakBetweenPhrases = duration) } playbackState.update { it.copy(breakBetweenPhrases = duration) }
} }
override suspend fun setAutoPause(enabled: Boolean) {
playbackState.update { it.copy(autoPause = enabled) }
}
override suspend fun setOnboardingCompleted() {
onboardingCompletedState.value = true
}
} }
@@ -10,6 +10,8 @@ interface PreferencesRepository {
val playbackPreferences: Flow<PlaybackPreferences> val playbackPreferences: Flow<PlaybackPreferences>
val onboardingCompleted: Flow<Boolean>
suspend fun setShowTranscription(enabled: Boolean) suspend fun setShowTranscription(enabled: Boolean)
suspend fun setShowTranslation(enabled: Boolean) suspend fun setShowTranslation(enabled: Boolean)
@@ -21,4 +23,8 @@ interface PreferencesRepository {
suspend fun setPlaybackSpeed(speed: Float) suspend fun setPlaybackSpeed(speed: Float)
suspend fun setBreakBetweenPhrases(duration: Duration) suspend fun setBreakBetweenPhrases(duration: Duration)
suspend fun setAutoPause(enabled: Boolean)
suspend fun setOnboardingCompleted()
} }
@@ -10,10 +10,13 @@ import kotlin.time.Duration.Companion.seconds
* [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed. * [SPEED_STEP] increments. [DEFAULT_SPEED] is the normal, unaltered speed.
* @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next, * @property breakBetweenPhrases silent pause inserted when moving from one phrase to the next,
* between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps. * between [DEFAULT_BREAK_BETWEEN_PHRASES] and [MAX_BREAK_BETWEEN_PHRASES] in [BREAK_STEP] steps.
* @property autoPause when enabled, playback stops at the end of every phrase and waits for the
* user to resume. This takes precedence over [breakBetweenPhrases], which is then ignored.
*/ */
data class PlaybackPreferences( data class PlaybackPreferences(
val speed: Float = DEFAULT_SPEED, val speed: Float = DEFAULT_SPEED,
val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES, val breakBetweenPhrases: Duration = DEFAULT_BREAK_BETWEEN_PHRASES,
val autoPause: Boolean = DEFAULT_AUTO_PAUSE,
) { ) {
companion object { companion object {
const val DEFAULT_SPEED = 1f const val DEFAULT_SPEED = 1f
@@ -24,5 +27,7 @@ data class PlaybackPreferences(
val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO val DEFAULT_BREAK_BETWEEN_PHRASES: Duration = Duration.ZERO
val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds val MAX_BREAK_BETWEEN_PHRASES: Duration = 5.seconds
val BREAK_STEP: Duration = 1.seconds val BREAK_STEP: Duration = 1.seconds
const val DEFAULT_AUTO_PAUSE = false
} }
} }
@@ -31,6 +31,10 @@ internal class DataStorePreferencesRepository(
dataStore.data dataStore.data
.map { it.toPlaybackPreferences() } .map { it.toPlaybackPreferences() }
override val onboardingCompleted: Flow<Boolean> =
dataStore.data
.map { it[OnboardingCompletedKey] ?: false }
override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled } override suspend fun setShowTranscription(enabled: Boolean) = edit { it[ShowTranscriptionKey] = enabled }
override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled } override suspend fun setShowTranslation(enabled: Boolean) = edit { it[ShowTranslationKey] = enabled }
@@ -44,6 +48,10 @@ internal class DataStorePreferencesRepository(
override suspend fun setBreakBetweenPhrases(duration: Duration) = override suspend fun setBreakBetweenPhrases(duration: Duration) =
edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() } edit { it[BreakBetweenPhrasesSecondsKey] = duration.inWholeSeconds.toInt() }
override suspend fun setAutoPause(enabled: Boolean) = edit { it[AutoPauseKey] = enabled }
override suspend fun setOnboardingCompleted() = edit { it[OnboardingCompletedKey] = true }
private suspend fun edit(transform: (MutablePreferences) -> Unit) { private suspend fun edit(transform: (MutablePreferences) -> Unit) {
dataStore.edit(transform) dataStore.edit(transform)
} }
@@ -63,6 +71,7 @@ internal class DataStorePreferencesRepository(
speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED, speed = this[PlaybackSpeedKey] ?: PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds breakBetweenPhrases = this[BreakBetweenPhrasesSecondsKey]?.seconds
?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, ?: PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
autoPause = this[AutoPauseKey] ?: PlaybackPreferences.DEFAULT_AUTO_PAUSE,
) )
private companion object { private companion object {
@@ -72,5 +81,7 @@ internal class DataStorePreferencesRepository(
val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker") val ShowPhoneticMarkersKey = booleanPreferencesKey("show_phonetic_marker")
val PlaybackSpeedKey = floatPreferencesKey("playback_speed") val PlaybackSpeedKey = floatPreferencesKey("playback_speed")
val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds") val BreakBetweenPhrasesSecondsKey = intPreferencesKey("break_between_phrases_seconds")
val AutoPauseKey = booleanPreferencesKey("auto_pause")
val OnboardingCompletedKey = booleanPreferencesKey("onboarding_completed")
} }
} }
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,44 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
@@ -3,24 +3,33 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover"> <meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11"> <link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev &#8211; À propos &#8211; Kerlenn Sten Kidna</title> <title>ℹ️ À propos – Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' /> <meta name="robots" content="max-image-preview:large">
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" /> <link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux"
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" /> href="https://ksk.bzh/fr/feed/">
<link rel="alternate" title="oEmbed (JSON)" type="application/json+oembed" href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fchom-bev-a-propos%2F&#038;lang=fr" /> <link rel="alternate" type="application/rss+xml"
<link rel="alternate" title="oEmbed (XML)" type="text/xml+oembed" href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fchom-bev-a-propos%2F&#038;format=xml&#038;lang=fr" /> title="Kerlenn Sten Kidna » Flux des commentaires"
href="https://ksk.bzh/fr/comments/feed/">
<link rel="alternate" title="oEmbed (JSON)" type="application/json+oembed"
href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fdonemat-a-propos%2F&amp;lang=fr">
<link rel="alternate" title="oEmbed (XML)" type="text/xml+oembed"
href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fdonemat-a-propos%2F&amp;format=xml&amp;lang=fr">
<style id="wp-img-auto-sizes-contain-inline-css"> <style id="wp-img-auto-sizes-contain-inline-css">
img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px} img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}
/*# sourceURL=wp-img-auto-sizes-contain-inline-css */ /*# sourceURL=wp-img-auto-sizes-contain-inline-css */
</style> </style>
<link rel='stylesheet' id='blocksy-dynamic-global-css' href='https://ksk.bzh/wp-content/uploads/blocksy/css/global.css?ver=64854' media='all' /> <link rel="stylesheet" id="blocksy-dynamic-global-css"
<link rel='stylesheet' id='ugb-style-css-css' href='https://ksk.bzh/wp-content/plugins/stackable-ultimate-gutenberg-blocks/dist/frontend_blocks.css?ver=3.19.9' media='all' /> href="https://ksk.bzh/wp-content/uploads/blocksy/css/global.css?ver=38936" media="all">
<link rel="stylesheet" id="ugb-style-css-css"
href="https://ksk.bzh/wp-content/plugins/stackable-ultimate-gutenberg-blocks/dist/frontend_blocks.css?ver=3.20.2"
media="all">
<style id="ugb-style-css-inline-css"> <style id="ugb-style-css-inline-css">
:root {--stk-block-width-default-detected: 1290px;} :root {--stk-block-width-default-detected: 1290px;}
#start-resizable-editor-section{display:none}@media only screen and (min-width:1000px){:where(.has-text-align-left) .stk-block-divider{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}:where(.has-text-align-right) .stk-block-divider{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}:where(.has-text-align-center) .stk-block-divider{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-center{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-right{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-left{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}.stk-block-feature>*>.stk-row{flex-wrap:var(--stk-feature-flex-wrap,nowrap)}.stk-row{flex-wrap:nowrap}.stk--hide-desktop,.stk--hide-desktop.stk-block{display:none!important}}@media only screen and (min-width:690px){:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(2)){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):last-child){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(3)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(2)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):last-child){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(4)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(3)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(2)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):last-child){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(5)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(4)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(3)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(2)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):last-child){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(6)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(5)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(4)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(3)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):nth-last-child(2)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(6):last-child){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical{grid-template-columns:auto 1fr auto}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:first-child{grid-column:1/2}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:last-child{grid-column:2/4}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:first-child{grid-column:1/3}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:last-child{grid-column:3/4}.stk-block .stk-block.aligncenter,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align:not(.alignwide):not(.alignfull),.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align:not(.alignwide):not(.alignfull){margin-left:auto;margin-right:auto;max-width:var(--stk-block-default-width,var(--stk-block-width-default-detected,900px));width:100%}.stk-block .stk-block.alignwide,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align.alignwide,.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align.alignwide{margin-left:auto;margin-right:auto;max-width:var(--stk-block-wide-widtLine truncated #start-resizable-editor-section{display:none}@media only screen and (min-width:1000px){:where(.has-text-align-left) .stk-block-divider{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}:where(.has-text-align-right) .stk-block-divider{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}:where(.has-text-align-center) .stk-block-divider{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-center{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-right{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-left{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}.stk-block-feature>*>.stk-row{flex-wrap:var(--stk-feature-flex-wrap,nowrap)}.stk-row{flex-wrap:nowrap}.stk--hide-desktop,.stk--hide-desktop.stk-block{display:none!important}}@media only screen and (min-width:690px){:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(2)){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):last-child){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(3)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(2)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):last-child){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(4)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(3)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(2)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):last-child){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(5)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(4)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(3)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(2)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):last-child){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(6)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(5)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(4)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(3)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):nth-last-child(2)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(6):last-child){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical{grid-template-columns:auto 1fr auto}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:first-child{grid-column:1/2}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:last-child{grid-column:2/4}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:first-child{grid-column:1/3}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:last-child{grid-column:3/4}.stk-block .stk-block.aligncenter,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align:not(.alignwide):not(.alignfull),.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align:not(.alignwide):not(.alignfull){margin-left:auto;margin-right:auto;max-width:var(--stk-block-default-width,var(--stk-block-width-default-detected,900px));width:100%}.stk-block .stk-block.alignwide,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align.alignwide,.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align.alignwide{margin-left:auto;margin-right:auto;max-width:var(--stk-block-wide-widtLine truncated
/*# sourceURL=ugb-style-css-inline-css */ /*# sourceURL=ugb-style-css-inline-css */
</style> </style>
<style id="ugb-block-style-inheritance-nodep-inline-css"> <style id="ugb-block-style-inheritance-nodep-inline-css">
@@ -38,7 +47,7 @@
/*# sourceURL=ugb-style-css-nodep-inline-css */ /*# sourceURL=ugb-style-css-nodep-inline-css */
</style> </style>
<style id="wp-block-library-inline-css"> <style id="wp-block-library-inline-css">
:root{--wp-block-synced-color:#7a00df;--wp-block-synced-color--rgb:122,0,223;--wp-bound-block-color:var(--wp-block-synced-color);--wp-editor-canvas-background:#ddd;--wp-admin-theme-color:#007cba;--wp-admin-theme-color--rgb:0,124,186;--wp-admin-theme-color-darker-10:#006ba1;--wp-admin-theme-color-darker-10--rgb:0,107,160.5;--wp-admin-theme-color-darker-20:#005a87;--wp-admin-theme-color-darker-20--rgb:0,90,135;--wp-admin-border-width-focus:2px}@media (min-resolution:192dpi){:root{--wp-admin-border-width-focus:1.5px}}.wp-element-button{cursor:pointer}:root .has-very-light-gray-background-color{background-color:#eee}:root .has-very-dark-gray-background-color{background-color:#313131}:root .has-very-light-gray-color{color:#eee}:root .has-very-dark-gray-color{color:#313131}:root .has-vivid-green-cyan-to-vivid-cyan-blue-gradient-background{background:linear-gradient(135deg,#00d084,#0693e3)}:root .has-purple-crush-gradient-background{background:linear-gradient(135deg,#34e2e4,#4721fb 50%,#ab1dfe)}:root .has-hazy-dawn-gradient-background{background:linear-gradient(135deg,#faaca8,#dad0ec)}:root .has-subdued-olive-gradient-background{background:linear-gradient(135deg,#fafae1,#67a671)}:root .has-atomic-cream-gradient-background{background:linear-gradient(135deg,#fdd79a,#004a59)}:root .has-nightshade-gradient-background{background:linear-gradient(135deg,#330968,#31cdcf)}:root .has-midnight-gradient-background{background:linear-gradient(135deg,#020381,#2874fc)}:root{--wp--preset--font-size--normal:16px;--wp--preset--font-size--huge:42px}.has-regular-font-size{font-size:1em}.has-larger-font-size{font-size:2.625em}.has-normal-font-size{font-size:var(--wp--preset--font-size--normal)}.has-huge-font-size{font-size:var(--wp--preset--font-size--huge)}:root .has-text-align-center{text-align:center}:root .has-text-align-left{text-align:left}:root .has-text-align-right{text-align:right}.has-fit-text{white-space:nowrap!important}#end-resizable-editor-section{display:none}.aligncenter{clear:both}.items-justified-left{justify-content:flex-start}.items-justified-center{justify-content:center}.items-justified-right{justify-content:flex-end}.items-justified-space-between{justify-content:space-between}.screen-reader-text{word-wrap:normal!important;border:0;clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.screen-reader-text:focus{background-color:#ddd;clip-path:none;color:#444;display:block;font-size:1em;height:auto;left:5px;line-height:normal;padding:15px 23px 14px;text-decoration:none;top:5px;width:auto;z-index:100000}html :where(.has-border-color){border-style:solid}html :where([style*=border-color]){border-style:solid}html :where([style*=border-top-color]){border-top-style:solid}html :where([style*=border-right-color]){border-right-style:solid}html :where([style*=border-bottom-color]){border-bottom-style:solid}html :where([style*=border-left-color]){border-left-style:solid}html :where([style*=border-width]){border-style:solid}html :where([style*=border-top-width]){border-top-style:solid}html :where([style*=border-right-width]){border-right-style:solid}html :where([style*=border-bottom-width]){border-bottom-style:solid}html :where([style*=border-left-width]){border-left-style:solid}html :where(img[class*=wp-image-]){height:auto;max-width:100%}:where(figure){margin:0 0 1em}html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:var(--wp-admin--admin-bar--height,0px)}@media screen and (max-width:600px){html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:0px}} :root{--wp-block-synced-color:#7a00df;--wp-block-synced-color--rgb:122,0,223;--wp-bound-block-color:var(--wp-block-synced-color);--wp-editor-canvas-background:#ddd;--wp-admin-theme-color:#007cba;--wp-admin-theme-color--rgb:0,124,186;--wp-admin-theme-color-darker-10:#006ba1;--wp-admin-theme-color-darker-10--rgb:0,107,160.5;--wp-admin-theme-color-darker-20:#005a87;--wp-admin-theme-color-darker-20--rgb:0,90,135;--wp-admin-border-width-focus:2px}@media (min-resolution:192dpi){:root{--wp-admin-border-width-focus:1.5px}}.wp-element-button{cursor:pointer}:root .has-very-light-gray-background-color{background-color:#eee}:root .has-very-dark-gray-background-color{background-color:#313131}:root .has-very-light-gray-color{color:#eee}:root .has-very-dark-gray-color{color:#313131}:root .has-vivid-green-cyan-to-vivid-cyan-blue-gradient-background{background:linear-gradient(135deg,#00d084,#0693e3)}:root .has-purple-crush-gradient-background{background:linear-gradient(135deg,#34e2e4,#4721fb 50%,#ab1dfe)}:root .has-hazy-dawn-gradient-background{background:linear-gradient(135deg,#faaca8,#dad0ec)}:root .has-subdued-olive-gradient-background{background:linear-gradient(135deg,#fafae1,#67a671)}:root .has-atomic-cream-gradient-background{background:linear-gradient(135deg,#fdd79a,#004a59)}:root .has-nightshade-gradient-background{background:linear-gradient(135deg,#330968,#31cdcf)}:root .has-midnight-gradient-background{background:linear-gradient(135deg,#020381,#2874fc)}:root{--wp--preset--font-size--normal:16px;--wp--preset--font-size--huge:42px}.has-regular-font-size{font-size:1em}.has-larger-font-size{font-size:2.625em}.has-normal-font-size{font-size:var(--wp--preset--font-size--normal)}.has-huge-font-size{font-size:var(--wp--preset--font-size--huge)}:root .has-text-align-center{text-align:center}:root .has-text-align-left{text-align:left}:root .has-text-align-right{text-align:right}.has-fit-text{white-space:nowrap!important}#end-resizable-editor-section{display:none}.aligncenter{clear:both}.items-justified-left{justify-content:flex-start}.items-justified-center{justify-content:center}.items-justified-right{justify-content:flex-end}.items-justified-space-between{justify-content:space-between}.screen-reader-text{word-wrap:normal!important;border:0;clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;word-break:normal!important}.screen-reader-text:focus{background-color:#ddd;clip-path:none;color:#444;display:block;font-size:1em;height:auto;left:5px;line-height:normal;padding:15px 23px 14px;text-decoration:none;top:5px;width:auto;z-index:100000}html :where(.has-border-color){border-style:solid}html :where([style^=border-color],[style*=";border-color"],[style*="; border-color"]){border-style:solid}html :where([style^=border-top-color],[style*=";border-top-color"],[style*="; border-top-color"]){border-top-style:solid}html :where([style^=border-right-color],[style*=";border-right-color"],[style*="; border-right-color"]){border-right-style:solid}html :where([style^=border-bottom-color],[style*=";border-bottom-color"],[style*="; border-bottom-color"]){border-bottom-style:solid}html :where([style^=border-left-color],[style*=";border-left-color"],[style*="; border-left-color"]){border-left-style:solid}html :where([style^=border-width],[style*=";border-width"],[style*="; border-width"]){border-style:solid}html :where([style^=border-top-width],[style*=";border-top-width"],[style*="; border-top-width"]){border-top-style:solid}html :where([style^=border-right-width],[style*=";border-right-width"],[style*="; border-right-width"]){border-right-style:solid}html :where([style^=border-bottom-width],[style*=";border-bottom-width"],[style*="; border-bottom-width"]){border-bottom-style:solid}html :where([style^=border-left-width],[style*=";border-left-width"],[style*="; border-left-width"]){border-left-style:solid}html :where(img[class*=wp-image-]){height:auto;max-width:100%}:where(figure){margin:0 0 1em}html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:var(--wp-admin--admin-bar--height,0px)}@media screen and (max-width:600px){html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:0px}}
/*# sourceURL=/wp-includes/css/dist/block-library/common.min.css */ /*# sourceURL=/wp-includes/css/dist/block-library/common.min.css */
</style> </style>
@@ -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} .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 */ /*# sourceURL=https://ksk.bzh/wp-includes/blocks/paragraph/style.min.css */
</style> </style>
<style id="wp-block-preformatted-inline-css">
.wp-block-preformatted{box-sizing:border-box;white-space:pre-wrap}:where(.wp-block-preformatted.has-background){padding:1.25em 2.375em}
/*# sourceURL=https://ksk.bzh/wp-includes/blocks/preformatted/style.min.css */
</style>
<style id="global-styles-inline-css"> <style id="global-styles-inline-css">
:root{--wp--preset--aspect-ratio--square: 1;--wp--preset--aspect-ratio--4-3: 4/3;--wp--preset--aspect-ratio--3-4: 3/4;--wp--preset--aspect-ratio--3-2: 3/2;--wp--preset--aspect-ratio--2-3: 2/3;--wp--preset--aspect-ratio--16-9: 16/9;--wp--preset--aspect-ratio--9-16: 9/16;--wp--preset--color--black: #000000;--wp--preset--color--cyan-bluish-gray: #abb8c3;--wp--preset--color--white: #ffffff;--wp--preset--color--pale-pink: #f78da7;--wp--preset--color--vivid-red: #cf2e2e;--wp--preset--color--luminous-vivid-orange: #ff6900;--wp--preset--color--luminous-vivid-amber: #fcb900;--wp--preset--color--light-green-cyan: #7bdcb5;--wp--preset--color--vivid-green-cyan: #00d084;--wp--preset--color--pale-cyan-blue: #8ed1fc;--wp--preset--color--vivid-cyan-blue: #0693e3;--wp--preset--color--vivid-purple: #9b51e0;--wp--preset--color--palette-color-1: var(--theme-palette-color-1, #ff9201);--wp--preset--color--palette-color-2: var(--theme-palette-color-2, #ff9201);--wp--preset--color--palette-color-3: var(--theme-palette-color-3, #1a242b);--wp--preset--color--palette-color-4: var(--theme-palette-color-4, #334654);--wp--preset--color--palette-color-5: var(--theme-palette-color-5, #ffebd1);--wp--preset--color--palette-color-6: var(--theme-palette-color-6, #fff7ec);--wp--preset--color--palette-color-7: var(--theme-palette-color-7, #ffffff);--wp--preset--color--palette-color-8: var(--theme-palette-color-8, #ffffff);--wp--preset--gradient--vivid-cyan-blue-to-vivid-purple: linear-gradient(135deg,rgb(6,147,227) 0%,rgb(155,81,224) 100%);--wp--preset--gradient--light-green-cyan-to-vivid-green-cyan: linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%);--wp--preset--gradient--luminous-vivid-amber-to-luminous-vivid-orange: linear-gradient(135deg,rgb(252,185,0) 0%,rgb(255,105,0) 100%);--wp--preset--gradient--luminous-vivid-orange-to-vivid-red: linear-gradient(135deg,rgb(255,105,0) 0%,rgb(207,46,46) 100%);--wp--preset--gradient--very-light-gray-to-cyan-bluish-gray: linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%);--wp--preset--gradient--cool-to-warm-spectrum: linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%);--wp--preset--gradient--blush-light-purple: linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%);--wp--preset--gradient--blush-bordeaux: linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%);--wp--preset--gradient--luminous-dusk: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%);--wp--preset--gradient--pale-ocean: linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%);--wp--preset--gradient--electric-grass: linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%);--wp--preset--gradient--midnight: linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%);--wp--preset--gradient--juicy-peach: linear-gradient(to right, #ffecd2 0%, #fcb69f 100%);--wp--preset--gradient--young-passion: linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%);--wp--preset--gradient--true-sunset: linear-gradient(to right, #fa709a 0%, #fee140 100%);--wp--preset--gradient--morpheus-den: linear-gradient(to top, #30cfd0 0%, #330867 100%);--wp--preset--gradient--plum-plate: linear-gradient(135deg, #667eea 0%, #764ba2 100%);--wp--preset--gradient--aqua-splash: linear-gradient(15deg, #13547a 0%, #80d0c7 100%);--wp--preset--gradient--love-kiss: linear-gradient(to top, #ff0844 0%, #ffb199 100%);--wp--preset--gradient--new-retrowave: linear-gradient(to top, #3b41c5 0%, #a981bb 49%, #ffc8a9 100%);--wp--preset--gradient--plum-bath: linear-gradient(to top, #cc208e 0%, #6713d2 100%);--wp--preset--gradient--high-flight: linear-gradient(to right, #0acffe 0%, #495aff 100%);--wp--preset--gradient--teen-party: linear-gradient(135deg, #FF057C 0%, #8D0B93 50%, #321575 100%);--wp--preset--gradient--fabled-sunset: linear-gradient(135deg, #231557 0%, #44107A 29%, #FF1361 67%, #FFF800 100%);--wp--preset--gradient--arielle-smile: radial-gradient(circle 248px at center, #16d9e3 0%, #30c7ec 47%, #46aef7 100%);--wp--preset--gradient--itmeo-branding: linear-gradient(180deg, #2af598 0%, #009efd 100%);--wp--preset--gradient--deep-blue: linear-gradient(to right, #6a11cb 0%, #2575fc 100%);--wp--preset--gradient--strong-bliss: linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%);--wp--preset--gradient--sweet-period: linear-gradient(to top, #3f51b1 0%, #5a55ae 13%, #7b5fac 25%, #8f6aae 38%, #a86aa4 50%, #cc6b8e 62%, #f18271 75%, #f3a469 87%, #f7c978 100%);--wp--preset--gradient--purple-division: linear-gradient(to top, #7028e4 0%, #e5b2ca 100%);--wp--preset--gradient--cold-evening: linear-gradient(to top, #0c3483 0%, #a2b6df 100%, #6b8cce 100%, #a2b6df 100%);--wp--preset--gradient--mountain-rock: linear-gradient(to right, #868f96 0%, #596164 100%);--wp--preset--gradient--desert-hump: lineaLine truncated :root{--wp--preset--aspect-ratio--square: 1;--wp--preset--aspect-ratio--4-3: 4/3;--wp--preset--aspect-ratio--3-4: 3/4;--wp--preset--aspect-ratio--3-2: 3/2;--wp--preset--aspect-ratio--2-3: 2/3;--wp--preset--aspect-ratio--16-9: 16/9;--wp--preset--aspect-ratio--9-16: 9/16;--wp--preset--color--black: #000000;--wp--preset--color--cyan-bluish-gray: #abb8c3;--wp--preset--color--white: #ffffff;--wp--preset--color--pale-pink: #f78da7;--wp--preset--color--vivid-red: #cf2e2e;--wp--preset--color--luminous-vivid-orange: #ff6900;--wp--preset--color--luminous-vivid-amber: #fcb900;--wp--preset--color--light-green-cyan: #7bdcb5;--wp--preset--color--vivid-green-cyan: #00d084;--wp--preset--color--pale-cyan-blue: #8ed1fc;--wp--preset--color--vivid-cyan-blue: #0693e3;--wp--preset--color--vivid-purple: #9b51e0;--wp--preset--color--palette-color-1: var(--theme-palette-color-1, #ff9201);--wp--preset--color--palette-color-2: var(--theme-palette-color-2, #ff9201);--wp--preset--color--palette-color-3: var(--theme-palette-color-3, #1a242b);--wp--preset--color--palette-color-4: var(--theme-palette-color-4, #334654);--wp--preset--color--palette-color-5: var(--theme-palette-color-5, #ffebd1);--wp--preset--color--palette-color-6: var(--theme-palette-color-6, #fff7ec);--wp--preset--color--palette-color-7: var(--theme-palette-color-7, #ffffff);--wp--preset--color--palette-color-8: var(--theme-palette-color-8, #ffffff);--wp--preset--gradient--vivid-cyan-blue-to-vivid-purple: linear-gradient(135deg,rgb(6,147,227) 0%,rgb(155,81,224) 100%);--wp--preset--gradient--light-green-cyan-to-vivid-green-cyan: linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%);--wp--preset--gradient--luminous-vivid-amber-to-luminous-vivid-orange: linear-gradient(135deg,rgb(252,185,0) 0%,rgb(255,105,0) 100%);--wp--preset--gradient--luminous-vivid-orange-to-vivid-red: linear-gradient(135deg,rgb(255,105,0) 0%,rgb(207,46,46) 100%);--wp--preset--gradient--very-light-gray-to-cyan-bluish-gray: linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%);--wp--preset--gradient--cool-to-warm-spectrum: linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%);--wp--preset--gradient--blush-light-purple: linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%);--wp--preset--gradient--blush-bordeaux: linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%);--wp--preset--gradient--luminous-dusk: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%);--wp--preset--gradient--pale-ocean: linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%);--wp--preset--gradient--electric-grass: linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%);--wp--preset--gradient--midnight: linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%);--wp--preset--gradient--juicy-peach: linear-gradient(to right, #ffecd2 0%, #fcb69f 100%);--wp--preset--gradient--young-passion: linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%);--wp--preset--gradient--true-sunset: linear-gradient(to right, #fa709a 0%, #fee140 100%);--wp--preset--gradient--morpheus-den: linear-gradient(to top, #30cfd0 0%, #330867 100%);--wp--preset--gradient--plum-plate: linear-gradient(135deg, #667eea 0%, #764ba2 100%);--wp--preset--gradient--aqua-splash: linear-gradient(15deg, #13547a 0%, #80d0c7 100%);--wp--preset--gradient--love-kiss: linear-gradient(to top, #ff0844 0%, #ffb199 100%);--wp--preset--gradient--new-retrowave: linear-gradient(to top, #3b41c5 0%, #a981bb 49%, #ffc8a9 100%);--wp--preset--gradient--plum-bath: linear-gradient(to top, #cc208e 0%, #6713d2 100%);--wp--preset--gradient--high-flight: linear-gradient(to right, #0acffe 0%, #495aff 100%);--wp--preset--gradient--teen-party: linear-gradient(135deg, #FF057C 0%, #8D0B93 50%, #321575 100%);--wp--preset--gradient--fabled-sunset: linear-gradient(135deg, #231557 0%, #44107A 29%, #FF1361 67%, #FFF800 100%);--wp--preset--gradient--arielle-smile: radial-gradient(circle 248px at center, #16d9e3 0%, #30c7ec 47%, #46aef7 100%);--wp--preset--gradient--itmeo-branding: linear-gradient(180deg, #2af598 0%, #009efd 100%);--wp--preset--gradient--deep-blue: linear-gradient(to right, #6a11cb 0%, #2575fc 100%);--wp--preset--gradient--strong-bliss: linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%);--wp--preset--gradient--sweet-period: linear-gradient(to top, #3f51b1 0%, #5a55ae 13%, #7b5fac 25%, #8f6aae 38%, #a86aa4 50%, #cc6b8e 62%, #f18271 75%, #f3a469 87%, #f7c978 100%);--wp--preset--gradient--purple-division: linear-gradient(to top, #7028e4 0%, #e5b2ca 100%);--wp--preset--gradient--cold-evening: linear-gradient(to top, #0c3483 0%, #a2b6df 100%, #6b8cce 100%, #a2b6df 100%);--wp--preset--gradient--mountain-rock: linear-gradient(to right, #868f96 0%, #596164 100%);--wp--preset--gradient--desert-hump: lineaLine truncated
/*# sourceURL=global-styles-inline-css */ /*# sourceURL=global-styles-inline-css */
</style> </style>
<link rel='stylesheet' id='ct-main-styles-css' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.min.css?ver=2.1.48' media='all' /> <link rel="stylesheet" id="ct-main-styles-css"
<link rel='stylesheet' id='ct-stackable-styles-css' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/stackable.min.css?ver=2.1.48' media='all' /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.min.css?ver=2.1.53"
<link rel="https://api.w.org/" href="https://ksk.bzh/wp-json/" /><link rel="alternate" title="JSON" type="application/json" href="https://ksk.bzh/wp-json/wp/v2/pages/1413" /><link rel="EditURI" type="application/rsd+xml" title="RSD" href="https://ksk.bzh/xmlrpc.php?rsd" /> media="all">
<meta name="generator" content="WordPress 7.0" /> <link rel="stylesheet" id="ct-page-title-styles-css"
<link rel="canonical" href="https://ksk.bzh/fr/chom-bev-a-propos/" /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/page-title.min.css?ver=2.1.53"
<link rel='shortlink' href='https://ksk.bzh/?p=1413' /> media="all">
<noscript><link rel='stylesheet' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/no-scripts.min.css' type='text/css'></noscript> <link rel="stylesheet" id="ct-stackable-styles-css"
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-32x32.png" sizes="32x32" /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/stackable.min.css?ver=2.1.53"
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-192x192.png" sizes="192x192" /> media="all">
<link rel="apple-touch-icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-180x180.png" /> <link rel="https://api.w.org/" href="https://ksk.bzh/wp-json/">
<meta name="msapplication-TileImage" content="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-270x270.png" /> <link rel="alternate" title="JSON" type="application/json"
href="https://ksk.bzh/wp-json/wp/v2/pages/1413">
<link rel="EditURI" type="application/rsd+xml" title="RSD"
href="https://ksk.bzh/xmlrpc.php?rsd">
<meta name="generator" content="WordPress 7.1.3">
<link rel="canonical" href="https://ksk.bzh/fr/donemat-a-propos/">
<link rel="shortlink" href="https://ksk.bzh/?p=1413">
<noscript>
<link rel='stylesheet'
href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/no-scripts.min.css'
type='text/css'>
</noscript>
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-32x32.png"
sizes="32x32">
<link rel="icon"
href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-192x192.png"
sizes="192x192">
<link rel="apple-touch-icon"
href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-180x180.png">
<meta name="msapplication-TileImage"
content="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-270x270.png">
<style id="wp-custom-css"> <style id="wp-custom-css">
/* Fonts */ /* Fonts */
@font-face { @font-face {
@@ -137,160 +162,203 @@
</head> </head>
<body class="wp-singular page-template-default page page-id-1413 wp-custom-logo wp-embed-responsive wp-theme-blocksy stk--is-blocksy-theme stk-has-block-style-inheritance" data-link="type-2" data-prefix="single_page" itemscope="itemscope" itemtype="https://schema.org/WebPage"> <body class="wp-singular page-template-default page page-id-1413 wp-custom-logo wp-embed-responsive wp-theme-blocksy stk--is-blocksy-theme stk-has-block-style-inheritance"
data-link="type-2" data-prefix="single_page" itemscope="itemscope"
itemtype="https://schema.org/WebPage">
<a class="skip-link screen-reader-text" href="#main">Passer au contenu</a>
<div id="main-container"> <div id="main-container">
<main id="main" class="site-main hfeed" tabindex="-1"> <main id="main" class="site-main hfeed">
<div <div class="ct-container-full" data-content="normal">
class="ct-container-full"
data-content="normal" >
<article id="post-1413" class="post-1413 page type-page status-publish hentry">
<article
id="post-1413"
class="post-1413 page type-page status-publish hentry">
<div class="hero-section is-width-constrained" data-type="type-1">
<header class="entry-header">
<h1 class="page-title" itemprop="headline"><span
aria-hidden="true">ℹ️</span> À propos</h1></header>
</div>
<div class="entry-content is-layout-constrained"> <div class="entry-content is-layout-constrained">
<div class="wp-block-stackable-heading stk-block-heading stk-block-heading--v2 stk-block stk-70cd5d3" id="ℹ️-a-propos" data-block-id="70cd5d3"><h2 class="stk-block-heading__text">ℹ️ À propos</h2></div> <p class="has-palette-color-6-background-color has-background wp-block-paragraph">
«&nbsp;<strong>Donemat – Breton d’Auray</strong>&nbsp;» est une application
qui vous est proposée gratuitement par l’association <strong>Kerlenn Sten
Kidna</strong> dans le but de vous aider à vous initier au Breton, et
particulièrement aux sonorités et tournures typiques du <strong>Breton
Vannetais du Pays d’Auray</strong>.</p>
<h2 class="wp-block-heading has-large-font-size"><span
<pre class="wp-block-preformatted">"<strong>Chom Bev e Brezhoneg</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre> aria-hidden="true">🎙️</span> Contributeurs</h2>
<h3 class="wp-block-heading"
<h2 class="wp-block-heading has-large-font-size">🎙️ Contributeurs</h2> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
Contenu</h3>
<p class="wp-block-paragraph">Cette application vous est proposée par
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">Contenu</h3> l’association <a href="https://ksk.bzh" target="_blank"
rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>.
Elle est basée sur «&nbsp;Chom Bev e Brezhoneg&nbsp;», une oeuvre créée par
KSK, initialement sous la forme d’un livret imprimé et accompagné d’un CD
audio. Il est adapté ici au format numérique. L’association KSK a notamment
pour mission de faire vivre le patrimoine culturel breton en pays d’Auray.
Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, …)
mais aussi des rencontres (stages, ateliers jeux, cafés conversation, club
lecteurs …) et des cours hebdomadaires de breton (en journée &amp; en
soirée, en présentiel &amp; à distance).</p>
<p class="wp-block-paragraph">Le contenu, les textes et enregistrements ont été
<p class="wp-block-paragraph">Cette application vous est proposée par l&rsquo;association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. « Chom Bev e Brezhoneg » est une oeuvre créée par KSK, initialement sous la forme d&rsquo;un livret imprimé et accompagné d&rsquo;un CD audio. Il est adapté ici au format numérique. L&rsquo;association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d&rsquo;Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, &#8230;) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur &#8230;) et des cours hebdomadaires de breton (en journée &amp; en soirée, en présentiel &amp; à distance). </p> réalisés par l’association, et restent la propriété de l’association. Pour
une réutilisation en dehors de l’application, merci de vous rapprocher de
l’association.</p>
<p class="wp-block-paragraph">Sur les enregistrement, nous retrouvons deux voix
<p class="wp-block-paragraph">Le contenu, les textes et enregistrements ont été réalisés par l&rsquo;association, et restent la propriété de l&rsquo;association. Pour une réutilisation en dehors de l&rsquo;application, merci de vous rapprocher de l&rsquo;association.</p> :</p>
<p class="wp-block-paragraph">Sur les enregistrement, nous retrouvons deux voix :</p>
<ul class="wp-block-list"> <ul class="wp-block-list">
<li>Patrig Dréan, actuel président de Kerlenn Sten Kidna, formateur et auteur de nombreuses œuvres en breton.</li> <li>Patrig Dréan, actuel président de Kerlenn Sten Kidna, formateur en
breton chez Stumdi et auteur d’œuvres en breton,
</li>
<li>Yann-Charlez Caudal, professeur de breton chez Diwan, et auteur d’œuvres
<li>?</li> en breton.
</li>
</ul> </ul>
<h3 class="wp-block-heading"
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">Conception de l&rsquo;app, pilotage &amp; développement</h3> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
Conception de l’app, pilotage &amp; développement</h3>
<p class="wp-block-paragraph">La conception et le développement de l’application
<p class="wp-block-paragraph">La conception et le développement de l&rsquo;application sont pilotés et réalisés par <a href="https://antoinejaury.github.io/">Antoine Jaury</a>, développeur professionnel d&rsquo;applications mobiles. Le code source de l&rsquo;application est ouvert (Open Source), et utilise Kotlin Multi-Platform (KMP) avec Compose Multi-Platform (CMP) afin de proposer une application fonctionnant sur Android, iOS, navigateur Web, mais aussi en application bureau.</p> sont pilotés et réalisés par <a href="https://antoinejaury.github.io/">Antoine
Jaury</a>, développeur professionnel d’applications mobiles. Le code
source de l’application est ouvert (Open Source), et utilise Kotlin
Multi-Platform (KMP) avec Compose Multi-Platform (CMP) afin de proposer une
application fonctionnant sur Android, iOS, navigateur Web, mais aussi en
application bureau.</p>
<p class="wp-block-paragraph">Le code source de l’application est disponible sur
<p class="wp-block-paragraph">Le code source de l&rsquo;application est disponible sur : <a href="https://.../chombev">https://&#8230;/chombev</a></p> : <a href="https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg"
target="_blank" rel="noreferrer noopener">https://git.kaz.bzh/Antoine_Jaury_KSK/Chom-Bev-e-Brezhoneg</a>
</p>
<h3 class="wp-block-heading"
<h2 class="wp-block-heading has-large-font-size">💬 C&rsquo;est quoi le Breton Vannetais (Gwenedeg) ?</h2> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
Tests, retours d’expériences, conception du produit</h3>
<p class="wp-block-paragraph">Patrick Dréan, Ronan Stéphan et David Le Roux ont
<p class="wp-block-paragraph">Le breton Vannetais regroupe une variété de bretons parlée dans la quasi-totalité de la partie bretonnante du Morbihan, et un peu au-delà. C&rsquo;est le cas notamment du Pays d&rsquo;Auray actuel.</p> participé activement à la conception du projet : réflexion globale sur le
besoin, les fonctionnalités, retours d’expérience et test de la solution.
Leur expérience en tant que formateurs en breton est essentielle pour la
bonne réalisation de ce projet.</p>
<h2 class="wp-block-heading has-large-font-size"><span
<p class="wp-block-paragraph">+ ajout d&rsquo;une carte ? </p> aria-hidden="true">💬</span> C’est quoi le Breton Vannetais (Gwenedeg) ?
</h2>
<p class="wp-block-paragraph">Le breton Vannetais regroupe une variété de
<h2 class="wp-block-heading has-large-font-size">🤙🏼 Contact</h2> bretons parlés dans la quasi-totalité de la partie bretonnante du Morbihan,
et un peu au-delà. C’est le cas notamment du pays d’Auray actuel.</p>
<p class="wp-block-paragraph">Cette application présente un Breton Vannetais
<p class="wp-block-paragraph">Vous souhaitez contacter l&rsquo;association Kerlenn Sten Kidna : <a href="mailto:ksk@ksk.bzh">ksk@ksk.bzh</a></p> issu du Pays d’Auray.</p>
<h2 class="wp-block-heading has-large-font-size"><span
<p class="wp-block-paragraph">Vous avez une question, une suggestion concernant l&rsquo;application : <a href="mailto:antoine@jaury.eu">antoine@jaury.eu</a></p> aria-hidden="true">🤙🏼</span> Contact</h2>
<p class="wp-block-paragraph">Vous souhaitez contacter l’association Kerlenn
<h2 class="wp-block-heading has-large-font-size">🔒 Politique de confidentialité</h2> Sten Kidna : <a href="mailto:ksk@ksk.bzh">ksk@ksk.bzh</a></p>
<p class="wp-block-paragraph">Vous avez une question, une suggestion concernant
<p class="wp-block-paragraph">Cette application ne demande ni ne collecte de données personnelles telles que votre nom, votre adresse électronique ou votre position géographique.</p> l’application : <a href="mailto:antoine@jaury.eu">antoine@jaury.eu</a></p>
<h2 class="wp-block-heading has-large-font-size"><span
<p class="wp-block-paragraph">Les contenus audio sont intégrés à l&rsquo;application et restent accessibles hors connexion.</p> aria-hidden="true">🔒</span> Politique de confidentialité</h2>
<p class="wp-block-paragraph">Cette application ne demande ni ne collecte de
<p class="wp-block-paragraph">Les sections <strong>« Approfondir »</strong> et <strong>« À propos »</strong> affichent des pages web hébergées sous WordPress. Lors de leur consultation, des données techniques nécessaires au fonctionnement du serveur web peuvent être traitées (par exemple l&rsquo;adresse IP, les informations du navigateur ou les journaux de connexion).</p> données personnelles telles que votre nom, votre adresse électronique ou
votre position géographique.</p>
<p class="wp-block-paragraph">Les contenus audio sont intégrés à l’application
<p class="wp-block-paragraph">Afin d&rsquo;améliorer la stabilité de l&rsquo;application, un service de collecte des erreurs (GlitchTip) est utilisé. En cas de plantage ou d&rsquo;erreur inattendue, des informations techniques (telles que la version de l&rsquo;application, le modèle de l&rsquo;appareil, la version du système d&rsquo;exploitation et la trace de l&rsquo;erreur) peuvent être transmises. Ces informations sont utilisées uniquement pour diagnostiquer et corriger les problèmes techniques. </p> et restent accessibles hors connexion.</p>
<p class="wp-block-paragraph">Les sections <strong>« Approfondir »</strong> et
<strong>« À propos »</strong> affichent des pages web hébergées sous
WordPress. Lors de leur consultation, des données techniques nécessaires au
fonctionnement du serveur web peuvent être traitées (par exemple l’adresse
IP, les informations du navigateur ou les journaux de connexion).</p>
<p class="wp-block-paragraph">L&rsquo;ensemble de ces données sont stockées sur des serveurs hébergés en Europe :</p>
<p class="wp-block-paragraph">Afin d’améliorer la stabilité de l’application, un
service de collecte des erreurs (GlitchTip) est utilisé. En cas de plantage
ou d’erreur inattendue, des informations techniques (telles que la version
de l’application, le modèle de l’appareil, la version du système
d’exploitation et la trace de l’erreur) peuvent être transmises. Ces
informations sont utilisées uniquement pour diagnostiquer et corriger les
problèmes techniques. </p>
<p class="wp-block-paragraph">L’ensemble de ces données sont stockées sur des
serveurs hébergés en Europe :</p>
<ul class="wp-block-list"> <ul class="wp-block-list">
<li>En France pour les pages web, hébergées chez l’Association LA KAZ,</li> <li>En France pour les pages web, hébergées chez l’Association LA KAZ,</li>
<li>En Allemagne pour les données de stabilité avec GlitchTip Europe.</li> <li>En Allemagne pour les données de stabilité avec GlitchTip Europe.</li>
</ul> </ul>
<p class="wp-block-paragraph">Pour plus de détails, veuillez consulter notre :
<p class="wp-block-paragraph">Pour plus de détails, veuillez consulter notre : <a href="https://ksk.bzh/fr/app-chom-bev-politique-de-confidentialite/">politique de confidentialité</a>.</p> <a href="https://ksk.bzh/fr/app-donemat-politique-de-confidentialite/">politique
de confidentialité</a>.</p>
<h2 class="wp-block-heading has-large-font-size"><span aria-hidden="true">🧑🏽‍⚖️</span>
<h2 class="wp-block-heading has-large-font-size">🧑🏽‍⚖️ Mentions légales</h2> Mentions légales</h2>
<p class="wp-block-paragraph">Les mentions légales de l’application sont
<p class="wp-block-paragraph">Les mentions légales de l&rsquo;application sont disponibles via <a href="https://ksk.bzh/fr/app-chom-bev-mentions-legales/">ce lien</a>.</p> disponibles via <a href="https://ksk.bzh/fr/app-donemat-mentions-legales/">ce
lien</a>.</p>
<div class="wp-block-stackable-spacer stk-block-spacer stk--no-padding stk-block stk-adc34e5"
<div class="wp-block-stackable-spacer stk-block-spacer stk--no-padding stk-block stk-adc34e5" data-block-id="adc34e5"></div> data-block-id="adc34e5"></div>
</div> </div>
</article> </article>
</div> </div>
</main> </main>
@@ -301,10 +369,14 @@
{"prefetch":[{"source":"document","where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":["/wp-*.php","/wp-admin/*","/wp-content/uploads/*","/wp-content/*","/wp-content/plugins/*","/wp-content/themes/blocksy/*","/*\\?(.+)"]}},{"not":{"selector_matches":"a[rel~=\"nofollow\"]"}},{"not":{"selector_matches":".no-prefetch, .no-prefetch a"}}]},"eagerness":"conservative"}]} {"prefetch":[{"source":"document","where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":["/wp-*.php","/wp-admin/*","/wp-content/uploads/*","/wp-content/*","/wp-content/plugins/*","/wp-content/themes/blocksy/*","/*\\?(.+)"]}},{"not":{"selector_matches":"a[rel~=\"nofollow\"]"}},{"not":{"selector_matches":".no-prefetch, .no-prefetch a"}}]},"eagerness":"conservative"}]}
</script> </script>
<script id="ct-scripts-js-extra"> <script id="ct-scripts-js-extra">
var ct_localizations = {"ajax_url":"https://ksk.bzh/wp-admin/admin-ajax.php","public_url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/","rest_url":"https://ksk.bzh/wp-json/","search_url":"https://ksk.bzh/fr/search/QUERY_STRING/","show_more_text":"Afficher plus","more_text":"Plus","search_live_results":"R\u00e9sultats de recherche","search_live_no_results":"Aucun r\u00e9sultat","search_live_results_closed":"R\u00e9sultats de recherche ferm\u00e9s.","search_live_no_result":"Aucun r\u00e9sultat","search_live_one_result":"Vous avez %s r\u00e9sultat. Veuillez appuyer sur Tab pour le s\u00e9lectionner.","search_live_many_results":"Vous avez %s r\u00e9sultats. Veuillez appuyer sur Tab pour en s\u00e9lectionner un.","search_live_stock_status_texts":{"instock":"En stock","outofstock":"En rupture de stock"},"clipboard_copied":"Copi\u00e9\u00a0!","clipboard_failed":"\u00c9chec de la copie","expand_submenu":"D\u00e9plier le menu d\u00e9roulant","collapse_submenu":"Replier le menu d\u00e9roulant","dynamic_js_chunks":[{"id":"blocksy_sticky_header","selector":"header [data-sticky]","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/sticky.js?ver=2.1.48","version":"2.1.48"}],"dynamic_styles":{"lazy_load":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-styles.min.css?ver=2.1.48","search_lazy":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-search-styles.min.css?ver=2.1.48","back_to_top":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/back-to-top.min.css?ver=2.1.48"},"dynamic_styles_selectors":[{"selector":".ct-header-cart, #woo-cart-panel","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/cart-header-element-lazy.min.css?ver=2.1.48"},{"selector":".flexy","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/flexy.min.css?ver=2.1.48"},{"selector":".ct-pagination","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/pagination.min.css?ver=2.1.48"},{"selector":"#account-modal","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-modal-lazy.min.css?ver=2.1.48"},{"selector":".ct-header-account","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-dropdown-lazy.min.css?ver=2.1.48"}],"login_generic_error_msg":"Une erreur inattendue s\u2019est produite. Veuillez r\u00e9essayer ult\u00e9rieurement.","lang":"fr"}; var ct_localizations = {"ajax_url":"https://ksk.bzh/wp-admin/admin-ajax.php","public_url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/","rest_url":"https://ksk.bzh/wp-json/","search_url":"https://ksk.bzh/fr/search/QUERY_STRING/","show_more_text":"Afficher plus","more_text":"Plus","search_live_results":"R\u00e9sultats de recherche","search_live_no_results":"Aucun r\u00e9sultat","search_live_results_closed":"R\u00e9sultats de recherche ferm\u00e9s.","search_live_no_result":"Aucun r\u00e9sultat","search_live_one_result":"Vous avez %s r\u00e9sultat. Veuillez appuyer sur Tab pour le s\u00e9lectionner.","search_live_many_results":"Vous avez %s r\u00e9sultats. Veuillez appuyer sur Tab pour en s\u00e9lectionner un.","search_live_stock_status_texts":{"instock":"En stock","outofstock":"En rupture de stock"},"clipboard_copied":"Copi\u00e9\u00a0!","clipboard_failed":"\u00c9chec de la copie","expand_submenu":"D\u00e9plier le menu d\u00e9roulant","collapse_submenu":"Replier le menu d\u00e9roulant","dynamic_js_chunks":[{"id":"blocksy_sticky_header","selector":"header [data-sticky]","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/sticky.js?ver=2.1.59","version":"2.1.59"}],"dynamic_styles":{"lazy_load":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-styles.min.css?ver=2.1.53","search_lazy":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-search-styles.min.css?ver=2.1.53","back_to_top":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/back-to-top.min.css?ver=2.1.53"},"dynamic_styles_selectors":[{"selector":".ct-header-cart, #woo-cart-panel","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/cart-header-element-lazy.min.css?ver=2.1.53"},{"selector":".flexy","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/flexy.min.css?ver=2.1.53"},{"selector":".ct-pagination","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/pagination.min.css?ver=2.1.53"},{"selector":"#account-modal","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-modal-lazy.min.css?ver=2.1.59"},{"selector":".ct-header-account","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-dropdown-lazy.min.css?ver=2.1.59"}],"login_generic_error_msg":"Une erreur inattendue s\u2019est produite. Veuillez r\u00e9essayer ult\u00e9rieurement.","lang":"fr"};
//# sourceURL=ct-scripts-js-extra //# sourceURL=ct-scripts-js-extra
</script> </script>
<script id="ct-scripts-js" src="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.js?ver=2.1.48"></script> <script id="ct-scripts-js"
src="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.js?ver=2.1.53"></script>
<link rel="stylesheet"
href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-styles.min.css?ver=2.1.53">
</body> </body>
</html> </html>
@@ -3,24 +3,33 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover"> <meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
<link rel="profile" href="https://gmpg.org/xfn/11"> <link rel="profile" href="https://gmpg.org/xfn/11">
<title>App Chom Bev -​ Approfondir &#8211; Kerlenn Sten Kidna</title> <title>📚 Approfondir – Kerlenn Sten Kidna</title>
<meta name='robots' content='max-image-preview:large' /> <meta name="robots" content="max-image-preview:large">
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux" href="https://ksk.bzh/fr/feed/" /> <link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux"
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna &raquo; Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" /> href="https://ksk.bzh/fr/feed/">
<link rel="alternate" title="oEmbed (JSON)" type="application/json+oembed" href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fchom-bev-approfondir%2F&#038;lang=fr" /> <link rel="alternate" type="application/rss+xml"
<link rel="alternate" title="oEmbed (XML)" type="text/xml+oembed" href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fchom-bev-approfondir%2F&#038;format=xml&#038;lang=fr" /> title="Kerlenn Sten Kidna » Flux des commentaires"
href="https://ksk.bzh/fr/comments/feed/">
<link rel="alternate" title="oEmbed (JSON)" type="application/json+oembed"
href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fdonemat-approfondir%2F&amp;lang=fr">
<link rel="alternate" title="oEmbed (XML)" type="text/xml+oembed"
href="https://ksk.bzh/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fksk.bzh%2Ffr%2Fdonemat-approfondir%2F&amp;format=xml&amp;lang=fr">
<style id="wp-img-auto-sizes-contain-inline-css"> <style id="wp-img-auto-sizes-contain-inline-css">
img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px} img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}
/*# sourceURL=wp-img-auto-sizes-contain-inline-css */ /*# sourceURL=wp-img-auto-sizes-contain-inline-css */
</style> </style>
<link rel='stylesheet' id='blocksy-dynamic-global-css' href='https://ksk.bzh/wp-content/uploads/blocksy/css/global.css?ver=64854' media='all' /> <link rel="stylesheet" id="blocksy-dynamic-global-css"
<link rel='stylesheet' id='ugb-style-css-css' href='https://ksk.bzh/wp-content/plugins/stackable-ultimate-gutenberg-blocks/dist/frontend_blocks.css?ver=3.19.9' media='all' /> href="https://ksk.bzh/wp-content/uploads/blocksy/css/global.css?ver=38936" media="all">
<link rel="stylesheet" id="ugb-style-css-css"
href="https://ksk.bzh/wp-content/plugins/stackable-ultimate-gutenberg-blocks/dist/frontend_blocks.css?ver=3.20.2"
media="all">
<style id="ugb-style-css-inline-css"> <style id="ugb-style-css-inline-css">
:root {--stk-block-width-default-detected: 1290px;} :root {--stk-block-width-default-detected: 1290px;}
#start-resizable-editor-section{display:none}@media only screen and (min-width:1000px){:where(.has-text-align-left) .stk-block-divider{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}:where(.has-text-align-right) .stk-block-divider{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}:where(.has-text-align-center) .stk-block-divider{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-center{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-right{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-left{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}.stk-block-feature>*>.stk-row{flex-wrap:var(--stk-feature-flex-wrap,nowrap)}.stk-row{flex-wrap:nowrap}.stk--hide-desktop,.stk--hide-desktop.stk-block{display:none!important}}@media only screen and (min-width:690px){:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(2)){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):last-child){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(3)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(2)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):last-child){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(4)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(3)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(2)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):last-child){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(5)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(4)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(3)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(2)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):last-child){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(6)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(5)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(4)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(3)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):nth-last-child(2)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(6):last-child){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical{grid-template-columns:auto 1fr auto}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:first-child{grid-column:1/2}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:last-child{grid-column:2/4}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:first-child{grid-column:1/3}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:last-child{grid-column:3/4}.stk-block .stk-block.aligncenter,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align:not(.alignwide):not(.alignfull),.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align:not(.alignwide):not(.alignfull){margin-left:auto;margin-right:auto;max-width:var(--stk-block-default-width,var(--stk-block-width-default-detected,900px));width:100%}.stk-block .stk-block.alignwide,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align.alignwide,.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align.alignwide{margin-left:auto;margin-right:auto;max-width:var(--stk-block-wide-widtLine truncated #start-resizable-editor-section{display:none}@media only screen and (min-width:1000px){:where(.has-text-align-left) .stk-block-divider{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}:where(.has-text-align-right) .stk-block-divider{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}:where(.has-text-align-center) .stk-block-divider{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-center{--stk-dots-margin-right:auto;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-right{--stk-dots-margin-right:0;--stk-dots-margin-left:auto}.stk-block.stk-block-divider.has-text-align-left{--stk-dots-margin-left:0;--stk-dots-margin-right:auto}.stk-block-feature>*>.stk-row{flex-wrap:var(--stk-feature-flex-wrap,nowrap)}.stk-row{flex-wrap:nowrap}.stk--hide-desktop,.stk--hide-desktop.stk-block{display:none!important}}@media only screen and (min-width:690px){:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(2)){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):last-child){flex:1 1 calc(50% - var(--stk-column-gap, 0px)*1/2)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(3)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(2)){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):last-child){flex:1 1 calc(33.33333% - var(--stk-column-gap, 0px)*2/3)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(4)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(3)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(2)){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):last-child){flex:1 1 calc(25% - var(--stk-column-gap, 0px)*3/4)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(5)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(4)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(3)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(2)){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):last-child){flex:1 1 calc(20% - var(--stk-column-gap, 0px)*4/5)!important}:where(body:not(.wp-admin) .stk-block-column:first-child:nth-last-child(6)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(2):nth-last-child(5)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(3):nth-last-child(4)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(4):nth-last-child(3)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(5):nth-last-child(2)){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}:where(body:not(.wp-admin) .stk-block-column:nth-child(6):last-child){flex:1 1 calc(16.66667% - var(--stk-column-gap, 0px)*5/6)!important}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical{grid-template-columns:auto 1fr auto}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:first-child{grid-column:1/2}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:last-child{grid-column:2/4}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-content:first-child{grid-column:1/3}.stk-block-tabs>.stk-inner-blocks.stk-block-tabs--vertical>.stk-block-tab-labels:last-child{grid-column:3/4}.stk-block .stk-block.aligncenter,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align:not(.alignwide):not(.alignfull),.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align:not(.alignwide):not(.alignfull){margin-left:auto;margin-right:auto;max-width:var(--stk-block-default-width,var(--stk-block-width-default-detected,900px));width:100%}.stk-block .stk-block.alignwide,.stk-block-carousel:is(.aligncenter,.alignwide,.alignfull)>:where(.stk-block-carousel__content-wrapper)>.stk-content-align.alignwide,.stk-block:is(.aligncenter,.alignwide,.alignfull)>.stk-content-align.alignwide{margin-left:auto;margin-right:auto;max-width:var(--stk-block-wide-widtLine truncated
/*# sourceURL=ugb-style-css-inline-css */ /*# sourceURL=ugb-style-css-inline-css */
</style> </style>
<style id="ugb-block-style-inheritance-nodep-inline-css"> <style id="ugb-block-style-inheritance-nodep-inline-css">
@@ -38,7 +47,7 @@
/*# sourceURL=ugb-style-css-nodep-inline-css */ /*# sourceURL=ugb-style-css-nodep-inline-css */
</style> </style>
<style id="wp-block-library-inline-css"> <style id="wp-block-library-inline-css">
:root{--wp-block-synced-color:#7a00df;--wp-block-synced-color--rgb:122,0,223;--wp-bound-block-color:var(--wp-block-synced-color);--wp-editor-canvas-background:#ddd;--wp-admin-theme-color:#007cba;--wp-admin-theme-color--rgb:0,124,186;--wp-admin-theme-color-darker-10:#006ba1;--wp-admin-theme-color-darker-10--rgb:0,107,160.5;--wp-admin-theme-color-darker-20:#005a87;--wp-admin-theme-color-darker-20--rgb:0,90,135;--wp-admin-border-width-focus:2px}@media (min-resolution:192dpi){:root{--wp-admin-border-width-focus:1.5px}}.wp-element-button{cursor:pointer}:root .has-very-light-gray-background-color{background-color:#eee}:root .has-very-dark-gray-background-color{background-color:#313131}:root .has-very-light-gray-color{color:#eee}:root .has-very-dark-gray-color{color:#313131}:root .has-vivid-green-cyan-to-vivid-cyan-blue-gradient-background{background:linear-gradient(135deg,#00d084,#0693e3)}:root .has-purple-crush-gradient-background{background:linear-gradient(135deg,#34e2e4,#4721fb 50%,#ab1dfe)}:root .has-hazy-dawn-gradient-background{background:linear-gradient(135deg,#faaca8,#dad0ec)}:root .has-subdued-olive-gradient-background{background:linear-gradient(135deg,#fafae1,#67a671)}:root .has-atomic-cream-gradient-background{background:linear-gradient(135deg,#fdd79a,#004a59)}:root .has-nightshade-gradient-background{background:linear-gradient(135deg,#330968,#31cdcf)}:root .has-midnight-gradient-background{background:linear-gradient(135deg,#020381,#2874fc)}:root{--wp--preset--font-size--normal:16px;--wp--preset--font-size--huge:42px}.has-regular-font-size{font-size:1em}.has-larger-font-size{font-size:2.625em}.has-normal-font-size{font-size:var(--wp--preset--font-size--normal)}.has-huge-font-size{font-size:var(--wp--preset--font-size--huge)}:root .has-text-align-center{text-align:center}:root .has-text-align-left{text-align:left}:root .has-text-align-right{text-align:right}.has-fit-text{white-space:nowrap!important}#end-resizable-editor-section{display:none}.aligncenter{clear:both}.items-justified-left{justify-content:flex-start}.items-justified-center{justify-content:center}.items-justified-right{justify-content:flex-end}.items-justified-space-between{justify-content:space-between}.screen-reader-text{word-wrap:normal!important;border:0;clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.screen-reader-text:focus{background-color:#ddd;clip-path:none;color:#444;display:block;font-size:1em;height:auto;left:5px;line-height:normal;padding:15px 23px 14px;text-decoration:none;top:5px;width:auto;z-index:100000}html :where(.has-border-color){border-style:solid}html :where([style*=border-color]){border-style:solid}html :where([style*=border-top-color]){border-top-style:solid}html :where([style*=border-right-color]){border-right-style:solid}html :where([style*=border-bottom-color]){border-bottom-style:solid}html :where([style*=border-left-color]){border-left-style:solid}html :where([style*=border-width]){border-style:solid}html :where([style*=border-top-width]){border-top-style:solid}html :where([style*=border-right-width]){border-right-style:solid}html :where([style*=border-bottom-width]){border-bottom-style:solid}html :where([style*=border-left-width]){border-left-style:solid}html :where(img[class*=wp-image-]){height:auto;max-width:100%}:where(figure){margin:0 0 1em}html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:var(--wp-admin--admin-bar--height,0px)}@media screen and (max-width:600px){html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:0px}} :root{--wp-block-synced-color:#7a00df;--wp-block-synced-color--rgb:122,0,223;--wp-bound-block-color:var(--wp-block-synced-color);--wp-editor-canvas-background:#ddd;--wp-admin-theme-color:#007cba;--wp-admin-theme-color--rgb:0,124,186;--wp-admin-theme-color-darker-10:#006ba1;--wp-admin-theme-color-darker-10--rgb:0,107,160.5;--wp-admin-theme-color-darker-20:#005a87;--wp-admin-theme-color-darker-20--rgb:0,90,135;--wp-admin-border-width-focus:2px}@media (min-resolution:192dpi){:root{--wp-admin-border-width-focus:1.5px}}.wp-element-button{cursor:pointer}:root .has-very-light-gray-background-color{background-color:#eee}:root .has-very-dark-gray-background-color{background-color:#313131}:root .has-very-light-gray-color{color:#eee}:root .has-very-dark-gray-color{color:#313131}:root .has-vivid-green-cyan-to-vivid-cyan-blue-gradient-background{background:linear-gradient(135deg,#00d084,#0693e3)}:root .has-purple-crush-gradient-background{background:linear-gradient(135deg,#34e2e4,#4721fb 50%,#ab1dfe)}:root .has-hazy-dawn-gradient-background{background:linear-gradient(135deg,#faaca8,#dad0ec)}:root .has-subdued-olive-gradient-background{background:linear-gradient(135deg,#fafae1,#67a671)}:root .has-atomic-cream-gradient-background{background:linear-gradient(135deg,#fdd79a,#004a59)}:root .has-nightshade-gradient-background{background:linear-gradient(135deg,#330968,#31cdcf)}:root .has-midnight-gradient-background{background:linear-gradient(135deg,#020381,#2874fc)}:root{--wp--preset--font-size--normal:16px;--wp--preset--font-size--huge:42px}.has-regular-font-size{font-size:1em}.has-larger-font-size{font-size:2.625em}.has-normal-font-size{font-size:var(--wp--preset--font-size--normal)}.has-huge-font-size{font-size:var(--wp--preset--font-size--huge)}:root .has-text-align-center{text-align:center}:root .has-text-align-left{text-align:left}:root .has-text-align-right{text-align:right}.has-fit-text{white-space:nowrap!important}#end-resizable-editor-section{display:none}.aligncenter{clear:both}.items-justified-left{justify-content:flex-start}.items-justified-center{justify-content:center}.items-justified-right{justify-content:flex-end}.items-justified-space-between{justify-content:space-between}.screen-reader-text{word-wrap:normal!important;border:0;clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;word-break:normal!important}.screen-reader-text:focus{background-color:#ddd;clip-path:none;color:#444;display:block;font-size:1em;height:auto;left:5px;line-height:normal;padding:15px 23px 14px;text-decoration:none;top:5px;width:auto;z-index:100000}html :where(.has-border-color){border-style:solid}html :where([style^=border-color],[style*=";border-color"],[style*="; border-color"]){border-style:solid}html :where([style^=border-top-color],[style*=";border-top-color"],[style*="; border-top-color"]){border-top-style:solid}html :where([style^=border-right-color],[style*=";border-right-color"],[style*="; border-right-color"]){border-right-style:solid}html :where([style^=border-bottom-color],[style*=";border-bottom-color"],[style*="; border-bottom-color"]){border-bottom-style:solid}html :where([style^=border-left-color],[style*=";border-left-color"],[style*="; border-left-color"]){border-left-style:solid}html :where([style^=border-width],[style*=";border-width"],[style*="; border-width"]){border-style:solid}html :where([style^=border-top-width],[style*=";border-top-width"],[style*="; border-top-width"]){border-top-style:solid}html :where([style^=border-right-width],[style*=";border-right-width"],[style*="; border-right-width"]){border-right-style:solid}html :where([style^=border-bottom-width],[style*=";border-bottom-width"],[style*="; border-bottom-width"]){border-bottom-style:solid}html :where([style^=border-left-width],[style*=";border-left-width"],[style*="; border-left-width"]){border-left-style:solid}html :where(img[class*=wp-image-]){height:auto;max-width:100%}:where(figure){margin:0 0 1em}html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:var(--wp-admin--admin-bar--height,0px)}@media screen and (max-width:600px){html :where(.is-position-sticky){--wp-admin--admin-bar--position-offset:0px}}
/*# sourceURL=/wp-includes/css/dist/block-library/common.min.css */ /*# sourceURL=/wp-includes/css/dist/block-library/common.min.css */
</style> </style>
@@ -61,21 +70,41 @@
<style id="global-styles-inline-css"> <style id="global-styles-inline-css">
:root{--wp--preset--aspect-ratio--square: 1;--wp--preset--aspect-ratio--4-3: 4/3;--wp--preset--aspect-ratio--3-4: 3/4;--wp--preset--aspect-ratio--3-2: 3/2;--wp--preset--aspect-ratio--2-3: 2/3;--wp--preset--aspect-ratio--16-9: 16/9;--wp--preset--aspect-ratio--9-16: 9/16;--wp--preset--color--black: #000000;--wp--preset--color--cyan-bluish-gray: #abb8c3;--wp--preset--color--white: #ffffff;--wp--preset--color--pale-pink: #f78da7;--wp--preset--color--vivid-red: #cf2e2e;--wp--preset--color--luminous-vivid-orange: #ff6900;--wp--preset--color--luminous-vivid-amber: #fcb900;--wp--preset--color--light-green-cyan: #7bdcb5;--wp--preset--color--vivid-green-cyan: #00d084;--wp--preset--color--pale-cyan-blue: #8ed1fc;--wp--preset--color--vivid-cyan-blue: #0693e3;--wp--preset--color--vivid-purple: #9b51e0;--wp--preset--color--palette-color-1: var(--theme-palette-color-1, #ff9201);--wp--preset--color--palette-color-2: var(--theme-palette-color-2, #ff9201);--wp--preset--color--palette-color-3: var(--theme-palette-color-3, #1a242b);--wp--preset--color--palette-color-4: var(--theme-palette-color-4, #334654);--wp--preset--color--palette-color-5: var(--theme-palette-color-5, #ffebd1);--wp--preset--color--palette-color-6: var(--theme-palette-color-6, #fff7ec);--wp--preset--color--palette-color-7: var(--theme-palette-color-7, #ffffff);--wp--preset--color--palette-color-8: var(--theme-palette-color-8, #ffffff);--wp--preset--gradient--vivid-cyan-blue-to-vivid-purple: linear-gradient(135deg,rgb(6,147,227) 0%,rgb(155,81,224) 100%);--wp--preset--gradient--light-green-cyan-to-vivid-green-cyan: linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%);--wp--preset--gradient--luminous-vivid-amber-to-luminous-vivid-orange: linear-gradient(135deg,rgb(252,185,0) 0%,rgb(255,105,0) 100%);--wp--preset--gradient--luminous-vivid-orange-to-vivid-red: linear-gradient(135deg,rgb(255,105,0) 0%,rgb(207,46,46) 100%);--wp--preset--gradient--very-light-gray-to-cyan-bluish-gray: linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%);--wp--preset--gradient--cool-to-warm-spectrum: linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%);--wp--preset--gradient--blush-light-purple: linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%);--wp--preset--gradient--blush-bordeaux: linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%);--wp--preset--gradient--luminous-dusk: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%);--wp--preset--gradient--pale-ocean: linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%);--wp--preset--gradient--electric-grass: linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%);--wp--preset--gradient--midnight: linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%);--wp--preset--gradient--juicy-peach: linear-gradient(to right, #ffecd2 0%, #fcb69f 100%);--wp--preset--gradient--young-passion: linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%);--wp--preset--gradient--true-sunset: linear-gradient(to right, #fa709a 0%, #fee140 100%);--wp--preset--gradient--morpheus-den: linear-gradient(to top, #30cfd0 0%, #330867 100%);--wp--preset--gradient--plum-plate: linear-gradient(135deg, #667eea 0%, #764ba2 100%);--wp--preset--gradient--aqua-splash: linear-gradient(15deg, #13547a 0%, #80d0c7 100%);--wp--preset--gradient--love-kiss: linear-gradient(to top, #ff0844 0%, #ffb199 100%);--wp--preset--gradient--new-retrowave: linear-gradient(to top, #3b41c5 0%, #a981bb 49%, #ffc8a9 100%);--wp--preset--gradient--plum-bath: linear-gradient(to top, #cc208e 0%, #6713d2 100%);--wp--preset--gradient--high-flight: linear-gradient(to right, #0acffe 0%, #495aff 100%);--wp--preset--gradient--teen-party: linear-gradient(135deg, #FF057C 0%, #8D0B93 50%, #321575 100%);--wp--preset--gradient--fabled-sunset: linear-gradient(135deg, #231557 0%, #44107A 29%, #FF1361 67%, #FFF800 100%);--wp--preset--gradient--arielle-smile: radial-gradient(circle 248px at center, #16d9e3 0%, #30c7ec 47%, #46aef7 100%);--wp--preset--gradient--itmeo-branding: linear-gradient(180deg, #2af598 0%, #009efd 100%);--wp--preset--gradient--deep-blue: linear-gradient(to right, #6a11cb 0%, #2575fc 100%);--wp--preset--gradient--strong-bliss: linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%);--wp--preset--gradient--sweet-period: linear-gradient(to top, #3f51b1 0%, #5a55ae 13%, #7b5fac 25%, #8f6aae 38%, #a86aa4 50%, #cc6b8e 62%, #f18271 75%, #f3a469 87%, #f7c978 100%);--wp--preset--gradient--purple-division: linear-gradient(to top, #7028e4 0%, #e5b2ca 100%);--wp--preset--gradient--cold-evening: linear-gradient(to top, #0c3483 0%, #a2b6df 100%, #6b8cce 100%, #a2b6df 100%);--wp--preset--gradient--mountain-rock: linear-gradient(to right, #868f96 0%, #596164 100%);--wp--preset--gradient--desert-hump: lineaLine truncated :root{--wp--preset--aspect-ratio--square: 1;--wp--preset--aspect-ratio--4-3: 4/3;--wp--preset--aspect-ratio--3-4: 3/4;--wp--preset--aspect-ratio--3-2: 3/2;--wp--preset--aspect-ratio--2-3: 2/3;--wp--preset--aspect-ratio--16-9: 16/9;--wp--preset--aspect-ratio--9-16: 9/16;--wp--preset--color--black: #000000;--wp--preset--color--cyan-bluish-gray: #abb8c3;--wp--preset--color--white: #ffffff;--wp--preset--color--pale-pink: #f78da7;--wp--preset--color--vivid-red: #cf2e2e;--wp--preset--color--luminous-vivid-orange: #ff6900;--wp--preset--color--luminous-vivid-amber: #fcb900;--wp--preset--color--light-green-cyan: #7bdcb5;--wp--preset--color--vivid-green-cyan: #00d084;--wp--preset--color--pale-cyan-blue: #8ed1fc;--wp--preset--color--vivid-cyan-blue: #0693e3;--wp--preset--color--vivid-purple: #9b51e0;--wp--preset--color--palette-color-1: var(--theme-palette-color-1, #ff9201);--wp--preset--color--palette-color-2: var(--theme-palette-color-2, #ff9201);--wp--preset--color--palette-color-3: var(--theme-palette-color-3, #1a242b);--wp--preset--color--palette-color-4: var(--theme-palette-color-4, #334654);--wp--preset--color--palette-color-5: var(--theme-palette-color-5, #ffebd1);--wp--preset--color--palette-color-6: var(--theme-palette-color-6, #fff7ec);--wp--preset--color--palette-color-7: var(--theme-palette-color-7, #ffffff);--wp--preset--color--palette-color-8: var(--theme-palette-color-8, #ffffff);--wp--preset--gradient--vivid-cyan-blue-to-vivid-purple: linear-gradient(135deg,rgb(6,147,227) 0%,rgb(155,81,224) 100%);--wp--preset--gradient--light-green-cyan-to-vivid-green-cyan: linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%);--wp--preset--gradient--luminous-vivid-amber-to-luminous-vivid-orange: linear-gradient(135deg,rgb(252,185,0) 0%,rgb(255,105,0) 100%);--wp--preset--gradient--luminous-vivid-orange-to-vivid-red: linear-gradient(135deg,rgb(255,105,0) 0%,rgb(207,46,46) 100%);--wp--preset--gradient--very-light-gray-to-cyan-bluish-gray: linear-gradient(135deg,rgb(238,238,238) 0%,rgb(169,184,195) 100%);--wp--preset--gradient--cool-to-warm-spectrum: linear-gradient(135deg,rgb(74,234,220) 0%,rgb(151,120,209) 20%,rgb(207,42,186) 40%,rgb(238,44,130) 60%,rgb(251,105,98) 80%,rgb(254,248,76) 100%);--wp--preset--gradient--blush-light-purple: linear-gradient(135deg,rgb(255,206,236) 0%,rgb(152,150,240) 100%);--wp--preset--gradient--blush-bordeaux: linear-gradient(135deg,rgb(254,205,165) 0%,rgb(254,45,45) 50%,rgb(107,0,62) 100%);--wp--preset--gradient--luminous-dusk: linear-gradient(135deg,rgb(255,203,112) 0%,rgb(199,81,192) 50%,rgb(65,88,208) 100%);--wp--preset--gradient--pale-ocean: linear-gradient(135deg,rgb(255,245,203) 0%,rgb(182,227,212) 50%,rgb(51,167,181) 100%);--wp--preset--gradient--electric-grass: linear-gradient(135deg,rgb(202,248,128) 0%,rgb(113,206,126) 100%);--wp--preset--gradient--midnight: linear-gradient(135deg,rgb(2,3,129) 0%,rgb(40,116,252) 100%);--wp--preset--gradient--juicy-peach: linear-gradient(to right, #ffecd2 0%, #fcb69f 100%);--wp--preset--gradient--young-passion: linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%);--wp--preset--gradient--true-sunset: linear-gradient(to right, #fa709a 0%, #fee140 100%);--wp--preset--gradient--morpheus-den: linear-gradient(to top, #30cfd0 0%, #330867 100%);--wp--preset--gradient--plum-plate: linear-gradient(135deg, #667eea 0%, #764ba2 100%);--wp--preset--gradient--aqua-splash: linear-gradient(15deg, #13547a 0%, #80d0c7 100%);--wp--preset--gradient--love-kiss: linear-gradient(to top, #ff0844 0%, #ffb199 100%);--wp--preset--gradient--new-retrowave: linear-gradient(to top, #3b41c5 0%, #a981bb 49%, #ffc8a9 100%);--wp--preset--gradient--plum-bath: linear-gradient(to top, #cc208e 0%, #6713d2 100%);--wp--preset--gradient--high-flight: linear-gradient(to right, #0acffe 0%, #495aff 100%);--wp--preset--gradient--teen-party: linear-gradient(135deg, #FF057C 0%, #8D0B93 50%, #321575 100%);--wp--preset--gradient--fabled-sunset: linear-gradient(135deg, #231557 0%, #44107A 29%, #FF1361 67%, #FFF800 100%);--wp--preset--gradient--arielle-smile: radial-gradient(circle 248px at center, #16d9e3 0%, #30c7ec 47%, #46aef7 100%);--wp--preset--gradient--itmeo-branding: linear-gradient(180deg, #2af598 0%, #009efd 100%);--wp--preset--gradient--deep-blue: linear-gradient(to right, #6a11cb 0%, #2575fc 100%);--wp--preset--gradient--strong-bliss: linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%);--wp--preset--gradient--sweet-period: linear-gradient(to top, #3f51b1 0%, #5a55ae 13%, #7b5fac 25%, #8f6aae 38%, #a86aa4 50%, #cc6b8e 62%, #f18271 75%, #f3a469 87%, #f7c978 100%);--wp--preset--gradient--purple-division: linear-gradient(to top, #7028e4 0%, #e5b2ca 100%);--wp--preset--gradient--cold-evening: linear-gradient(to top, #0c3483 0%, #a2b6df 100%, #6b8cce 100%, #a2b6df 100%);--wp--preset--gradient--mountain-rock: linear-gradient(to right, #868f96 0%, #596164 100%);--wp--preset--gradient--desert-hump: lineaLine truncated
/*# sourceURL=global-styles-inline-css */ /*# sourceURL=global-styles-inline-css */
</style> </style>
<link rel='stylesheet' id='ct-main-styles-css' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.min.css?ver=2.1.48' media='all' /> <link rel="stylesheet" id="ct-main-styles-css"
<link rel='stylesheet' id='ct-stackable-styles-css' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/stackable.min.css?ver=2.1.48' media='all' /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.min.css?ver=2.1.53"
<link rel="https://api.w.org/" href="https://ksk.bzh/wp-json/" /><link rel="alternate" title="JSON" type="application/json" href="https://ksk.bzh/wp-json/wp/v2/pages/1381" /><link rel="EditURI" type="application/rsd+xml" title="RSD" href="https://ksk.bzh/xmlrpc.php?rsd" /> media="all">
<meta name="generator" content="WordPress 7.0" /> <link rel="stylesheet" id="ct-page-title-styles-css"
<link rel="canonical" href="https://ksk.bzh/fr/chom-bev-approfondir/" /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/page-title.min.css?ver=2.1.53"
<link rel='shortlink' href='https://ksk.bzh/?p=1381' /> media="all">
<noscript><link rel='stylesheet' href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/no-scripts.min.css' type='text/css'></noscript> <link rel="stylesheet" id="ct-stackable-styles-css"
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-32x32.png" sizes="32x32" /> href="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/stackable.min.css?ver=2.1.53"
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-192x192.png" sizes="192x192" /> media="all">
<link rel="apple-touch-icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-180x180.png" /> <link rel="https://api.w.org/" href="https://ksk.bzh/wp-json/">
<meta name="msapplication-TileImage" content="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-270x270.png" /> <link rel="alternate" title="JSON" type="application/json"
href="https://ksk.bzh/wp-json/wp/v2/pages/1381">
<link rel="EditURI" type="application/rsd+xml" title="RSD"
href="https://ksk.bzh/xmlrpc.php?rsd">
<meta name="generator" content="WordPress 7.1.3">
<link rel="canonical" href="https://ksk.bzh/fr/donemat-approfondir/">
<link rel="shortlink" href="https://ksk.bzh/?p=1381">
<noscript>
<link rel='stylesheet'
href='https://ksk.bzh/wp-content/themes/blocksy/static/bundle/no-scripts.min.css'
type='text/css'>
</noscript>
<link rel="icon" href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-32x32.png"
sizes="32x32">
<link rel="icon"
href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-192x192.png"
sizes="192x192">
<link rel="apple-touch-icon"
href="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-180x180.png">
<meta name="msapplication-TileImage"
content="https://ksk.bzh/wp-content/uploads/2025/10/cropped-logo_round-270x270.png">
<style id="wp-custom-css"> <style id="wp-custom-css">
/* Fonts */ /* Fonts */
@font-face { @font-face {
@@ -142,122 +171,161 @@
</head> </head>
<body class="wp-singular page-template-default page page-id-1381 wp-custom-logo wp-embed-responsive wp-theme-blocksy stk--is-blocksy-theme stk-has-block-style-inheritance" data-link="type-2" data-prefix="single_page" itemscope="itemscope" itemtype="https://schema.org/WebPage"> <body class="wp-singular page-template-default page page-id-1381 wp-custom-logo wp-embed-responsive wp-theme-blocksy stk--is-blocksy-theme stk-has-block-style-inheritance"
data-link="type-2" data-prefix="single_page" itemscope="itemscope"
itemtype="https://schema.org/WebPage">
<a class="skip-link screen-reader-text" href="#main">Passer au contenu</a>
<div id="main-container"> <div id="main-container">
<main id="main" class="site-main hfeed" tabindex="-1"> <main id="main" class="site-main hfeed">
<div <div class="ct-container-full" data-content="narrow">
class="ct-container-full"
data-content="narrow" >
<article id="post-1381" class="post-1381 page type-page status-publish hentry">
<article
id="post-1381"
class="post-1381 page type-page status-publish hentry">
<div class="hero-section is-width-constrained" data-type="type-1">
<header class="entry-header">
<h1 class="page-title" itemprop="headline"><span aria-hidden="true">📚</span>
Approfondir</h1></header>
</div>
<div class="entry-content is-layout-constrained"> <div class="entry-content is-layout-constrained">
<div class="wp-block-stackable-heading stk-block-heading stk-block-heading--v2 stk-block stk-e165ec5" id="📚-approfondir" data-block-id="e165ec5"><h2 class="stk-block-heading__text">📚 Approfondir</h2></div> <pre class="wp-block-preformatted">Voici quelques propositions pour aller plus loin dans votre apprentissage du Breton, et particulièrement du Breton Vannetais en Pays d'Auray.<br><br>Pour trouver des ressources concernant la culture et la langue bretonnes sur le Pays d'Auray, suivez les publications de <a
href="https://www.tidouaralre.com/">Ti Douar Alre.</a></pre>
<h2 class="wp-block-heading has-large-font-size"><span aria-hidden="true">🧑🏼‍🏫</span>
<pre class="wp-block-preformatted">Voici quelques propositions pour aller plus loin dans votre apprentissage du Breton, et particulièrement du Breton Vannetais.<br><br>Pour trouver des ressources concernant la culture et la langue bretonnes sur le Pays d'Auray, suivez les publications de <a href="https://www.tidouaralre.com/">Ti Douar Alre.</a></pre> Cours hebdo.</h2>
<h3 class="wp-block-heading"
<h2 class="wp-block-heading has-large-font-size">🧑🏼‍🏫 Cours hebdo.</h2> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">Sur
le Pays d’Auray</h3>
<p class="wp-block-paragraph"><a href="https://ksk.bzh" target="_blank"
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">Sur le Pays d&rsquo;Auray</h3> rel="noreferrer noopener">Kerlenn Sten Kidna
(KSK)</a> propose des cours hebdomadaires : en journée, mais aussi en soirée
; en présentiel, mais aussi une ligne. Différents niveaux sont proposés, de
quoi permettre à chacun·e de trouver un créneau qui lui convienne.</p>
<h3 class="wp-block-heading"
<p class="wp-block-paragraph"><a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a> propose des cours hebdomadaires : en journée, mais aussi en soirée ; en présentiel, mais aussi une ligne. Différents niveaux sont proposés, de quoi permettre à chacun·e de trouver un créneau qui lui convienne.</p> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
Locoal-Mendon, Plouhinec</h3>
<p class="wp-block-paragraph">L’association <a
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">Locoal-Mendon, Plouhinec</h3> href="https://www.tidouaralre.com/ar-vammenn-member-15.html">Ar
Vammenn</a> propose des cours du soir pour les débutant·e·s à Locoal-Mendon.
</p>
<h3 class="wp-block-heading"
<p class="wp-block-paragraph">L&rsquo;association <a href="https://www.tidouaralre.com/ar-vammenn-member-15.html">Ar Vammenn</a> propose des cours du soir pour les débutant·e·s à Locoal-Mendon.</p> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">À
proximité</h3>
<p class="wp-block-paragraph">On trouve, ailleurs dans le pays vannetais,
<h2 class="wp-block-heading has-large-font-size">🍻 Les rencontres</h2> d’autres structures proposant des cours, particulièrement dans les régions
de Lorient, Port-Louis, Pontivy, Vannes …</p>
<h2 class="wp-block-heading has-large-font-size"><span
<p class="wp-block-paragraph"><a href="https://bev.bzh">bev.bzh</a> permet aux personnes souhaitant parler Breton entre elles de se retrouver, quelque soit le lieu. Une carte vous permettra de voir les animations et rencontres autour de chez vous, et de proposer vos propres activités.</p> aria-hidden="true">🍻</span> Les rencontres</h2>
<p class="wp-block-paragraph"><a href="https://bev.bzh">bev.bzh</a> permet aux
<h2 class="wp-block-heading has-large-font-size">💻 L&rsquo;apprentissage en ligne</h2> personnes souhaitant parler Breton entre elles de se retrouver, quelque soit
le lieu. Une carte vous permettra de voir les animations et rencontres
autour de chez vous, et de proposer vos propres activités.</p>
<h2 class="wp-block-heading has-large-font-size"><span
<p class="wp-block-paragraph"><a href="https://desketa.bzh">Desketa.bzh</a> est une application en ligne, proposée gratuitement par l&rsquo;<a href="https://www.fr.brezhoneg.bzh/">Office Public de la Langue Bretonne</a> afin d&rsquo;apprendre et perfectionner le Breton unifié (KLT). Si vous souhaitez apprendre le Breton du Pays d&rsquo;Auray, cette ressource pourra vous être d&rsquo;une bonne aide pour appréhender la construction des phrases, enrichir votre vocabulaire et pouvoir interagir facilement avec d&rsquo;autres bretonnant·e·s qui ne sont pas familiers avec le Vannetais. L&rsquo;écoute et les échanges en Vannetais vous permettront de compléter vos acquis et de pratiquer pleinement le Vannetais.</p> aria-hidden="true">💻</span> L’apprentissage en ligne</h2>
<p class="wp-block-paragraph"><a href="https://desketa.bzh">Desketa.bzh</a> est
une application en ligne, proposée gratuitement par l’<a
href="https://www.fr.brezhoneg.bzh/">Office Public de la Langue
Bretonne</a> afin d’apprendre et perfectionner le Breton unifié (KLT).
Si vous souhaitez apprendre le Breton du Pays d’Auray, cette ressource
pourra vous être d’une bonne aide pour appréhender la construction des
phrases, enrichir votre vocabulaire et pouvoir interagir facilement avec
d’autres bretonnant·e·s qui ne sont pas familiers avec le Vannetais.
L’écoute et les échanges en Vannetais vous permettront de compléter vos
acquis et de pratiquer pleinement le Vannetais.</p>
<h2 class="wp-block-heading has-large-font-size">L&rsquo;écoute</h2>
<h2 class="wp-block-heading has-large-font-size">L’écoute</h2>
<div class="wp-block-group is-nowrap is-layout-flex wp-container-core-group-is-layout-995f960e wp-block-group-is-layout-flex"> <div class="wp-block-group is-nowrap is-layout-flex wp-container-core-group-is-layout-995f960e wp-block-group-is-layout-flex">
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">📻 Radio Bro Gwened</h3> <h3 class="wp-block-heading"
style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
<span aria-hidden="true">📻</span> Radio Bro Gwened</h3>
</div> </div>
<p class="wp-block-paragraph">Sur le Pays de Vannes (<em>Bro-Gwened</em>), vous
<p class="wp-block-paragraph">Sur le Pays de Vannes (<em>Bro-Gwened</em>), vous avez certainement déjà croisé les ondes de Radio Bro Gwened (<a href="https://rbg.bzh">RBG.bzh</a>). Si ce n&rsquo;est pas le cas, allez vite faire un tour sur la radio en ligne. Vous y trouverez une écoute en direct et en rediffusion d&rsquo;émissions en Breton et en français, ainsi qu&rsquo;un bouillon de culture bretonne. Pour le Vannetais, à vous de creuser pour trouver les bonnes émissions.<br></p> avez certainement déjà croisé les ondes de Radio Bro Gwened (<a
href="https://rbg.bzh">RBG.bzh</a>). Si ce n’est pas le cas, allez
vite faire un tour sur la radio en ligne. Vous y trouverez une écoute en
direct et en rediffusion d’émissions en Breton et en français, ainsi qu’un
bouillon de culture bretonne. Pour le Vannetais, il faut mentionner
l’incontournable Kreiz Mintin à podcaster, ou les rubriques de Loeiz Beleg
(War hentoù an antroposen), mais à vous de creuser pour trouver d’autres
pépites.<br></p>
<h3 class="wp-block-heading"
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">🎧​ Kleweled</h3> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
<span aria-hidden="true">🎧​</span> Kleweled</h3>
<p class="wp-block-paragraph"><a href="https://kleweled.bzh">Kleweled.bzh</a>
<p class="wp-block-paragraph"><a href="https://kleweled.bzh">Kleweled.bzh</a> est un portail qui référence un nombre important de ressources audiovisuelles en langue bretonne. Elle regroupe des podcasts des différentes radios bretonnantes, des films, des séries, des émissions, des documentaires, des dessins animés &#8230; et plus on creuse, plus on trouve de contenus !</p> est un portail qui référence un nombre important de ressources
audiovisuelles en langue bretonne. Elle regroupe des podcasts des
différentes radios bretonnantes, des films, des séries, des émissions, des
documentaires, des dessins animés … et plus on fouille, plus on trouve de
contenus !</p>
<h3 class="wp-block-heading"
<h3 class="wp-block-heading" style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">🎬 La chaîne Youtube KSK<sup> </sup></h3> style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
<span aria-hidden="true">🎬</span> La chaîne Youtube KSK<sup> </sup></h3>
<p class="wp-block-paragraph"><a href="https://www.youtube.com/@kskanalre6535">La
<p class="wp-block-paragraph"><a href="https://www.youtube.com/@kskanalre6535">La chaîne Youtube de Kerlenn Sten Kidna</a> diffuse un nombre important de collectage vidéos essentiellement réalisés sur le Pays d&rsquo;Auray. Vous y trouverez plus de 250 vidéos !</p> chaîne Youtube de Kerlenn Sten Kidna</a> diffuse un nombre important de
collectage vidéos essentiellement réalisés sur le Pays d’Auray. Vous y
trouverez plus de 250 vidéos !</p>
<h2 class="wp-block-heading has-large-font-size"><span
<h2 class="wp-block-heading has-large-font-size">👋🏻 Votre voisin·e</h2> aria-hidden="true">👋🏻</span> Votre voisin·e</h2>
<p class="wp-block-paragraph">Des fois, il ne faut pas aller bien loin ! Oser un
<p class="wp-block-paragraph">Des fois, il ne faut pas aller bien loin ! Un « Demat » à l&rsquo;occasion de croiser une tête que l&rsquo;on connait crée parfois de belles surprises. Et quoi de mieux que de pratiquer le breton dans son quotidien pour progresser. Les meilleurs collectages de breton que l&rsquo;on puisse entendre sont ceux que l&rsquo;on vit !</p> «&nbsp;Mont a ra mat&nbsp;?&nbsp;» lorsqu’on a l’occasion de croiser une
tête que l’on connaît crée parfois de belles surprises. Et quoi de mieux que
de pratiquer le breton dans son quotidien pour progresser. Les meilleurs
collectages de breton que l’on puisse entendre sont ceux que l’on vit !</p>
<div class="wp-block-stackable-spacer stk-block-spacer stk--no-padding stk-block stk-ef24a69"
<div class="wp-block-stackable-spacer stk-block-spacer stk--no-padding stk-block stk-ef24a69" data-block-id="ef24a69"></div> data-block-id="ef24a69"></div>
</div> </div>
</article> </article>
</div> </div>
</main> </main>
@@ -268,10 +336,12 @@
{"prefetch":[{"source":"document","where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":["/wp-*.php","/wp-admin/*","/wp-content/uploads/*","/wp-content/*","/wp-content/plugins/*","/wp-content/themes/blocksy/*","/*\\?(.+)"]}},{"not":{"selector_matches":"a[rel~=\"nofollow\"]"}},{"not":{"selector_matches":".no-prefetch, .no-prefetch a"}}]},"eagerness":"conservative"}]} {"prefetch":[{"source":"document","where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":["/wp-*.php","/wp-admin/*","/wp-content/uploads/*","/wp-content/*","/wp-content/plugins/*","/wp-content/themes/blocksy/*","/*\\?(.+)"]}},{"not":{"selector_matches":"a[rel~=\"nofollow\"]"}},{"not":{"selector_matches":".no-prefetch, .no-prefetch a"}}]},"eagerness":"conservative"}]}
</script> </script>
<script id="ct-scripts-js-extra"> <script id="ct-scripts-js-extra">
var ct_localizations = {"ajax_url":"https://ksk.bzh/wp-admin/admin-ajax.php","public_url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/","rest_url":"https://ksk.bzh/wp-json/","search_url":"https://ksk.bzh/fr/search/QUERY_STRING/","show_more_text":"Afficher plus","more_text":"Plus","search_live_results":"R\u00e9sultats de recherche","search_live_no_results":"Aucun r\u00e9sultat","search_live_results_closed":"R\u00e9sultats de recherche ferm\u00e9s.","search_live_no_result":"Aucun r\u00e9sultat","search_live_one_result":"Vous avez %s r\u00e9sultat. Veuillez appuyer sur Tab pour le s\u00e9lectionner.","search_live_many_results":"Vous avez %s r\u00e9sultats. Veuillez appuyer sur Tab pour en s\u00e9lectionner un.","search_live_stock_status_texts":{"instock":"En stock","outofstock":"En rupture de stock"},"clipboard_copied":"Copi\u00e9\u00a0!","clipboard_failed":"\u00c9chec de la copie","expand_submenu":"D\u00e9plier le menu d\u00e9roulant","collapse_submenu":"Replier le menu d\u00e9roulant","dynamic_js_chunks":[{"id":"blocksy_sticky_header","selector":"header [data-sticky]","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/sticky.js?ver=2.1.48","version":"2.1.48"}],"dynamic_styles":{"lazy_load":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-styles.min.css?ver=2.1.48","search_lazy":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-search-styles.min.css?ver=2.1.48","back_to_top":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/back-to-top.min.css?ver=2.1.48"},"dynamic_styles_selectors":[{"selector":".ct-header-cart, #woo-cart-panel","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/cart-header-element-lazy.min.css?ver=2.1.48"},{"selector":".flexy","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/flexy.min.css?ver=2.1.48"},{"selector":".ct-pagination","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/pagination.min.css?ver=2.1.48"},{"selector":"#account-modal","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-modal-lazy.min.css?ver=2.1.48"},{"selector":".ct-header-account","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-dropdown-lazy.min.css?ver=2.1.48"}],"login_generic_error_msg":"Une erreur inattendue s\u2019est produite. Veuillez r\u00e9essayer ult\u00e9rieurement.","lang":"fr"}; var ct_localizations = {"ajax_url":"https://ksk.bzh/wp-admin/admin-ajax.php","public_url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/","rest_url":"https://ksk.bzh/wp-json/","search_url":"https://ksk.bzh/fr/search/QUERY_STRING/","show_more_text":"Afficher plus","more_text":"Plus","search_live_results":"R\u00e9sultats de recherche","search_live_no_results":"Aucun r\u00e9sultat","search_live_results_closed":"R\u00e9sultats de recherche ferm\u00e9s.","search_live_no_result":"Aucun r\u00e9sultat","search_live_one_result":"Vous avez %s r\u00e9sultat. Veuillez appuyer sur Tab pour le s\u00e9lectionner.","search_live_many_results":"Vous avez %s r\u00e9sultats. Veuillez appuyer sur Tab pour en s\u00e9lectionner un.","search_live_stock_status_texts":{"instock":"En stock","outofstock":"En rupture de stock"},"clipboard_copied":"Copi\u00e9\u00a0!","clipboard_failed":"\u00c9chec de la copie","expand_submenu":"D\u00e9plier le menu d\u00e9roulant","collapse_submenu":"Replier le menu d\u00e9roulant","dynamic_js_chunks":[{"id":"blocksy_sticky_header","selector":"header [data-sticky]","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/sticky.js?ver=2.1.59","version":"2.1.59"}],"dynamic_styles":{"lazy_load":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-styles.min.css?ver=2.1.53","search_lazy":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/non-critical-search-styles.min.css?ver=2.1.53","back_to_top":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/back-to-top.min.css?ver=2.1.53"},"dynamic_styles_selectors":[{"selector":".ct-header-cart, #woo-cart-panel","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/cart-header-element-lazy.min.css?ver=2.1.53"},{"selector":".flexy","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/flexy.min.css?ver=2.1.53"},{"selector":".ct-pagination","url":"https://ksk.bzh/wp-content/themes/blocksy/static/bundle/pagination.min.css?ver=2.1.53"},{"selector":"#account-modal","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-modal-lazy.min.css?ver=2.1.59"},{"selector":".ct-header-account","url":"https://ksk.bzh/wp-content/plugins/blocksy-companion/static/bundle/header-account-dropdown-lazy.min.css?ver=2.1.59"}],"login_generic_error_msg":"Une erreur inattendue s\u2019est produite. Veuillez r\u00e9essayer ult\u00e9rieurement.","lang":"fr"};
//# sourceURL=ct-scripts-js-extra //# sourceURL=ct-scripts-js-extra
</script> </script>
<script id="ct-scripts-js" src="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.js?ver=2.1.48"></script> <script id="ct-scripts-js"
src="https://ksk.bzh/wp-content/themes/blocksy/static/bundle/main.js?ver=2.1.53"></script>
</body> </body>
</html> </html>
@@ -1,35 +0,0 @@
<resources>
<!-- Breton (br) -->
<string name="app_name">Chom bev</string>
<string name="nav_records">Dastumoù</string>
<string name="nav_deepen">Donaat</string>
<string name="nav_about">Diwar-benn</string>
<string name="web_unavailable_message">N\'haller ket diskouez ar bajenn-mañ amañ.</string>
<string name="web_open_in_browser">Digeriñ er merdeer</string>
<string name="subtitle_preferences_title">Istitloù</string>
<string name="subtitle_preferences_action">Gwellvezioù istitloù</string>
<string name="subtitle_transcription_label">Treuzskrivañ (BZH)</string>
<string name="subtitle_transcription_description">Diskouez an treuzskrivadur brezhonek</string>
<string name="subtitle_translation_label">Troidigezh (FR)</string>
<string name="subtitle_translation_description">Diskouez an droidigezh c\'hallek</string>
<string name="subtitle_text_size_label">Ment an destenn</string>
<string name="subtitle_text_size_decrease">Bihanaat ment an destenn</string>
<string name="subtitle_text_size_increase">Brasaat ment an destenn</string>
<string name="subtitle_marked_letters_label">Merkoù distagañ</string>
<string name="subtitle_marked_letters_description">Diskouez ar c\'hensonennoù mut ha al liammoù</string>
<string name="playback_speed_title">Tizh al lenn</string>
<string name="playback_settings_action">Arventennoù al lenn</string>
<string name="break_between_phrases_title">Ehan etre ar frazennoù</string>
<string name="break_between_phrases_value">%1$d s</string>
<string name="player_back">Distreiñ</string>
<string name="player_previous_sentence">Frazenn a-raok</string>
<string name="player_next_sentence">Frazenn war-lerc\'h</string>
<string name="player_play">Lenn</string>
<string name="player_pause">Ehan</string>
<string name="player_replay">Adlenn</string>
</resources>
@@ -1,34 +1,65 @@
<resources> <resources>
<!-- Default locale: French --> <!-- Default locale: French -->
<string name="app_name">Chom bev</string> <string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Enregistrements</string> <string name="nav_records">Écouter</string>
<string name="nav_deepen">Approfondir</string> <string name="nav_deepen">Approfondir</string>
<string name="nav_about">À propos</string> <string name="nav_about">À propos</string>
<string name="web_unavailable_message">Impossible d\'afficher cette page ici.</string> <string name="web_unavailable_message">Impossible d'afficher cette page ici.</string>
<string name="web_open_in_browser">Ouvrir dans le navigateur</string> <string name="web_open_in_browser">Ouvrir dans le navigateur</string>
<string name="subtitle_preferences_title">Sous-titres</string> <string name="subtitle_preferences_title">Sous-titres</string>
<string name="subtitle_preferences_action">Préférences de sous-titrage</string> <string name="subtitle_preferences_action">Préférences de sous-titrage</string>
<string name="settings_open_action_description">accéder aux paramètres</string>
<string name="subtitle_transcription_label">Transcription (BZH)</string> <string name="subtitle_transcription_label">Transcription (BZH)</string>
<string name="subtitle_transcription_description">Afficher la transcription bretonne</string> <string name="subtitle_transcription_description">Afficher la transcription bretonne</string>
<string name="subtitle_translation_label">Traduction (FR)</string> <string name="subtitle_translation_label">Traduction (FR)</string>
<string name="subtitle_translation_description">Afficher la traduction française</string> <string name="subtitle_translation_description">Afficher la traduction française</string>
<string name="subtitle_text_size_label">Taille du texte</string> <string name="subtitle_text_size_label">Taille du texte</string>
<string name="subtitle_text_size_value">%1$d %</string>
<string name="subtitle_text_size_decrease">Réduire la taille du texte</string> <string name="subtitle_text_size_decrease">Réduire la taille du texte</string>
<string name="subtitle_text_size_increase">Augmenter la taille du texte</string> <string name="subtitle_text_size_increase">Augmenter la taille du texte</string>
<string name="subtitle_marked_letters_label">Marques de prononciation</string> <string name="subtitle_marked_letters_label">Marques de prononciation</string>
<string name="subtitle_marked_letters_description">Afficher les consonnes muettes et les liaisons</string> <string name="subtitle_marked_letters_description">Afficher les consonnes muettes et les liaisons</string>
<string name="playback_speed_title">Vitesse de lecture</string> <string name="playback_speed_title">Vitesse de lecture</string>
<string name="playback_speed_value">%1$d %</string>
<string name="playback_settings_action">Réglages de lecture</string> <string name="playback_settings_action">Réglages de lecture</string>
<string name="break_between_phrases_title">Pause entre les phrases</string> <string name="break_between_phrases_title">Pause entre les phrases</string>
<string name="break_between_phrases_value">%1$d s</string> <string name="break_between_phrases_value">%1$ds</string>
<plurals name="break_between_phrases_value_description">
<item quantity="one">%1$d seconde</item>
<item quantity="other">%1$d secondes</item>
</plurals>
<string name="auto_pause_title">Pause automatique</string>
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
<string name="onboarding_goal_title">Bienvenue dans\n Donemat - Breton d'Auray</string>
<string name="onboarding_goal_description">Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais.</string>
<string name="onboarding_subtitles_title">Des sous-titres à votre rythme</string>
<string name="onboarding_subtitles_description">Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation.</string>
<string name="onboarding_playback_title">Un rythme adapté</string>
<string name="onboarding_playback_description">Ralentissez la vitesse de lecture et ajoutez une pause entre les phrases pour vous laisser le temps de répéter.</string>
<string name="onboarding_level_title">Choisissez votre niveau</string>
<string name="onboarding_level_description">Vous pourrez modifier ces réglages à tout moment depuis le lecteur.</string>
<string name="onboarding_level_beginner_title">Je suis débutant·e</string>
<string name="onboarding_level_beginner_description">Lecture ralentie (x0.9) avec une pause de 3 secondes entre les phrases</string>
<string name="onboarding_level_advanced_title">Je parle un peu, beaucoup ...</string>
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
<string name="onboarding_next">Suivant</string>
<string name="onboarding_start">Commencer</string>
<string name="onboarding_close">Fermer</string>
<string name="records_chapter_action_description">écouter</string>
<string name="records_chapter_number">Chapitre %1$d</string>
<string name="player_back">Retour</string> <string name="player_back">Retour</string>
<string name="player_back_action_label">revenir à la liste des contenus</string>
<string name="player_previous_sentence">Phrase précédente</string> <string name="player_previous_sentence">Phrase précédente</string>
<string name="player_next_sentence">Phrase suivante</string> <string name="player_next_sentence">Phrase suivante</string>
<string name="player_previous_sentence_action_label">passer à la phrase précédente</string>
<string name="player_next_sentence_action_label">passer à la phrase suivante</string>
<string name="player_play">Lire</string> <string name="player_play">Lire</string>
<string name="player_pause">Pause</string> <string name="player_pause">Pause</string>
<string name="player_replay">Rejouer</string> <string name="player_replay">Rejouer</string>
@@ -7,7 +7,7 @@ fun main() {
application { application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Chom Bev", title = "Donemat - Breton d'Auray",
) { ) {
App() App()
} }
+164
View File
@@ -0,0 +1,164 @@
# Crash reporting — Sentry SDK on GlitchTip
"Donemat - Breton d'Auray" reports crashes and handled errors with the
[Sentry Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/),
but sends them to [GlitchTip](https://glitchtip.com) instead of Sentry. GlitchTip is an
open-source, Sentry-API-compatible backend, so the Sentry SDKs and `glitchtip-cli` work against
it unchanged — only the DSN (and the CLI's `url`) point somewhere else.
Our instance is the hosted EU one: <https://eu.glitchtip.com>.
| | |
|-----------------------|------------------------------------------------------------------------------------------------------|
| SDK | `io.sentry.kotlin.multiplatform` (see `sentry` in `gradle/libs.versions.toml`) |
| Gradle plugin | `io.sentry.kotlin.multiplatform.gradle`, applied in `shared/build.gradle.kts` |
| iOS native dependency | `sentry-cocoa` via Swift Package Manager, declared in the Xcode project |
| Initialisation | `initializeSentry()` in `shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt` |
| Called from | `ChomBevApp.onCreate()` (Android), `AppDelegate.application(_:didFinishLaunchingWithOptions:)` (iOS) |
There are two distinct pieces of configuration, and they are easy to confuse:
1. **The DSN** — where the *app* sends events at runtime. Injected at build time from
`local.properties`. See [Runtime configuration](#1-runtime-configuration--the-dsn).
2. **The auth token** — how *your machine or CI* uploads debug symbols to GlitchTip.
Stored in `.sentryclirc`. See [CLI configuration](#2-cli-configuration--sentryclirc).
Neither is committed.
---
## 1. Runtime configuration — the DSN
The DSN is a write-only endpoint URL identifying the GlitchTip project. It is kept out of
version control by the `gwenedeg.secrets` convention plugin
(`build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts`), which reads it at build
time and generates an internal `BuildSecrets` object into `commonMain`.
### Setup
Add the DSN to `local.properties` at the repository root (this file is git-ignored):
```properties
glitchtip.dsn=https://<key>@eu.glitchtip.com/<project-id>
```
Find the value in GlitchTip under **Settings → Projects → _Donemat_ → Client Keys (DSN)**.
On CI, set the `GLITCHTIP_DSN` environment variable instead — it takes precedence over
`local.properties`.
### Behaviour when it is missing
`initializeSentry()` returns early when the DSN is blank, so a fresh clone builds and runs
with crash reporting simply disabled. No placeholder or dummy DSN is needed.
### Adding other secrets
Extend the `secret(environmentVariable, localPropertyKey)` helper in the convention plugin
rather than hardcoding values in Kotlin sources.
---
## 2. CLI configuration — `.sentryclirc`
`sentry-cli` needs an auth token to upload debug symbols. It reads its settings from a
`.sentryclirc` INI file in the current working directory (and from `~/.sentryclirc`),
or from environment variables.
> **`.sentryclirc` contains a credential and must never be committed.**
> It is listed in `.gitignore`. Only `.sentryclirc.example` is versioned.
### Setup
```bash
cp .sentryclirc.example .sentryclirc
chmod 600 .sentryclirc
```
Then edit `.sentryclirc` and replace the token placeholder:
```ini
[auth]
token=<your-glitchtip-auth-token>
[defaults]
url=https://eu.glitchtip.com/
org=kerlenn-sten-kidna
project=chom-bev-e-brezhoneg
```
- `url` is mandatory: without it `glitchtip-cli` talks to sentry.io, not GlitchTip.
- `org` and `project` are the *slugs* shown in the GlitchTip URLs, not the display names.
### Creating the token
In GlitchTip: **user menu → Profile → Auth Tokens → Create New Token**. Grant it at least
`project:read`, `project:write` and `project:releases`. Copy the token immediately — it is
shown only once.
### On CI
Do not write the file. Export the equivalent environment variables, sourced from your CI
secret store:
```bash
export SENTRY_URL=https://eu.glitchtip.com/
export SENTRY_ORG=kerlenn-sten-kidna
export SENTRY_PROJECT=chom-bev-e-brezhoneg
export SENTRY_AUTH_TOKEN=***
```
### Installing glitchtip-cli
```bash
curl -fsSL https://glitchtip.com/install.sh | sh
```
Check that the configuration resolves:
```bash
glitchtip-cli info
```
It should print the GlitchTip URL and report the token as valid.
---
## 3. iOS Archive — uploading dSYM files
### Why this is needed
A release iOS binary ships without symbol names. Without the matching dSYM, GlitchTip shows
crash frames as raw memory addresses. Uploading the dSYM lets it resolve them back to
function names and line numbers.
The shared Kotlin framework is linked **statically** (`isStatic = true` in
`shared/build.gradle.kts`), so Kotlin/Native frames end up in the app's own dSYM — there is
no separate framework dSYM to upload.
### Prerequisites
You need `glitchtip-cli` installed and `.sentryclirc` filled in ([section 2](#2-cli-configuration--sentryclirc)).
### Upload after an archive
1. **Product → Archive** in Xcode.
2. In the Organizer, right-click the archive → **Show in Finder**.
3. Right-click the `.xcarchive` → **Show Package Contents**.
4. Upload the `dSYMs` folder:
```bash
cd /path/to/Gwenedeg
glitchtip-cli debug-files upload /path/to/Gwenedeg.xcarchive/dSYMs
```
Add `--include-sources` if you also want source context attached to native frames.
## 4. References
- [Sentry — Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/)
- [Sentry — Uploading debug symbols for Apple platforms](https://docs.sentry.io/platforms/apple/guides/ios/dsym/)
- [Sentry — data collected by the SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/data-management/data-collected/)
- [GlitchTip documentation](https://glitchtip.com/documentation)
- [GlitchTip CLI documentation](https://glitchtip.com/documentation/cli)
- [Kotlin — symbolicating iOS crash reports](https://kotlinlang.org/docs/native-ios-symbolication.html)
+47
View File
@@ -0,0 +1,47 @@
plugins {
id("gwenedeg.kmp.library")
id("gwenedeg.compose")
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.onboarding"
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
}
commonMain.dependencies {
// Core modules
implementation(projects.core.accessibility)
// Data modules
implementation(projects.data.preferences)
implementation(projects.data.resources)
// UI - Compose
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.components.resources)
// Lifecycle
implementation(libs.lifecycle.viewmodelCompose)
implementation(libs.lifecycle.runtimeCompose)
// DI
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose.viewmodel)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.onboarding.di
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.preferences.di.preferencesModule
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
/**
* DI for the onboarding feature: the first-launch introduction flow.
*/
val onboardingFeatureModule: Module = module {
includes(preferencesModule)
viewModelOf(::OnboardingViewModel)
}
@@ -0,0 +1,26 @@
package bzh.ajaury.chombev.onboarding.model
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Starting configuration proposed at the end of onboarding. Each level maps to the playback
* preferences applied when the user confirms it.
*/
enum class OnboardingLevel(
val playbackSpeed: Float,
val breakBetweenPhrases: Duration,
) {
/** Slowed-down playback with a generous break, to give beginners time to repeat. */
BEGINNER(
playbackSpeed = 0.9f,
breakBetweenPhrases = 3.seconds,
),
/** The app defaults: normal speed, no break between phrases. */
ADVANCED(
playbackSpeed = PlaybackPreferences.DEFAULT_SPEED,
breakBetweenPhrases = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
),
}
@@ -0,0 +1,246 @@
package bzh.ajaury.chombev.onboarding.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.core.accessibility.ui.ProvideDynamicTypeFontScale
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingLevelPage
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingPageIndicator
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingUiState
import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_close
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_next
import bzh.ajaury.chombev.resources.generated.resources.onboarding_playback_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_playback_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_start
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_subtitles_title
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
private const val PAGE_COUNT = 4
private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1
private val DIALOG_MAX_WIDTH = 560.dp
private val DIALOG_MAX_HEIGHT = 680.dp
private const val DIALOG_WINDOW_FRACTION = 0.9f
@Composable
fun OnboardingScreen(
showAsDialog: Boolean = false,
viewModel: OnboardingViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
OnboardingScreen(
uiState = uiState,
showAsDialog = showAsDialog,
onLevelSelected = viewModel::selectLevel,
onFinish = viewModel::completeOnboarding,
onDismiss = viewModel::skipOnboarding,
)
}
@Composable
fun OnboardingScreen(
uiState: OnboardingUiState,
showAsDialog: Boolean = false,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
if (showAsDialog) {
Dialog(
onDismissRequest = onDismiss,
// The content sizes itself against the window rather than the platform default width.
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
ProvideDynamicTypeFontScale {
Surface(
modifier = Modifier
.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
.fillMaxSize(fraction = DIALOG_WINDOW_FRACTION),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = uiState,
onLevelSelected = onLevelSelected,
onFinish = onFinish,
onClose = onDismiss,
)
}
}
}
} else {
Scaffold { innerPadding ->
OnboardingContent(
uiState = uiState,
modifier = Modifier.padding(innerPadding),
onLevelSelected = onLevelSelected,
onFinish = onFinish,
)
}
}
}
@Composable
private fun OnboardingContent(
uiState: OnboardingUiState,
modifier: Modifier = Modifier,
onLevelSelected: (OnboardingLevel) -> Unit = {},
onFinish: () -> Unit = {},
onClose: (() -> Unit)? = null,
) {
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
val scope = rememberCoroutineScope()
val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (onClose != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(Res.string.onboarding_close),
)
}
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) { page ->
when (page) {
0 -> OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = stringResource(Res.string.onboarding_goal_title),
description = stringResource(Res.string.onboarding_goal_description),
)
1 -> OnboardingInfoPage(
icon = Icons.Filled.Subtitles,
title = stringResource(Res.string.onboarding_subtitles_title),
description = stringResource(Res.string.onboarding_subtitles_description),
)
2 -> OnboardingInfoPage(
icon = Icons.Filled.Speed,
title = stringResource(Res.string.onboarding_playback_title),
description = stringResource(Res.string.onboarding_playback_description),
)
else -> OnboardingLevelPage(
selectedLevel = uiState.selectedLevel,
onLevelSelected = onLevelSelected,
)
}
}
OnboardingPageIndicator(
pageCount = PAGE_COUNT,
currentPage = pagerState.currentPage,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
if (isLastPage) {
onFinish()
} else {
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
},
) {
Text(
text = stringResource(
if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next,
),
)
}
}
}
@Preview
@Composable
private fun OnboardingScreenPreview() {
MaterialTheme {
OnboardingScreen(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
)
}
}
@Preview
@Composable
private fun OnboardingContentAsDialogPreview() {
MaterialTheme {
Surface(
modifier = Modifier.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
OnboardingContent(
uiState = OnboardingUiState(
isCompleted = false,
selectedLevel = OnboardingLevel.BEGINNER,
),
onClose = {},
)
}
}
}
@@ -0,0 +1,64 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Hearing
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingInfoPage(
icon: ImageVector,
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
@Preview
@Composable
private fun OnboardingInfoPagePreview() {
MaterialTheme {
OnboardingInfoPage(
icon = Icons.Filled.Hearing,
title = "Bienvenue dans Donemat - Breton d'Auray",
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
)
}
}
@@ -0,0 +1,126 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_advanced_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_beginner_title
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_description
import bzh.ajaury.chombev.resources.generated.resources.onboarding_level_title
import org.jetbrains.compose.resources.stringResource
/** Last onboarding page: pick the starting configuration between beginner and normal/advanced. */
@Composable
fun OnboardingLevelPage(
selectedLevel: OnboardingLevel,
onLevelSelected: (OnboardingLevel) -> Unit = {},
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = stringResource(Res.string.onboarding_level_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.onboarding_level_description),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
LevelOption(
title = stringResource(Res.string.onboarding_level_beginner_title),
description = stringResource(Res.string.onboarding_level_beginner_description),
selected = selectedLevel == OnboardingLevel.BEGINNER,
onClick = { onLevelSelected(OnboardingLevel.BEGINNER) },
)
LevelOption(
title = stringResource(Res.string.onboarding_level_advanced_title),
description = stringResource(Res.string.onboarding_level_advanced_description),
selected = selectedLevel == OnboardingLevel.ADVANCED,
onClick = { onLevelSelected(OnboardingLevel.ADVANCED) },
)
}
}
@Composable
private fun LevelOption(
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit = {},
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = onClick,
),
border = BorderStroke(
width = if (selected) 2.dp else 1.dp,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
},
),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = null,
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Preview
@Composable
private fun OnboardingLevelPagePreview() {
MaterialTheme {
OnboardingLevelPage(
selectedLevel = OnboardingLevel.BEGINNER,
)
}
}
@@ -0,0 +1,52 @@
package bzh.ajaury.chombev.onboarding.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingPageIndicator(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(
if (index == currentPage) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
),
)
}
}
}
@Preview
@Composable
private fun OnboardingPageIndicatorPreview() {
MaterialTheme {
OnboardingPageIndicator(
pageCount = 4,
currentPage = 1,
)
}
}
@@ -0,0 +1,13 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
/**
* @property isCompleted whether onboarding has already been completed; null while the persisted
* preference is still being read, so the app can avoid flashing either screen.
* @property selectedLevel the starting configuration currently picked on the last page.
*/
data class OnboardingUiState(
val isCompleted: Boolean? = null,
val selectedLevel: OnboardingLevel = OnboardingLevel.BEGINNER,
)
@@ -0,0 +1,53 @@
package bzh.ajaury.chombev.onboarding.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.onboarding.model.OnboardingLevel
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
class OnboardingViewModel(
private val preferencesRepository: PreferencesRepository,
) : ViewModel() {
private val selectedLevel = MutableStateFlow(OnboardingLevel.BEGINNER)
val uiState: StateFlow<OnboardingUiState> = combine(
preferencesRepository.onboardingCompleted,
selectedLevel,
) { isCompleted, selectedLevel ->
OnboardingUiState(
isCompleted = isCompleted,
selectedLevel = selectedLevel,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = OnboardingUiState(),
)
fun selectLevel(level: OnboardingLevel) {
selectedLevel.value = level
}
fun skipOnboarding() {
viewModelScope.launch {
preferencesRepository.setOnboardingCompleted()
}
}
fun completeOnboarding() {
val level = selectedLevel.value
viewModelScope.launch {
preferencesRepository.setPlaybackSpeed(level.playbackSpeed)
preferencesRepository.setBreakBetweenPhrases(level.breakBetweenPhrases)
preferencesRepository.setOnboardingCompleted()
}
}
}
+21
View File
@@ -6,6 +6,16 @@ plugins {
kotlin { kotlin {
androidLibrary { androidLibrary {
namespace = "bzh.ajaury.chombev.records" namespace = "bzh.ajaury.chombev.records"
// Packages the Compose resources (strings of the screens) into the device test APK.
androidResources {
enable = true
}
// Accessibility tests: they run the Accessibility Test Framework checks, which need an
// Android device or emulator on API 34+ (Robolectric does not support them).
withDeviceTestBuilder {}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
} }
sourceSets { sourceSets {
@@ -14,6 +24,7 @@ kotlin {
} }
commonMain.dependencies { commonMain.dependencies {
// Core modules // Core modules
implementation(projects.core.accessibility)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.logging) implementation(projects.core.logging)
implementation(projects.core.audioplayer) implementation(projects.core.audioplayer)
@@ -44,6 +55,16 @@ kotlin {
implementation(libs.koin.core.viewmodel) implementation(libs.koin.core.viewmodel)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
} }
getByName("androidDeviceTest").dependencies {
implementation(libs.kotlin.test)
implementation(libs.androidx.testRunner)
// Espresso 3.5, which Compose UI tests depend on, breaks on API 37 (InputManager.getInstance).
implementation(libs.androidx.espressoCore)
implementation(libs.compose.uiTest)
implementation(libs.compose.uiTestAccessibility)
// Declares the activity the UI tests host their content in.
implementation(libs.compose.uiTestManifest)
}
} }
} }
@@ -0,0 +1,100 @@
package bzh.ajaury.chombev.player.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.tryPerformAccessibilityChecks
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.test.filters.SdkSuppress
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
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerUiState
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import kotlin.test.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Runs the Accessibility Test Framework checks (labels, touch targets, contrast, traversal order...)
* on the player, in each state that changes its controls.
*/
@OptIn(ExperimentalTestApi::class)
@SdkSuppress(minSdkVersion = 34)
class PlayerScreenAccessibilityTest {
@Test
fun playingPlayerPassesAccessibilityChecks() =
assertPassesAccessibilityChecks(uiState(playbackState = PlaybackState.PLAYING))
@Test
fun pausedPlayerPassesAccessibilityChecks() =
assertPassesAccessibilityChecks(uiState(playbackState = PlaybackState.PAUSED))
@Test
fun endedPlayerPassesAccessibilityChecks() =
assertPassesAccessibilityChecks(uiState(playbackState = PlaybackState.ENDED))
@Test
fun playerOnFirstSentencePassesAccessibilityChecks() =
assertPassesAccessibilityChecks(uiState(position = 0.seconds, currentSubtitleIndex = 0))
@Test
fun playerOnLastSentencePassesAccessibilityChecks() = assertPassesAccessibilityChecks(
uiState(position = 8.seconds, currentSubtitleIndex = subtitleLines.lastIndex),
)
private fun assertPassesAccessibilityChecks(uiState: PlayerUiState) = runComposeUiTest {
enableAccessibilityChecks()
setContent {
MaterialTheme {
PlayerScreen(uiState = uiState)
}
}
onRoot().tryPerformAccessibilityChecks()
}
private companion object {
val record = Record(
id = 1,
index = 1,
emoji = "🙏",
title = Phrase(transcription = "An onestiz", translation = "La politesse"),
audioResourcePath = "",
subtitleResourcePath = "",
)
val subtitleLines = listOf(
SubtitleLine(
startTime = 0.seconds,
phrase = Phrase(transcription = "Demat deoc'h !", translation = "Bonjour !"),
),
SubtitleLine(
startTime = 4.seconds,
phrase = Phrase(transcription = "Kenavo !", translation = "Au revoir !"),
),
SubtitleLine(
startTime = 7.seconds,
phrase = Phrase(transcription = "Kenavo emberr !", translation = "À ce soir !"),
),
)
fun uiState(
playbackState: PlaybackState = PlaybackState.PLAYING,
position: Duration = 5.seconds,
currentSubtitleIndex: Int = 1,
) = PlayerUiState(
record = record,
playerState = PlayerState(
playbackState = playbackState,
playbackTiming = PlaybackTiming(position = position, duration = 10.seconds),
subtitleLines = subtitleLines,
currentSubtitleIndex = currentSubtitleIndex,
),
)
}
}
@@ -0,0 +1,67 @@
package bzh.ajaury.chombev.records.ui
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.tryPerformAccessibilityChecks
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.test.filters.SdkSuppress
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record
import kotlin.test.Test
/**
* Runs the Accessibility Test Framework checks (labels, touch targets, contrast, traversal order...)
* on the list of records.
*/
@OptIn(ExperimentalTestApi::class)
@SdkSuppress(minSdkVersion = 34)
class RecordsScreenAccessibilityTest {
@Test
fun recordsScreenPassesAccessibilityChecks() = runComposeUiTest {
enableAccessibilityChecks()
setContent {
MaterialTheme {
RecordsScreen(
records = records,
contentPadding = PaddingValues(),
onRecordClick = {},
)
}
}
onRoot().tryPerformAccessibilityChecks()
}
private companion object {
val records = listOf(
Record(
id = 1,
index = 1,
emoji = "🙏",
title = Phrase(transcription = "An onestiz", translation = "La politesse"),
audioResourcePath = "",
subtitleResourcePath = "",
),
Record(
id = 2,
index = 2,
emoji = "👋",
title = Phrase(transcription = "En em brezantiñ", translation = "Se présenter"),
audioResourcePath = "",
subtitleResourcePath = "",
),
Record(
id = 3,
index = 3,
emoji = "🌦️",
title = Phrase(transcription = "An amzer", translation = null),
audioResourcePath = "",
subtitleResourcePath = "",
),
)
}
}
@@ -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
}
@@ -8,44 +8,59 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.core.accessibility.ui.ProvideDynamicTypeFontScale
import bzh.ajaury.chombev.player.ui.components.LabeledSlider import bzh.ajaury.chombev.player.ui.components.LabeledSlider
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.ui.PreferenceToggle
import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.Res
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_title
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value 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_title
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_value
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Bottom sheet letting the user adjust playback: the audio [speed] and the [breakSeconds] silent * Bottom sheet letting the user adjust playback: the audio [speed], whether playback stops after
* pause inserted between phrases. * each phrase ([autoPause]), and the [breakSeconds] silent pause inserted between phrases. When
* [autoPause] is on, the break setting is ignored and shown as disabled.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PlaybackSpeedBottomSheet( fun PlaybackSpeedBottomSheet(
speed: Float, speed: Float,
breakSeconds: Float, breakSeconds: Float,
autoPause: Boolean,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
) { ) {
PlaybackSpeedContent( ProvideDynamicTypeFontScale {
speed = speed, PlaybackSpeedContent(
breakSeconds = breakSeconds, speed = speed,
onSpeedChange = onSpeedChange, breakSeconds = breakSeconds,
onBreakChange = onBreakChange, autoPause = autoPause,
) onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange,
onAutoPauseChange = onAutoPauseChange,
)
}
} }
} }
@@ -53,8 +68,10 @@ fun PlaybackSpeedBottomSheet(
private fun PlaybackSpeedContent( private fun PlaybackSpeedContent(
speed: Float, speed: Float,
breakSeconds: Float, breakSeconds: Float,
autoPause: Boolean,
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
) { ) {
// Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4). // Number of discrete stops strictly between the endpoints (e.g. 0.5..1.0 by 0.1 → 4).
val speedSteps = with(PlaybackPreferences) { val speedSteps = with(PlaybackPreferences) {
@@ -74,21 +91,37 @@ private fun PlaybackSpeedContent(
LabeledSlider( LabeledSlider(
title = stringResource(Res.string.playback_speed_title), title = stringResource(Res.string.playback_speed_title),
valueLabel = formatPlaybackSpeed(speed), 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, value = speed,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED, valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
steps = speedSteps, steps = speedSteps,
onValueChange = onSpeedChange, onValueChange = onSpeedChange,
) )
PreferenceToggle(
title = stringResource(Res.string.auto_pause_title),
subtitle = stringResource(Res.string.auto_pause_description),
checked = autoPause,
onCheckedChange = onAutoPauseChange,
)
LabeledSlider( LabeledSlider(
title = stringResource(Res.string.break_between_phrases_title), title = stringResource(Res.string.break_between_phrases_title),
valueLabel = stringResource( valueLabel = stringResource(
Res.string.break_between_phrases_value, Res.string.break_between_phrases_value,
breakSeconds.roundToInt().toString(), 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, value = breakSeconds,
valueRange = 0f..maxBreakSeconds.toFloat(), valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps, steps = breakSteps,
onValueChange = onBreakChange, onValueChange = onBreakChange,
// Auto-pause supersedes the break between phrases, so disable it while on.
enabled = !autoPause,
) )
} }
} }
@@ -106,6 +139,19 @@ private fun PlaybackSpeedContentPreview() {
PlaybackSpeedContent( PlaybackSpeedContent(
speed = 0.7f, speed = 0.7f,
breakSeconds = 2f, breakSeconds = 2f,
autoPause = false,
)
}
}
@Preview
@Composable
private fun PlaybackSpeedContentAutoPausePreview() {
MaterialTheme {
PlaybackSpeedContent(
speed = 0.7f,
breakSeconds = 2f,
autoPause = true,
) )
} }
} }
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -48,7 +49,7 @@ fun PlayerControl(
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.widthIn(max = 350.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Left region: the speed button is centered in the space left of the transport group. // Left region: the speed button is centered in the space left of the transport group.
@@ -57,6 +58,7 @@ fun PlayerControl(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
SpeedPreferenceButton( SpeedPreferenceButton(
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAYBACK_SETTINGS),
isActive = isPlaybackModified, isActive = isPlaybackModified,
onSpeedClicked = onSpeedClicked, onSpeedClicked = onSpeedClicked,
) )
@@ -68,17 +70,20 @@ fun PlayerControl(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
PreviousButton( PreviousButton(
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PREVIOUS),
canGoToPreviousSentence = canGoToPreviousSentence, canGoToPreviousSentence = canGoToPreviousSentence,
onPreviousSentenceClicked = onPreviousSentenceClicked, onPreviousSentenceClicked = onPreviousSentenceClicked,
) )
PlayButton( PlayButton(
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAY),
playbackState = playbackState, playbackState = playbackState,
breakDuration = breakBetweenPhrases, breakDuration = breakBetweenPhrases,
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked, onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
) )
NextButton( NextButton(
modifier = Modifier.traversalOrder(PlayerTraversalOrder.NEXT),
canGoToNextSentence = canGoToNextSentence, canGoToNextSentence = canGoToNextSentence,
onNextSentenceClicked = onNextSentenceClicked, onNextSentenceClicked = onNextSentenceClicked,
) )
@@ -30,11 +30,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow 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 import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import bzh.ajaury.chombev.core.model.Phrase 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.PlaybackState
import bzh.ajaury.chombev.player.model.PlaybackTiming import bzh.ajaury.chombev.player.model.PlaybackTiming
import bzh.ajaury.chombev.player.model.PlayerState import bzh.ajaury.chombev.player.model.PlayerState
@@ -43,7 +49,10 @@ import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet
import bzh.ajaury.chombev.resources.generated.resources.Res 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
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.resources.generated.resources.subtitle_preferences_action
import bzh.ajaury.chombev.rowDescription
import bzh.ajaury.chombev.subtitle.model.SubtitleLine import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@@ -82,6 +91,7 @@ fun PlayerScreen(
onTextSizeIncrease = viewModel::increaseSubtitleTextSize, onTextSizeIncrease = viewModel::increaseSubtitleTextSize,
onSpeedChange = viewModel::setPlaybackSpeed, onSpeedChange = viewModel::setPlaybackSpeed,
onBreakChange = viewModel::setBreakBetweenPhrases, onBreakChange = viewModel::setBreakBetweenPhrases,
onAutoPauseChange = viewModel::setAutoPause,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
) )
} }
@@ -102,12 +112,16 @@ fun PlayerScreen(
onTextSizeIncrease: () -> Unit = {}, onTextSizeIncrease: () -> Unit = {},
onSpeedChange: (Float) -> Unit = {}, onSpeedChange: (Float) -> Unit = {},
onBreakChange: (Float) -> Unit = {}, onBreakChange: (Float) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},
) { ) {
var showPreferences by remember { mutableStateOf(false) } var showPreferences by remember { mutableStateOf(false) }
var showSpeed by remember { mutableStateOf(false) } var showSpeed by remember { mutableStateOf(false) }
Scaffold( Scaffold(
// Root of the screen reader order defined by PlayerTraversalOrder, which interleaves the
// top bar and the controls.
modifier = Modifier.semantics { isTraversalGroup = true },
topBar = { topBar = {
PlayerTopBar( PlayerTopBar(
uiState = uiState, uiState = uiState,
@@ -166,8 +180,10 @@ fun PlayerScreen(
PlaybackSpeedBottomSheet( PlaybackSpeedBottomSheet(
speed = uiState.playbackSpeed, speed = uiState.playbackSpeed,
breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(), breakSeconds = uiState.breakBetweenPhrases.inWholeSeconds.toFloat(),
autoPause = uiState.autoPause,
onSpeedChange = onSpeedChange, onSpeedChange = onSpeedChange,
onBreakChange = onBreakChange, onBreakChange = onBreakChange,
onAutoPauseChange = onAutoPauseChange,
onDismiss = { showSpeed = false }, onDismiss = { showSpeed = false },
) )
} }
@@ -181,39 +197,50 @@ private fun PlayerTopBar(
showPreferences: () -> Unit, showPreferences: () -> Unit,
) { ) {
TopAppBar( 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 = { title = {
// Animate the title in as it loads, and between records: the record data isn't there on // 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. // the first frame, so fade + rise it into place once uiState carries it.
AnimatedContent( AnimatedContent(
targetState = uiState.recordEmoji to uiState.recordTitle, targetState = uiState.record,
transitionSpec = { transitionSpec = {
fadeIn(tween()) togetherWith fadeIn(tween()) togetherWith
fadeOut(tween()) fadeOut(tween())
}, },
label = "PlayerTitle", 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( Row(
modifier = Modifier
.traversalOrder(PlayerTraversalOrder.TITLE)
.clearAndSetSemantics {
description?.let { contentDescription = it }
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = emoji, text = record?.emoji.orEmpty(),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Column { Column {
Text( Text(
text = title.transcription, text = record?.title?.transcription.orEmpty(),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
title.translation?.let { translation -> record?.title?.translation?.let { translation ->
Text( Text(
text = translation, text = translation,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -223,15 +250,22 @@ private fun PlayerTopBar(
} }
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClicked) { BackButton(onBackClicked = onBackClicked)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
}, },
actions = { 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
.traversalOrder(PlayerTraversalOrder.SUBTITLE_SETTINGS)
.semantics {
onClick(label = openSettingsLabel) {
showPreferences()
true
}
},
onClick = showPreferences,
) {
Icon( Icon(
imageVector = Icons.Filled.Subtitles, imageVector = Icons.Filled.Subtitles,
contentDescription = stringResource(Res.string.subtitle_preferences_action), contentDescription = stringResource(Res.string.subtitle_preferences_action),
@@ -241,6 +275,27 @@ private fun PlayerTopBar(
) )
} }
@Composable
private fun BackButton(onBackClicked: () -> Unit) {
val backActionLabel = stringResource(Res.string.player_back_action_label)
IconButton(
onClick = onBackClicked,
modifier = Modifier
.traversalOrder(PlayerTraversalOrder.BACK)
.semantics {
onClick(label = backActionLabel) {
onBackClicked()
true
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
}
private val previewSubtitleLines = private val previewSubtitleLines =
listOf( listOf(
SubtitleLine( SubtitleLine(
@@ -266,17 +321,25 @@ private val previewSubtitleLines =
), ),
) )
private val fakeRecord = Record(
id = 1,
index = 1,
emoji = "🙏",
title = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
audioResourcePath = "",
subtitleResourcePath = "",
)
@Preview @Preview
@Composable @Composable
private fun PlayerScreenPlayingPreview() { private fun PlayerScreenPlayingPreview() {
MaterialTheme { MaterialTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordEmoji = "🙏", fakeRecord,
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState( playerState = PlayerState(
playbackState = PlaybackState.PLAYING, playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
@@ -297,14 +360,13 @@ private fun PlayerScreenPausedPreview() {
MaterialTheme { MaterialTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordEmoji = "🙏", record = fakeRecord,
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState( playerState = PlayerState(
playbackState = PlaybackState.PAUSED, playbackState = PlaybackState.PAUSED,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(
position = 1.2.seconds,
duration = 5.seconds,
),
subtitleLines = previewSubtitleLines, subtitleLines = previewSubtitleLines,
currentSubtitleIndex = 1, currentSubtitleIndex = 1,
), ),
@@ -322,11 +384,7 @@ private fun PlayerScreenEndedPreview() {
MaterialTheme { MaterialTheme {
PlayerScreen( PlayerScreen(
uiState = PlayerUiState( uiState = PlayerUiState(
recordEmoji = "🙏", record = fakeRecord,
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
playerState = PlayerState( playerState = PlayerState(
playbackState = PlaybackState.ENDED, playbackState = PlaybackState.ENDED,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds), playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
@@ -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()
}
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -97,7 +98,9 @@ fun SubtitleList(
} }
val density = LocalDensity.current 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 // 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. // because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
val verticalPadding = maxHeight / 2 val verticalPadding = maxHeight / 2
@@ -10,8 +10,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable @Composable
fun LabeledSlider( fun LabeledSlider(
@@ -20,22 +29,55 @@ fun LabeledSlider(
value: Float, value: Float,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
steps: Int, steps: Int,
valueDescription: String = valueLabel,
onValueChange: (Float) -> Unit = {}, onValueChange: (Float) -> Unit = {},
enabled: Boolean = true,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { // Dim the labels to match the Slider's disabled appearance.
val contentAlpha = if (enabled) 1f else 0.38f
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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// The title takes the remaining width and wraps if needed, so the value always keeps
// its full width on a single line.
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f).alpha(contentAlpha),
) )
Text( Text(
modifier = Modifier.alpha(contentAlpha),
text = valueLabel, text = valueLabel,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, maxLines = 1,
softWrap = false,
) )
} }
Slider( Slider(
@@ -43,10 +85,22 @@ fun LabeledSlider(
onValueChange = onValueChange, onValueChange = onValueChange,
valueRange = valueRange, valueRange = valueRange,
steps = steps, steps = steps,
enabled = enabled,
) )
} }
} }
private fun Float.snapToSteps(
range: ClosedFloatingPointRange<Float>,
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 @Preview
@Composable @Composable
private fun LabeledSliderPreview() { private fun LabeledSliderPreview() {
@@ -60,3 +114,32 @@ private fun LabeledSliderPreview() {
) )
} }
} }
@Preview(widthDp = 200)
@Composable
private fun LabeledSliderLongTitlePreview() {
MaterialTheme {
LabeledSlider(
title = "Break between phrases",
valueLabel = "3 s",
value = 3f,
valueRange = 0f..5f,
steps = 4,
)
}
}
@Preview
@Composable
private fun LabeledSliderDisabledPreview() {
MaterialTheme {
LabeledSlider(
title = "Playback speed",
valueLabel = "x0.7",
value = 0.7f,
valueRange = 0.5f..2f,
steps = 10,
enabled = false,
)
}
}
@@ -8,19 +8,33 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res 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
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence_action_label
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun NextButton( fun NextButton(
canGoToNextSentence: Boolean, canGoToNextSentence: Boolean,
modifier: Modifier = Modifier,
onNextSentenceClicked: () -> Unit = {}, onNextSentenceClicked: () -> Unit = {},
) { ) {
val actionLabel = stringResource(Res.string.player_next_sentence_action_label)
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier
.size(60.dp)
.semantics {
if (canGoToNextSentence) {
onClick(label = actionLabel) {
onNextSentenceClicked()
true
}
}
},
enabled = canGoToNextSentence, enabled = canGoToNextSentence,
onClick = onNextSentenceClicked, onClick = onNextSentenceClicked,
) { ) {
@@ -38,6 +38,7 @@ private val BreakRingSize = 68.dp
fun PlayButton( fun PlayButton(
playbackState: PlaybackState, playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO, breakDuration: Duration = Duration.ZERO,
modifier: Modifier = Modifier,
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
) { ) {
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO 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 // 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. // starts; the indicator itself is only drawn while breaking.
Box( Box(
modifier = Modifier.size(BreakRingSize), modifier = modifier.size(BreakRingSize),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (isBreaking) { if (isBreaking) {
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import bzh.ajaury.chombev.player.model.PlaybackTiming import bzh.ajaury.chombev.player.model.PlaybackTiming
import kotlin.time.Duration import kotlin.time.Duration
@@ -33,7 +34,9 @@ fun PlaybackProgress(
} }
seekProgress = null seekProgress = null
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics { },
) )
} }
@@ -8,19 +8,33 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res 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
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence_action_label
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun PreviousButton( fun PreviousButton(
canGoToPreviousSentence: Boolean, canGoToPreviousSentence: Boolean,
modifier: Modifier = Modifier,
onPreviousSentenceClicked: () -> Unit = {}, onPreviousSentenceClicked: () -> Unit = {},
) { ) {
val actionLabel = stringResource(Res.string.player_previous_sentence_action_label)
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier
.size(60.dp)
.semantics {
if (canGoToPreviousSentence) {
onClick(label = actionLabel) {
onPreviousSentenceClicked()
true
}
}
},
enabled = canGoToPreviousSentence, enabled = canGoToPreviousSentence,
onClick = onPreviousSentenceClicked, onClick = onPreviousSentenceClicked,
) { ) {
@@ -9,10 +9,13 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res 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.playback_settings_action
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
/** /**
@@ -22,10 +25,19 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun SpeedPreferenceButton( fun SpeedPreferenceButton(
isActive: Boolean, isActive: Boolean,
modifier: Modifier = Modifier,
onSpeedClicked: () -> Unit = {}, onSpeedClicked: () -> Unit = {},
) { ) {
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier
.size(60.dp)
.semantics {
onClick(label = openSettingsLabel) {
onSpeedClicked()
true
}
},
onClick = onSpeedClicked, onClick = onSpeedClicked,
) { ) {
Icon( Icon(
@@ -1,6 +1,6 @@
package bzh.ajaury.chombev.player.ui.viewmodel 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.player.model.PlayerState
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
@@ -8,21 +8,24 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
data class PlayerUiState( data class PlayerUiState(
val recordEmoji: String = "", val record: Record? = null,
val recordTitle: Phrase = Phrase(transcription = ""),
val playerState: PlayerState = PlayerState(), val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(), val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED, val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES, val breakBetweenPhrases: Duration = PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES,
val autoPause: Boolean = PlaybackPreferences.DEFAULT_AUTO_PAUSE,
) { ) {
val canGoToPreviousSentence: Boolean val canGoToPreviousSentence: Boolean
get() = playerState.playbackTiming.position > 2.seconds get() = playerState.playbackTiming.position > 2.seconds
val canGoToNextSentence: Boolean val canGoToNextSentence: Boolean
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex get() = (
playerState.currentSubtitleIndex
?: Int.MAX_VALUE
) < playerState.subtitleLines.lastIndex
/** True when either playback setting differs from its default, used to highlight the button. */ /** True when any playback setting differs from its default, used to highlight the button. */
val isPlaybackModified: Boolean val isPlaybackModified: Boolean
get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED || get() = playbackSpeed != PlaybackPreferences.DEFAULT_SPEED ||
breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES breakBetweenPhrases != PlaybackPreferences.DEFAULT_BREAK_BETWEEN_PHRASES || autoPause
} }
@@ -2,8 +2,8 @@ package bzh.ajaury.chombev.player.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.logging.domain.Logger
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record import bzh.ajaury.chombev.core.model.Record
import bzh.ajaury.chombev.player.domain.AudioSessionManager import bzh.ajaury.chombev.player.domain.AudioSessionManager
import bzh.ajaury.chombev.player.domain.PlaybackRepository import bzh.ajaury.chombev.player.domain.PlaybackRepository
@@ -12,6 +12,8 @@ import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.records.domain.RecordRepository import bzh.ajaury.chombev.records.domain.RecordRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class PlayerViewModel( class PlayerViewModel(
@@ -29,10 +32,14 @@ class PlayerViewModel(
private val playbackRepository: PlaybackRepository, private val playbackRepository: PlaybackRepository,
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val audioSessionManager: AudioSessionManager, private val audioSessionManager: AudioSessionManager,
private val accessibilityRepository: AccessibilityRepository,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
private val record = MutableStateFlow<Record?>(null) private val record = MutableStateFlow<Record?>(null)
// Playback held while the screen reader speaks the control that was just activated.
private var pendingPlaybackJob: Job? = null
val uiState: StateFlow<PlayerUiState> = combine( val uiState: StateFlow<PlayerUiState> = combine(
record, record,
playbackRepository.playerState, playbackRepository.playerState,
@@ -40,12 +47,12 @@ class PlayerViewModel(
preferencesRepository.playbackPreferences, preferencesRepository.playbackPreferences,
) { record, playerState, subtitlePreferences, playbackPreferences -> ) { record, playerState, subtitlePreferences, playbackPreferences ->
PlayerUiState( PlayerUiState(
recordEmoji = record?.emoji ?: "", record = record,
recordTitle = record?.title ?: Phrase(transcription = ""),
playerState = playerState, playerState = playerState,
subtitlePreferences = subtitlePreferences, subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed, playbackSpeed = playbackPreferences.speed,
breakBetweenPhrases = playbackPreferences.breakBetweenPhrases, breakBetweenPhrases = playbackPreferences.breakBetweenPhrases,
autoPause = playbackPreferences.autoPause,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -61,11 +68,14 @@ class PlayerViewModel(
viewModelScope.launch { viewModelScope.launch {
playbackRepository.stop() 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") logger.debug("Loading record $recordId")
val alreadyLoadedRecord = record.value val alreadyLoadedRecord = record.value
if (alreadyLoadedRecord != null) { if (alreadyLoadedRecord != null) {
logger.debug("Record $recordId already loaded") logger.debug("Record $recordId already loaded")
playbackRepository.load(record = alreadyLoadedRecord) playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay)
return@launch return@launch
} }
@@ -75,45 +85,81 @@ class PlayerViewModel(
return@launch return@launch
} }
playbackRepository.load(record = loadedRecord) playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
record.value = loadedRecord record.value = loadedRecord
} }
} }
fun performMainPlayAction() { fun performMainPlayAction() {
viewModelScope.launch { when (uiState.value.playerState.playbackState) {
when (uiState.value.playerState.playbackState) { PlaybackState.PLAYING, PlaybackState.BREAKING -> {
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause() cancelPendingPlayback()
PlaybackState.ENDED -> playbackRepository.replay() viewModelScope.launch { playbackRepository.pause() }
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
} }
PlaybackState.ENDED -> playAfterScreenReader { playbackRepository.replay() }
PlaybackState.PAUSED, PlaybackState.IDLE -> playAfterScreenReader { playbackRepository.play() }
} }
} }
fun seekToTimePart(progress: Duration) { fun seekToTimePart(progress: Duration) {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.seekTo(progress) }
playbackRepository.seekTo(progress)
}
} }
fun seekToSentence(sentenceIndex: Int) { fun seekToSentence(sentenceIndex: Int) {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.seekToSentence(sentenceIndex) }
playbackRepository.seekToSentence(sentenceIndex)
}
} }
fun goToPreviousSentence() { fun goToPreviousSentence() {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.goToPreviousSentence() }
playbackRepository.goToPreviousSentence()
}
} }
fun goToNextSentence() { fun goToNextSentence() {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.goToNextSentence() }
playbackRepository.goToNextSentence() }
/**
* Runs [play], which starts playback. A screen reader still speaks the control that was just
* activated: while one is running, [play] is delayed by [SCREEN_READER_PLAYBACK_DELAY] so that
* the audio does not play over it.
*/
private fun playAfterScreenReader(play: suspend () -> Unit) {
pendingPlaybackJob?.cancel()
pendingPlaybackJob = viewModelScope.launch {
if (accessibilityRepository.isScreenReaderEnabled()) delay(SCREEN_READER_PLAYBACK_DELAY)
play()
} }
} }
/**
* Runs [seek]. While a screen reader is running, ongoing playback is paused during the seek and
* only resumes after [SCREEN_READER_PLAYBACK_DELAY], like [playAfterScreenReader]. Successive
* seeks within the delay keep playback held until the delay of the last one has elapsed.
*/
private fun seekAfterScreenReader(seek: suspend () -> Unit) {
val isPlaybackPending = pendingPlaybackJob?.isActive == true
val playbackState = uiState.value.playerState.playbackState
val wasPlaying = isPlaybackPending || playbackState in PLAYING_STATES
pendingPlaybackJob?.cancel()
pendingPlaybackJob = viewModelScope.launch {
if (!wasPlaying || !accessibilityRepository.isScreenReaderEnabled()) {
seek()
return@launch
}
playbackRepository.pause()
seek()
delay(SCREEN_READER_PLAYBACK_DELAY)
playbackRepository.play()
}
}
private fun cancelPendingPlayback() {
pendingPlaybackJob?.cancel()
pendingPlaybackJob = null
}
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
val steppedSpeed = (speed * 10).roundToInt() / 10f val steppedSpeed = (speed * 10).roundToInt() / 10f
val clampedSpeed = steppedSpeed.coerceIn( val clampedSpeed = steppedSpeed.coerceIn(
@@ -136,6 +182,12 @@ class PlayerViewModel(
} }
} }
fun setAutoPause(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setAutoPause(enabled)
}
}
fun setShowTranscription(enabled: Boolean) { fun setShowTranscription(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setShowTranscription(enabled) preferencesRepository.setShowTranscription(enabled)
@@ -171,6 +223,7 @@ class PlayerViewModel(
} }
fun stop() { fun stop() {
cancelPendingPlayback()
viewModelScope.launch { viewModelScope.launch {
playbackRepository.stop() playbackRepository.stop()
} }
@@ -180,4 +233,10 @@ class PlayerViewModel(
super.onCleared() super.onCleared()
audioSessionManager.deactivate() audioSessionManager.deactivate()
} }
private companion object {
// Long enough for a screen reader to speak a short control label such as "Phrase suivante".
val SCREEN_READER_PLAYBACK_DELAY = 1500.milliseconds
val PLAYING_STATES = setOf(PlaybackState.PLAYING, PlaybackState.BREAKING)
}
} }
@@ -46,12 +46,11 @@ fun PreferenceToggle(
Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { Column(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Remove
@@ -14,13 +16,23 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.core.accessibility.ui.ProvideDynamicTypeFontScale
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_description import bzh.ajaury.chombev.resources.generated.resources.subtitle_marked_letters_description
@@ -29,6 +41,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_decrease
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase 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_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_description
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
@@ -50,20 +63,22 @@ fun SubtitlePreferencesBottomSheet(
onTextSizeDecrease: () -> Unit = {}, onTextSizeDecrease: () -> Unit = {},
onTextSizeIncrease: () -> Unit = {}, onTextSizeIncrease: () -> Unit = {},
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
) { ) {
SubtitlePreferencesContent( ProvideDynamicTypeFontScale {
subtitlePreferences = subtitlePreferences, SubtitlePreferencesContent(
onShowTranscriptionChange = onShowTranscriptionChange, subtitlePreferences = subtitlePreferences,
onShowTranslationChange = onShowTranslationChange, onShowTranscriptionChange = onShowTranscriptionChange,
onShowMarkedLettersChange = onShowPhoneticMarkerChange, onShowTranslationChange = onShowTranslationChange,
onTextSizeDecrease = onTextSizeDecrease, onShowMarkedLettersChange = onShowPhoneticMarkerChange,
onTextSizeIncrease = onTextSizeIncrease, onTextSizeDecrease = onTextSizeDecrease,
) onTextSizeIncrease = onTextSizeIncrease,
)
}
} }
} }
@@ -79,13 +94,15 @@ private fun SubtitlePreferencesContent(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// Scrolls when a large system font size makes the content taller than the sheet.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
text = stringResource(Res.string.subtitle_preferences_title), text = stringResource(Res.string.subtitle_preferences_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
) )
PreferenceToggle( PreferenceToggle(
title = stringResource(Res.string.subtitle_transcription_label), title = stringResource(Res.string.subtitle_transcription_label),
@@ -119,10 +136,50 @@ private fun SubtitleTextSizeControl(
onDecrease: () -> Unit = {}, onDecrease: () -> Unit = {},
onIncrease: () -> 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -11,23 +11,39 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.core.model.Phrase import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record 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.rowDescription
import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun RecordView( fun RecordView(
record: Record, record: Record,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
val rowDescription = rowDescription(record)
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .clickable(
.padding(16.dp), role = Role.Button,
onClickLabel = stringResource(Res.string.records_chapter_action_description),
) {
onClick()
}.padding(16.dp)
.clearAndSetSemantics {
contentDescription = rowDescription
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -44,7 +60,6 @@ fun RecordView(
Text( Text(
text = translation, text = translation,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -70,3 +85,23 @@ private fun RecordViewPreview() {
onClick = {}, 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 = {},
)
}
@@ -1,5 +1,6 @@
package bzh.ajaury.chombev.records.ui.di 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.audioPlayerModule
import bzh.ajaury.chombev.player.di.playbackModule import bzh.ajaury.chombev.player.di.playbackModule
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
@@ -23,6 +24,7 @@ val recordsFeatureModule: Module = module {
recordsModule, recordsModule,
resourcesModule, resourcesModule,
audioPlayerModule, audioPlayerModule,
accessibilityModule,
subtitleModule, subtitleModule,
) )
+9
View File
@@ -14,6 +14,15 @@ kotlin {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
} }
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
webMain.dependencies {
// DOM access (document, navigator) to embed the page in an <iframe>
implementation(libs.kotlinx.browser)
}
wasmJsMain {
// HTMLIFrameElement interop on Wasm requires this opt-in.
languageSettings.optIn("kotlin.js.ExperimentalWasmJsInterop")
}
commonMain.dependencies { commonMain.dependencies {
// Core modules // Core modules
implementation(projects.core.logging) implementation(projects.core.logging)
@@ -4,11 +4,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
/** /**
* Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline, * Embeds a live web page. Android and iOS use their native web view, the web uses an `<iframe>`
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to * overlaying the Compose canvas, and all three honour [darkTheme] so the page matches the app's
* [WebViewUnavailable], which offers to open the page in the system browser. * light/dark appearance — as far as the page itself allows. Desktop, which has no web view to
* embed, falls back to [WebViewUnavailable] and offers to open the page in the system browser.
* *
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline * When [url] cannot be reached, the embedded views swap in [offlineHtml] — a bundled offline
* snapshot — so the page still shows content. When it is `null` no fallback is loaded. * snapshot — so the page still shows content. When it is `null` no fallback is loaded.
* *
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a * [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -0,0 +1,65 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.HtmlElementView
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLIFrameElement
/**
* Scripts, forms and target=_blank links keep the embedded site usable; top-level navigation is
* withheld, so a framed page cannot replace the app with itself.
*/
private const val IFRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/**
* Embeds the page in an `<iframe>`. [HtmlElementView] overlays the element on the Compose canvas and
* keeps it aligned with this composable's bounds, so the page is browsed inside the app rather than
* in a separate tab.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The element outlives recompositions, so its load listener has to read the latest callback.
val currentOnLoadingStateChange by rememberUpdatedState(onLoadingStateChange)
HtmlElementView(
modifier = modifier,
factory = {
(document.createElement("iframe") as HTMLIFrameElement).apply {
setAttribute("sandbox", IFRAME_SANDBOX)
setAttribute("referrerpolicy", "no-referrer")
style.border = "none"
style.width = "100%"
style.height = "100%"
addEventListener("load") { currentOnLoadingStateChange(false) }
}
},
// Re-applied on recomposition, but only the theme changes without reloading the page: `src`
// is left alone unless it actually differs.
update = { iframe ->
// Propagating a colour scheme is all a cross-origin frame allows; a page with
// `prefers-color-scheme` styles then matches the app.
iframe.style.setProperty("color-scheme", if (darkTheme) "dark" else "light")
if (offlineHtml != null && !window.navigator.onLine) {
// The snapshots use absolute URLs, so they need no base URL to render correctly.
if (iframe.srcdoc != offlineHtml) iframe.srcdoc = offlineHtml
} else if (iframe.src != url) {
currentOnLoadingStateChange(true)
iframe.src = url
}
},
)
}
+3
View File
@@ -1,5 +1,8 @@
#Kotlin #Kotlin
kotlin.code.style=official kotlin.code.style=official
# Install the JS/Wasm build dependencies with npm instead of Yarn Classic, which is unmaintained and
# warns about a deprecated Node API (DEP0169) on recent Node versions.
kotlin.js.yarn=false
kotlin.daemon.jvmargs=-Xmx6G kotlin.daemon.jvmargs=-Xmx6G
#Gradle #Gradle
+1 -1
View File
@@ -8,5 +8,5 @@ toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/109553caae279a6
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect
toolchainVendor=AMAZON toolchainVendor=AZUL
toolchainVersion=21 toolchainVersion=21
+37 -28
View File
@@ -1,29 +1,33 @@
[versions] [versions]
agp = "9.2.1" agp = "9.5.0" # https://developer.android.com/build/releases/gradle-plugin
androidCompileSdk = "37" androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
androidMinSdk = "24" androidCompileSdkMinor = "1" # Compose 1.13 requires compileSdk 37.1
androidTargetSdk = "37" androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
androidxActivity = "1.13.0" androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
androidxLifecycle = "2.11.0-rc01" androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
androidxWebkit = "1.16.0" androidxComposeUi = "1.13.0-beta02" # https://developer.android.com/jetpack/androidx/releases/compose-ui
media3 = "1.10.1" androidxEspresso = "3.7.0" # https://developer.android.com/jetpack/androidx/releases/test
navigation3 = "1.1.1" androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
composeMultiplatform = "1.11.1" androidxTestRunner = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/test
composeIconsExtended = "1.7.3" androidxWebkit = "1.18.0" # https://developer.android.com/jetpack/androidx/releases/webkit
glitchtips = "8.47.0" composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
googleServices = "4.5.0" composeMultiplatform = "1.13.0-beta01" # https://github.com/JetBrains/compose-multiplatform/releases
kotlin = "2.4.0" datastore = "1.3.0" # https://developer.android.com/jetpack/androidx/releases/datastore
kotlinWrappers = "2026.7.0" googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
kotlinxBrowser = "0.5.0" jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
kotlinxCoroutines = "1.11.0" kermit = "2.2.0" # https://github.com/touchlab/Kermit/releases
kotlinSerialization = "2.4.0" koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
kotlinxSerializationJson = "1.11.0" kotlin = "2.4.21" # https://github.com/JetBrains/kotlin/releases
material3 = "1.11.0-alpha07" kotlinSerialization = "2.4.21" # https://github.com/JetBrains/kotlin/releases
koin = "4.2.2" kotlinWrappers = "2026.11.0" # https://github.com/JetBrains/kotlin-wrappers/releases
kermit = "2.1.0" kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
vorbisspi = "1.0.3.3" kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore kotlinxSerializationJson = "1.12.0" # https://github.com/Kotlin/kotlinx.serialization/releases
okio = "3.17.0" material3 = "1.13.0-beta01" # https://developer.android.com/jetpack/androidx/releases/compose-material3
media3 = "1.11.1" # https://developer.android.com/jetpack/androidx/releases/media3
navigation3 = "1.2.0" # https://developer.android.com/jetpack/androidx/releases/navigation3
okio = "3.18.2" # https://square.github.io/okio/changelog/
sentry = "0.29.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
[libraries] [libraries]
# Gradle plugin artifacts, used by build-logic convention plugins only # Gradle plugin artifacts, used by build-logic convention plugins only
@@ -34,6 +38,8 @@ composeCompiler-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" } androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
androidx-espressoCore = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" }
androidx-testRunner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidxWebkit" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidxWebkit" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
@@ -42,9 +48,12 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers
compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeIconsExtended" } compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeIconsExtended" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
compose-uiTestAccessibility = { module = "androidx.compose.ui:ui-test-accessibility", version.ref = "androidxComposeUi" }
compose-uiTestManifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidxComposeUi" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
glitchtips = { module = "io.sentry:sentry-android", version.ref = "glitchtips" } jaad = { module = "com.tianscar.javasound:jaad", version.ref = "jaad" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
@@ -64,7 +73,6 @@ lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:li
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
navigation3 = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } navigation3 = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
vorbisspi = { module = "com.googlecode.soundlibs:vorbisspi", version.ref = "vorbisspi" }
wrappers-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlinWrappers" } wrappers-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlinWrappers" }
[plugins] [plugins]
@@ -75,4 +83,5 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k
googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
sentry = { id = "io.sentry.kotlin.multiplatform.gradle", version.ref = "sentry" }
+2 -2
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+6 -4
View File
@@ -1,7 +1,9 @@
TEAM_ID= TEAM_ID=
PRODUCT_NAME=Chom Bev PRODUCT_NAME=Donemat
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID) PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
CURRENT_PROJECT_VERSION=1 // Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
MARKETING_VERSION=1.0 // Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
CURRENT_PROJECT_VERSION=8
MARKETING_VERSION=1.0.0
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more