Merge pull request #7979 from vector-im/bugfix/fre/rework_vb_media_player

Voice Broadcast - Rework internal media players coordination
This commit is contained in:
Florian Renaud 2023-01-23 14:22:27 +01:00 committed by GitHub
commit 1e951cd838
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 92 additions and 78 deletions

1
changelog.d/7979.misc Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Rework internal media players coordination

View file

@ -160,8 +160,7 @@ class DefaultErrorFormatter @Inject constructor(
RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message)
RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message) RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message)
RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message)
is VoiceBroadcastFailure.ListeningError.UnableToPlay, is VoiceBroadcastFailure.ListeningError -> stringProvider.getString(R.string.error_voice_broadcast_unable_to_play)
is VoiceBroadcastFailure.ListeningError.DownloadError -> stringProvider.getString(R.string.error_voice_broadcast_unable_to_play)
} }
} }

View file

@ -31,6 +31,6 @@ sealed class VoiceBroadcastFailure : Throwable() {
* @property extra an extra code, specific to the error, see [MediaPlayer.OnErrorListener.onError]. * @property extra an extra code, specific to the error, see [MediaPlayer.OnErrorListener.onError].
*/ */
data class UnableToPlay(val what: Int, val extra: Int) : ListeningError() data class UnableToPlay(val what: Int, val extra: Int) : ListeningError()
data class DownloadError(override val cause: Throwable?) : ListeningError() data class PrepareMediaPlayerError(override val cause: Throwable? = null) : ListeningError()
} }
} }

View file

