refactor: improve player accessibility

This commit is contained in:
Antoine Jaury
2026-10-12 14:03:21 +02:00
parent 477cb445e3
commit 242d494c86
24 changed files with 193 additions and 21 deletions
+1
View File
@@ -14,6 +14,7 @@ kotlin {
}
commonMain.dependencies {
// Core modules
implementation(projects.core.accessibility)
implementation(projects.core.model)
implementation(projects.core.logging)
implementation(projects.core.audioplayer)
@@ -30,6 +30,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -43,6 +47,8 @@ import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.player_back
import bzh.ajaury.chombev.resources.generated.resources.player_back_action_label
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import org.jetbrains.compose.resources.stringResource
@@ -198,6 +204,9 @@ private fun PlayerTopBar(
label = "PlayerTitle",
) { (emoji, title) ->
Row(
modifier = Modifier.clearAndSetSemantics {
contentDescription = title.translation ?: title.transcription
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -227,15 +236,20 @@ private fun PlayerTopBar(
}
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
BackButton(onBackClicked = onBackClicked)
},
actions = {
IconButton(onClick = showPreferences) {
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
IconButton(
// Names the action: "double-tap to open settings" rather than the generic "to activate".
modifier = Modifier.semantics {
onClick(label = openSettingsLabel) {
showPreferences()
true
}
},
onClick = showPreferences,
) {
Icon(
imageVector = Icons.Filled.Subtitles,
contentDescription = stringResource(Res.string.subtitle_preferences_action),
@@ -245,6 +259,26 @@ private fun PlayerTopBar(
)
}
@Composable
private fun BackButton(onBackClicked: () -> Unit) {
val backActionLabel = stringResource(Res.string.player_back_action_label)
IconButton(
onClick = onBackClicked,
modifier = Modifier
.semantics {
onClick(label = backActionLabel) {
onBackClicked()
true
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
}
private val previewSubtitleLines =
listOf(
SubtitleLine(
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -97,7 +98,9 @@ fun SubtitleList(
}
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
// Subtitles transcribe the audio being played: hide them from screen readers, which would
// otherwise read the same phrases a second time.
BoxWithConstraints(modifier = modifier.clearAndSetSemantics {}) {
// Half-height padding so the first and last lines can reach the vertical center. Captured here
// because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
val verticalPadding = maxHeight / 2
@@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun NextButton(
canGoToNextSentence: Boolean,
modifier: Modifier = Modifier,
onNextSentenceClicked: () -> Unit = {},
) {
IconButton(
modifier = Modifier.size(60.dp),
modifier = modifier.size(60.dp),
enabled = canGoToNextSentence,
onClick = onNextSentenceClicked,
) {
@@ -38,6 +38,7 @@ private val BreakRingSize = 68.dp
fun PlayButton(
playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO,
modifier: Modifier = Modifier,
onMainPlayActionButtonClicked: () -> Unit = {},
) {
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
@@ -61,7 +62,7 @@ fun PlayButton(
// The ring reserves its footprint at all times so the transport row does not jump when a break
// starts; the indicator itself is only drawn while breaking.
Box(
modifier = Modifier.size(BreakRingSize),
modifier = modifier.size(BreakRingSize),
contentAlignment = Alignment.Center,
) {
if (isBreaking) {
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import bzh.ajaury.chombev.player.model.PlaybackTiming
import kotlin.time.Duration
@@ -33,7 +34,9 @@ fun PlaybackProgress(
}
seekProgress = null
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics { },
)
}
@@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun PreviousButton(
canGoToPreviousSentence: Boolean,
modifier: Modifier = Modifier,
onPreviousSentenceClicked: () -> Unit = {},
) {
IconButton(
modifier = Modifier.size(60.dp),
modifier = modifier.size(60.dp),
enabled = canGoToPreviousSentence,
onClick = onPreviousSentenceClicked,
) {
@@ -9,10 +9,13 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
import org.jetbrains.compose.resources.stringResource
/**
@@ -22,10 +25,19 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun SpeedPreferenceButton(
isActive: Boolean,
modifier: Modifier = Modifier,
onSpeedClicked: () -> Unit = {},
) {
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
IconButton(
modifier = Modifier.size(60.dp),
modifier = modifier
.size(60.dp)
.semantics {
onClick(label = openSettingsLabel) {
onSpeedClicked()
true
}
},
onClick = onSpeedClicked,
) {
Icon(
@@ -2,6 +2,7 @@ package bzh.ajaury.chombev.player.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
import bzh.ajaury.chombev.core.logging.domain.Logger
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record
@@ -29,6 +30,7 @@ class PlayerViewModel(
private val playbackRepository: PlaybackRepository,
private val preferencesRepository: PreferencesRepository,
private val audioSessionManager: AudioSessionManager,
private val accessibilityRepository: AccessibilityRepository,
private val logger: Logger,
) : ViewModel() {
private val record = MutableStateFlow<Record?>(null)
@@ -62,11 +64,14 @@ class PlayerViewModel(
viewModelScope.launch {
playbackRepository.stop()
// A screen reader speaks over the audio: let its user start playback once oriented.
val autoPlay = !accessibilityRepository.isScreenReaderEnabled()
logger.debug("Loading record $recordId")
val alreadyLoadedRecord = record.value
if (alreadyLoadedRecord != null) {
logger.debug("Record $recordId already loaded")
playbackRepository.load(record = alreadyLoadedRecord)
playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay)
return@launch
}
@@ -76,7 +81,7 @@ class PlayerViewModel(
return@launch
}
playbackRepository.load(record = loadedRecord)
playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
record.value = loadedRecord
}
}
@@ -1,5 +1,6 @@
package bzh.ajaury.chombev.records.ui.di
import bzh.ajaury.chombev.core.accessibility.di.accessibilityModule
import bzh.ajaury.chombev.player.di.audioPlayerModule
import bzh.ajaury.chombev.player.di.playbackModule
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
@@ -23,6 +24,7 @@ val recordsFeatureModule: Module = module {
recordsModule,
resourcesModule,
audioPlayerModule,
accessibilityModule,
subtitleModule,
)