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:
commit
1e951cd838
|
@ -0,0 +1 @@
|
||||||
|
[Voice Broadcast] Rework internal media players coordination
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue