Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7646.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Voice Broadcast - Fix playback stuck in buffering mode
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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.session.room.model.message.MessageAudioContent
import timber.log.Timber
Expand Down Expand Up @@ -73,7 +72,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
@MainThread
set(value) {
if (field != value) {
Timber.w("isLiveListening: $field -> $value")
Timber.d("## Voice Broadcast | isLiveListening: $field -> $value")
field = value
onLiveListeningChanged(value)
}
Expand All @@ -83,7 +82,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
@MainThread
set(value) {
if (field != value) {
Timber.w("playingState: $field -> $value")
Timber.d("## Voice Broadcast | playingState: $field -> $value")
field = value
onPlayingStateChanged(value)
}
Expand Down Expand Up @@ -175,41 +174,35 @@ class VoiceBroadcastPlayerImpl @Inject constructor(

private fun onPlaylistUpdated() {
when (playingState) {
State.PLAYING -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer()
}
}
State.PLAYING,
State.PAUSED -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer()
}
}
State.BUFFERING -> {
val nextItem = playlist.getNextItem()
val nextItem = if (isLiveListening && playlist.currentSequence == null) {
// live listening, jump to the last item if playback has not started
playlist.lastOrNull()
} else {
// not live or playback already started, request next item
playlist.getNextItem()
}
if (nextItem != null) {
val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
startPlayback(savedPosition?.takeIf { it > 0 })
startPlayback(nextItem.startTime)
}
}
State.IDLE -> {
val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
startPlayback(savedPosition?.takeIf { it > 0 })
}
State.IDLE -> Unit // Should not happen
}
}

private fun startPlayback(position: Int? = null) {
private fun startPlayback(position: Int) {
stopPlayer()

val playlistItem = when {
position != null -> playlist.findByPosition(position)
mostRecentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull()
else -> playlist.firstOrNull()
}
val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return }
val sequence = playlistItem.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return }
val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0
val playlistItem = playlist.findByPosition(position)
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 sequencePosition = position - playlistItem.startTime
sessionScope.launch {
try {
prepareMediaPlayer(content) { mp ->
Expand All @@ -223,7 +216,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
prepareNextMediaPlayer()
}
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure")
throw VoiceFailure.UnableToPlay(failure)
}
}
Expand All @@ -248,8 +241,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
currentMediaPlayer?.start()
playingState = State.PLAYING
} else {
val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) }
startPlayback(position)
val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
startPlayback(savedPosition)
}
}

Expand All @@ -274,9 +267,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
isPreparingNextPlayer = true
sessionScope.launch {
prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
nextMediaPlayer = mp
currentMediaPlayer?.setNextMediaPlayer(mp)
isPreparingNextPlayer = false
nextMediaPlayer = mp
when (playingState) {
State.PLAYING,
State.PAUSED -> {
currentMediaPlayer?.setNextMediaPlayer(mp)
}
State.BUFFERING -> {
mp.start()
onNextMediaPlayerStarted(mp)
}
State.IDLE -> stopPlayer()
}
}
}
}
Expand All @@ -287,7 +290,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val audioFile = try {
session.fileService().downloadFile(messageAudioContent)
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
Timber.e(failure, "Voice Broadcast | Download has failed: $failure")
throw VoiceFailure.UnableToPlay(failure)
}

Expand Down Expand Up @@ -373,6 +376,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// Notify live mode change to all the listeners attached to the current voice broadcast id
listeners[voiceBroadcastId]?.forEach { listener -> listener.onLiveModeChanged(isLiveListening) }
}

// Live has ended and last chunk has been reached, we can stop the playback
if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid possible bugs, we should encapsulate these logic in state data classes and create use case classes with unit test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you but I am not sure to follow exactly what you have in mind about the use cases. There are a lot of combinations with several parameters to take into account, For the moment, I don't know how many combinations there are and if I would be able to enumerate all of them.

I already refactored this class but I confirm it is still not perfect, it will require new refactoring and unit tests to prevent regression as every change can be dangerous. I created an issue to track the missing tests on this part. I'll try to improve this mechanism by introducing a state machine at this moment.

stop()
}
}

private fun onNextMediaPlayerStarted(mp: MediaPlayer) {
playingState = State.PLAYING
playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp
nextMediaPlayer = null
prepareNextMediaPlayer()
}

private fun getCurrentPlaybackPosition(): Int? {
Expand All @@ -398,23 +414,24 @@ class VoiceBroadcastPlayerImpl @Inject constructor(

override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean {
when (what) {
MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> {
playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp
nextMediaPlayer = null
playingState = State.PLAYING
prepareNextMediaPlayer()
}
MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> onNextMediaPlayerStarted(mp)
}
return false
}

override fun onCompletion(mp: MediaPlayer) {
// Next media player is already attached to this player and will start playing automatically
if (nextMediaPlayer != null) return

if (isLiveListening || mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
// Next media player is preparing but not attached yet, reset the currentMediaPlayer and let the new player take over
if (isPreparingNextPlayer) {
currentMediaPlayer?.release()
currentMediaPlayer = null
playingState = State.BUFFERING
} else {
return
}

if (!isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
// We'll not receive new chunks anymore so we can stop the live listening
stop()
}
Expand Down