diff --git a/changelog.d/7496.wip b/changelog.d/7496.wip new file mode 100644 index 0000000000..49d15d084f --- /dev/null +++ b/changelog.d/7496.wip @@ -0,0 +1 @@ +[Voice Broadcast] Add seekbar in listening tile diff --git a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt index 92d28d26c9..07c7b4588f 100644 --- a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt +++ b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt @@ -103,14 +103,12 @@ class VideoViewHolder constructor(itemView: View) : views.videoView.setOnPreparedListener { stopTimer() countUpTimer = CountUpTimer(100).also { - it.tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val duration = views.videoView.duration - val progress = views.videoView.currentPosition - val isPlaying = views.videoView.isPlaying -// Log.v("FOO", "isPlaying $isPlaying $progress/$duration") - eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) - } + it.tickListener = CountUpTimer.TickListener { + val duration = views.videoView.duration + val progress = views.videoView.currentPosition + val isPlaying = views.videoView.isPlaying + // Log.v("FOO", "isPlaying $isPlaying $progress/$duration") + eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration)) } it.resume() } diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt index e9d311fe03..a4fd8bb4e1 100644 --- a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt +++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt @@ -66,7 +66,7 @@ class CountUpTimer(private val intervalInMs: Long = 1_000) { coroutineScope.cancel() } - interface TickListener { + fun interface TickListener { fun onTick(milliseconds: Long) } } diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt index 00b9a76de7..0bf70690ba 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -167,12 +167,10 @@ class WebRtcCall( private var screenSender: RtpSender? = null private val timer = CountUpTimer(1000L).apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) - listeners.forEach { - tryOrNull { it.onTick(formattedDuration) } - } + tickListener = CountUpTimer.TickListener { milliseconds -> + val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) + listeners.forEach { + tryOrNull { it.onTick(formattedDuration) } } } } 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 8c49213a42..faee8f652c 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 @@ -20,6 +20,7 @@ import android.net.Uri import android.view.View import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.conference.ConferenceEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent @@ -129,10 +130,10 @@ sealed class RoomDetailAction : VectorViewModelAction { } sealed class Listening : VoiceBroadcastAction() { - data class PlayOrResume(val voiceBroadcastId: String) : Listening() + data class PlayOrResume(val voiceBroadcast: VoiceBroadcast) : Listening() object Pause : Listening() object Stop : Listening() - data class SeekTo(val voiceBroadcastId: String, val positionMillis: Int) : Listening() + data class SeekTo(val voiceBroadcast: VoiceBroadcast, val positionMillis: Int, val duration: 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 3f4fae1ce9..ef238d56e6 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 @@ -634,10 +634,10 @@ class TimelineViewModel @AssistedInject constructor( 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) + is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast) VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() - is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcastId, action.positionMillis) + is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcast, action.positionMillis, action.duration) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt index bede02c17f..eddfe500b3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt @@ -199,11 +199,7 @@ class AudioMessageHelper @Inject constructor( private fun startRecordingAmplitudes() { amplitudeTicker?.stop() amplitudeTicker = CountUpTimer(50).apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onAmplitudeTick() - } - } + tickListener = CountUpTimer.TickListener { onAmplitudeTick() } resume() } } @@ -234,11 +230,7 @@ class AudioMessageHelper @Inject constructor( private fun startPlaybackTicker(id: String) { playbackTicker?.stop() playbackTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onPlaybackTick(id) - } - } + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } resume() } onPlaybackTick(id) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 13e0477ab6..a7b926f29a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -189,11 +189,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor( val startMs = ((clock.epochMillis() - startAt)).coerceAtLeast(0) recordingTicker?.stop() recordingTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked - onRecordingTick(isLocked, milliseconds + startMs) - } + tickListener = CountUpTimer.TickListener { milliseconds -> + val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked + onRecordingTick(isLocked, milliseconds + startMs) } resume() } 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 5d9c663210..e4f7bed72f 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 @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.core.resources.ColorProvider 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.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem @@ -28,6 +29,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadca import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_ import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder @@ -44,6 +46,7 @@ class VoiceBroadcastItemFactory @Inject constructor( private val drawableProvider: DrawableProvider, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, + private val playbackTracker: AudioMessagePlaybackTracker, ) { fun create( @@ -58,19 +61,20 @@ class VoiceBroadcastItemFactory @Inject constructor( val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null - val voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId + val voiceBroadcast = VoiceBroadcast(voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId, roomId = params.event.roomId) val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && voiceBroadcastEvent.root.stateKey == session.myUserId && messageContent.deviceId == session.sessionParams.deviceId val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( - voiceBroadcastId = voiceBroadcastId, + voiceBroadcast = voiceBroadcast, voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, duration = voiceBroadcastEventsGroup.getDuration(), recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), recorder = voiceBroadcastRecorder, player = voiceBroadcastPlayer, + playbackTracker = playbackTracker, roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), colorProvider = colorProvider, drawableProvider = drawableProvider, @@ -89,7 +93,7 @@ class VoiceBroadcastItemFactory @Inject constructor( voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastRecordingItem { return MessageVoiceBroadcastRecordingItem_() - .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) @@ -102,7 +106,7 @@ class VoiceBroadcastItemFactory @Inject constructor( voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastListeningItem { return MessageVoiceBroadcastListeningItem_() - .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt index 6937cd3a46..91f27ce5a8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt @@ -127,7 +127,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { } } - private fun getPercentage(id: String): Float { + fun getPercentage(id: String): Float { return when (val state = states[id]) { is Listener.State.Playing -> state.percentage is Listener.State.Paused -> state.percentage @@ -148,7 +148,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { const val RECORDING_ID = "RECORDING_ID" } - interface Listener { + fun interface Listener { fun onUpdate(state: State) 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 7ada0c71f2..0329adf12b 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 @@ -25,7 +25,9 @@ import im.vector.app.R import im.vector.app.core.extensions.tintBackground import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.util.MatrixItem @@ -35,11 +37,13 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol @EpoxyAttribute lateinit var voiceBroadcastAttributes: Attributes - protected val voiceBroadcastId get() = voiceBroadcastAttributes.voiceBroadcastId + protected val voiceBroadcast get() = voiceBroadcastAttributes.voiceBroadcast protected val voiceBroadcastState get() = voiceBroadcastAttributes.voiceBroadcastState protected val recorderName get() = voiceBroadcastAttributes.recorderName protected val recorder get() = voiceBroadcastAttributes.recorder protected val player get() = voiceBroadcastAttributes.player + protected val playbackTracker get() = voiceBroadcastAttributes.playbackTracker + protected val duration get() = voiceBroadcastAttributes.duration protected val roomItem get() = voiceBroadcastAttributes.roomItem protected val colorProvider get() = voiceBroadcastAttributes.colorProvider protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider @@ -92,12 +96,13 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol } data class Attributes( - val voiceBroadcastId: String, + val voiceBroadcast: VoiceBroadcast, val voiceBroadcastState: VoiceBroadcastState?, val duration: Int, val recorderName: String, val recorder: VoiceBroadcastRecorder?, val player: VoiceBroadcastPlayer, + val playbackTracker: AudioMessagePlaybackTracker, val roomItem: MatrixItem?, val colorProvider: ColorProvider, val drawableProvider: DrawableProvider, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt index fda9a1465f..3e8d6cb487 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageAudioItem.kt @@ -140,16 +140,14 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() { } private fun renderStateBasedOnAudioPlayback(holder: Holder) { - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) - is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) + is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit } - }) + } } private fun renderIdleState(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index a2d1e30c99..4b91bbfb0e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -27,6 +27,7 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick 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.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @@ -34,6 +35,7 @@ import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem<MessageVoiceBroadcastListeningItem.Holder>() { private lateinit var playerListener: VoiceBroadcastPlayer.Listener + private var isUserSeeking = false override fun bind(holder: Holder) { super.bind(holder) @@ -41,11 +43,35 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem } private fun bindVoiceBroadcastItem(holder: Holder) { - playerListener = VoiceBroadcastPlayer.Listener { state -> - renderPlayingState(holder, state) - } - player.addListener(voiceBroadcastId, playerListener) + playerListener = VoiceBroadcastPlayer.Listener { renderPlayingState(holder, it) } + player.addListener(voiceBroadcast, playerListener) bindSeekBar(holder) + bindButtons(holder) + } + + private fun bindButtons(holder: Holder) { + with(holder) { + playPauseButton.onClick { + if (player.currentVoiceBroadcast == voiceBroadcast) { + when (player.playingState) { + VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) + VoiceBroadcastPlayer.State.PAUSED, + VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) + VoiceBroadcastPlayer.State.BUFFERING -> Unit + } + } else { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) + } + } + fastBackwardButton.onClick { + val newPos = seekBar.progress.minus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) + } + fastForwardButton.onClick { + val newPos = seekBar.progress.plus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) + } + } } override fun renderMetadata(holder: Holder) { @@ -61,50 +87,67 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING - fastBackwardButton.isInvisible = true - fastForwardButton.isInvisible = true - when (state) { VoiceBroadcastPlayer.State.PLAYING -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - 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(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 + with(holder) { + durationView.text = formatPlaybackTime(duration) + seekBar.max = duration + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit - override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + override fun onStartTrackingTouch(seekBar: SeekBar) { + isUserSeeking = true + } - override fun onStopTrackingTouch(seekBar: SeekBar) { - callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcastId, seekBar.progress)) + override fun onStopTrackingTouch(seekBar: SeekBar) { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, seekBar.progress, duration)) + isUserSeeking = false + } + }) + } + playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState -> + renderBackwardForwardButtons(holder, playbackState) + if (!isUserSeeking) { + holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) } - }) + } + } + + private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { + val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused + val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + val canBackward = isPlayingOrPaused && playbackTime > 0 + val canForward = isPlayingOrPaused && playbackTime < duration + holder.fastBackwardButton.isInvisible = !canBackward + holder.fastForwardButton.isInvisible = !canForward } 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) + player.removeListener(voiceBroadcast, playerListener) + playbackTracker.untrack(voiceBroadcast.voiceBroadcastId) + with(holder) { + seekBar.onClick(null) + playPauseButton.onClick(null) + fastForwardButton.onClick(null) + fastBackwardButton.onClick(null) + } } override fun getViewStubId() = STUB_ID diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index e057950790..d3f320db7d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -122,16 +122,14 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() { true } - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, 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.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, 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.Recording -> Unit } - }) + } } private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f) diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt index bab7f4c7f9..c108e83e76 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt @@ -79,10 +79,8 @@ abstract class LiveLocationUserItem : VectorEpoxyModel<LiveLocationUserItem.Hold } } - holder.timer.tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - holder.itemLastUpdatedAtTextView.text = getFormattedLastUpdatedAt(locationUpdateTimeMillis) - } + holder.timer.tickListener = CountUpTimer.TickListener { + holder.itemLastUpdatedAtTextView.text = getFormattedLastUpdatedAt(locationUpdateTimeMillis) } holder.timer.resume() 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 48554f51d0..fa8033a211 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 @@ -16,7 +16,11 @@ package im.vector.app.features.voicebroadcast +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -34,3 +38,9 @@ fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? { val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence val MessageAudioEvent.duration get() = content.audioInfo?.duration ?: content.audioWaveformInfo?.duration ?: 0 + +val VoiceBroadcastEvent.isLive + get() = content?.isLive.orFalse() + +val MessageVoiceBroadcastInfoContent.isLive + get() = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED 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 7864d3b4e3..38fb157748 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 @@ -17,6 +17,7 @@ package im.vector.app.features.voicebroadcast import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase @@ -41,15 +42,13 @@ class VoiceBroadcastHelper @Inject constructor( suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) - fun playOrResumePlayback(roomId: String, voiceBroadcastId: String) = voiceBroadcastPlayer.playOrResume(roomId, voiceBroadcastId) + fun playOrResumePlayback(voiceBroadcast: VoiceBroadcast) = voiceBroadcastPlayer.playOrResume(voiceBroadcast) fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() - fun seekTo(voiceBroadcastId: String, positionMillis: Int) { - if (voiceBroadcastPlayer.currentVoiceBroadcastId == voiceBroadcastId) { - voiceBroadcastPlayer.seekTo(positionMillis) - } + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + voiceBroadcastPlayer.seekTo(voiceBroadcast, positionMillis, duration) } } 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 2a2a549af0..8c11db4f43 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 @@ -16,12 +16,14 @@ package im.vector.app.features.voicebroadcast.listening +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast + interface VoiceBroadcastPlayer { /** - * The current playing voice broadcast identifier, if any. + * The current playing voice broadcast, if any. */ - val currentVoiceBroadcastId: String? + val currentVoiceBroadcast: VoiceBroadcast? /** * The current playing [State], [State.IDLE] by default. @@ -31,7 +33,7 @@ interface VoiceBroadcastPlayer { /** * Start playback of the given voice broadcast. */ - fun playOrResume(roomId: String, voiceBroadcastId: String) + fun playOrResume(voiceBroadcast: VoiceBroadcast) /** * Pause playback of the current voice broadcast, if any. @@ -44,19 +46,19 @@ interface VoiceBroadcastPlayer { fun stop() /** - * Seek to the given playback position, is milliseconds. + * Seek the given voice broadcast playback to the given position, is milliseconds. */ - fun seekTo(positionMillis: Int) + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) /** - * Add a [Listener] to the given voice broadcast id. + * Add a [Listener] to the given voice broadcast. */ - fun addListener(voiceBroadcastId: String, listener: Listener) + fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) /** - * Remove a [Listener] from the given voice broadcast id. + * Remove a [Listener] from the given voice broadcast. */ - fun removeListener(voiceBroadcastId: String, listener: Listener) + fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) /** * Player states. 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 166e5a12e5..6a6dc6a9e8 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 @@ -18,28 +18,28 @@ package im.vector.app.features.voicebroadcast.listening import android.media.AudioAttributes import android.media.MediaPlayer +import android.media.MediaPlayer.OnPreparedListener 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.session.coroutineScope import im.vector.app.features.voice.VoiceFailure -import im.vector.app.features.voicebroadcast.duration +import im.vector.app.features.voicebroadcast.isLive 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 -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.sequence -import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase +import im.vector.lib.core.utils.timer.CountUpTimer import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob 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.orFalse 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 import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject @@ -49,179 +49,161 @@ import javax.inject.Singleton class VoiceBroadcastPlayerImpl @Inject constructor( private val sessionHolder: ActiveSessionHolder, private val playbackTracker: AudioMessagePlaybackTracker, - private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase ) : VoiceBroadcastPlayer { - private val session - get() = sessionHolder.getActiveSession() - - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var voiceBroadcastStateJob: Job? = null + private val session get() = sessionHolder.getActiveSession() + private val sessionScope get() = session.coroutineScope private val mediaPlayerListener = MediaPlayerListener() + private val playbackTicker = PlaybackTicker() + private val playlist = VoiceBroadcastPlaylist() + + private var fetchPlaylistTask: Job? = null + private var voiceBroadcastStateObserver: Job? = null private var currentMediaPlayer: MediaPlayer? = null private var nextMediaPlayer: MediaPlayer? = null - private var currentSequence: Int? = null + private var isPreparingNextPlayer: Boolean = false - private var fetchPlaylistJob: Job? = null - private var playlist = emptyList<PlaylistItem>() + private var currentVoiceBroadcastEvent: VoiceBroadcastEvent? = null - private var isLive: Boolean = false - - override var currentVoiceBroadcastId: String? = null + override var currentVoiceBroadcast: VoiceBroadcast? = null override var playingState = State.IDLE @MainThread set(value) { - Timber.w("## VoiceBroadcastPlayer state: $field -> $value") - field = value - // Notify state change to all the listeners attached to the current voice broadcast id - currentVoiceBroadcastId?.let { voiceBroadcastId -> - listeners[voiceBroadcastId]?.forEach { listener -> listener.onStateChanged(value) } + if (field != value) { + Timber.w("## VoiceBroadcastPlayer state: $field -> $value") + field = value + onPlayingStateChanged(value) } } - private var currentRoomId: String? = null - /** - * Map voiceBroadcastId to listeners. - */ + /** Map voiceBroadcastId to listeners.*/ private val listeners: MutableMap<String, CopyOnWriteArrayList<Listener>> = mutableMapOf() - override fun playOrResume(roomId: String, voiceBroadcastId: String) { - val hasChanged = currentVoiceBroadcastId != voiceBroadcastId + override fun playOrResume(voiceBroadcast: VoiceBroadcast) { + val hasChanged = currentVoiceBroadcast != voiceBroadcast when { - hasChanged -> startPlayback(roomId, voiceBroadcastId) + hasChanged -> startPlayback(voiceBroadcast) playingState == State.PAUSED -> resumePlayback() else -> Unit } } override fun pause() { - currentMediaPlayer?.pause() - currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) } - playingState = State.PAUSED + pausePlayback() } override fun stop() { - // Stop playback - currentMediaPlayer?.stop() - currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } - isLive = false - - // Release current player - release(currentMediaPlayer) - currentMediaPlayer = null - - // Release next player - release(nextMediaPlayer) - nextMediaPlayer = null - - // Do not observe anymore voice broadcast state changes - voiceBroadcastStateJob?.cancel() - voiceBroadcastStateJob = null - - // Do not fetch the playlist anymore - fetchPlaylistJob?.cancel() - fetchPlaylistJob = null - // Update state playingState = State.IDLE + // Stop and release media players + stopPlayer() + + // Do not observe anymore voice broadcast changes + fetchPlaylistTask?.cancel() + fetchPlaylistTask = null + voiceBroadcastStateObserver?.cancel() + voiceBroadcastStateObserver = null + // Clear playlist - playlist = emptyList() - currentSequence = null + playlist.reset() - currentRoomId = null - currentVoiceBroadcastId = null + currentVoiceBroadcastEvent = null + currentVoiceBroadcast = null } - override fun addListener(voiceBroadcastId: String, listener: Listener) { - listeners[voiceBroadcastId]?.add(listener) ?: run { - listeners[voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) } + override fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { + listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) } } - if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(playingState) else listener.onStateChanged(State.IDLE) + listener.onStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) } - override fun removeListener(voiceBroadcastId: String, listener: Listener) { - listeners[voiceBroadcastId]?.remove(listener) + override fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.remove(listener) } - private fun startPlayback(roomId: String, eventId: String) { + private fun startPlayback(voiceBroadcast: VoiceBroadcast) { // Stop listening previous voice broadcast if any if (playingState != State.IDLE) stop() - currentRoomId = roomId - currentVoiceBroadcastId = eventId + currentVoiceBroadcast = voiceBroadcast playingState = State.BUFFERING - val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState - isLive = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED - fetchPlaylistAndStartPlayback(roomId, eventId) + observeVoiceBroadcastLiveState(voiceBroadcast) + fetchPlaylistAndStartPlayback(voiceBroadcast) } - private fun fetchPlaylistAndStartPlayback(roomId: String, voiceBroadcastId: String) { - fetchPlaylistJob = getLiveVoiceBroadcastChunksUseCase.execute(roomId, voiceBroadcastId) - .onEach(this::updatePlaylist) - .launchIn(coroutineScope) + private fun observeVoiceBroadcastLiveState(voiceBroadcast: VoiceBroadcast) { + voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) + .onEach { currentVoiceBroadcastEvent = it.getOrNull() } + .launchIn(sessionScope) } - private fun updatePlaylist(audioEvents: List<MessageAudioEvent>) { - 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() + private fun fetchPlaylistAndStartPlayback(voiceBroadcast: VoiceBroadcast) { + fetchPlaylistTask = getLiveVoiceBroadcastChunksUseCase.execute(voiceBroadcast) + .onEach { + playlist.setItems(it) + onPlaylistUpdated() + } + .launchIn(sessionScope) } private fun onPlaylistUpdated() { when (playingState) { State.PLAYING -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } + if (nextMediaPlayer == null && !isPreparingNextPlayer) { + prepareNextMediaPlayer() } } State.PAUSED -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } + if (nextMediaPlayer == null && !isPreparingNextPlayer) { + prepareNextMediaPlayer() } } State.BUFFERING -> { - val newMediaContent = getNextAudioContent() - if (newMediaContent != null) startPlayback() + val nextItem = playlist.getNextItem() + if (nextItem != null) { + val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } + startPlayback(savedPosition?.takeIf { it > 0 }) + } + } + State.IDLE -> { + val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } + startPlayback(savedPosition?.takeIf { it > 0 }) } - State.IDLE -> startPlayback() } } - private fun startPlayback(sequence: Int? = null, position: Int = 0) { + private fun startPlayback(position: Int? = null) { + stopPlayer() + val playlistItem = when { - sequence != null -> playlist.find { it.audioEvent.sequence == sequence } - isLive -> playlist.lastOrNull() + position != null -> playlist.findByPosition(position) + currentVoiceBroadcastEvent?.isLive.orFalse() -> 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 { + val sequence = playlistItem.audioEvent.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return } + val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0 + sessionScope.launch { try { - currentMediaPlayer = prepareMediaPlayer(content) - currentMediaPlayer?.start() - if (position > 0) { - currentMediaPlayer?.seekTo(position) + prepareMediaPlayer(content) { mp -> + currentMediaPlayer = mp + playlist.currentSequence = sequence + mp.start() + if (sequencePosition > 0) { + mp.seekTo(sequencePosition) + } + playingState = State.PLAYING + prepareNextMediaPlayer() } - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - currentSequence = computedSequence - withContext(Dispatchers.Main) { playingState = State.PLAYING } - nextMediaPlayer = prepareNextMediaPlayer() } catch (failure: Throwable) { Timber.e(failure, "Unable to start playback") throw VoiceFailure.UnableToPlay(failure) @@ -229,41 +211,59 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } + private fun pausePlayback(positionMillis: Int? = null) { + if (positionMillis == null) { + currentMediaPlayer?.pause() + } else { + stopPlayer() + val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId + val duration = playlist.duration.takeIf { it > 0 } + if (voiceBroadcastId != null && duration != null) { + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + } + playingState = State.PAUSED + } + private fun resumePlayback() { - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - playingState = State.PLAYING + if (currentMediaPlayer != null) { + currentMediaPlayer?.start() + playingState = State.PLAYING + } else { + val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } + startPlayback(position) + } } - 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) + override fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + when { + voiceBroadcast != currentVoiceBroadcast -> { + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + playingState == State.PLAYING || playingState == State.BUFFERING -> { + startPlayback(positionMillis) + } + playingState == State.IDLE || playingState == State.PAUSED -> { + pausePlayback(positionMillis) + } + } } - private fun getNextAudioContent(): MessageAudioContent? { - val nextSequence = currentSequence?.plus(1) - ?: playlist.lastOrNull()?.audioEvent?.sequence - ?: 1 - return playlist.find { it.audioEvent.sequence == nextSequence }?.audioEvent?.content + private fun prepareNextMediaPlayer() { + val nextItem = playlist.getNextItem() + if (nextItem != null) { + isPreparingNextPlayer = true + sessionScope.launch { + prepareMediaPlayer(nextItem.audioEvent.content) { mp -> + nextMediaPlayer = mp + currentMediaPlayer?.setNextMediaPlayer(mp) + isPreparingNextPlayer = false + } + } + } } - private suspend fun prepareNextMediaPlayer(): MediaPlayer? { - val nextContent = getNextAudioContent() ?: return null - return prepareMediaPlayer(nextContent) - } - - private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer { + private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent, onPreparedListener: OnPreparedListener): MediaPlayer { // Download can fail val audioFile = try { session.fileService().downloadFile(messageAudioContent) @@ -284,58 +284,76 @@ class VoiceBroadcastPlayerImpl @Inject constructor( setDataSource(fis.fd) setOnInfoListener(mediaPlayerListener) setOnErrorListener(mediaPlayerListener) + setOnPreparedListener(onPreparedListener) setOnCompletionListener(mediaPlayerListener) prepare() } } } - private fun release(mp: MediaPlayer?) { - mp?.apply { - release() - setOnInfoListener(null) - setOnCompletionListener(null) - setOnErrorListener(null) + private fun stopPlayer() { + tryOrNull { currentMediaPlayer?.stop() } + currentMediaPlayer?.release() + currentMediaPlayer = null + + nextMediaPlayer?.release() + nextMediaPlayer = null + isPreparingNextPlayer = false + } + + private fun onPlayingStateChanged(playingState: State) { + // Notify state change to all the listeners attached to the current voice broadcast id + currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> + when (playingState) { + State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) + State.PAUSED, + State.BUFFERING, + State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) + } + listeners[voiceBroadcastId]?.forEach { listener -> listener.onStateChanged(playingState) } } } + private fun getCurrentPlaybackPosition(): Int? { + val playlistPosition = playlist.currentItem?.startTime + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition + val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } + return computedPosition ?: savedPosition + } + + private fun getCurrentPlaybackPercentage(): Float? { + val playlistPosition = playlist.currentItem?.startTime + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition + val duration = playlist.duration.takeIf { it > 0 } + val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null + val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } + return computedPercentage ?: savedPercentage + } + private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, - MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { when (what) { MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { - release(currentMediaPlayer) + playlist.currentSequence = playlist.currentSequence?.inc() currentMediaPlayer = mp - currentSequence = currentSequence?.plus(1) - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } + nextMediaPlayer = null + playingState = State.PLAYING + prepareNextMediaPlayer() } } 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 - val voiceBroadcastId = currentVoiceBroadcastId ?: return - val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return - isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED - if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) { + val content = currentVoiceBroadcastEvent?.content + val isLive = content?.isLive.orFalse() + if (!isLive && content?.lastChunkSequence == playlist.currentSequence) { // We'll not receive new chunks anymore so we can stop the live listening stop() } else { @@ -349,7 +367,48 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private fun getVoiceBroadcastDuration() = playlist.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0 + private inner class PlaybackTicker( + private var playbackTicker: CountUpTimer? = null, + ) { - private data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int) + fun startPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = CountUpTimer(50L).apply { + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } + resume() + } + onPlaybackTick(id) + } + + fun stopPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = null + onPlaybackTick(id) + } + + private fun onPlaybackTick(id: String) { + val playbackTime = getCurrentPlaybackPosition() + val percentage = getCurrentPlaybackPercentage() + when (playingState) { + State.PLAYING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) + } + } + State.PAUSED, + State.BUFFERING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + State.IDLE -> { + if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { + playbackTracker.stopPlayback(id) + } else { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + } + } + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt new file mode 100644 index 0000000000..ff388c2313 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.listening + +import im.vector.app.features.voicebroadcast.duration +import im.vector.app.features.voicebroadcast.sequence +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent + +class VoiceBroadcastPlaylist( + private val items: MutableList<PlaylistItem> = mutableListOf(), +) : List<PlaylistItem> by items { + + var currentSequence: Int? = null + val currentItem get() = currentSequence?.let { findBySequence(it) } + + val duration + get() = items.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0 + + fun setItems(audioEvents: List<MessageAudioEvent>) { + items.clear() + val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs } + val chunkPositions = sorted + .map { it.duration } + .runningFold(0) { acc, i -> acc + i } + .dropLast(1) + val newItems = sorted.mapIndexed { index, messageAudioEvent -> + PlaylistItem( + audioEvent = messageAudioEvent, + startTime = chunkPositions.getOrNull(index) ?: 0 + ) + } + items.addAll(newItems) + } + + fun reset() { + currentSequence = null + items.clear() + } + + fun findByPosition(positionMillis: Int): PlaylistItem? { + return items.lastOrNull { it.startTime <= positionMillis } + } + + fun findBySequence(sequenceNumber: Int): PlaylistItem? { + return items.find { it.audioEvent.sequence == sequenceNumber } + } + + fun getNextItem() = findBySequence(currentSequence?.plus(1) ?: 1) + + fun firstOrNull() = findBySequence(1) +} + +data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt index 4f9f2de673..d12a329142 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt @@ -19,18 +19,21 @@ package im.vector.app.features.voicebroadcast.listening.usecase import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.sequence -import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.runningReduce +import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent @@ -44,19 +47,19 @@ import javax.inject.Inject */ class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, - private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, ) { - fun execute(roomId: String, voiceBroadcastId: String): Flow<List<MessageAudioEvent>> { + fun execute(voiceBroadcast: VoiceBroadcast): Flow<List<MessageAudioEvent>> { val session = activeSessionHolder.getSafeActiveSession() ?: return emptyFlow() - val room = session.roomService().getRoom(roomId) ?: return emptyFlow() + val room = session.roomService().getRoom(voiceBroadcast.roomId) ?: return emptyFlow() val timeline = room.timelineService().createTimeline(null, TimelineSettings(5)) // Get initial chunks - val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcastId) + val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) .mapNotNull { timelineEvent -> timelineEvent.root.asMessageAudioEvent().takeIf { it.isVoiceBroadcast() } } - val voiceBroadcastEvent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId) + val voiceBroadcastEvent = runBlocking { getVoiceBroadcastEventUseCase.execute(voiceBroadcast).firstOrNull()?.getOrNull() } val voiceBroadcastState = voiceBroadcastEvent?.content?.voiceBroadcastState return if (voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED) { @@ -82,7 +85,7 @@ class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( lastSequence = stopEvent.content?.lastChunkSequence } - val newChunks = newEvents.mapToChunkEvents(voiceBroadcastId, voiceBroadcastEvent.root.senderId) + val newChunks = newEvents.mapToChunkEvents(voiceBroadcast.voiceBroadcastId, voiceBroadcastEvent.root.senderId) // Notify about new chunks if (newChunks.isNotEmpty()) { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt new file mode 100644 index 0000000000..62207d5b87 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.model + +data class VoiceBroadcast( + val voiceBroadcastId: String, + val roomId: String, +) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt new file mode 100644 index 0000000000..696d300fc3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import org.matrix.android.sdk.api.query.QueryStringValue +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.getRoom +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.unwrap +import timber.log.Timber +import javax.inject.Inject + +class GetVoiceBroadcastEventUseCase @Inject constructor( + private val session: Session, +) { + + fun execute(voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> { + val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}") + + Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $voiceBroadcast") + + val initialEvent = room.timelineService().getTimelineEvent(voiceBroadcast.voiceBroadcastId)?.root?.asVoiceBroadcastEvent() + val latestEvent = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) + .mapNotNull { it.root.asVoiceBroadcastEvent() } + .maxByOrNull { it.root.originServerTs ?: 0 } + ?: initialEvent + + return when (latestEvent?.content?.voiceBroadcastState) { + null, VoiceBroadcastState.STOPPED -> flowOf(latestEvent.toOptional()) + else -> { + room.flow() + .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(latestEvent.root.stateKey.orEmpty())) + .unwrap() + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.reference?.eventId == voiceBroadcast.voiceBroadcastId } + .map { it.toOptional() } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt deleted file mode 100644 index d08fa14a95..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.voicebroadcast.usecase - -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -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.getRoom -import timber.log.Timber -import javax.inject.Inject - -class GetVoiceBroadcastUseCase @Inject constructor( - private val session: Session, -) { - - fun execute(roomId: String, eventId: String): VoiceBroadcastEvent? { - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - - Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $eventId") - - val initialEvent = room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() // Fallback to initial event - val relatedEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId).sortedBy { it.root.originServerTs } - return relatedEvents.mapNotNull { it.root.asVoiceBroadcastEvent() }.lastOrNull() ?: initialEvent - } -} 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 bed9407dfa..1d31afba99 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 @@ -100,10 +100,12 @@ android:id="@+id/fastBackwardButton" android:layout_width="24dp" android:layout_height="24dp" - android:background="@android:color/transparent" + android:background="@drawable/bg_rounded_button" android:contentDescription="@string/a11y_voice_broadcast_fast_backward" android:src="@drawable/ic_player_backward_30" - app:tint="?vctr_content_secondary" /> + android:visibility="invisible" + app:tint="?vctr_content_secondary" + tools:visibility="visible" /> <ImageButton android:id="@+id/playPauseButton" @@ -121,16 +123,20 @@ android:layout_height="@dimen/voice_broadcast_player_button_size" android:contentDescription="@string/a11y_voice_broadcast_buffering" android:indeterminate="true" - android:indeterminateTint="?vctr_content_secondary" /> + android:indeterminateTint="?vctr_content_secondary" + android:visibility="gone" + tools:visibility="visible" /> <ImageButton android:id="@+id/fastForwardButton" android:layout_width="24dp" android:layout_height="24dp" - android:background="@android:color/transparent" + android:background="@drawable/bg_rounded_button" android:contentDescription="@string/a11y_voice_broadcast_fast_forward" android:src="@drawable/ic_player_forward_30" - app:tint="?vctr_content_secondary" /> + android:visibility="invisible" + app:tint="?vctr_content_secondary" + tools:visibility="visible" /> <SeekBar android:id="@+id/seekBar"