Merge pull request #7919 from vector-im/bugfix/fre/handle_vb_playback_crash

Voice Broadcast - Handle exceptions during playback
This commit is contained in:
Florian Renaud 2023-01-13 18:29:28 +01:00 committed by GitHub
commit 31e599f2e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 208 additions and 90 deletions

1
changelog.d/7829.bugfix Normal file
View file

@ -0,0 +1 @@
Handle exceptions when listening a voice broadcast

View file

@ -3122,6 +3122,7 @@
<string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string> <string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string>
<string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string> <string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string>
<string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string> <string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string>
<string name="error_voice_broadcast_unable_to_play">Unable to play this voice broadcast.</string>
<!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left --> <!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left -->
<string name="voice_broadcast_recording_time_left">%1$s left</string> <string name="voice_broadcast_recording_time_left">%1$s left</string>
<string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string> <string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string>

View file

@ -157,6 +157,8 @@ 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.DownloadError -> stringProvider.getString(R.string.error_voice_broadcast_unable_to_play)
} }
} }

View file

@ -229,6 +229,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.renderPlaying(state) voiceMessageViews.renderPlaying(state)
} }
is AudioMessagePlaybackTracker.Listener.State.Paused, is AudioMessagePlaybackTracker.Listener.State.Paused,
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> { is AudioMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle() voiceMessageViews.renderIdle()
} }

View file

