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
+9
View File
@@ -0,0 +1,9 @@
plugins {
id("gwenedeg.kmp.library")
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.core.accessibility"
}
}
@@ -0,0 +1,15 @@
package bzh.ajaury.chombev.core.accessibility.data
import android.content.Context
import android.view.accessibility.AccessibilityManager
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
internal class AndroidAccessibilityRepository(
context: Context,
) : AccessibilityRepository {
private val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
// Screen readers such as TalkBack turn on touch exploration. Other accessibility services
// (password managers, automation tools) do not, so `isEnabled` alone would misreport them.
override fun isScreenReaderEnabled(): Boolean = accessibilityManager?.isTouchExplorationEnabled == true
}
@@ -0,0 +1,12 @@
package bzh.ajaury.chombev.core.accessibility.di
import bzh.ajaury.chombev.core.accessibility.data.AndroidAccessibilityRepository
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val accessibilityModule: Module = module {
singleOf(::AndroidAccessibilityRepository) { bind<AccessibilityRepository>() }
}
@@ -0,0 +1,7 @@
package bzh.ajaury.chombev.core.accessibility.data
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
internal class UndetectableAccessibilityRepository : AccessibilityRepository {
override fun isScreenReaderEnabled(): Boolean = false
}
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.di
import org.koin.core.module.Module
expect val accessibilityModule: Module
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.domain
interface AccessibilityRepository {
fun isScreenReaderEnabled(): Boolean
}
@@ -0,0 +1,8 @@
package bzh.ajaury.chombev.core.accessibility.data
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
import platform.UIKit.UIAccessibilityIsVoiceOverRunning
internal class IosAccessibilityRepository : AccessibilityRepository {
override fun isScreenReaderEnabled(): Boolean = UIAccessibilityIsVoiceOverRunning()
}
@@ -0,0 +1,12 @@
package bzh.ajaury.chombev.core.accessibility.di
import bzh.ajaury.chombev.core.accessibility.data.IosAccessibilityRepository
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val accessibilityModule: Module = module {
singleOf(::IosAccessibilityRepository) { bind<AccessibilityRepository>() }
}
@@ -0,0 +1,12 @@
package bzh.ajaury.chombev.core.accessibility.di
import bzh.ajaury.chombev.core.accessibility.data.UndetectableAccessibilityRepository
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
actual val accessibilityModule: Module = module {
singleOf(::UndetectableAccessibilityRepository) { bind<AccessibilityRepository>() }
}
@@ -0,0 +1,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>() }
}
@@ -138,13 +138,16 @@ internal class PlaybackRepositoryImpl(
) )
} }
override suspend fun load(record: Record) { override suspend fun load(
record: Record,
autoPlay: Boolean,
) {
phraseBreakJob?.cancel() phraseBreakJob?.cancel()
cancelRunningBreak() cancelRunningBreak()
pendingPhraseStart = null pendingPhraseStart = null
pendingSeekTarget = null pendingSeekTarget = null
playAudio(filePath = record.audioResourcePath) loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
subtitle.value = loadSubtitle(record = record) subtitle.value = loadSubtitle(record = record)
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO) reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
recordTitle.value = record.title recordTitle.value = record.title
@@ -264,12 +267,15 @@ internal class PlaybackRepositoryImpl(
audioPlayer.seekTo(position) audioPlayer.seekTo(position)
} }
private fun playAudio(filePath: String) { 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)
} }
} }
@@ -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()
@@ -11,6 +11,7 @@
<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>
@@ -48,6 +49,7 @@
<string name="records_chapter_number">Chapitre %1$d</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_play">Lire</string> <string name="player_play">Lire</string>
+1
View File
@@ -14,6 +14,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)
@@ -30,6 +30,10 @@ 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.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
@@ -43,6 +47,8 @@ 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.subtitle.model.SubtitleLine import bzh.ajaury.chombev.subtitle.model.SubtitleLine
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -198,6 +204,9 @@ private fun PlayerTopBar(
label = "PlayerTitle", label = "PlayerTitle",
) { (emoji, title) -> ) { (emoji, title) ->
Row( Row(
modifier = Modifier.clearAndSetSemantics {
contentDescription = title.translation ?: title.transcription
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -227,15 +236,20 @@ 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.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 +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 = private val previewSubtitleLines =
listOf( listOf(
SubtitleLine( SubtitleLine(
@@ -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
@@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun NextButton( fun NextButton(
canGoToNextSentence: Boolean, canGoToNextSentence: Boolean,
modifier: Modifier = Modifier,
onNextSentenceClicked: () -> Unit = {}, onNextSentenceClicked: () -> Unit = {},
) { ) {
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier.size(60.dp),
enabled = canGoToNextSentence, enabled = canGoToNextSentence,
onClick = onNextSentenceClicked, onClick = onNextSentenceClicked,
) { ) {
@@ -38,6 +38,7 @@ private val BreakRingSize = 68.dp
fun PlayButton( fun PlayButton(
playbackState: PlaybackState, playbackState: PlaybackState,
breakDuration: Duration = Duration.ZERO, breakDuration: Duration = Duration.ZERO,
modifier: Modifier = Modifier,
onMainPlayActionButtonClicked: () -> Unit = {}, onMainPlayActionButtonClicked: () -> Unit = {},
) { ) {
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
@@ -61,7 +62,7 @@ fun PlayButton(
// The ring reserves its footprint at all times so the transport row does not jump when a break // The ring reserves its footprint at all times so the transport row does not jump when a break
// starts; the indicator itself is only drawn while breaking. // starts; the indicator itself is only drawn while breaking.
Box( Box(
modifier = Modifier.size(BreakRingSize), modifier = modifier.size(BreakRingSize),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (isBreaking) { if (isBreaking) {
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import bzh.ajaury.chombev.player.model.PlaybackTiming import bzh.ajaury.chombev.player.model.PlaybackTiming
import kotlin.time.Duration import kotlin.time.Duration
@@ -33,7 +34,9 @@ fun PlaybackProgress(
} }
seekProgress = null seekProgress = null
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics { },
) )
} }
@@ -17,10 +17,11 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun PreviousButton( fun PreviousButton(
canGoToPreviousSentence: Boolean, canGoToPreviousSentence: Boolean,
modifier: Modifier = Modifier,
onPreviousSentenceClicked: () -> Unit = {}, onPreviousSentenceClicked: () -> Unit = {},
) { ) {
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier.size(60.dp),
enabled = canGoToPreviousSentence, enabled = canGoToPreviousSentence,
onClick = onPreviousSentenceClicked, onClick = onPreviousSentenceClicked,
) { ) {
@@ -9,10 +9,13 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import bzh.ajaury.chombev.resources.generated.resources.Res import bzh.ajaury.chombev.resources.generated.resources.Res
import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
/** /**
@@ -22,10 +25,19 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun SpeedPreferenceButton( fun SpeedPreferenceButton(
isActive: Boolean, isActive: Boolean,
modifier: Modifier = Modifier,
onSpeedClicked: () -> Unit = {}, onSpeedClicked: () -> Unit = {},
) { ) {
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
IconButton( IconButton(
modifier = Modifier.size(60.dp), modifier = modifier
.size(60.dp)
.semantics {
onClick(label = openSettingsLabel) {
onSpeedClicked()
true
}
},
onClick = onSpeedClicked, onClick = onSpeedClicked,
) { ) {
Icon( Icon(
@@ -2,6 +2,7 @@ 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.Phrase
import bzh.ajaury.chombev.core.model.Record import bzh.ajaury.chombev.core.model.Record
@@ -29,6 +30,7 @@ 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)
@@ -62,11 +64,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,7 +81,7 @@ class PlayerViewModel(
return@launch return@launch
} }
playbackRepository.load(record = loadedRecord) playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
record.value = loadedRecord record.value = loadedRecord
} }
} }
@@ -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,
) )
+1
View File
@@ -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")