refactor: enhance playback behavior with screen reader support and delayed actions
This commit is contained in:
1 parent
38c5d2678a
commit
df38936893
2 files changed
+69
-17
No files matched your search
+65
-16
@@ -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<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(
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user