diff --git a/changelog.d/7655.wip b/changelog.d/7655.wip new file mode 100644 index 0000000000..24358007a9 --- /dev/null +++ b/changelog.d/7655.wip @@ -0,0 +1 @@ +Voice Broadcast - Update the buffering display in the timeline diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 307dc8814c..58fc62b347 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3094,12 +3094,13 @@ (%1$s) Live + + Buffering… Resume voice broadcast record Pause voice broadcast record Stop voice broadcast record Play or resume voice broadcast Pause voice broadcast - Buffering Fast backward 30 seconds Fast forward 30 seconds Can’t start a new voice broadcast diff --git a/vector/src/main/java/im/vector/app/core/extensions/Flow.kt b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt new file mode 100644 index 0000000000..82e6e5f9a6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt @@ -0,0 +1,35 @@ +/* + * Copyright 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.core.extensions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Returns a flow that invokes the given action after the first value of the upstream flow is emitted downstream. + */ +fun Flow.onFirst(action: (T) -> Unit): Flow = flow { + var emitted = false + collect { value -> + emit(value) // always emit value + + if (!emitted) { + action(value) // execute the action after the first emission + emitted = true + } + } +} 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 b5ea528bd7..900de041d0 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 @@ -149,7 +149,7 @@ class AudioMessageHelper @Inject constructor( } private fun startPlayback(id: String, file: File) { - val currentPlaybackTime = playbackTracker.getPlaybackTime(id) + val currentPlaybackTime = playbackTracker.getPlaybackTime(id) ?: 0 try { FileInputStream(file).use { fis -> 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 90fd66f9ab..c34cbbc74a 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 @@ -67,8 +67,8 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun startPlayback(id: String) { - val currentPlaybackTime = getPlaybackTime(id) - val currentPercentage = getPercentage(id) + val currentPlaybackTime = getPlaybackTime(id) ?: 0 + val currentPercentage = getPercentage(id) ?: 0f val currentState = Listener.State.Playing(currentPlaybackTime, currentPercentage) setState(id, currentState) // Pause any active playback @@ -85,9 +85,10 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun pausePlayback(id: String) { - if (getPlaybackState(id) is Listener.State.Playing) { - val currentPlaybackTime = getPlaybackTime(id) - val currentPercentage = getPercentage(id) + val state = getPlaybackState(id) + if (state is Listener.State.Playing) { + val currentPlaybackTime = state.playbackTime + val currentPercentage = state.percentage setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage)) } } @@ -110,21 +111,23 @@ class AudioMessagePlaybackTracker @Inject constructor() { fun getPlaybackState(id: String) = states[id] - fun getPlaybackTime(id: String): Int { + fun getPlaybackTime(id: String): Int? { return when (val state = states[id]) { is Listener.State.Playing -> state.playbackTime is Listener.State.Paused -> state.playbackTime - /* Listener.State.Idle, */ - else -> 0 + is Listener.State.Recording, + Listener.State.Idle, + null -> null } } - 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 - /* Listener.State.Idle, */ - else -> 0f + is Listener.State.Recording, + Listener.State.Idle, + null -> null } } 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 e5cb677763..38fe1e8f17 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 @@ -17,7 +17,6 @@ package im.vector.app.features.home.room.detail.timeline.item import android.text.format.DateUtils -import android.view.View import android.widget.ImageButton import android.widget.SeekBar import android.widget.TextView @@ -30,6 +29,7 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc 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.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass @@ -63,10 +63,10 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem playPauseButton.setOnClickListener { if (player.currentVoiceBroadcast == voiceBroadcast) { when (player.playingState) { - VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) + VoiceBroadcastPlayer.State.PLAYING, + VoiceBroadcastPlayer.State.BUFFERING -> 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)) @@ -86,7 +86,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem override fun renderMetadata(holder: Holder) { with(holder) { broadcasterNameMetadata.value = recorderName - voiceBroadcastMetadata.isVisible = true listenersCountMetadata.isVisible = false } } @@ -102,10 +101,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { with(holder) { bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING - playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING + voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING when (state) { - VoiceBroadcastPlayer.State.PLAYING -> { + VoiceBroadcastPlayer.State.PLAYING, + VoiceBroadcastPlayer.State.BUFFERING -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) } @@ -114,7 +114,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) } - VoiceBroadcastPlayer.State.BUFFERING -> Unit } renderLiveIndicator(holder) @@ -142,14 +141,14 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem renderBackwardForwardButtons(holder, playbackState) renderLiveIndicator(holder) if (!isUserSeeking) { - holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 } } } private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused - val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 val canBackward = isPlayingOrPaused && playbackTime > 0 val canForward = isPlayingOrPaused && playbackTime < duration holder.fastBackwardButton.isInvisible = !canBackward @@ -174,7 +173,7 @@ 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 bufferingView by bind(R.id.bufferingMetadata) val fastBackwardButton by bind(R.id.fastBackwardButton) val fastForwardButton by bind(R.id.fastForwardButton) val seekBar by bind(R.id.seekBar) 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 724be600a3..f8025d078e 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 @@ -21,6 +21,7 @@ import android.media.MediaPlayer import android.media.MediaPlayer.OnPreparedListener import androidx.annotation.MainThread import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.onFirst 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 @@ -145,11 +146,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( playingState = State.BUFFERING observeVoiceBroadcastStateEvent(voiceBroadcast) - fetchPlaylistAndStartPlayback(voiceBroadcast) } private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) { voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) + .onFirst { fetchPlaylistAndStartPlayback(voiceBroadcast) } .onEach { onVoiceBroadcastStateEventUpdated(it.getOrNull()) } .launchIn(sessionScope) } @@ -222,24 +223,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private fun pausePlayback(positionMillis: Int? = null) { - if (positionMillis == null) { + private fun pausePlayback() { + playingState = State.PAUSED // This will trigger a playing state update and save the current position + if (currentMediaPlayer != 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() { if (currentMediaPlayer != null) { - currentMediaPlayer?.start() playingState = State.PLAYING + currentMediaPlayer?.start() } else { val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 startPlayback(savedPosition) @@ -256,7 +252,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor( startPlayback(positionMillis) } playingState == State.IDLE || playingState == State.PAUSED -> { - pausePlayback(positionMillis) + stopPlayer() + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) } } } @@ -366,8 +363,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor( isLiveListening && newSequence == playlist.currentSequence } } - // otherwise, stay in live or go in live if we reached the latest sequence - else -> isLiveListening || playlist.currentSequence == playlist.lastOrNull()?.sequence + // if there is no saved position, go in live + getCurrentPlaybackPosition() == null -> true + // if we reached the latest sequence, go in live + playlist.currentSequence == playlist.lastOrNull()?.sequence -> true + // otherwise, do not change + else -> isLiveListening } } @@ -392,9 +393,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } 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) } + val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } + val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) return computedPosition ?: savedPosition } @@ -423,17 +424,15 @@ class VoiceBroadcastPlayerImpl @Inject constructor( // Next media player is already attached to this player and will start playing automatically if (nextMediaPlayer != null) return - // Next media player is preparing but not attached yet, reset the currentMediaPlayer and let the new player take over - if (isPreparingNextPlayer) { - currentMediaPlayer?.release() - currentMediaPlayer = null - playingState = State.BUFFERING - return - } - - if (!isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) { + val hasEnded = !isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence + if (hasEnded) { // We'll not receive new chunks anymore so we can stop the live listening stop() + } else { + // Enter in buffering mode and release current media player + playingState = State.BUFFERING + currentMediaPlayer?.release() + currentMediaPlayer = null } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt new file mode 100644 index 0000000000..eabefa323e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt @@ -0,0 +1,37 @@ +/* + * 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.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import im.vector.app.databinding.ViewVoiceBroadcastBufferingBinding + +class VoiceBroadcastBufferingView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + ViewVoiceBroadcastBufferingBinding.inflate( + LayoutInflater.from(context), + this + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt index e142cb15ce..c743d8a542 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt @@ -37,9 +37,9 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor( ) var value: String - get() = views.metadataValue.text.toString() + get() = views.metadataText.text.toString() set(newValue) { - views.metadataValue.text = newValue + views.metadataText.text = newValue } init { @@ -61,6 +61,6 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor( private fun setValue(typedArray: TypedArray) { val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue) - views.metadataValue.text = value + views.metadataText.text = value } } diff --git a/vector/src/main/res/drawable/bg_seek_bar.xml b/vector/src/main/res/drawable/bg_seek_bar.xml index 0a33522dfd..eff461091e 100644 --- a/vector/src/main/res/drawable/bg_seek_bar.xml +++ b/vector/src/main/res/drawable/bg_seek_bar.xml @@ -13,9 +13,9 @@ - \ No newline at end of file + diff --git a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml index 2a6fbf5a9e..4c4286af9b 100644 --- a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml @@ -73,7 +73,7 @@ android:layout_marginTop="12dp" android:layout_marginBottom="10dp" android:progressDrawable="@drawable/bg_seek_bar" - android:thumbTint="?vctr_content_tertiary" + android:thumbTint="?vctr_content_secondary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime" app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton" @@ -85,7 +85,7 @@ style="@style/Widget.Vector.TextView.Caption" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="?vctr_content_tertiary" + android:textColor="?vctr_content_secondary" android:layout_marginEnd="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/audioSeekBar" @@ -104,4 +104,4 @@ android:visibility="gone" tools:visibility="visible" /> - \ No newline at end of file + 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 1d31afba99..3c59d49418 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 @@ -7,7 +7,9 @@ android:layout_height="wrap_content" android:background="@drawable/rounded_rect_shape_8" android:backgroundTint="?vctr_content_quinary" - android:padding="@dimen/layout_vertical_margin"> + android:clipChildren="false" + android:clipToPadding="false" + android:padding="12dp"> + + @@ -117,16 +124,6 @@ android:src="@drawable/ic_play_pause_play" app:tint="?vctr_content_secondary" /> - - + tools:progress="0" /> + android:padding="12dp"> + + + + + + diff --git a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml index 3bc31cd9a0..70de3e330e 100644 --- a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml +++ b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml @@ -18,7 +18,7 @@ tools:src="@drawable/ic_voice_broadcast" />