refactor: enhance playback behavior with screen reader support and delayed actions
This commit is contained in:
1 parent
38c5d2678a
commit
df38936893
2 files changed
+68
-16
No files matched your search
+4
-1
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-15
@@ -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,37 +91,73 @@ class PlayerViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun performMainPlayAction() {
|
fun performMainPlayAction() {
|
||||||
viewModelScope.launch {
|
|
||||||
when (uiState.value.playerState.playbackState) {
|
when (uiState.value.playerState.playbackState) {
|
||||||
PlaybackState.PLAYING, PlaybackState.BREAKING -> playbackRepository.pause()
|
PlaybackState.PLAYING, PlaybackState.BREAKING -> {
|
||||||
PlaybackState.ENDED -> playbackRepository.replay()
|
cancelPendingPlayback()
|
||||||
PlaybackState.PAUSED, PlaybackState.IDLE -> playbackRepository.play()
|
viewModelScope.launch { playbackRepository.pause() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user