refactor: enhance playback behavior with screen reader support and delayed actions

This commit is contained in:
Antoine Jaury committed 2026-10-22 13:58:30 +02:00
1 parent 38c5d2678a
commit df38936893
2 files changed
+69 -17

No files matched your search

@@ -55,8 +55,11 @@ internal class IosAudioPlayer : AudioPlayer {
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
override fun seekTo(position: Duration) { override fun seekTo(position: Duration) {
val seconds = position.inWholeMilliseconds / 1000.0 val seconds = position.inWholeMilliseconds / 1000.0
// Resume after seeking (e.g. back from the end of the record), unless playback was paused
// on purpose: the caller may be holding it, for instance while a screen reader speaks.
val resume = _playbackState.value != PlaybackState.PAUSED
player?.seekToTime(CMTimeMakeWithSeconds(seconds, preferredTimescale = 1000)) { player?.seekToTime(CMTimeMakeWithSeconds(seconds, preferredTimescale = 1000)) {
play() if (resume) play()
} }
} }
@@ -12,6 +12,8 @@ import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
import bzh.ajaury.chombev.records.domain.RecordRepository import bzh.ajaury.chombev.records.domain.RecordRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class PlayerViewModel( class PlayerViewModel(
@@ -34,6 +37,9 @@ class PlayerViewModel(
) : ViewModel() { ) : ViewModel() {
private val record = MutableStateFlow<Record?>(null) private val record = MutableStateFlow<Record?>(null)
// Playback held while the screen reader speaks the control that was just activated.
private var pendingPlaybackJob: Job? = null
val uiState: StateFlow<PlayerUiState> = combine( val uiState: StateFlow<PlayerUiState> = combine(
record, record,
playbackRepository.playerState, playbackRepository.playerState,
@@ -85,39 +91,75 @@ class PlayerViewModel(
} }
fun performMainPlayAction() { fun performMainPlayAction() {
viewModelScope.launch { when (uiState.value.playerState.playbackState) {
when (uiState.value.playerState.playbackState) { PlaybackState.PLAYING, PlaybackState.BREAKING -> {
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause() cancelPendingPlayback()
PlaybackState.ENDED -> playbackRepository.replay() viewModelScope.launch { playbackRepository.pause() }
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
} }
PlaybackState.ENDED -> playAfterScreenReader { playbackRepository.replay() }
PlaybackState.PAUSED, PlaybackState.IDLE -> playAfterScreenReader { playbackRepository.play() }
} }
} }
fun seekToTimePart(progress: Duration) { fun seekToTimePart(progress: Duration) {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.seekTo(progress) }
playbackRepository.seekTo(progress)
}
} }
fun seekToSentence(sentenceIndex: Int) { fun seekToSentence(sentenceIndex: Int) {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.seekToSentence(sentenceIndex) }
playbackRepository.seekToSentence(sentenceIndex)
}
} }
fun goToPreviousSentence() { fun goToPreviousSentence() {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.goToPreviousSentence() }
playbackRepository.goToPreviousSentence()
}
} }
fun goToNextSentence() { fun goToNextSentence() {
viewModelScope.launch { seekAfterScreenReader { playbackRepository.goToNextSentence() }
playbackRepository.goToNextSentence() }
/**
* Runs [play], which starts playback. A screen reader still speaks the control that was just
* activated: while one is running, [play] is delayed by [SCREEN_READER_PLAYBACK_DELAY] so that
* the audio does not play over it.
*/
private fun playAfterScreenReader(play: suspend () -> Unit) {
pendingPlaybackJob?.cancel()
pendingPlaybackJob = viewModelScope.launch {
if (accessibilityRepository.isScreenReaderEnabled()) delay(SCREEN_READER_PLAYBACK_DELAY)
play()
} }
} }
/**
* Runs [seek]. While a screen reader is running, ongoing playback is paused during the seek and
* only resumes after [SCREEN_READER_PLAYBACK_DELAY], like [playAfterScreenReader]. Successive
* seeks within the delay keep playback held until the delay of the last one has elapsed.
*/
private fun seekAfterScreenReader(seek: suspend () -> Unit) {
val isPlaybackPending = pendingPlaybackJob?.isActive == true
val playbackState = uiState.value.playerState.playbackState
val wasPlaying = isPlaybackPending || playbackState in PLAYING_STATES
pendingPlaybackJob?.cancel()
pendingPlaybackJob = viewModelScope.launch {
if (!wasPlaying || !accessibilityRepository.isScreenReaderEnabled()) {
seek()
return@launch
}
playbackRepository.pause()
seek()
delay(SCREEN_READER_PLAYBACK_DELAY)
playbackRepository.play()
}
}
private fun cancelPendingPlayback() {
pendingPlaybackJob?.cancel()
pendingPlaybackJob = null
}
fun setPlaybackSpeed(speed: Float) { fun setPlaybackSpeed(speed: Float) {
val steppedSpeed = (speed * 10).roundToInt() / 10f val steppedSpeed = (speed * 10).roundToInt() / 10f
val clampedSpeed = steppedSpeed.coerceIn( val clampedSpeed = steppedSpeed.coerceIn(
@@ -181,6 +223,7 @@ class PlayerViewModel(
} }
fun stop() { fun stop() {
cancelPendingPlayback()
viewModelScope.launch { viewModelScope.launch {
playbackRepository.stop() playbackRepository.stop()
} }
@@ -190,4 +233,10 @@ class PlayerViewModel(
super.onCleared() super.onCleared()
audioSessionManager.deactivate() audioSessionManager.deactivate()
} }
private companion object {
// Long enough for a screen reader to speak a short control label such as "Phrase suivante".
val SCREEN_READER_PLAYBACK_DELAY = 1500.milliseconds
val PLAYING_STATES = setOf(PlaybackState.PLAYING, PlaybackState.BREAKING)
}
} }