refactor: improve player accessibility
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
id("gwenedeg.kmp.library")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "bzh.ajaury.chombev.core.accessibility"
|
||||
}
|
||||
}
|
||||
+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.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
|
||||
}
|
||||
+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>() }
|
||||
}
|
||||
+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>() }
|
||||
}
|
||||
+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>() }
|
||||
}
|
||||
+11
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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 { },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+13
-1
@@ -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(
|
||||
|
||||
+7
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ include(":webApp")
|
||||
include(":shared")
|
||||
|
||||
// Core modules
|
||||
include(":core:accessibility")
|
||||
include(":core:audioplayer")
|
||||
include(":core:coroutines")
|
||||
include(":core:logging")
|
||||
|
||||
Reference in New Issue
Block a user