@ -18,7 +18,6 @@ package im.vector.app.features.voicebroadcast.listening
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.media.MediaPlayer.OnPreparedListener
import androidx.annotation.MainThread import androidx.annotation.MainThread
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.extensions.onFirst import im.vector.app.core.extensions.onFirst
@ -33,10 +32,13 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase
import im.vector.lib.core.utils.timer.CountUpTimer import im.vector.lib.core.utils.timer.CountUpTimer
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import timber.log.Timber import timber.log.Timber
@ -63,8 +65,29 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private var voiceBroadcastStateObserver: Job? = null private var voiceBroadcastStateObserver: Job? = null
private var currentMediaPlayer: MediaPlayer? = null private var currentMediaPlayer: MediaPlayer? = null
private set(value) {
Timber.d("## Voice Broadcast | currentMediaPlayer changed: old=${field.hashCode()}, new=${value.hashCode()}")
field = value
}
private var nextMediaPlayer: MediaPlayer? = null private var nextMediaPlayer: MediaPlayer? = null
private var isPreparingNextPlayer: Boolean = false private set(value) {
Timber.d("## Voice Broadcast | nextMediaPlayer changed: old=${field.hashCode()}, new=${value.hashCode()}")
field = value
}
private var prepareCurrentPlayerJob: Job? = null
set(value) {
if (field?.isActive.orFalse()) field?.cancel()
field = value
}
private var prepareNextPlayerJob: Job? = null
set(value) {
if (field?.isActive.orFalse()) field?.cancel()
field = value
}
private val isPreparingCurrentPlayer: Boolean get() = prepareCurrentPlayerJob?.isActive.orFalse()
private val isPreparingNextPlayer: Boolean get() = prepareNextPlayerJob?.isActive.orFalse()
private var mostRecentVoiceBroadcastEvent: VoiceBroadcastEvent? = null private var mostRecentVoiceBroadcastEvent: VoiceBroadcastEvent? = null
@ -83,7 +106,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
@MainThread @MainThread
set(value) { set(value) {
if (field != value) { if (field != value) {
Timber.d("## Voice Broadcast | playingState: $field -> $value") Timber.d("## Voice Broadcast | playingState: ${field::class.java.simpleName} -> ${value::class.java.simpleName}")
field = value field = value
onPlayingStateChanged(value) onPlayingStateChanged(value)
} }
@ -174,10 +197,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
private fun onPlaylistUpdated() { private fun onPlaylistUpdated() {
if (isPreparingCurrentPlayer || isPreparingNextPlayer) return
when (playingState) { when (playingState) {
State.Playing, State.Playing,
State.Paused -> { State.Paused -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) { if (nextMediaPlayer == null) {
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} }
@ -206,19 +230,16 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## Voice Broadcast | No content to play at position $position"); return } val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## Voice Broadcast | No content to play at position $position"); return }
val sequence = playlistItem.sequence ?: run { Timber.w("## Voice Broadcast | Playlist item has no sequence"); return } val sequence = playlistItem.sequence ?: run { Timber.w("## Voice Broadcast | Playlist item has no sequence"); return }
val sequencePosition = position - playlistItem.startTime val sequencePosition = position - playlistItem.startTime
sessionScope.launch { prepareCurrentPlayerJob = sessionScope.launch {
try { try {
prepareMediaPlayer(content) { mp -> val mp = prepareMediaPlayer(content)
currentMediaPlayer = mp playlist.currentSequence = sequence - 1 // will be incremented in onNextMediaPlayerStarted
playlist.currentSequence = sequence
mp.start() mp.start()
if (sequencePosition > 0) { if (sequencePosition > 0) {
mp.seekTo(sequencePosition) mp.seekTo(sequencePosition)
} }
playingState = State.Playing onNextMediaPlayerStarted(mp)
prepareNextMediaPlayer() } catch (failure: VoiceBroadcastFailure.ListeningError) {
}
} catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
playingState = State.Error(failure) playingState = State.Error(failure)
} }
} }
@ -249,7 +270,6 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
playingState == State.Playing || playingState == State.Buffering -> { playingState == State.Playing || playingState == State.Buffering -> {
updateLiveListeningMode(positionMillis)
startPlayback(positionMillis) startPlayback(positionMillis)
} }
playingState == State.Idle || playingState == State.Paused -> { playingState == State.Idle || playingState == State.Paused -> {
@ -261,12 +281,10 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun prepareNextMediaPlayer() { private fun prepareNextMediaPlayer() {
val nextItem = playlist.getNextItem() val nextItem = playlist.getNextItem()
if (nextItem != null) { if (!isPreparingNextPlayer && nextMediaPlayer == null && nextItem != null) {
isPreparingNextPlayer = true prepareNextPlayerJob = sessionScope.launch {
sessionScope.launch {
try { try {
prepareMediaPlayer(nextItem.audioEvent.content) { mp -> val mp = prepareMediaPlayer(nextItem.audioEvent.content)
isPreparingNextPlayer = false
nextMediaPlayer = mp nextMediaPlayer = mp
when (playingState) { when (playingState) {
State.Playing, State.Playing,
@ -280,9 +298,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
is State.Error, is State.Error,
State.Idle -> stopPlayer() State.Idle -> stopPlayer()
} }
} } catch (failure: VoiceBroadcastFailure.ListeningError) {
} catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
isPreparingNextPlayer = false
// Do not change the playingState if the current player is still valid, // Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player // the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) { if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
@ -293,18 +309,30 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
} }
private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent, onPreparedListener: OnPreparedListener): MediaPlayer { /**
* Create and prepare a [MediaPlayer] instance for the given [messageAudioContent].
* This methods takes care of downloading the audio file and returns the player when it is ready to use.
*
* Do not forget to release the resulting player when you don't need it anymore, in case you cancel the job related to this method, the player will be
* automatically released.
*/
private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer {
// Download can fail // Download can fail
val audioFile = try { val audioFile = try {
session.fileService().downloadFile(messageAudioContent) session.fileService().downloadFile(messageAudioContent)
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "Voice Broadcast | Download has failed: $failure") Timber.e(failure, "Voice Broadcast | Download has failed: $failure")
throw VoiceBroadcastFailure.ListeningError.DownloadError(failure) throw VoiceBroadcastFailure.ListeningError.PrepareMediaPlayerError(failure)
} }
return audioFile.inputStream().use { fis -> val latch = CompletableDeferred<MediaPlayer>()
MediaPlayer().apply { val mp = MediaPlayer()
setOnErrorListener(mediaPlayerListener) return try {
mp.apply {
setOnErrorListener { mp, what, extra ->
mediaPlayerListener.onError(mp, what, extra)
latch.completeExceptionally(VoiceBroadcastFailure.ListeningError.PrepareMediaPlayerError())
}
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
// Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here
@ -312,12 +340,16 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.build() .build()
) )
setDataSource(fis.fd) audioFile.inputStream().use { fis -> setDataSource(fis.fd) }
setOnInfoListener(mediaPlayerListener) setOnInfoListener(mediaPlayerListener)
setOnPreparedListener(onPreparedListener) setOnPreparedListener(latch::complete)
setOnCompletionListener(mediaPlayerListener) setOnCompletionListener(mediaPlayerListener)
prepareAsync() prepareAsync()
} }
latch.await()
} catch (e: CancellationException) {
mp.release()
throw e
} }
} }
@ -328,7 +360,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
nextMediaPlayer?.release() nextMediaPlayer?.release()
nextMediaPlayer = null nextMediaPlayer = null
isPreparingNextPlayer = false
prepareCurrentPlayerJob = null
prepareNextPlayerJob = null
} }
private fun onPlayingStateChanged(playingState: State) { private fun onPlayingStateChanged(playingState: State) {
@ -358,36 +392,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
/** /**
* Update the live listening state according to: * Update the live listening state according to:
* - the voice broadcast state (started/paused/resumed/stopped), * - the voice broadcast state (started/paused/resumed/stopped),
* - the playing state (IDLE, PLAYING, PAUSED, BUFFERING), * - the playing state (IDLE, PLAYING, PAUSED, BUFFERING).
* - the potential seek position (backward/forward).
*/ */
private fun updateLiveListeningMode(seekPosition: Int? = null) { private fun updateLiveListeningMode() {
isLiveListening = when { val isLiveVoiceBroadcast = mostRecentVoiceBroadcastEvent?.isLive.orFalse()
// the current voice broadcast is not live (ended) val isPlaying = playingState == State.Playing || playingState == State.Buffering
mostRecentVoiceBroadcastEvent?.isLive != true -> false isLiveListening = isLiveVoiceBroadcast && isPlaying
// the player is stopped or paused
playingState == State.Idle || playingState == State.Paused -> false
seekPosition != null -> {
val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0)
val newSequence = playlist.findByPosition(seekPosition)?.sequence
// the user has sought forward
if (seekDirection >= 0) {
// stay in live or latest sequence reached
isLiveListening || newSequence == playlist.lastOrNull()?.sequence
}
// the user has sought backward
else {
// was in live and stay in the same sequence
isLiveListening && newSequence == playlist.currentSequence
}
}
// if there is no saved position, go in live
getCurrentPlaybackPosition() == null -> true
// if we reached the latest sequence, go in live
playlist.currentSequence == playlist.lastOrNull()?.sequence -> true
// otherwise, do not change
else -> isLiveListening
}
} }
private fun onLiveListeningChanged(isLiveListening: Boolean) { private fun onLiveListeningChanged(isLiveListening: Boolean) {
@ -440,7 +450,10 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (currentMediaPlayer == mp) { if (currentMediaPlayer == mp) {
currentMediaPlayer = null currentMediaPlayer = null
} else { } else {
error("The media player which has completed mismatches the current media player instance.") Timber.w(
"## Voice Broadcast | onCompletion: The media player which has completed mismatches the current media player instance.\n" +
"currentMediaPlayer=${currentMediaPlayer.hashCode()}, mp=${mp.hashCode()}"
)
} }
// Next media player is already attached to this player and will start playing automatically // Next media player is already attached to this player and will start playing automatically
@ -459,7 +472,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
Timber.d("## Voice Broadcast | onError: what=$what, extra=$extra") Timber.w("## Voice Broadcast | onError: what=$what, extra=$extra")
// Do not change the playingState if the current player is still valid, // Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player // the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) { if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
@ -504,7 +517,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
} }
State.Idle -> { State.Idle -> {
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { // restart the playback time if player completed with less than 250 ms remaining time
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 250) {
playbackTracker.stopPlayback(id) playbackTracker.stopPlayback(id)
} else { } else {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)