diff --git a/changelog.d/7421.wip b/changelog.d/7421.wip new file mode 100644 index 0000000000..4a399eee04 --- /dev/null +++ b/changelog.d/7421.wip @@ -0,0 +1 @@ +[Voice Broadcast] Improve rendering 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 e6714005a1..ea9b4b5999 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3078,6 +3078,14 @@ %1$s (%2$s) (%1$s) + Live + Resume voice broadcast record + Pause voice broadcast record + Stop voice broadcast record + Play or resume voice broadcast + Pause voice broadcast + Buffering + Anyone in %s will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 52d16eae7d..50d5aaf014 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -73,6 +73,9 @@ 12dp 22dp + + 48dp + 112dp diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index cb947a67ce..245d92f95b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -201,7 +201,7 @@ class MessageItemFactory @Inject constructor( is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) - is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(messageContent, params.eventsGroup, highlight, callback, attributes) + is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { 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 f2dfb020a1..5dc601a91a 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 @@ -15,46 +15,116 @@ */ package im.vector.app.features.home.room.detail.timeline.factory +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController -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.TimelineEventsGroup import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem -import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem -import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem +import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_ +import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class VoiceBroadcastItemFactory @Inject constructor( private val session: Session, private val avatarSizeProvider: AvatarSizeProvider, - private val audioMessagePlaybackTracker: AudioMessagePlaybackTracker, + private val colorProvider: ColorProvider, + private val drawableProvider: DrawableProvider, + private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, + private val voiceBroadcastPlayer: VoiceBroadcastPlayer, ) { fun create( + params: TimelineItemFactoryParams, messageContent: MessageVoiceBroadcastInfoContent, - eventsGroup: TimelineEventsGroup?, highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, - ): MessageVoiceBroadcastItem? { + ): VectorEpoxyModel? { // Only display item of the initial event with updated data if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null - val voiceBroadcastEventsGroup = eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null + val eventsGroup = params.eventsGroup ?: return null + val voiceBroadcastEventsGroup = VoiceBroadcastEventsGroup(eventsGroup) val mostRecentTimelineEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent() val mostRecentEvent = mostRecentTimelineEvent.root.asVoiceBroadcastEvent() val mostRecentMessageContent = mostRecentEvent?.content ?: return null val isRecording = mostRecentMessageContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && mostRecentEvent.root.stateKey == session.myUserId - return MessageVoiceBroadcastItem_() + val recorderName = mostRecentTimelineEvent.root.stateKey?.let { session.getUser(it) }?.displayName ?: mostRecentTimelineEvent.root.stateKey + return if (isRecording) { + createRecordingItem( + params.event.roomId, + eventsGroup.groupId, + highlight, + callback, + attributes + ) + } else { + createListeningItem( + params.event.roomId, + eventsGroup.groupId, + mostRecentMessageContent.voiceBroadcastState, + recorderName, + highlight, + callback, + attributes + ) + } + } + + private fun createRecordingItem( + roomId: String, + voiceBroadcastId: String, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes, + ): MessageVoiceBroadcastRecordingItem { + val roomSummary = session.getRoom(roomId)?.roomSummary() + return MessageVoiceBroadcastRecordingItem_() + .id("voice_broadcast_$voiceBroadcastId") .attributes(attributes) .highlighted(highlight) - .voiceBroadcastState(mostRecentMessageContent.voiceBroadcastState) - .recording(isRecording) - .audioMessagePlaybackTracker(audioMessagePlaybackTracker) + .roomItem(roomSummary?.toMatrixItem()) + .colorProvider(colorProvider) + .drawableProvider(drawableProvider) + .voiceBroadcastRecorder(voiceBroadcastRecorder) + .leftGuideline(avatarSizeProvider.leftGuideline) + .callback(callback) + } + + private fun createListeningItem( + roomId: String, + voiceBroadcastId: String, + voiceBroadcastState: VoiceBroadcastState?, + broadcasterName: String?, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes, + ): MessageVoiceBroadcastListeningItem { + val roomSummary = session.getRoom(roomId)?.roomSummary() + return MessageVoiceBroadcastListeningItem_() + .id("voice_broadcast_$voiceBroadcastId") + .attributes(attributes) + .highlighted(highlight) + .roomItem(roomSummary?.toMatrixItem()) + .colorProvider(colorProvider) + .drawableProvider(drawableProvider) + .voiceBroadcastPlayer(voiceBroadcastPlayer) + .voiceBroadcastId(voiceBroadcastId) + .voiceBroadcastState(voiceBroadcastState) + .broadcasterName(broadcasterName) .leftGuideline(avatarSizeProvider.leftGuideline) .callback(callback) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt deleted file mode 100644 index 1927024a36..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastItem.kt +++ /dev/null @@ -1,104 +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.home.room.detail.timeline.item - -import android.annotation.SuppressLint -import android.widget.ImageButton -import android.widget.TextView -import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.R -import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker -import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState - -@EpoxyModelClass -abstract class MessageVoiceBroadcastItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - - @EpoxyAttribute - var recording: Boolean = false - - @EpoxyAttribute - lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker - - private val voiceBroadcastEventId - get() = attributes.informationData.eventId - - override fun isCacheable(): Boolean = false - - override fun bind(holder: Holder) { - super.bind(holder) - bindVoiceBroadcastItem(holder) - } - - @SuppressLint("SetTextI18n") // Temporary text - private fun bindVoiceBroadcastItem(holder: Holder) { - holder.currentStateText.text = "Voice Broadcast state: ${voiceBroadcastState?.value ?: "None"}" - if (recording) { - renderRecording(holder) - } else { - renderListening(holder) - } - } - - private fun renderListening(holder: Holder) { - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: State) { - holder.playButton.isEnabled = state !is State.Playing - holder.pauseButton.isEnabled = state is State.Playing - holder.stopButton.isEnabled = state !is State.Idle - } - }) - holder.playButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastEventId)) } - holder.pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) } - holder.stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Stop) } - } - - private fun renderRecording(holder: Holder) { - with(holder) { - playButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.PAUSED - pauseButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || voiceBroadcastState == VoiceBroadcastState.RESUMED - stopButton.isEnabled = voiceBroadcastState == VoiceBroadcastState.STARTED || - voiceBroadcastState == VoiceBroadcastState.RESUMED || - voiceBroadcastState == VoiceBroadcastState.PAUSED - playButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } - pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } - stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - } - - override fun getViewStubId() = STUB_ID - - class Holder : AbsMessageLocationItem.Holder(STUB_ID) { - val currentStateText by bind(R.id.currentStateText) - val playButton by bind(R.id.playButton) - val pauseButton by bind(R.id.pauseButton) - val stopButton by bind(R.id.stopButton) - } - - companion object { - private val STUB_ID = R.id.messageVoiceBroadcastStub - } -} 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 new file mode 100644 index 0000000000..5b58dda4e6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -0,0 +1,179 @@ +/* + * 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.home.room.detail.timeline.item + +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.onClick +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.RoomDetailAction +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass +abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem() { + + @EpoxyAttribute + var callback: TimelineEventController.Callback? = null + + @EpoxyAttribute + var voiceBroadcastPlayer: VoiceBroadcastPlayer? = null + + @EpoxyAttribute + lateinit var voiceBroadcastId: String + + @EpoxyAttribute + var voiceBroadcastState: VoiceBroadcastState? = null + + @EpoxyAttribute + var broadcasterName: String? = null + + @EpoxyAttribute + lateinit var colorProvider: ColorProvider + + @EpoxyAttribute + lateinit var drawableProvider: DrawableProvider + + @EpoxyAttribute + var roomItem: MatrixItem? = null + + @EpoxyAttribute + var title: String? = null + + private lateinit var playerListener: VoiceBroadcastPlayer.Listener + + override fun isCacheable(): Boolean = false + + override fun bind(holder: Holder) { + super.bind(holder) + bindVoiceBroadcastItem(holder) + } + + private fun bindVoiceBroadcastItem(holder: Holder) { + playerListener = VoiceBroadcastPlayer.Listener { state -> + renderState(holder, state) + } + voiceBroadcastPlayer?.addListener(playerListener) + renderHeader(holder) + renderLiveIcon(holder) + } + + private fun renderHeader(holder: Holder) { + with(holder) { + roomItem?.let { + attributes.avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + broadcasterNameText.text = broadcasterName + } + } + + private fun renderLiveIcon(holder: Holder) { + with(holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) + liveIndicator.isVisible = true + } + VoiceBroadcastState.PAUSED -> { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + liveIndicator.isVisible = true + } + VoiceBroadcastState.STOPPED, null -> { + liveIndicator.isVisible = false + } + } + } + } + + private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) { + if (isCurrentMediaActive()) { + renderActiveMedia(holder, state) + } else { + renderInactiveMedia(holder) + } + } + + private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) { + with(holder) { + bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING + playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING + + when (state) { + VoiceBroadcastPlayer.State.PLAYING -> { + playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) + playPauseButton.onClick { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } + } + VoiceBroadcastPlayer.State.IDLE, + VoiceBroadcastPlayer.State.PAUSED -> { + playPauseButton.setImageResource(R.drawable.ic_play_pause_play) + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) + playPauseButton.onClick { + attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) + } + } + VoiceBroadcastPlayer.State.BUFFERING -> Unit + } + } + } + + private fun renderInactiveMedia(holder: Holder) { + with(holder) { + bufferingView.isVisible = false + playPauseButton.isVisible = true + playPauseButton.setImageResource(R.drawable.ic_play_pause_play) + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) + playPauseButton.onClick { + attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) + } + } + } + + private fun isCurrentMediaActive() = voiceBroadcastPlayer?.currentVoiceBroadcastId == voiceBroadcastId + + override fun unbind(holder: Holder) { + super.unbind(holder) + voiceBroadcastPlayer?.removeListener(playerListener) + } + + override fun getViewStubId() = STUB_ID + + class Holder : AbsMessageItem.Holder(STUB_ID) { + val liveIndicator by bind(R.id.liveIndicator) + val roomAvatarImageView by bind(R.id.roomAvatarImageView) + val titleText by bind(R.id.titleText) + val playPauseButton by bind(R.id.playPauseButton) + val bufferingView by bind(R.id.bufferingView) + val broadcasterNameText by bind(R.id.broadcasterNameText) + } + + companion object { + private val STUB_ID = R.id.messageVoiceBroadcastListeningStub + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt new file mode 100644 index 0000000000..c417053b2a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -0,0 +1,140 @@ +/* + * 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.home.room.detail.timeline.item + +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.onClick +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.RoomDetailAction.VoiceBroadcastAction +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass +abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() { + + @EpoxyAttribute + var callback: TimelineEventController.Callback? = null + + @EpoxyAttribute + var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null + + @EpoxyAttribute + lateinit var colorProvider: ColorProvider + + @EpoxyAttribute + lateinit var drawableProvider: DrawableProvider + + @EpoxyAttribute + var roomItem: MatrixItem? = null + + @EpoxyAttribute + var title: String? = null + + private lateinit var recorderListener: VoiceBroadcastRecorder.Listener + + override fun isCacheable(): Boolean = false + + override fun bind(holder: Holder) { + super.bind(holder) + bindVoiceBroadcastItem(holder) + } + + private fun bindVoiceBroadcastItem(holder: Holder) { + recorderListener = object : VoiceBroadcastRecorder.Listener { + override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { + renderState(holder, state) + } + } + voiceBroadcastRecorder?.addListener(recorderListener) + renderHeader(holder) + } + + private fun renderHeader(holder: Holder) { + with(holder) { + roomItem?.let { + attributes.avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + } + } + + private fun renderState(holder: Holder, state: VoiceBroadcastRecorder.State) { + with(holder) { + when (state) { + VoiceBroadcastRecorder.State.Recording -> { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + liveIndicator.isVisible = true + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) + + val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) + recordButton.setImageDrawable(drawable) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) + recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } + stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + VoiceBroadcastRecorder.State.Paused -> { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + liveIndicator.isVisible = true + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + + recordButton.setImageResource(R.drawable.ic_recording_dot) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) + recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + VoiceBroadcastRecorder.State.Idle -> { + recordButton.isEnabled = false + stopRecordButton.isEnabled = false + liveIndicator.isVisible = false + } + } + } + } + + override fun unbind(holder: Holder) { + super.unbind(holder) + voiceBroadcastRecorder?.removeListener(recorderListener) + } + + override fun getViewStubId() = STUB_ID + + class Holder : AbsMessageItem.Holder(STUB_ID) { + val liveIndicator by bind(R.id.liveIndicator) + val roomAvatarImageView by bind(R.id.roomAvatarImageView) + val titleText by bind(R.id.titleText) + val recordButton by bind(R.id.recordButton) + val stopRecordButton by bind(R.id.stopRecordButton) + } + + companion object { + private val STUB_ID = R.id.messageVoiceBroadcastRecordingStub + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt index 62252570c6..2c892c8306 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -18,6 +18,7 @@ package im.vector.app.features.voicebroadcast import android.media.AudioAttributes import android.media.MediaPlayer +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 @@ -29,8 +30,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent @@ -40,6 +41,7 @@ import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import timber.log.Timber +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton @@ -73,15 +75,17 @@ class VoiceBroadcastPlayer @Inject constructor( private var currentSequence: Int? = null private var playlist = emptyList() - private val currentVoiceBroadcastId - get() = playlist.firstOrNull()?.root?.getRelationContent()?.eventId + var currentVoiceBroadcastId: String? = null private var state: State = State.IDLE + @MainThread set(value) { Timber.w("## VoiceBroadcastPlayer state: $field -> $value") field = value + listeners.forEach { it.onStateChanged(value) } } private var currentRoomId: String? = null + private var listeners = CopyOnWriteArrayList() fun playOrResume(roomId: String, eventId: String) { val hasChanged = currentVoiceBroadcastId != eventId @@ -126,15 +130,26 @@ class VoiceBroadcastPlayer @Inject constructor( playlist = emptyList() currentSequence = null currentRoomId = null + currentVoiceBroadcastId = null + } + + fun addListener(listener: Listener) { + listeners.add(listener) + listener.onStateChanged(state) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) } private fun startPlayback(roomId: String, eventId: String) { val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - currentRoomId = roomId - // Stop listening previous voice broadcast if any if (state != State.IDLE) stop() + currentRoomId = roomId + currentVoiceBroadcastId = eventId + state = State.BUFFERING val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState @@ -157,7 +172,7 @@ class VoiceBroadcastPlayer @Inject constructor( currentMediaPlayer?.start() currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } currentSequence = sequence - state = State.PLAYING + withContext(Dispatchers.Main) { state = State.PLAYING } nextMediaPlayer = prepareNextMediaPlayer() } catch (failure: Throwable) { Timber.e(failure, "Unable to start playback") @@ -316,4 +331,8 @@ class VoiceBroadcastPlayer @Inject constructor( BUFFERING, IDLE } + + fun interface Listener { + fun onStateChanged(state: State) + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt index c9bb0c5f54..8b69051823 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt @@ -22,12 +22,21 @@ import java.io.File interface VoiceBroadcastRecorder : VoiceRecorder { - var listener: Listener? - var currentSequence: Int + val currentSequence: Int + val state: State fun startRecord(roomId: String, chunkLength: Int) + fun addListener(listener: Listener) + fun removeListener(listener: Listener) - fun interface Listener { - fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) + interface Listener { + fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit + fun onStateUpdated(state: State) = Unit + } + + enum class State { + Recording, + Paused, + Idle, } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt index a65aae6f8a..5285dc5e3b 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt @@ -23,6 +23,7 @@ import androidx.annotation.RequiresApi import im.vector.app.features.voice.AbstractVoiceRecorderQ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import java.util.concurrent.CopyOnWriteArrayList @RequiresApi(Build.VERSION_CODES.Q) class VoiceBroadcastRecorderQ( @@ -32,8 +33,13 @@ class VoiceBroadcastRecorderQ( private var maxFileSize = 0L // zero or negative for no limit private var currentRoomId: String? = null override var currentSequence = 0 + override var state = VoiceBroadcastRecorder.State.Idle + set(value) { + field = value + listeners.forEach { it.onStateUpdated(value) } + } - override var listener: VoiceBroadcastRecorder.Listener? = null + private val listeners = CopyOnWriteArrayList() override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 override val audioEncoder = MediaRecorder.AudioEncoder.HE_AAC @@ -57,24 +63,28 @@ class VoiceBroadcastRecorderQ( maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() currentSequence = 1 startRecord(roomId) + state = VoiceBroadcastRecorder.State.Recording } override fun pauseRecord() { tryOrNull { mediaRecorder?.stop() } mediaRecorder?.reset() notifyOutputFileCreated() + state = VoiceBroadcastRecorder.State.Paused } override fun resumeRecord() { currentSequence++ currentRoomId?.let { startRecord(it) } + state = VoiceBroadcastRecorder.State.Recording } override fun stopRecord() { super.stopRecord() notifyOutputFileCreated() - listener = null + listeners.clear() currentSequence = 0 + state = VoiceBroadcastRecorder.State.Idle } override fun release() { @@ -82,6 +92,15 @@ class VoiceBroadcastRecorderQ( super.release() } + override fun addListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.add(listener) + listener.onStateUpdated(state) + } + + override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.remove(listener) + } + private fun onMaxFileSizeApproaching(roomId: String) { setNextOutputFile(roomId) } @@ -92,8 +111,8 @@ class VoiceBroadcastRecorderQ( } private fun notifyOutputFileCreated() { - outputFile?.let { - listener?.onVoiceMessageCreated(it, currentSequence) + outputFile?.let { file -> + listeners.forEach { it.onVoiceMessageCreated(file, currentSequence) } outputFile = nextOutputFile nextOutputFile = null } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt index d5d58f822e..7934d18e36 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt @@ -81,9 +81,11 @@ class StartVoiceBroadcastUseCase @Inject constructor( } private fun startRecording(room: Room, eventId: String, chunkLength: Int) { - voiceBroadcastRecorder?.listener = VoiceBroadcastRecorder.Listener { file, sequence -> - sendVoiceFile(room, file, eventId, sequence) - } + voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener { + override fun onVoiceMessageCreated(file: File, sequence: Int) { + sendVoiceFile(room, file, eventId, sequence) + } + }) voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength) } diff --git a/vector/src/main/res/drawable/ic_recording_dot.xml b/vector/src/main/res/drawable/ic_recording_dot.xml new file mode 100644 index 0000000000..f5d92f9718 --- /dev/null +++ b/vector/src/main/res/drawable/ic_recording_dot.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_stop.xml b/vector/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000000..459a7cfce2 --- /dev/null +++ b/vector/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_16.xml b/vector/src/main/res/drawable/ic_voice_broadcast_16.xml new file mode 100644 index 0000000000..7d427a56d0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast_16.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/rounded_rect_shape_2.xml b/vector/src/main/res/drawable/rounded_rect_shape_2.xml new file mode 100644 index 0000000000..977de2fd09 --- /dev/null +++ b/vector/src/main/res/drawable/rounded_rect_shape_2.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml index 6fcf5711f7..643f4a89c8 100644 --- a/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml +++ b/vector/src/main/res/layout/item_timeline_event_view_stubs_container.xml @@ -48,10 +48,17 @@ tools:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..e3bb85138d --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml deleted file mode 100644 index e35060f72a..0000000000 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_stub.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - -