diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 2ea209a8f0..450eb64849 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3094,6 +3094,8 @@ Play or resume voice broadcast Pause voice broadcast Buffering + Fast backward 30 seconds + Fast forward 30 seconds Can’t start a new voice broadcast You don’t have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 50d5aaf014..22c2a3e62c 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -74,7 +74,8 @@ 22dp - 48dp + 48dp + 36dp 112dp diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index f773671694..8c49213a42 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -129,9 +129,10 @@ sealed class RoomDetailAction : VectorViewModelAction { } sealed class Listening : VoiceBroadcastAction() { - data class PlayOrResume(val eventId: String) : Listening() + data class PlayOrResume(val voiceBroadcastId: String) : Listening() object Pause : Listening() object Stop : Listening() + data class SeekTo(val voiceBroadcastId: String, val positionMillis: Int) : Listening() } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 50bebc81e4..3f4fae1ce9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -50,6 +50,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.error.RoomNotFound import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler @@ -478,7 +479,7 @@ class TimelineViewModel @AssistedInject constructor( is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.VoiceBroadcastAction -> handleVoiceBroadcastAction(action) + is VoiceBroadcastAction -> handleVoiceBroadcastAction(action) is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() is RoomDetailAction.StartCall -> handleStartCall(action) is RoomDetailAction.AcceptCall -> handleAcceptCall(action) @@ -620,22 +621,23 @@ class TimelineViewModel @AssistedInject constructor( } } - private fun handleVoiceBroadcastAction(action: RoomDetailAction.VoiceBroadcastAction) { + private fun handleVoiceBroadcastAction(action: VoiceBroadcastAction) { if (room == null) return viewModelScope.launch { when (action) { - RoomDetailAction.VoiceBroadcastAction.Recording.Start -> { + VoiceBroadcastAction.Recording.Start -> { voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold( { _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) }, { _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) }, ) } - RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) - is RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.eventId) - RoomDetailAction.VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() - RoomDetailAction.VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.voiceBroadcastId) + VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() + VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcastId, action.positionMillis) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 56498fa8d3..5d9c663210 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -67,6 +67,7 @@ class VoiceBroadcastItemFactory @Inject constructor( val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( voiceBroadcastId = voiceBroadcastId, voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, + duration = voiceBroadcastEventsGroup.getDuration(), recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), recorder = voiceBroadcastRecorder, player = voiceBroadcastPlayer, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index 8a3be7d5f2..a4bfa9e155 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.utils.TextUtils import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.duration import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState @@ -148,4 +149,8 @@ class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } ?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L } } + + fun getDuration(): Int { + return group.events.mapNotNull { it.root.asMessageAudioEvent()?.duration }.sum() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt index ba9d582ea4..7ada0c71f2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -94,6 +94,7 @@ abstract class AbsMessageVoiceBroadcastItem { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - playPauseButton.onClick { callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } + playPauseButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) } + seekBar.isEnabled = true } VoiceBroadcastPlayer.State.IDLE, VoiceBroadcastPlayer.State.PAUSED -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) - playPauseButton.onClick { - callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) - } + playPauseButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) } + seekBar.isEnabled = false + } + VoiceBroadcastPlayer.State.BUFFERING -> { + seekBar.isEnabled = true } - VoiceBroadcastPlayer.State.BUFFERING -> Unit } } } + private fun bindSeekBar(holder: Holder) { + holder.durationView.text = formatPlaybackTime(voiceBroadcastAttributes.duration) + holder.seekBar.max = voiceBroadcastAttributes.duration + holder.seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit + + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar) { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcastId, seekBar.progress)) + } + }) + } + + private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) + override fun unbind(holder: Holder) { super.unbind(holder) player.removeListener(voiceBroadcastId, playerListener) + holder.seekBar.setOnSeekBarChangeListener(null) } override fun getViewStubId() = STUB_ID @@ -85,6 +112,10 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { val playPauseButton by bind(R.id.playPauseButton) val bufferingView by bind(R.id.bufferingView) + val fastBackwardButton by bind(R.id.fastBackwardButton) + val fastForwardButton by bind(R.id.fastForwardButton) + val seekBar by bind(R.id.seekBar) + val durationView by bind(R.id.playbackDuration) val broadcasterNameMetadata by bind(R.id.broadcasterNameMetadata) val voiceBroadcastMetadata by bind(R.id.voiceBroadcastMetadata) val listenersCountMetadata by bind(R.id.listenersCountMetadata) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt index f9da2e76b1..48554f51d0 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt @@ -32,3 +32,5 @@ fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? { } val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence + +val MessageAudioEvent.duration get() = content.audioInfo?.duration ?: content.audioWaveformInfo?.duration ?: 0 diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index dfc8e35422..7864d3b4e3 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -41,9 +41,15 @@ class VoiceBroadcastHelper @Inject constructor( suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) - fun playOrResumePlayback(roomId: String, eventId: String) = voiceBroadcastPlayer.playOrResume(roomId, eventId) + fun playOrResumePlayback(roomId: String, voiceBroadcastId: String) = voiceBroadcastPlayer.playOrResume(roomId, voiceBroadcastId) fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() + + fun seekTo(voiceBroadcastId: String, positionMillis: Int) { + if (voiceBroadcastPlayer.currentVoiceBroadcastId == voiceBroadcastId) { + voiceBroadcastPlayer.seekTo(positionMillis) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt index e2870c4011..2a2a549af0 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt @@ -43,6 +43,11 @@ interface VoiceBroadcastPlayer { */ fun stop() + /** + * Seek to the given playback position, is milliseconds. + */ + fun seekTo(positionMillis: Int) + /** * Add a [Listener] to the given voice broadcast id. */ diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt index 3999a0e0af..166e5a12e5 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -22,7 +22,7 @@ import androidx.annotation.MainThread import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.voice.VoiceFailure -import im.vector.app.features.voicebroadcast.getVoiceBroadcastChunk +import im.vector.app.features.voicebroadcast.duration 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.usecase.GetLiveVoiceBroadcastChunksUseCase @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +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.MessageAudioEvent import timber.log.Timber @@ -62,14 +63,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( private var currentMediaPlayer: MediaPlayer? = null private var nextMediaPlayer: MediaPlayer? = null - set(value) { - field = value - currentMediaPlayer?.setNextMediaPlayer(value) - } private var currentSequence: Int? = null private var fetchPlaylistJob: Job? = null - private var playlist = emptyList() + private var playlist = emptyList() + private var isLive: Boolean = false override var currentVoiceBroadcastId: String? = null @@ -170,8 +168,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor( .launchIn(coroutineScope) } - private fun updatePlaylist(playlist: List) { - this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } + private fun updatePlaylist(audioEvents: List) { + val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs } + val chunkPositions = sorted + .map { it.duration } + .runningFold(0) { acc, i -> acc + i } + .dropLast(1) + playlist = sorted.mapIndexed { index, messageAudioEvent -> + PlaylistItem( + audioEvent = messageAudioEvent, + startTime = chunkPositions.getOrNull(index) ?: 0 + ) + } onPlaylistUpdated() } @@ -195,16 +203,23 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private fun startPlayback() { - 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 + private fun startPlayback(sequence: Int? = null, position: Int = 0) { + val playlistItem = when { + sequence != null -> playlist.find { it.audioEvent.sequence == sequence } + isLive -> playlist.lastOrNull() + else -> playlist.firstOrNull() + } + val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } + val computedSequence = playlistItem.audioEvent.sequence coroutineScope.launch { try { currentMediaPlayer = prepareMediaPlayer(content) currentMediaPlayer?.start() + if (position > 0) { + currentMediaPlayer?.seekTo(position) + } currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - currentSequence = sequence + currentSequence = computedSequence withContext(Dispatchers.Main) { playingState = State.PLAYING } nextMediaPlayer = prepareNextMediaPlayer() } catch (failure: Throwable) { @@ -220,11 +235,27 @@ class VoiceBroadcastPlayerImpl @Inject constructor( playingState = State.PLAYING } + override fun seekTo(positionMillis: Int) { + val duration = getVoiceBroadcastDuration() + val playlistItem = playlist.lastOrNull { it.startTime <= positionMillis } ?: return + val audioEvent = playlistItem.audioEvent + val eventPosition = positionMillis - playlistItem.startTime + + Timber.d("## Voice Broadcast | seekTo - duration=$duration, position=$positionMillis, sequence=${audioEvent.sequence}, sequencePosition=$eventPosition") + + tryOrNull { currentMediaPlayer?.stop() } + release(currentMediaPlayer) + tryOrNull { nextMediaPlayer?.stop() } + release(nextMediaPlayer) + + startPlayback(audioEvent.sequence, eventPosition) + } + private fun getNextAudioContent(): MessageAudioContent? { val nextSequence = currentSequence?.plus(1) - ?: playlist.lastOrNull()?.sequence + ?: playlist.lastOrNull()?.audioEvent?.sequence ?: 1 - return playlist.find { it.getVoiceBroadcastChunk()?.sequence == nextSequence }?.content + return playlist.find { it.audioEvent.sequence == nextSequence }?.audioEvent?.content } private suspend fun prepareNextMediaPlayer(): MediaPlayer? { @@ -268,7 +299,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { + private inner class MediaPlayerListener : + MediaPlayer.OnInfoListener, + MediaPlayer.OnPreparedListener, + MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener { override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { when (what) { @@ -282,6 +317,17 @@ class VoiceBroadcastPlayerImpl @Inject constructor( return false } + override fun onPrepared(mp: MediaPlayer) { + when (mp) { + currentMediaPlayer -> { + nextMediaPlayer?.let { mp.setNextMediaPlayer(it) } + } + nextMediaPlayer -> { + tryOrNull { currentMediaPlayer?.setNextMediaPlayer(mp) } + } + } + } + override fun onCompletion(mp: MediaPlayer) { if (nextMediaPlayer != null) return val roomId = currentRoomId ?: return @@ -302,4 +348,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor( return true } } + + private fun getVoiceBroadcastDuration() = playlist.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0 + + private data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int) } diff --git a/vector/src/main/res/drawable/ic_player_backward_30.xml b/vector/src/main/res/drawable/ic_player_backward_30.xml new file mode 100644 index 0000000000..cb244806b3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_backward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_player_forward_30.xml b/vector/src/main/res/drawable/ic_player_forward_30.xml new file mode 100644 index 0000000000..be61fda8ff --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_forward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index d508569cb0..bed9407dfa 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -84,22 +84,31 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:barrierDirection="bottom" - app:barrierMargin="12dp" + app:barrierMargin="10dp" app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" /> + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml index 3296134919..7da0701cc7 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -91,8 +91,8 @@