diff --git a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt index f0dc816..7f96886 100644 --- a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt +++ b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/data/IosAudioPlayer.kt @@ -55,8 +55,11 @@ internal class IosAudioPlayer : AudioPlayer { @OptIn(ExperimentalForeignApi::class) override fun seekTo(position: Duration) { 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)) { - play() + if (resume) play() } } diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt index 608e597..a811f50 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/viewmodel/PlayerViewModel.kt @@ -12,6 +12,8 @@ import bzh.ajaury.chombev.preferences.domain.PreferencesRepository import bzh.ajaury.chombev.preferences.model.PlaybackPreferences import bzh.ajaury.chombev.preferences.model.SubtitlePreferences import bzh.ajaury.chombev.records.domain.RecordRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -21,6 +23,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.math.roundToInt import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class PlayerViewModel( @@ -34,6 +37,9 @@ class PlayerViewModel( ) : ViewModel() { private val record = MutableStateFlow(null) + // Playback held while the screen reader speaks the control that was just activated. + private var pendingPlaybackJob: Job? = null + val uiState: StateFlow = combine( record, playbackRepository.playerState, @@ -85,39 +91,75 @@ class PlayerViewModel( } fun performMainPlayAction() { - viewModelScope.launch { - when (uiState.value.playerState.playbackState) { - PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause() - PlaybackState.ENDED -> playbackRepository.replay() - PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play() + when (uiState.value.playerState.playbackState) { + PlaybackState.PLAYING, PlaybackState.BREAKING -> { + cancelPendingPlayback() + viewModelScope.launch { playbackRepository.pause() } } + + PlaybackState.ENDED -> playAfterScreenReader { playbackRepository.replay() } + PlaybackState.PAUSED, PlaybackState.IDLE -> playAfterScreenReader { playbackRepository.play() } } } fun seekToTimePart(progress: Duration) { - viewModelScope.launch { - playbackRepository.seekTo(progress) - } + seekAfterScreenReader { playbackRepository.seekTo(progress) } } fun seekToSentence(sentenceIndex: Int) { - viewModelScope.launch { - playbackRepository.seekToSentence(sentenceIndex) - } + seekAfterScreenReader { playbackRepository.seekToSentence(sentenceIndex) } } fun goToPreviousSentence() { - viewModelScope.launch { - playbackRepository.goToPreviousSentence() - } + seekAfterScreenReader { playbackRepository.goToPreviousSentence() } } fun goToNextSentence() { - viewModelScope.launch { - playbackRepository.goToNextSentence() + seekAfterScreenReader { 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) { val steppedSpeed = (speed * 10).roundToInt() / 10f val clampedSpeed = steppedSpeed.coerceIn( @@ -181,6 +223,7 @@ class PlayerViewModel( } fun stop() { + cancelPendingPlayback() viewModelScope.launch { playbackRepository.stop() } @@ -190,4 +233,10 @@ class PlayerViewModel( super.onCleared() 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) + } }