@ -15,9 +15,9 @@
*/ */
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup
@ -36,7 +36,6 @@ import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getUserOrDefault
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
@ -45,6 +44,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val drawableProvider: DrawableProvider, private val drawableProvider: DrawableProvider,
private val errorFormatter: ErrorFormatter,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
private val voiceBroadcastPlayer: VoiceBroadcastPlayer, private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
private val playbackTracker: AudioMessagePlaybackTracker, private val playbackTracker: AudioMessagePlaybackTracker,
@ -75,13 +75,14 @@ class VoiceBroadcastItemFactory @Inject constructor(
voiceBroadcast = voiceBroadcast, voiceBroadcast = voiceBroadcast,
voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState,
duration = voiceBroadcastEventsGroup.getDuration(), duration = voiceBroadcastEventsGroup.getDuration(),
recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), recorderName = params.event.senderInfo.disambiguatedDisplayName,
recorder = voiceBroadcastRecorder, recorder = voiceBroadcastRecorder,
player = voiceBroadcastPlayer, player = voiceBroadcastPlayer,
playbackTracker = playbackTracker, playbackTracker = playbackTracker,
roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(),
colorProvider = colorProvider, colorProvider = colorProvider,
drawableProvider = drawableProvider, drawableProvider = drawableProvider,
errorFormatter = errorFormatter,
) )
return if (isRecording) { return if (isRecording) {

View file

@ -50,8 +50,11 @@ class AudioMessagePlaybackTracker @Inject constructor() {
listeners.remove(id) listeners.remove(id)
} }
fun pauseAllPlaybacks() { fun unregisterListeners() {
listeners.keys.forEach(::pausePlayback) listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
} }
/** /**
@ -84,6 +87,10 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
} }
fun pauseAllPlaybacks() {
listeners.keys.forEach(::pausePlayback)
}
fun pausePlayback(id: String) { fun pausePlayback(id: String) {
val state = getPlaybackState(id) val state = getPlaybackState(id)
if (state is Listener.State.Playing) { if (state is Listener.State.Playing) {
@ -94,7 +101,14 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
fun stopPlayback(id: String) { fun stopPlayback(id: String) {
setState(id, Listener.State.Idle) val state = getPlaybackState(id)
if (state !is Listener.State.Error) {
setState(id, Listener.State.Idle)
}
}
fun onError(id: String, error: Throwable) {
setState(id, Listener.State.Error(error))
} }
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) { fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
@ -116,6 +130,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.playbackTime is Listener.State.Playing -> state.playbackTime
is Listener.State.Paused -> state.playbackTime is Listener.State.Paused -> state.playbackTime
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
@ -126,18 +141,12 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.percentage is Listener.State.Playing -> state.percentage
is Listener.State.Paused -> state.percentage is Listener.State.Paused -> state.percentage
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
} }
fun unregisterListeners() {
listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
}
companion object { companion object {
const val RECORDING_ID = "RECORDING_ID" const val RECORDING_ID = "RECORDING_ID"
} }
@ -148,6 +157,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
sealed class State { sealed class State {
object Idle : State() object Idle : State()
data class Error(val failure: Throwable) : State()
data class Playing(val playbackTime: Int, val percentage: Float) : State() data class Playing(val playbackTime: Int, val percentage: Float) : State()
data class Paused(val playbackTime: Int, val percentage: Float) : State() data class Paused(val playbackTime: Int, val percentage: Float) : State()
data class Recording(val amplitudeList: List<Int>) : State() data class Recording(val amplitudeList: List<Int>) : State()

View file

@ -22,6 +22,7 @@ import androidx.annotation.IdRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.tintBackground import im.vector.app.core.extensions.tintBackground
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
@ -48,6 +49,7 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
protected val colorProvider get() = voiceBroadcastAttributes.colorProvider protected val colorProvider get() = voiceBroadcastAttributes.colorProvider
protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider
protected val avatarRenderer get() = attributes.avatarRenderer protected val avatarRenderer get() = attributes.avatarRenderer
protected val errorFormatter get() = voiceBroadcastAttributes.errorFormatter
protected val callback get() = attributes.callback protected val callback get() = attributes.callback
override fun isCacheable(): Boolean = false override fun isCacheable(): Boolean = false
@ -107,5 +109,6 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
val roomItem: MatrixItem?, val roomItem: MatrixItem?,
val colorProvider: ColorProvider, val colorProvider: ColorProvider,
val drawableProvider: DrawableProvider, val drawableProvider: DrawableProvider,
val errorFormatter: ErrorFormatter,
) )
} }

View file

@ -142,6 +142,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
private fun renderStateBasedOnAudioPlayback(holder: Holder) { private fun renderStateBasedOnAudioPlayback(holder: Holder) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)

View file

@ -20,11 +20,13 @@ import android.text.format.DateUtils
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.Group
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
@ -54,6 +56,16 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
player.addListener(voiceBroadcast, playerListener) player.addListener(voiceBroadcast, playerListener)
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderPlaybackError(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
bindSeekBar(holder) bindSeekBar(holder)
bindButtons(holder) bindButtons(holder)
} }
@ -63,10 +75,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
playPauseButton.setOnClickListener { playPauseButton.setOnClickListener {
if (player.currentVoiceBroadcast == voiceBroadcast) { if (player.currentVoiceBroadcast == voiceBroadcast) {
when (player.playingState) { when (player.playingState) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) VoiceBroadcastPlayer.State.Buffering -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
VoiceBroadcastPlayer.State.PAUSED, VoiceBroadcastPlayer.State.Paused,
VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.Idle -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
} }
} else { } else {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
@ -100,17 +113,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) {
with(holder) { with(holder) {
bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING bufferingView.isVisible = state == VoiceBroadcastPlayer.State.Buffering
voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.Buffering
when (state) { when (state) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> { VoiceBroadcastPlayer.State.Buffering -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
} }
VoiceBroadcastPlayer.State.IDLE, is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.PAUSED -> { VoiceBroadcastPlayer.State.Idle,
VoiceBroadcastPlayer.State.Paused -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
} }
@ -120,6 +134,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
private fun renderPlaybackError(holder: Holder, playbackState: State) {
with(holder) {
if (playbackState is State.Error) {
controlsGroup.isVisible = false
errorView.setTextOrHide(errorFormatter.toHumanReadable(playbackState.failure))
} else {
errorView.isVisible = false
controlsGroup.isVisible = true
}
}
}
private fun bindSeekBar(holder: Holder) { private fun bindSeekBar(holder: Holder) {
with(holder) { with(holder) {
remainingTimeView.text = formatRemainingTime(duration) remainingTimeView.text = formatRemainingTime(duration)
@ -141,13 +167,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
}) })
} }
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
} }
private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) {
@ -187,6 +206,8 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata) val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata)
val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata) val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata)
val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata) val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata)
val errorView by bind<TextView>(R.id.errorView)
val controlsGroup by bind<Group>(R.id.controlsGroup)
} }
companion object { companion object {

View file

@ -124,6 +124,7 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)

View file

@ -16,10 +16,21 @@
package im.vector.app.features.voicebroadcast package im.vector.app.features.voicebroadcast
import android.media.MediaPlayer
sealed class VoiceBroadcastFailure : Throwable() { sealed class VoiceBroadcastFailure : Throwable() {
sealed class RecordingError : VoiceBroadcastFailure() { sealed class RecordingError : VoiceBroadcastFailure() {
object NoPermission : RecordingError() object NoPermission : RecordingError()
object BlockedBySomeoneElse : RecordingError() object BlockedBySomeoneElse : RecordingError()
object UserAlreadyBroadcasting : RecordingError() object UserAlreadyBroadcasting : RecordingError()
} }
sealed class ListeningError : VoiceBroadcastFailure() {
/**
* @property what the type of error that has occurred, 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 DownloadError(override val cause: Throwable?) : ListeningError()
}
} }

View file

@ -16,6 +16,7 @@
package im.vector.app.features.voicebroadcast.listening package im.vector.app.features.voicebroadcast.listening
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
interface VoiceBroadcastPlayer { interface VoiceBroadcastPlayer {
@ -26,7 +27,7 @@ interface VoiceBroadcastPlayer {
val currentVoiceBroadcast: VoiceBroadcast? val currentVoiceBroadcast: VoiceBroadcast?
/** /**
* The current playing [State], [State.IDLE] by default. * The current playing [State], [State.Idle] by default.
*/ */
val playingState: State val playingState: State
@ -68,11 +69,12 @@ interface VoiceBroadcastPlayer {
/** /**
* Player states. * Player states.
*/ */
enum class State { sealed interface State {
PLAYING, object Playing : State
PAUSED, object Paused : State
BUFFERING, object Buffering : State
IDLE data class Error(val failure: VoiceBroadcastFailure.ListeningError) : State
object Idle : State
} }
/** /**

View file

@ -24,7 +24,7 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.extensions.onFirst import im.vector.app.core.extensions.onFirst
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State
@ -79,7 +79,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
} }
override var playingState = State.IDLE override var playingState: State = State.Idle
@MainThread @MainThread
set(value) { set(value) {
if (field != value) { if (field != value) {
@ -96,7 +96,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val hasChanged = currentVoiceBroadcast != voiceBroadcast val hasChanged = currentVoiceBroadcast != voiceBroadcast
when { when {
hasChanged -> startPlayback(voiceBroadcast) hasChanged -> startPlayback(voiceBroadcast)
playingState == State.PAUSED -> resumePlayback() playingState == State.Paused -> resumePlayback()
else -> Unit else -> Unit
} }
} }
@ -107,7 +107,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
override fun stop() { override fun stop() {
// Update state // Update state
playingState = State.IDLE playingState = State.Idle
// Stop and release media players // Stop and release media players
stopPlayer() stopPlayer()
@ -129,7 +129,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run {
listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) } listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) }
} }
listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.Idle)
listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast) listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast)
} }
@ -139,11 +139,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun startPlayback(voiceBroadcast: VoiceBroadcast) { private fun startPlayback(voiceBroadcast: VoiceBroadcast) {
// Stop listening previous voice broadcast if any // Stop listening previous voice broadcast if any
if (playingState != State.IDLE) stop() if (playingState != State.Idle) stop()
currentVoiceBroadcast = voiceBroadcast currentVoiceBroadcast = voiceBroadcast
playingState = State.BUFFERING playingState = State.Buffering
observeVoiceBroadcastStateEvent(voiceBroadcast) observeVoiceBroadcastStateEvent(voiceBroadcast)
} }
@ -175,13 +175,13 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onPlaylistUpdated() { private fun onPlaylistUpdated() {
when (playingState) { when (playingState) {
State.PLAYING, State.Playing,
State.PAUSED -> { State.Paused -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) { if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} }
State.BUFFERING -> { State.Buffering -> {
val nextItem = if (isLiveListening && playlist.currentSequence == null) { val nextItem = if (isLiveListening && playlist.currentSequence == null) {
// live listening, jump to the last item if playback has not started // live listening, jump to the last item if playback has not started
playlist.lastOrNull() playlist.lastOrNull()
@ -193,7 +193,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
startPlayback(nextItem.startTime) startPlayback(nextItem.startTime)
} }
} }
State.IDLE -> Unit // Should not happen is State.Error -> Unit
State.Idle -> Unit // Should not happen
} }
} }
@ -213,18 +214,17 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (sequencePosition > 0) { if (sequencePosition > 0) {
mp.seekTo(sequencePosition) mp.seekTo(sequencePosition)
} }
playingState = State.PLAYING playingState = State.Playing
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} catch (failure: Throwable) { } catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure") playingState = State.Error(failure)
throw VoiceFailure.UnableToPlay(failure)
} }
} }
} }
private fun pausePlayback() { private fun pausePlayback() {
playingState = State.PAUSED // This will trigger a playing state update and save the current position playingState = State.Paused // This will trigger a playing state update and save the current position
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
currentMediaPlayer?.pause() currentMediaPlayer?.pause()
} else { } else {
@ -234,7 +234,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun resumePlayback() { private fun resumePlayback() {
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
playingState = State.PLAYING playingState = State.Playing
currentMediaPlayer?.start() currentMediaPlayer?.start()
} else { } else {
val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
@ -247,11 +247,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
voiceBroadcast != currentVoiceBroadcast -> { voiceBroadcast != currentVoiceBroadcast -> {
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) updateLiveListeningMode(positionMillis)
startPlayback(positionMillis) startPlayback(positionMillis)
} }
playingState == State.IDLE || playingState == State.PAUSED -> { playingState == State.Idle || playingState == State.Paused -> {
stopPlayer() stopPlayer()
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
@ -263,19 +263,29 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (nextItem != null) { if (nextItem != null) {
isPreparingNextPlayer = true isPreparingNextPlayer = true
sessionScope.launch { sessionScope.launch {
prepareMediaPlayer(nextItem.audioEvent.content) { mp -> try {
prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
isPreparingNextPlayer = false
nextMediaPlayer = mp
when (playingState) {
State.Playing,
State.Paused -> {
currentMediaPlayer?.setNextMediaPlayer(mp)
}
State.Buffering -> {
mp.start()
onNextMediaPlayerStarted(mp)
}
is State.Error,
State.Idle -> stopPlayer()
}
}
} catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
isPreparingNextPlayer = false isPreparingNextPlayer = false
nextMediaPlayer = mp // Do not change the playingState if the current player is still valid,
when (playingState) { // the error will be thrown again when switching to the next player
State.PLAYING, if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
State.PAUSED -> { playingState = State.Error(failure)
currentMediaPlayer?.setNextMediaPlayer(mp)
}
State.BUFFERING -> {
mp.start()
onNextMediaPlayerStarted(mp)
}
State.IDLE -> stopPlayer()
} }
} }
} }
@ -288,11 +298,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
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 VoiceFailure.UnableToPlay(failure) throw VoiceBroadcastFailure.ListeningError.DownloadError(failure)
} }
return audioFile.inputStream().use { fis -> return audioFile.inputStream().use { fis ->
MediaPlayer().apply { MediaPlayer().apply {
setOnErrorListener(mediaPlayerListener)
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
@ -302,10 +313,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
) )
setDataSource(fis.fd) setDataSource(fis.fd)
setOnInfoListener(mediaPlayerListener) setOnInfoListener(mediaPlayerListener)
setOnErrorListener(mediaPlayerListener)
setOnPreparedListener(onPreparedListener) setOnPreparedListener(onPreparedListener)
setOnCompletionListener(mediaPlayerListener) setOnCompletionListener(mediaPlayerListener)
prepare() prepareAsync()
} }
} }
} }
@ -327,11 +337,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId ->
// Start or stop playback ticker // Start or stop playback ticker
when (playingState) { when (playingState) {
State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) State.Playing -> playbackTicker.startPlaybackTicker(voiceBroadcastId)
State.PAUSED, State.Paused,
State.BUFFERING, State.Buffering,
State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) is State.Error,
State.Idle -> playbackTicker.stopPlaybackTicker(voiceBroadcastId)
} }
// Notify playback tracker about error
if (playingState is State.Error) {
playbackTracker.onError(voiceBroadcastId, playingState.failure)
}
// Notify state change to all the listeners attached to the current voice broadcast id // Notify state change to all the listeners attached to the current voice broadcast id
listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) } listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) }
} }
@ -348,7 +365,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// the current voice broadcast is not live (ended) // the current voice broadcast is not live (ended)
mostRecentVoiceBroadcastEvent?.isLive != true -> false mostRecentVoiceBroadcastEvent?.isLive != true -> false
// the player is stopped or paused // the player is stopped or paused
playingState == State.IDLE || playingState == State.PAUSED -> false playingState == State.Idle || playingState == State.Paused -> false
seekPosition != null -> { seekPosition != null -> {
val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0) val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0)
val newSequence = playlist.findByPosition(seekPosition)?.sequence val newSequence = playlist.findByPosition(seekPosition)?.sequence
@ -374,13 +391,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onLiveListeningChanged(isLiveListening: Boolean) { private fun onLiveListeningChanged(isLiveListening: Boolean) {
// Live has ended and last chunk has been reached, we can stop the playback // Live has ended and last chunk has been reached, we can stop the playback
if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) { val hasReachedLastChunk = playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence
if (!isLiveListening && playingState == State.Buffering && hasReachedLastChunk) {
stop() stop()
} }
} }
private fun onNextMediaPlayerStarted(mp: MediaPlayer) { private fun onNextMediaPlayerStarted(mp: MediaPlayer) {
playingState = State.PLAYING playingState = State.Playing
playlist.currentSequence = playlist.currentSequence?.inc() playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp currentMediaPlayer = mp
nextMediaPlayer = null nextMediaPlayer = null
@ -389,16 +407,16 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun getCurrentPlaybackPosition(): Int? { private fun getCurrentPlaybackPosition(): Int? {
val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlist.currentItem?.startTime?.plus(it) }
val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId)
return computedPosition ?: savedPosition return computedPosition ?: savedPosition
} }
private fun getCurrentPlaybackPercentage(): Float? { private fun getCurrentPlaybackPercentage(): Float? {
val playlistPosition = playlist.currentItem?.startTime val playlistPosition = playlist.currentItem?.startTime
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlistPosition?.plus(it) } ?: playlistPosition
val duration = playlist.duration.takeIf { it > 0 } val duration = playlist.duration
val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null val computedPercentage = if (computedPosition != null && duration > 0) computedPosition.toFloat() / duration else null
val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) }
return computedPercentage ?: savedPercentage return computedPercentage ?: savedPercentage
} }
@ -416,6 +434,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
override fun onCompletion(mp: MediaPlayer) { override fun onCompletion(mp: MediaPlayer) {
// Release media player as soon as it completed
mp.release()
if (currentMediaPlayer == mp) {
currentMediaPlayer = null
} else {
error("The media player which has completed mismatches the current media player instance.")
}
// 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
if (nextMediaPlayer != null) return if (nextMediaPlayer != null) return
@ -426,15 +452,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// We'll not receive new chunks anymore so we can stop the live listening // We'll not receive new chunks anymore so we can stop the live listening
stop() stop()
} else { } else {
// Enter in buffering mode and release current media player playingState = State.Buffering
playingState = State.BUFFERING prepareNextMediaPlayer()
currentMediaPlayer?.release()
currentMediaPlayer = null
} }
} }
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
stop() Timber.d("## Voice Broadcast | onError: what=$what, extra=$extra")
// Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
playingState = State.Error(VoiceBroadcastFailure.ListeningError.UnableToPlay(what, extra))
}
return true return true
} }
} }
@ -462,24 +491,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val playbackTime = getCurrentPlaybackPosition() val playbackTime = getCurrentPlaybackPosition()
val percentage = getCurrentPlaybackPercentage() val percentage = getCurrentPlaybackPercentage()
when (playingState) { when (playingState) {
State.PLAYING -> { State.Playing -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.PAUSED, State.Paused,
State.BUFFERING -> { State.Buffering -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.IDLE -> { State.Idle -> {
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) {
playbackTracker.stopPlayback(id) playbackTracker.stopPlayback(id)
} else { } else {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
is State.Error -> Unit
} }
} }
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,16C12.418,16 16,12.418 16,8C16,3.582 12.418,0 8,0C3.582,0 0,3.582 0,8C0,12.418 3.582,16 8,16ZM6.777,4.135C6.717,3.451 7.221,2.851 7.905,2.803C8.577,2.755 9.177,3.259 9.249,3.943V4.135L8.865,8.935C8.829,9.379 8.457,9.715 8.013,9.715H7.941C7.521,9.679 7.197,9.355 7.161,8.935L6.777,4.135ZM9.056,12.067C9.056,12.651 8.583,13.123 8,13.123C7.417,13.123 6.944,12.651 6.944,12.067C6.944,11.484 7.417,11.011 8,11.011C8.583,11.011 9.056,11.484 9.056,12.067Z"
android:fillColor="#FF5B55"
android:fillType="evenOdd" />
</vector>

View file

@ -176,4 +176,27 @@
tools:ignore="NegativeMargin" tools:ignore="NegativeMargin"
tools:text="-0:12" /> tools:text="-0:12" />
<androidx.constraintlayout.widget.Group
android:id="@+id/controlsGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="controllerButtonsFlow,seekBar,elapsedTime,remainingTime" />
<TextView
android:id="@+id/errorView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:drawablePadding="4dp"
android:text="@string/error_voice_broadcast_unable_to_play"
android:textColor="?colorError"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_voice_broadcast_error"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>