Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8276112a2a | ||
|
|
524bedf663 | ||
|
|
dbd899cde7 | ||
|
|
36a23d88c0 | ||
|
|
471be37fe4 | ||
|
|
2c1de5cf37 | ||
|
|
abeb484258 | ||
|
|
df38936893 | ||
|
|
38c5d2678a | ||
|
|
399b7964cf | ||
|
|
70be7bf332 | ||
|
|
e90d37c8e0 | ||
|
|
b3fe80aad9 | ||
|
|
b7076a8b07 | ||
|
|
6bdbc0467f | ||
|
|
82a2b9683d | ||
|
|
7a8b535a17 | ||
|
|
073a1a6de0 | ||
|
|
34cb8d24a4 | ||
|
|
0add1176db | ||
|
|
0d42413136 | ||
|
|
f2e35fc8f2 | ||
|
|
6d31dcda93 | ||
|
|
032ba458f7 | ||
|
|
d51223bb19 | ||
|
|
d721b0475d | ||
|
|
9cbb4181b3 | ||
|
|
0324186130 | ||
|
|
5539bf8b0d | ||
|
|
82a2ef3bdb | ||
|
|
8339ee128b | ||
|
|
8dc1e8e71e | ||
|
|
1a23703911 | ||
|
|
242d494c86 | ||
|
|
477cb445e3 | ||
|
|
da9619b8fe | ||
|
|
8f9e1ab87d | ||
|
|
e12b7f1bfd | ||
|
|
59116a8248 | ||
|
|
77f76a22ea | ||
|
|
a143ac1a68 | ||
|
|
1700e2ea72 | ||
|
|
021f1e773e |
No files matched your search
@@ -7,6 +7,57 @@ 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
|
### Changed
|
||||||
|
|
||||||
- show on boarding as a modal on large screens
|
- show on boarding as a modal on large screens
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -35,10 +34,17 @@ dependencies {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "bzh.ajaury.chombev"
|
namespace = "bzh.ajaury.chombev"
|
||||||
compileSdk =
|
compileSdk {
|
||||||
|
version = release(
|
||||||
libs.versions.androidCompileSdk
|
libs.versions.androidCompileSdk
|
||||||
|
.get()
|
||||||
|
.toInt(),
|
||||||
|
) {
|
||||||
|
minorApiLevel = libs.versions.androidCompileSdkMinor
|
||||||
.get()
|
.get()
|
||||||
.toInt()
|
.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "bzh.ksk.donemat"
|
applicationId = "bzh.ksk.donemat"
|
||||||
@@ -50,8 +56,8 @@ android {
|
|||||||
libs.versions.androidTargetSdk
|
libs.versions.androidTargetSdk
|
||||||
.get()
|
.get()
|
||||||
.toInt()
|
.toInt()
|
||||||
versionCode = 3
|
versionCode = 8
|
||||||
versionName = "1.0.0-beta02"
|
versionName = "1.0.0"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
+12
@@ -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>() }
|
||||||
|
}
|
||||||
+7
@@ -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()
|
||||||
+7
@@ -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
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package bzh.ajaury.chombev.core.accessibility.di
|
||||||
|
|
||||||
|
import org.koin.core.module.Module
|
||||||
|
|
||||||
|
expect val accessibilityModule: Module
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package bzh.ajaury.chombev.core.accessibility.domain
|
||||||
|
|
||||||
|
interface AccessibilityRepository {
|
||||||
|
fun isScreenReaderEnabled(): Boolean
|
||||||
|
}
|
||||||
+13
@@ -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)
|
||||||
+8
@@ -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()
|
||||||
|
}
|
||||||
+12
@@ -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>() }
|
||||||
|
}
|
||||||
+80
@@ -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,
|
||||||
|
)
|
||||||
+12
@@ -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>() }
|
||||||
|
}
|
||||||
+7
@@ -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()
|
||||||
+12
@@ -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>() }
|
||||||
|
}
|
||||||
+7
@@ -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()
|
||||||
+4
-1
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+140
-102
@@ -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,20 +138,31 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,57 +175,107 @@ internal class PlaybackRepositoryImpl(
|
|||||||
*
|
*
|
||||||
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
||||||
*
|
*
|
||||||
* This only happens on automatic advancement — manual seeking and previous/next navigation are
|
* A phrase is considered reached as soon as a polled position is past its start minus
|
||||||
* ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING]
|
* [PHRASE_LOOKAHEAD], even if the poll came late and the phrase already started: in that case
|
||||||
* through this path.
|
* playback is rewound to the phrase start when resuming. Each phrase is only handled once.
|
||||||
|
*
|
||||||
|
* Manual seeking and previous/next navigation never trigger a break: [seekAudio] marks the
|
||||||
|
* phrase at the target position as already reached.
|
||||||
*/
|
*/
|
||||||
private suspend fun observePhraseBreaks() {
|
private fun observePhraseBreaks() {
|
||||||
phraseBreakJob?.cancel()
|
phraseBreakJob?.cancel()
|
||||||
cancelRunningBreakJob()
|
phraseBreakJob = scope.launch {
|
||||||
phraseBreakJob = subtitleProgress
|
playbackState.collectLatest { state ->
|
||||||
.map { it.isGoingToPlayNextPhrase }
|
if (state != PlaybackState.PLAYING) return@collectLatest
|
||||||
.distinctUntilChanged()
|
while (true) {
|
||||||
.filter { it }
|
checkPhraseBoundary()
|
||||||
.onEach {
|
delay(POSITION_POLL_INTERVAL)
|
||||||
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val playbackPreferences = preferencesRepository.playbackPreferences.first()
|
private fun checkPhraseBoundary() {
|
||||||
if (playbackPreferences.autoPause) {
|
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()
|
audioPlayer.pause()
|
||||||
return@onEach
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val breakDuration = playbackPreferences.breakBetweenPhrases
|
preferences.breakBetweenPhrases > Duration.ZERO -> {
|
||||||
if (breakDuration <= Duration.ZERO) return@onEach
|
insertBreakBeforePhrase(
|
||||||
|
breakDuration = preferences.breakBetweenPhrases,
|
||||||
insertBreakBeforePhrase(breakDuration = breakDuration)
|
phraseStart = phraseStart,
|
||||||
}.launchIn(scope)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun insertBreakBeforePhrase(breakDuration: Duration) {
|
private fun insertBreakBeforePhrase(
|
||||||
breakJob?.cancelAndJoin()
|
breakDuration: Duration,
|
||||||
breakJob = scope.launch {
|
phraseStart: Duration,
|
||||||
try {
|
) {
|
||||||
// Pause and rewind the polling overshoot so the next phrase resumes from its very start.
|
breakJob?.cancel()
|
||||||
|
pendingPhraseStart = phraseStart
|
||||||
isBreaking.value = true
|
isBreaking.value = true
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
|
breakJob = scope.launch {
|
||||||
delay(breakDuration)
|
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
|
isBreaking.value = false
|
||||||
}
|
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playAudio(filePath: String) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,24 +297,30 @@ internal class PlaybackRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun play() {
|
override suspend fun play() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
|
val phraseStart = pendingPhraseStart
|
||||||
|
if (phraseStart != null) {
|
||||||
|
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||||
|
} else {
|
||||||
audioPlayer.play()
|
audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun pause() {
|
override suspend fun pause() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun replay() {
|
override suspend fun replay() {
|
||||||
cancelRunningBreakJob()
|
cancelRunningBreak()
|
||||||
audioPlayer.seekTo(Duration.ZERO)
|
seekAudio(position = Duration.ZERO)
|
||||||
audioPlayer.play()
|
audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun seekTo(position: Duration) {
|
override suspend fun seekTo(position: Duration) {
|
||||||
cancelRunningBreakJob()
|
val wasBreaking = cancelRunningBreak()
|
||||||
audioPlayer.seekTo(position)
|
seekAudio(position = position)
|
||||||
|
if (wasBreaking) audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSpeed(speed: Float) {
|
private fun setSpeed(speed: Float) {
|
||||||
@@ -310,9 +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? =
|
||||||
@@ -322,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,
|
|
||||||
)
|
)
|
||||||
+4
-1
@@ -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()
|
||||||
|
|
||||||
|
|||||||
+249
@@ -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
|
||||||
|
}
|
||||||
@@ -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 Donemat - Breton d'Auray – À propos – 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 » 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 » 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&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&format=xml&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&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&format=xml&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">
|
||||||
|
« <strong>Donemat – Breton d’Auray</strong> » est une application
|
||||||
|
qui vous est proposée gratuitement par l’association <strong>Kerlenn Sten
|
||||||
|
Kidna</strong> dans le but de vous aider à vous initier au Breton, et
|
||||||
|
particulièrement aux sonorités et tournures typiques du <strong>Breton
|
||||||
|
Vannetais du Pays d’Auray</strong>.</p>
|
||||||
|
|
||||||
|
|
||||||
|
<h2 class="wp-block-heading has-large-font-size"><span
|
||||||
<pre class="wp-block-preformatted">"<strong>Donemat - Breton d'Auray</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
|
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 « Chom Bev e Brezhoneg », 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 & en
|
||||||
|
soirée, en présentiel & à 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’association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. «Donemat - Breton d'Auray» est une oeuvre créée par KSK, initialement sous la forme d’un livret imprimé et accompagné d’un CD audio ("Chom Bev e Brezhoneg"). 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, soirées jeux, cafés pain-beurre, club lecteur …) et des cours hebdomadaires de breton (en journée & en soirée, en présentiel & à 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’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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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’app, pilotage & développement</h3>
|
style="font-size:clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px);">
|
||||||
|
Conception de l’app, pilotage & 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’application 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>
|
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’application est disponible sur : <a href="https://.../chombev">https://…/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’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’est le cas notamment du Pays d’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’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’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’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’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’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’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>
|
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’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’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 Donemat - Breton d'Auray - Approfondir – 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 » 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 » 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&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&format=xml&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&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&format=xml&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’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’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’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’<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>
|
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’é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’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, à 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 … 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’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’occasion de croiser une tête que l’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’on puisse entendre sont ceux que l’on vit !</p>
|
« Mont a ra mat ? » 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,53 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<!-- Breton (br) -->
|
|
||||||
<string name="app_name">Donemat - Breton d'Auray</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="auto_pause_title">Ehan emgefreek</string>
|
|
||||||
<string name="auto_pause_description">Ehanañ goude pep frazenn</string>
|
|
||||||
|
|
||||||
<string name="onboarding_goal_title">Degemer mat e "Donemat - Breton d'Auray"</string>
|
|
||||||
<string name="onboarding_goal_description">Deskit brezhoneg en ur selaou enrolladennoù, frazenn ha frazenn, evit gwellaat ho komprenidigezh hag ho tistagadur.</string>
|
|
||||||
<string name="onboarding_subtitles_title">Istitloù diouzh ho tro</string>
|
|
||||||
<string name="onboarding_subtitles_description">Adalek al lenner, diskouezit pe kuzhit an treuzskrivadur brezhonek hag an droidigezh c\'hallek, cheñchit ment an destenn hag ar merkoù distagañ.</string>
|
|
||||||
<string name="onboarding_playback_title">Un tizh diouzh ho live</string>
|
|
||||||
<string name="onboarding_playback_description">Gorrekait tizh al lenn hag ouzhpennit un ehan etre ar frazennoù evit kaout amzer da adlavaret.</string>
|
|
||||||
<string name="onboarding_level_title">Dibabit ho live</string>
|
|
||||||
<string name="onboarding_level_description">Gallout a reot cheñch an arventennoù-se pa garot adalek al lenner.</string>
|
|
||||||
<string name="onboarding_level_beginner_title">Deraouad</string>
|
|
||||||
<string name="onboarding_level_beginner_description">Lenn gorrekaet (x0.9) gant un ehan 3 s etre ar frazennoù</string>
|
|
||||||
<string name="onboarding_level_advanced_title">Boas / araokaet</string>
|
|
||||||
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
|
|
||||||
<string name="onboarding_next">War-lerc\'h</string>
|
|
||||||
<string name="onboarding_start">Kregiñ</string>
|
|
||||||
<string name="onboarding_close">Serriñ</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>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<!-- Default locale: French -->
|
<!-- Default locale: French -->
|
||||||
<string name="app_name">Donemat - Breton d'Auray</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>
|
||||||
|
|
||||||
@@ -11,20 +11,27 @@
|
|||||||
|
|
||||||
<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$ds</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_title">Pause automatique</string>
|
||||||
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
|
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
|
||||||
|
|
||||||
@@ -44,9 +51,15 @@
|
|||||||
<string name="onboarding_start">Commencer</string>
|
<string name="onboarding_start">Commencer</string>
|
||||||
<string name="onboarding_close">Fermer</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>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
// Core modules
|
||||||
|
implementation(projects.core.accessibility)
|
||||||
|
|
||||||
// Data modules
|
// Data modules
|
||||||
implementation(projects.data.preferences)
|
implementation(projects.data.preferences)
|
||||||
implementation(projects.data.resources)
|
implementation(projects.data.resources)
|
||||||
|
|||||||
+3
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.model.OnboardingLevel
|
||||||
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
|
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage
|
||||||
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingLevelPage
|
import bzh.ajaury.chombev.onboarding.ui.components.OnboardingLevelPage
|
||||||
@@ -90,6 +91,7 @@ fun OnboardingScreen(
|
|||||||
// The content sizes itself against the window rather than the platform default width.
|
// The content sizes itself against the window rather than the platform default width.
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
) {
|
) {
|
||||||
|
ProvideDynamicTypeFontScale {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
|
.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT)
|
||||||
@@ -105,6 +107,7 @@ fun OnboardingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Scaffold { innerPadding ->
|
Scaffold { innerPadding ->
|
||||||
OnboardingContent(
|
OnboardingContent(
|
||||||
|
|||||||
-1
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -51,7 +51,6 @@ fun OnboardingLevelPage(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.onboarding_level_description),
|
text = stringResource(Res.string.onboarding_level_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
LevelOption(
|
LevelOption(
|
||||||
@@ -110,7 +109,6 @@ private fun LevelOption(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
|
|||||||
if (index == currentPage) {
|
if (index == currentPage) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.onBackground
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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
|
||||||
|
}
|
||||||
+17
-2
@@ -8,11 +8,13 @@ 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.preferences.ui.PreferenceToggle
|
||||||
@@ -21,7 +23,10 @@ import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description
|
|||||||
import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title
|
import bzh.ajaury.chombev.resources.generated.resources.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
|
||||||
|
|
||||||
@@ -40,12 +45,13 @@ fun PlaybackSpeedBottomSheet(
|
|||||||
onBreakChange: (Float) -> Unit = {},
|
onBreakChange: (Float) -> Unit = {},
|
||||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||||
onDismiss: () -> Unit = {},
|
onDismiss: () -> Unit = {},
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
|
ProvideDynamicTypeFontScale {
|
||||||
PlaybackSpeedContent(
|
PlaybackSpeedContent(
|
||||||
speed = speed,
|
speed = speed,
|
||||||
breakSeconds = breakSeconds,
|
breakSeconds = breakSeconds,
|
||||||
@@ -56,6 +62,7 @@ fun PlaybackSpeedBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaybackSpeedContent(
|
private fun PlaybackSpeedContent(
|
||||||
@@ -84,6 +91,8 @@ 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,
|
||||||
@@ -101,6 +110,12 @@ private fun PlaybackSpeedContent(
|
|||||||
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,
|
||||||
|
|||||||
@@ -58,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,
|
||||||
)
|
)
|
||||||
@@ -69,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,
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-30
@@ -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
|
||||||
@@ -110,6 +119,9 @@ fun PlayerScreen(
|
|||||||
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,
|
||||||
@@ -185,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,
|
||||||
)
|
)
|
||||||
@@ -227,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),
|
||||||
@@ -245,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(
|
||||||
@@ -270,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),
|
||||||
@@ -301,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,
|
||||||
),
|
),
|
||||||
@@ -326,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),
|
||||||
|
|||||||
+30
@@ -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
|
||||||
|
|||||||
+67
-5
@@ -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,27 +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,
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
// Dim the labels to match the Slider's disabled appearance.
|
// Dim the labels to match the Slider's disabled appearance.
|
||||||
val contentAlpha = if (enabled) 1f else 0.38f
|
val contentAlpha = if (enabled) 1f else 0.38f
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(
|
||||||
|
// The whole block (title, value and slider) is a single adjustable element for screen
|
||||||
|
// readers, named by the title: swiping up or down moves one step and reads the new value.
|
||||||
|
modifier = Modifier.clearAndSetSemantics {
|
||||||
|
contentDescription = title
|
||||||
|
stateDescription = valueDescription
|
||||||
|
progressBarRangeInfo = ProgressBarRangeInfo(
|
||||||
|
current = value,
|
||||||
|
range = valueRange,
|
||||||
|
steps = steps,
|
||||||
|
)
|
||||||
|
if (enabled) {
|
||||||
|
setProgress { target ->
|
||||||
|
val newValue = target.snapToSteps(valueRange, steps)
|
||||||
|
if (newValue == value) return@setProgress false
|
||||||
|
onValueChange(newValue)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
disabled()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
Row(
|
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,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
|
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.copy(alpha = contentAlpha),
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
@@ -53,6 +90,17 @@ fun LabeledSlider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
@@ -67,6 +115,20 @@ 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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabeledSliderDisabledPreview() {
|
private fun LabeledSliderDisabledPreview() {
|
||||||
|
|||||||
+15
-1
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+2
-1
@@ -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) {
|
||||||
|
|||||||
+4
-1
@@ -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 { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+13
-1
@@ -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(
|
||||||
|
|||||||
+6
-4
@@ -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,8 +8,7 @@ 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,
|
||||||
@@ -20,7 +19,10 @@ data class PlayerUiState(
|
|||||||
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 any 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
|
||||||
|
|||||||
+72
-20
@@ -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,8 +47,7 @@ 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,
|
||||||
@@ -62,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,43 +85,79 @@ 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 -> playbackRepository.pause()
|
PlaybackState.PLAYING, PlaybackState.BREAKING -> {
|
||||||
PlaybackState.ENDED -> playbackRepository.replay()
|
cancelPendingPlayback()
|
||||||
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
|
viewModelScope.launch { playbackRepository.pause() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -178,6 +223,7 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
cancelPendingPlayback()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackRepository.stop()
|
playbackRepository.stop()
|
||||||
}
|
}
|
||||||
@@ -187,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-1
@@ -51,7 +51,6 @@ fun PreferenceToggle(
|
|||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-3
@@ -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,12 +63,13 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
ProvideDynamicTypeFontScale {
|
||||||
SubtitlePreferencesContent(
|
SubtitlePreferencesContent(
|
||||||
subtitlePreferences = subtitlePreferences,
|
subtitlePreferences = subtitlePreferences,
|
||||||
onShowTranscriptionChange = onShowTranscriptionChange,
|
onShowTranscriptionChange = onShowTranscriptionChange,
|
||||||
@@ -66,6 +80,7 @@ fun SubtitlePreferencesBottomSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubtitlePreferencesContent(
|
private fun SubtitlePreferencesContent(
|
||||||
@@ -79,6 +94,8 @@ 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),
|
||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -1,29 +1,33 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.3.2" # https://developer.android.com/build/releases/gradle-plugin
|
agp = "9.5.0" # https://developer.android.com/build/releases/gradle-plugin
|
||||||
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
|
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||||
|
androidCompileSdkMinor = "1" # Compose 1.13 requires compileSdk 37.1
|
||||||
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
|
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
|
||||||
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
|
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||||
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
|
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
|
||||||
|
androidxComposeUi = "1.13.0-beta02" # https://developer.android.com/jetpack/androidx/releases/compose-ui
|
||||||
|
androidxEspresso = "3.7.0" # https://developer.android.com/jetpack/androidx/releases/test
|
||||||
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
|
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||||
androidxWebkit = "1.17.0" # https://developer.android.com/jetpack/androidx/releases/webkit
|
androidxTestRunner = "1.7.0" # https://developer.android.com/jetpack/androidx/releases/test
|
||||||
|
androidxWebkit = "1.18.0" # https://developer.android.com/jetpack/androidx/releases/webkit
|
||||||
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
|
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||||
composeMultiplatform = "1.12.0" # https://github.com/JetBrains/compose-multiplatform/releases
|
composeMultiplatform = "1.13.0-beta01" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||||
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
|
datastore = "1.3.0" # https://developer.android.com/jetpack/androidx/releases/datastore
|
||||||
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
||||||
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
|
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
|
||||||
kermit = "2.1.0" # https://github.com/touchlab/Kermit/releases
|
kermit = "2.2.0" # https://github.com/touchlab/Kermit/releases
|
||||||
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
|
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
|
||||||
kotlin = "2.4.10" # https://github.com/JetBrains/kotlin/releases
|
kotlin = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||||
kotlinSerialization = "2.4.10" # https://github.com/JetBrains/kotlin/releases
|
kotlinSerialization = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||||
kotlinWrappers = "2026.9.1" # https://github.com/JetBrains/kotlin-wrappers/releases
|
kotlinWrappers = "2026.11.0" # https://github.com/JetBrains/kotlin-wrappers/releases
|
||||||
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
|
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
|
||||||
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
||||||
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases
|
kotlinxSerializationJson = "1.12.0" # https://github.com/Kotlin/kotlinx.serialization/releases
|
||||||
material3 = "1.11.0-alpha07" # https://developer.android.com/jetpack/androidx/releases/compose-material3
|
material3 = "1.13.0-beta01" # https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||||
media3 = "1.11.0" # https://developer.android.com/jetpack/androidx/releases/media3
|
media3 = "1.11.1" # https://developer.android.com/jetpack/androidx/releases/media3
|
||||||
navigation3 = "1.1.1" # https://developer.android.com/jetpack/androidx/releases/navigation3
|
navigation3 = "1.2.0" # https://developer.android.com/jetpack/androidx/releases/navigation3
|
||||||
okio = "3.18.1" # https://square.github.io/okio/changelog/
|
okio = "3.18.2" # https://square.github.io/okio/changelog/
|
||||||
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
|
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,6 +48,9 @@ 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" }
|
||||||
jaad = { module = "com.tianscar.javasound:jaad", version.ref = "jaad" }
|
jaad = { module = "com.tianscar.javasound:jaad", version.ref = "jaad" }
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
TEAM_ID=
|
TEAM_ID=
|
||||||
|
|
||||||
PRODUCT_NAME=Donemat - Breton d'Auray
|
PRODUCT_NAME=Donemat
|
||||||
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
|
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
|
||||||
|
|
||||||
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
||||||
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
||||||
CURRENT_PROJECT_VERSION=3
|
CURRENT_PROJECT_VERSION=8
|
||||||
MARKETING_VERSION=1.0.0.2
|
MARKETING_VERSION=1.0.0
|
||||||
@@ -7,15 +7,15 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = F36B7318302CAEC900B3E490 /* Sentry */; };
|
49231CBE6031106FDA386B1B /* KotlinMultiplatformLinkedPackage in Frameworks */ = {isa = PBXBuildFile; productRef = BF1E7FC8A3BBAFF25B00CB9D /* KotlinMultiplatformLinkedPackage */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Donemat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
95ABF2C8EE96D87C60CB4B62 /* Exceptions for "iosApp" folder in "iosApp" target */ = {
|
95ABF2C8EE96D87C60CB4B62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -28,13 +28,21 @@
|
|||||||
89B7F8019E767C8CDD8E9E81 /* iosApp */ = {
|
89B7F8019E767C8CDD8E9E81 /* iosApp */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
95ABF2C8EE96D87C60CB4B62 /* Exceptions for "iosApp" folder in "iosApp" target */,
|
95ABF2C8EE96D87C60CB4B62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
|
||||||
|
);
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
);
|
);
|
||||||
path = iosApp;
|
path = iosApp;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D8035B75140A5CBF147B4F8A /* Configuration */ = {
|
D8035B75140A5CBF147B4F8A /* Configuration */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
|
);
|
||||||
path = Configuration;
|
path = Configuration;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -45,7 +53,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */,
|
49231CBE6031106FDA386B1B /* KotlinMultiplatformLinkedPackage in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -90,7 +98,7 @@
|
|||||||
);
|
);
|
||||||
name = iosApp;
|
name = iosApp;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
F36B7318302CAEC900B3E490 /* Sentry */,
|
BF1E7FC8A3BBAFF25B00CB9D /* KotlinMultiplatformLinkedPackage */,
|
||||||
);
|
);
|
||||||
productName = iosApp;
|
productName = iosApp;
|
||||||
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
|
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
|
||||||
@@ -112,16 +120,16 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 7D7378CC21ADE468E3035338 /* Build configuration list for PBXProject "iosApp" */;
|
buildConfigurationList = 7D7378CC21ADE468E3035338 /* Build configuration list for PBXProject "iosApp" */;
|
||||||
developmentRegion = en;
|
developmentRegion = fr;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
fr,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = A562F15E4A0F12F420BB5B12;
|
mainGroup = A562F15E4A0F12F420BB5B12;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
E9D3AD5E9D18D87B871B00C0 /* XCLocalSwiftPackageReference "KotlinMultiplatformLinkedPackage" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 47B0C8770C82BEADFE13EF77 /* Products */;
|
productRefGroup = 47B0C8770C82BEADFE13EF77 /* Products */;
|
||||||
@@ -183,21 +191,28 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 492RZGQ5SU;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = UXC58NXTGL;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Donemat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Antoine;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -276,21 +291,28 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 492RZGQ5SU;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = UXC58NXTGL;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = iosApp/Info.plist;
|
INFOPLIST_FILE = iosApp/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Donemat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = Antoine;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -378,22 +400,17 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
E9D3AD5E9D18D87B871B00C0 /* XCLocalSwiftPackageReference "KotlinMultiplatformLinkedPackage" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/getsentry/sentry-cocoa";
|
relativePath = KotlinMultiplatformLinkedPackage;
|
||||||
requirement = {
|
|
||||||
kind = exactVersion;
|
|
||||||
version = 8.58.2;
|
|
||||||
};
|
};
|
||||||
};
|
/* End XCLocalSwiftPackageReference section */
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
F36B7318302CAEC900B3E490 /* Sentry */ = {
|
BF1E7FC8A3BBAFF25B00CB9D /* KotlinMultiplatformLinkedPackage */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */;
|
productName = KotlinMultiplatformLinkedPackage;
|
||||||
productName = Sentry;
|
|
||||||
};
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "app-icon-1024.png",
|
"filename" : "logo_ios.jpg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -4,6 +4,12 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>fr</string>
|
||||||
|
</array>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "fr",
|
||||||
|
"strings" : {
|
||||||
|
"CFBundleDisplayName" : {
|
||||||
|
"comment" : "Bundle display name",
|
||||||
|
"localizations" : {
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Donemat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CFBundleName" : {
|
||||||
|
"comment" : "Bundle name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Donemat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version" : "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<!-- Crash and diagnostic data collected by GlitchTip are declared by Sentry.framework's own manifest. -->
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<!-- stat/fstat/lstat, NSFileCreationDate: DataStore (okio) and Compose resources reading files in the app container. -->
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<!-- NSProcessInfo.systemUptime: measuring elapsed time (Kotlin/Native runtime, coroutines). -->
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>35F9.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -39,6 +39,7 @@ include(":webApp")
|
|||||||
include(":shared")
|
include(":shared")
|
||||||
|
|
||||||
// Core modules
|
// Core modules
|
||||||
|
include(":core:accessibility")
|
||||||
include(":core:audioplayer")
|
include(":core:audioplayer")
|
||||||
include(":core:coroutines")
|
include(":core:coroutines")
|
||||||
include(":core:logging")
|
include(":core:logging")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Core modules
|
// Core modules
|
||||||
|
implementation(projects.core.accessibility)
|
||||||
implementation(projects.core.coroutines)
|
implementation(projects.core.coroutines)
|
||||||
implementation(projects.core.logging)
|
implementation(projects.core.logging)
|
||||||
implementation(projects.core.model)
|
implementation(projects.core.model)
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import bzh.ajaury.chombev.web.model.WebPage
|
|||||||
*/
|
*/
|
||||||
object WebPages {
|
object WebPages {
|
||||||
val DEEPEN = WebPage(
|
val DEEPEN = WebPage(
|
||||||
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
|
url = "https://ksk.bzh/fr/donemat-approfondir/",
|
||||||
offlineAsset = "files/offline_deepen.html",
|
offlineAsset = "files/offline_deepen.html",
|
||||||
)
|
)
|
||||||
val ABOUT = WebPage(
|
val ABOUT = WebPage(
|
||||||
url = "https://ksk.bzh/fr/chom-bev-a-propos/",
|
url = "https://ksk.bzh/fr/donemat-a-propos/",
|
||||||
offlineAsset = "files/offline_about.html",
|
offlineAsset = "files/offline_about.html",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
|
|
||||||
// region Light
|
// region Light
|
||||||
internal val md_light_primary = Color(0xFFFF9201)
|
internal val md_light_primary = Color(0xFFFF9201)
|
||||||
internal val md_light_onPrimary = Color(0xFFFFFFFF)
|
internal val md_light_onPrimary = Color(0xFF000000)
|
||||||
internal val md_light_primaryContainer = Color(0xFFFFEBD1)
|
internal val md_light_primaryContainer = Color(0xFFFFEBD1)
|
||||||
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
|
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
|
||||||
|
|
||||||
@@ -25,10 +25,10 @@ internal val md_light_onErrorContainer = Color(0xFF410002)
|
|||||||
|
|
||||||
internal val md_light_background = Color(0xFFF5FAFB)
|
internal val md_light_background = Color(0xFFF5FAFB)
|
||||||
internal val md_light_onBackground = Color(0xFF171D1E)
|
internal val md_light_onBackground = Color(0xFF171D1E)
|
||||||
internal val md_light_surface = Color(0xFFF5FAFB)
|
internal val md_light_surface = Color(0xFFFFFFFF)
|
||||||
internal val md_light_onSurface = Color(0xFF171D1E)
|
internal val md_light_onSurface = Color(0xFF171D1E)
|
||||||
internal val md_light_surfaceVariant = Color(0xFF91949C)
|
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
|
||||||
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
|
internal val md_light_onSurfaceVariant = Color(0xFF000000)
|
||||||
|
|
||||||
internal val md_light_outline = Color(0xFF6F797B)
|
internal val md_light_outline = Color(0xFF6F797B)
|
||||||
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
|
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
|
||||||
@@ -64,7 +64,7 @@ internal val md_dark_onBackground = Color(0xFFDEE3E4)
|
|||||||
internal val md_dark_surface = Color(0xFF0E1415)
|
internal val md_dark_surface = Color(0xFF0E1415)
|
||||||
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
||||||
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
|
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
|
||||||
internal val md_dark_onSurfaceVariant = Color(0xFFBFC8CA)
|
internal val md_dark_onSurfaceVariant = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
internal val md_dark_outline = Color(0xFF899294)
|
internal val md_dark_outline = Color(0xFF899294)
|
||||||
internal val md_dark_outlineVariant = Color(0xFF3F484A)
|
internal val md_dark_outlineVariant = Color(0xFF3F484A)
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package bzh.ajaury.chombev
|
package bzh.ajaury.chombev
|
||||||
|
|
||||||
import androidx.compose.ui.window.ComposeUIViewController
|
import androidx.compose.ui.window.ComposeUIViewController
|
||||||
|
import bzh.ajaury.chombev.core.accessibility.ui.ProvideDynamicTypeFontScale
|
||||||
|
|
||||||
@Suppress("ktlint:standard:function-naming")
|
@Suppress("ktlint:standard:function-naming")
|
||||||
fun MainViewController() = ComposeUIViewController { App() }
|
fun MainViewController() =
|
||||||
|
ComposeUIViewController {
|
||||||
|
ProvideDynamicTypeFontScale {
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package bzh.ajaury.chombev.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import kotlin.math.round
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the color pairs of both themes against the WCAG 2.2 AA contrast ratios: 4.5:1 for text
|
||||||
|
* (1.4.3) and 3:1 for meaningful non-text elements such as borders (1.4.11).
|
||||||
|
*
|
||||||
|
* Each failure lists every offending pair at once, with its ratio, so a palette change can be fixed
|
||||||
|
* in one go. The palette lives in commonMain, so checking it here covers every platform.
|
||||||
|
*/
|
||||||
|
class ColorContrastTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lightColorSchemeMeetsWcagAa() {
|
||||||
|
assertMeetsWcagAa(ChomBevLightColorScheme, themeName = "light")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun darkColorSchemeMeetsWcagAa() {
|
||||||
|
assertMeetsWcagAa(ChomBevDarkColorScheme, themeName = "dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contrastRatioMatchesWcagReferenceValues() {
|
||||||
|
assertEquals(21.0, contrastRatio(Color.Black, Color.White), absoluteTolerance = 0.01)
|
||||||
|
assertEquals(1.0, contrastRatio(Color.White, Color.White), absoluteTolerance = 0.01)
|
||||||
|
// Reference value from the WebAIM contrast checker: #767676 is the lightest gray passing
|
||||||
|
// 4.5:1 on white.
|
||||||
|
assertEquals(4.54, contrastRatio(Color(0xFF767676), Color.White), absoluteTolerance = 0.01)
|
||||||
|
// The ratio does not depend on which color is the foreground.
|
||||||
|
assertEquals(contrastRatio(Color.Red, Color.White), contrastRatio(Color.White, Color.Red))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertMeetsWcagAa(scheme: ColorScheme, themeName: String) {
|
||||||
|
val failures = contrastPairs(scheme).mapNotNull { pair ->
|
||||||
|
val ratio = contrastRatio(pair.foreground, pair.background)
|
||||||
|
if (ratio >= pair.minRatio) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
"${pair.name}: ${ratio.format()}:1 (minimum ${pair.minRatio}:1)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failures.isNotEmpty()) {
|
||||||
|
fail(
|
||||||
|
"The $themeName color scheme fails WCAG AA contrast for:\n" +
|
||||||
|
failures.joinToString(separator = "\n") { " - $it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contrastPairs(scheme: ColorScheme): List<ContrastPair> = with(scheme) {
|
||||||
|
val surfaces = listOf(
|
||||||
|
"surface" to surface,
|
||||||
|
"surfaceDim" to surfaceDim,
|
||||||
|
"surfaceBright" to surfaceBright,
|
||||||
|
"surfaceContainerLowest" to surfaceContainerLowest,
|
||||||
|
"surfaceContainerLow" to surfaceContainerLow,
|
||||||
|
"surfaceContainer" to surfaceContainer,
|
||||||
|
"surfaceContainerHigh" to surfaceContainerHigh,
|
||||||
|
"surfaceContainerHighest" to surfaceContainerHighest,
|
||||||
|
)
|
||||||
|
buildList {
|
||||||
|
// Content colors on the color they are paired with.
|
||||||
|
text("onPrimary", onPrimary, "primary", primary)
|
||||||
|
text("onPrimaryContainer", onPrimaryContainer, "primaryContainer", primaryContainer)
|
||||||
|
text("onSecondary", onSecondary, "secondary", secondary)
|
||||||
|
text("onSecondaryContainer", onSecondaryContainer, "secondaryContainer", secondaryContainer)
|
||||||
|
text("onTertiary", onTertiary, "tertiary", tertiary)
|
||||||
|
text("onTertiaryContainer", onTertiaryContainer, "tertiaryContainer", tertiaryContainer)
|
||||||
|
text("onError", onError, "error", error)
|
||||||
|
text("onErrorContainer", onErrorContainer, "errorContainer", errorContainer)
|
||||||
|
text("onBackground", onBackground, "background", background)
|
||||||
|
text("onSurfaceVariant", onSurfaceVariant, "surfaceVariant", surfaceVariant)
|
||||||
|
text("inverseOnSurface", inverseOnSurface, "inverseSurface", inverseSurface)
|
||||||
|
// Snackbar action.
|
||||||
|
text("inversePrimary", inversePrimary, "inverseSurface", inverseSurface)
|
||||||
|
|
||||||
|
surfaces.forEach { (surfaceName, surfaceColor) ->
|
||||||
|
text("onSurface", onSurface, surfaceName, surfaceColor)
|
||||||
|
text("onSurfaceVariant", onSurfaceVariant, surfaceName, surfaceColor)
|
||||||
|
// Colored text: text buttons, slider values, error messages.
|
||||||
|
text("primary", primary, surfaceName, surfaceColor)
|
||||||
|
text("error", error, surfaceName, surfaceColor)
|
||||||
|
// Borders of text fields and outlined components.
|
||||||
|
nonText("outline", outline, surfaceName, surfaceColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<ContrastPair>.text(
|
||||||
|
foregroundName: String,
|
||||||
|
foreground: Color,
|
||||||
|
backgroundName: String,
|
||||||
|
background: Color,
|
||||||
|
) {
|
||||||
|
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, TEXT_MIN_RATIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<ContrastPair>.nonText(
|
||||||
|
foregroundName: String,
|
||||||
|
foreground: Color,
|
||||||
|
backgroundName: String,
|
||||||
|
background: Color,
|
||||||
|
) {
|
||||||
|
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, NON_TEXT_MIN_RATIO))
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ContrastPair(
|
||||||
|
val name: String,
|
||||||
|
val foreground: Color,
|
||||||
|
val background: Color,
|
||||||
|
val minRatio: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TEXT_MIN_RATIO = 4.5
|
||||||
|
const val NON_TEXT_MIN_RATIO = 3.0
|
||||||
|
|
||||||
|
/** WCAG contrast ratio, from 1 (identical) to 21 (black on white). */
|
||||||
|
fun contrastRatio(first: Color, second: Color): Double {
|
||||||
|
// Color.luminance() is the WCAG relative luminance of the sRGB color.
|
||||||
|
val lighter = maxOf(first.luminance(), second.luminance())
|
||||||
|
val darker = minOf(first.luminance(), second.luminance())
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Double.format(): String {
|
||||||
|
val hundredths = round(this * 100).toInt()
|
||||||
|
return "${hundredths / 100}.${(hundredths % 100).toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user