diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml
index 42c5548..5c648fd 100644
--- a/data/resources/src/commonMain/composeResources/values/strings.xml
+++ b/data/resources/src/commonMain/composeResources/values/strings.xml
@@ -24,9 +24,14 @@
Afficher les consonnes muettes et les liaisons
Vitesse de lecture
+ %1$d %
Réglages de lecture
Pause entre les phrases
%1$d s
+
+ - %1$d seconde
+ - %1$d secondes
+
Pause automatique
Mettre en pause après chaque phrase
diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt
index 2f08c9c..f5fbef2 100644
--- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt
+++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlaybackSpeedBottomSheet.kt
@@ -22,7 +22,10 @@ import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description
import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value
+import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value_description
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title
+import bzh.ajaury.chombev.resources.generated.resources.playback_speed_value
+import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import kotlin.math.roundToInt
@@ -85,6 +88,8 @@ private fun PlaybackSpeedContent(
LabeledSlider(
title = stringResource(Res.string.playback_speed_title),
valueLabel = formatPlaybackSpeed(speed),
+ // "x0.7" reads poorly aloud: screen readers get "70 %" instead.
+ valueDescription = stringResource(Res.string.playback_speed_value, (speed * 100).roundToInt()),
value = speed,
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
steps = speedSteps,
@@ -102,6 +107,12 @@ private fun PlaybackSpeedContent(
Res.string.break_between_phrases_value,
breakSeconds.roundToInt().toString(),
),
+ // "2 s" may be spelled out letter by letter: screen readers get "2 secondes" instead.
+ valueDescription = pluralStringResource(
+ Res.plurals.break_between_phrases_value_description,
+ breakSeconds.roundToInt(),
+ breakSeconds.roundToInt(),
+ ),
value = breakSeconds,
valueRange = 0f..maxBreakSeconds.toFloat(),
steps = breakSteps,
diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt
index 0a8bffd..873d6e7 100644
--- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt
+++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/components/LabeledSlider.kt
@@ -10,8 +10,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.progressBarRangeInfo
+import androidx.compose.ui.semantics.setProgress
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
@Composable
fun LabeledSlider(
@@ -20,13 +28,37 @@ fun LabeledSlider(
value: Float,
valueRange: ClosedFloatingPointRange,
steps: Int,
+ valueDescription: String = valueLabel,
onValueChange: (Float) -> Unit = {},
enabled: Boolean = true,
) {
// Dim the labels to match the Slider's disabled appearance.
val contentAlpha = if (enabled) 1f else 0.38f
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Column(
+ // The whole block (title, value and slider) is a single adjustable element for screen
+ // readers, named by the title: swiping up or down moves one step and reads the new value.
+ modifier = Modifier.clearAndSetSemantics {
+ contentDescription = title
+ stateDescription = valueDescription
+ progressBarRangeInfo = ProgressBarRangeInfo(
+ current = value,
+ range = valueRange,
+ steps = steps,
+ )
+ if (enabled) {
+ setProgress { target ->
+ val newValue = target.snapToSteps(valueRange, steps)
+ if (newValue == value) return@setProgress false
+ onValueChange(newValue)
+ true
+ }
+ } else {
+ disabled()
+ }
+ },
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -53,6 +85,17 @@ fun LabeledSlider(
}
}
+private fun Float.snapToSteps(
+ range: ClosedFloatingPointRange,
+ steps: Int,
+): Float {
+ val clamped = coerceIn(range)
+ if (steps <= 0) return clamped
+ val stepSize = (range.endInclusive - range.start) / (steps + 1)
+ val stepIndex = ((clamped - range.start) / stepSize).roundToInt()
+ return (range.start + stepIndex * stepSize).coerceIn(range)
+}
+
@Preview
@Composable
private fun LabeledSliderPreview() {