-
Notifications
You must be signed in to change notification settings - Fork 855
Voice broadcast - live listening #7419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Florian14
merged 12 commits into
develop
from
feature/fre/voice_broadcast_live_listening
Oct 20, 2022
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
6554f57
VoiceBroadcastPlayer - Inject ActiveSessionHolder instead of Session
b89ab6c
VoiceBroadcastPlayer - release previous MediaPlayer
0c847cf
VoiceBroadcastPlayer - Use more accurate coroutine scope
fe44a82
VoiceBroadcastPlayer - Improve currentVoiceBroadcastId
e9c81ca
VoiceBroadcastPlayer - Live listening
f05f0a8
VoiceBroadcastRecorder - Improve recorder by sending chunk when pausing
6d6b4e5
VoiceBroadcast - Ignore voice broadcast info with empty content (eg. …
9439069
VoiceBroadcastPlayer - Filter live broadcast state listening on the r…
99a2afa
Add changelog
bafa2f8
VoiceBroadcastRecorder - Send last sequence number on pause and stop
05eeef9
VoiceBroadcastListener - Handle end of live listening
0a9f2bf
Fix some PR comments
yostyle File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| [Voice Broadcast] Live listening support |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,105 +18,194 @@ package im.vector.app.features.voicebroadcast | |
|
|
||
| import android.media.AudioAttributes | ||
| import android.media.MediaPlayer | ||
| import im.vector.app.core.di.ActiveSessionHolder | ||
| import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker | ||
| import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State | ||
| import im.vector.app.features.voice.VoiceFailure | ||
| import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState | ||
| import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent | ||
| import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.launch | ||
| import org.matrix.android.sdk.api.extensions.orFalse | ||
| import org.matrix.android.sdk.api.session.Session | ||
| import org.matrix.android.sdk.api.session.events.model.RelationType | ||
| import org.matrix.android.sdk.api.session.events.model.getRelationContent | ||
| import org.matrix.android.sdk.api.session.getRoom | ||
| import org.matrix.android.sdk.api.session.room.Room | ||
| import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent | ||
| import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent | ||
| import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent | ||
| import org.matrix.android.sdk.api.session.room.timeline.Timeline | ||
| import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent | ||
| import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings | ||
| import timber.log.Timber | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class VoiceBroadcastPlayer @Inject constructor( | ||
| private val session: Session, | ||
| private val sessionHolder: ActiveSessionHolder, | ||
| private val playbackTracker: AudioMessagePlaybackTracker, | ||
| private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, | ||
| ) { | ||
| private val session | ||
| get() = sessionHolder.getActiveSession() | ||
|
|
||
| private val mediaPlayerScope = CoroutineScope(Dispatchers.IO) | ||
| private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a thought: you will see but you may have a hard time when trying to add unit tests on this class due to the usage of this |
||
| private var voiceBroadcastStateJob: Job? = null | ||
| private var currentTimeline: Timeline? = null | ||
| set(value) { | ||
| field?.removeAllListeners() | ||
| field?.dispose() | ||
| field = value | ||
| } | ||
|
|
||
| private val mediaPlayerListener = MediaPlayerListener() | ||
| private var timelineListener: TimelineListener? = null | ||
|
|
||
| private var currentMediaPlayer: MediaPlayer? = null | ||
| private var currentPlayingIndex: Int = -1 | ||
| private var nextMediaPlayer: MediaPlayer? = null | ||
| set(value) { | ||
| field = value | ||
| currentMediaPlayer?.setNextMediaPlayer(value) | ||
| } | ||
| private var currentSequence: Int? = null | ||
|
|
||
| private var playlist = emptyList<MessageAudioEvent>() | ||
| private val currentVoiceBroadcastEventId | ||
| private val currentVoiceBroadcastId | ||
| get() = playlist.firstOrNull()?.root?.getRelationContent()?.eventId | ||
|
|
||
| private val mediaPlayerListener = MediaPlayerListener() | ||
|
|
||
| fun play(roomId: String, eventId: String) { | ||
| val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") | ||
| private var state: State = State.IDLE | ||
| set(value) { | ||
| Timber.w("## VoiceBroadcastPlayer state: $field -> $value") | ||
| field = value | ||
| } | ||
| private var currentRoomId: String? = null | ||
|
|
||
| fun playOrResume(roomId: String, eventId: String) { | ||
| val hasChanged = currentVoiceBroadcastId != eventId | ||
| when { | ||
| currentVoiceBroadcastEventId != eventId -> { | ||
| stop() | ||
| updatePlaylist(room, eventId) | ||
| startPlayback() | ||
| } | ||
| playbackTracker.getPlaybackState(eventId) is State.Playing -> pause() | ||
| else -> resumePlayback() | ||
| hasChanged -> startPlayback(roomId, eventId) | ||
| state == State.PAUSED -> resumePlayback() | ||
| else -> Unit | ||
| } | ||
| } | ||
|
|
||
| fun pause() { | ||
| currentMediaPlayer?.pause() | ||
| currentVoiceBroadcastEventId?.let { playbackTracker.pausePlayback(it) } | ||
| currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) } | ||
| state = State.PAUSED | ||
| } | ||
|
|
||
| fun stop() { | ||
| // Stop playback | ||
| currentMediaPlayer?.stop() | ||
| currentMediaPlayer?.release() | ||
| currentMediaPlayer?.setOnInfoListener(null) | ||
| currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } | ||
|
|
||
| // Release current player | ||
| release(currentMediaPlayer) | ||
| currentMediaPlayer = null | ||
| currentVoiceBroadcastEventId?.let { playbackTracker.stopPlayback(it) } | ||
|
|
||
| // Release next player | ||
| release(nextMediaPlayer) | ||
| nextMediaPlayer = null | ||
|
|
||
| // Do not observe anymore voice broadcast state changes | ||
| voiceBroadcastStateJob?.cancel() | ||
| voiceBroadcastStateJob = null | ||
|
|
||
| // In case of live broadcast, stop observing new chunks | ||
| currentTimeline = null | ||
giomfo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| timelineListener = null | ||
|
|
||
| // Update state | ||
| state = State.IDLE | ||
|
|
||
| // Clear playlist | ||
| playlist = emptyList() | ||
| currentPlayingIndex = -1 | ||
| currentSequence = null | ||
| currentRoomId = null | ||
| } | ||
|
|
||
| private fun updatePlaylist(room: Room, eventId: String) { | ||
| val timelineEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId) | ||
| val audioEvents = timelineEvents.mapNotNull { it.root.asMessageAudioEvent() } | ||
| playlist = audioEvents.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } | ||
| private fun startPlayback(roomId: String, eventId: String) { | ||
| val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") | ||
| currentRoomId = roomId | ||
|
|
||
| // Stop listening previous voice broadcast if any | ||
| if (state != State.IDLE) stop() | ||
|
|
||
| state = State.BUFFERING | ||
|
|
||
| val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState | ||
| if (voiceBroadcastState == VoiceBroadcastState.STOPPED) { | ||
| // Get static playlist | ||
| updatePlaylist(getExistingChunks(room, eventId)) | ||
| startPlayback(false) | ||
| } else { | ||
| playLiveVoiceBroadcast(room, eventId) | ||
| } | ||
| } | ||
|
|
||
| private fun startPlayback() { | ||
| val content = playlist.firstOrNull()?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } | ||
| mediaPlayerScope.launch { | ||
| private fun startPlayback(isLive: Boolean) { | ||
| val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() | ||
| val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } | ||
| val sequence = event.getVoiceBroadcastChunk()?.sequence | ||
| coroutineScope.launch { | ||
| try { | ||
| currentMediaPlayer = prepareMediaPlayer(content) | ||
| currentMediaPlayer?.start() | ||
| currentPlayingIndex = 0 | ||
| currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) } | ||
| prepareNextFile() | ||
| currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } | ||
| currentSequence = sequence | ||
| state = State.PLAYING | ||
| nextMediaPlayer = prepareNextMediaPlayer() | ||
| } catch (failure: Throwable) { | ||
| Timber.e(failure, "Unable to start playback") | ||
| throw VoiceFailure.UnableToPlay(failure) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun playLiveVoiceBroadcast(room: Room, eventId: String) { | ||
| room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() ?: error("Cannot retrieve voice broadcast $eventId") | ||
| updatePlaylist(getExistingChunks(room, eventId)) | ||
| startPlayback(true) | ||
| observeIncomingEvents(room, eventId) | ||
| } | ||
|
|
||
| private fun getExistingChunks(room: Room, eventId: String): List<MessageAudioEvent> { | ||
| return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId) | ||
| .mapNotNull { it.root.asMessageAudioEvent() } | ||
| .filter { it.isVoiceBroadcast() } | ||
| } | ||
|
|
||
| private fun observeIncomingEvents(room: Room, eventId: String) { | ||
| currentTimeline = room.timelineService().createTimeline(null, TimelineSettings(5)).also { timeline -> | ||
giomfo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| timelineListener = TimelineListener(eventId).also { timeline.addListener(it) } | ||
| timeline.start() | ||
| } | ||
| } | ||
|
|
||
| private fun resumePlayback() { | ||
| currentMediaPlayer?.start() | ||
| currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) } | ||
| currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } | ||
| state = State.PLAYING | ||
| } | ||
|
|
||
| private suspend fun prepareNextFile() { | ||
| val nextContent = playlist.getOrNull(currentPlayingIndex + 1)?.content | ||
| if (nextContent == null) { | ||
| currentMediaPlayer?.setOnCompletionListener(mediaPlayerListener) | ||
| } else { | ||
| val nextMediaPlayer = prepareMediaPlayer(nextContent) | ||
| currentMediaPlayer?.setNextMediaPlayer(nextMediaPlayer) | ||
| } | ||
| private fun updatePlaylist(playlist: List<MessageAudioEvent>) { | ||
| this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } | ||
| } | ||
|
|
||
| private fun getNextAudioContent(): MessageAudioContent? { | ||
| val nextSequence = currentSequence?.plus(1) | ||
| ?: timelineListener?.let { playlist.lastOrNull()?.sequence } | ||
| ?: 1 | ||
| return playlist.find { it.getVoiceBroadcastChunk()?.sequence == nextSequence }?.content | ||
| } | ||
|
|
||
| private suspend fun prepareNextMediaPlayer(): MediaPlayer? { | ||
| val nextContent = getNextAudioContent() ?: return null | ||
| return prepareMediaPlayer(nextContent) | ||
| } | ||
|
|
||
| private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer { | ||
|
|
@@ -140,28 +229,78 @@ class VoiceBroadcastPlayer @Inject constructor( | |
| setDataSource(fis.fd) | ||
| setOnInfoListener(mediaPlayerListener) | ||
| setOnErrorListener(mediaPlayerListener) | ||
| setOnCompletionListener(mediaPlayerListener) | ||
| prepare() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { | ||
| private fun release(mp: MediaPlayer?) { | ||
| mp?.apply { | ||
| release() | ||
| setOnInfoListener(null) | ||
| setOnCompletionListener(null) | ||
| setOnErrorListener(null) | ||
| } | ||
| } | ||
|
|
||
| private inner class TimelineListener(private val voiceBroadcastId: String) : Timeline.Listener { | ||
| override fun onTimelineUpdated(snapshot: List<TimelineEvent>) { | ||
| val currentSequences = playlist.map { it.sequence } | ||
| val newChunks = snapshot | ||
| .mapNotNull { timelineEvent -> | ||
| timelineEvent.root.asMessageAudioEvent() | ||
| ?.takeIf { it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && it.sequence !in currentSequences } | ||
| } | ||
| if (newChunks.isEmpty()) return | ||
| updatePlaylist(playlist + newChunks) | ||
|
|
||
| when (state) { | ||
| State.PLAYING -> { | ||
| if (nextMediaPlayer == null) { | ||
| coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } | ||
| } | ||
| } | ||
| State.PAUSED -> { | ||
| if (nextMediaPlayer == null) { | ||
| coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } | ||
| } | ||
| } | ||
| State.BUFFERING -> { | ||
| val newMediaContent = getNextAudioContent() | ||
| if (newMediaContent != null) startPlayback(true) | ||
| } | ||
| State.IDLE -> startPlayback(true) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { | ||
|
|
||
| override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { | ||
| when (what) { | ||
| MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { | ||
| release(currentMediaPlayer) | ||
| currentMediaPlayer = mp | ||
| currentPlayingIndex++ | ||
| mediaPlayerScope.launch { prepareNextFile() } | ||
| currentSequence = currentSequence?.plus(1) | ||
| coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| override fun onCompletion(mp: MediaPlayer) { | ||
| // Verify that a new media has not been set in the mean time | ||
| if (!currentMediaPlayer?.isPlaying.orFalse()) { | ||
| if (nextMediaPlayer != null) return | ||
| val roomId = currentRoomId ?: return | ||
| val voiceBroadcastId = currentVoiceBroadcastId ?: return | ||
| val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return | ||
| val isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED | ||
|
|
||
| if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) { | ||
| // We'll not receive new chunks anymore so we can stop the live listening | ||
| stop() | ||
| } else { | ||
| state = State.BUFFERING | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -170,4 +309,11 @@ class VoiceBroadcastPlayer @Inject constructor( | |
| return true | ||
| } | ||
| } | ||
|
|
||
| enum class State { | ||
| PLAYING, | ||
| PAUSED, | ||
| BUFFERING, | ||
| IDLE | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use
getSafeActiveSessioninstead? ThegetActiveSessionmay throw an exception. If we keep, maybe we should try/catch in the call sites of this variable.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will be handled by #7423