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()
cancelRunningBreak()
pendingPhraseStart = null
pendingSeekTarget = null
playAudio(filePath = record.audioResourcePath)
loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
subtitle.value = loadSubtitle(record = record)
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
recordTitle.value = record.title
@@ -264,12 +267,15 @@ internal class PlaybackRepositoryImpl(
audioPlayer.seekTo(position)
}
private fun playAudio(filePath: String) {
private fun loadAudio(
filePath: String,
autoPlay: Boolean,
) {
try {
audioPlayer.load(uri = resourceReader.uri(filePath))
audioPlayer.play()
if (autoPlay) audioPlayer.play()
} 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 {
val playerState: Flow<PlayerState>
suspend fun load(record: Record)
suspend fun load(
record: Record,
autoPlay: Boolean = true,
)
suspend fun play()
@@ -11,6 +11,7 @@
<string name="subtitle_preferences_title">Sous-titres</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_description">Afficher la transcription bretonne</string>
<string name="subtitle_translation_label">Traduction (FR)</string>
@@ -48,6 +49,7 @@
<string name="records_chapter_number">Chapitre %1$d</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_next_sentence">Phrase suivante</string>
<string name="player_play">Lire</string>
+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,
)
+1
View File
@@ -39,6 +39,7 @@ include(":webApp")
include(":shared")
// Core modules
include(":core:accessibility")
include(":core:audioplayer")
include(":core:coroutines")
include(":core:logging")