Merge pull request #7387 from vector-im/feature/fre/voice_broadcast_start_listening

Voice Broadcast - Listening
This commit is contained in:
Florian Renaud 2022-10-18 17:54:07 +02:00 committed by GitHub
commit 0dad78a24a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 428 additions and 44 deletions

1
changelog.d/7387.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Start listening to a voice broadcast

View file

@ -31,6 +31,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
import org.matrix.android.sdk.api.session.room.send.SendState
@ -357,6 +358,10 @@ fun Event.isAudioMessage(): Boolean {
}
}
fun Event.isVoiceMessage(): Boolean {
return this.asMessageAudioEvent()?.content?.voiceMessageIndicator != null
}
fun Event.isFileMessage(): Boolean {
return when (getMsgType()) {
MessageType.MSGTYPE_FILE -> true

View file

@ -0,0 +1,47 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.message
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
/**
* [Event] wrapper for [EventType.MESSAGE] event type.
* Provides additional fields and functions related to this event type.
*/
@JvmInline
value class MessageAudioEvent(val root: Event) {
/**
* The mapped [MessageAudioContent] model of the event content.
*/
val content: MessageAudioContent
get() = root.getClearContent().toModel<MessageContent>() as MessageAudioContent
init {
require(tryOrNull { content } != null)
}
}
/**
* Map a [EventType.MESSAGE] event to a [MessageAudioEvent].
*/
fun Event.asMessageAudioEvent() = if (getClearType() == EventType.MESSAGE) {
tryOrNull { MessageAudioEvent(this) }
} else null

View file

@ -55,4 +55,9 @@ interface TimelineService {
* Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO.
*/
fun getAttachmentMessages(): List<TimelineEvent>
/**
* Returns a snapshot list of TimelineEvent with a content relation of the given type to the given eventId.
*/
fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List<TimelineEvent>
}

View file

@ -96,4 +96,8 @@ internal class DefaultTimelineService @AssistedInject constructor(
override fun getAttachmentMessages(): List<TimelineEvent> {
return timelineEventDataSource.getAttachmentMessages(roomId)
}
override fun getTimelineEventsRelatedTo(relationType: String, eventId: String): List<TimelineEvent> {
return timelineEventDataSource.getTimelineEventsRelatedTo(roomId, relationType, eventId)
}
}

View file

@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
import androidx.lifecycle.LiveData
import com.zhuinden.monarchy.Monarchy
import io.realm.Sort
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.isVideoMessage
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -63,4 +64,18 @@ internal class TimelineEventDataSource @Inject constructor(
.orEmpty()
}
}
fun getTimelineEventsRelatedTo(roomId: String, eventType: String, eventId: String): List<TimelineEvent> {
// TODO Remove this trick and call relations API
// see https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1roomsroomidrelationseventidreltypeeventtype
return realmSessionProvider.withRealm { realm ->
TimelineEventEntity.whereRoomId(realm, roomId)
.sort(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, Sort.ASCENDING)
.distinct(TimelineEventEntityFields.EVENT_ID)
.findAll()
.mapNotNull {
timelineEventMapper.map(it).takeIf { it.root.getRelationContent()?.takeIf { it.type == eventType && it.eventId == eventId } != null }
}
}
}
}

View file

@ -121,9 +121,17 @@ sealed class RoomDetailAction : VectorViewModelAction {
object OpenElementCallWidget : RoomDetailAction()
sealed class VoiceBroadcastAction : RoomDetailAction() {
object Start : VoiceBroadcastAction()
object Pause : VoiceBroadcastAction()
object Resume : VoiceBroadcastAction()
object Stop : VoiceBroadcastAction()
sealed class Recording : VoiceBroadcastAction() {
object Start : Recording()
object Pause : Recording()
object Resume : Recording()
object Stop : Recording()
}
sealed class Listening : VoiceBroadcastAction() {
data class PlayOrResume(val eventId: String) : Listening()
object Pause : Listening()
object Stop : Listening()
}
}
}

View file

@ -604,10 +604,13 @@ class TimelineViewModel @AssistedInject constructor(
if (room == null) return
viewModelScope.launch {
when (action) {
RoomDetailAction.VoiceBroadcastAction.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)
is RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.eventId)
RoomDetailAction.VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback()
RoomDetailAction.VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback()
}
}
}

View file

@ -234,8 +234,9 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
}
// TODO remove this when there will be a recording indicator outside of the timeline
// Pause voice broadcast if the timeline is not shown anymore
it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Pause)
it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause)
else -> {
timelineViewModel.handle(VoiceBroadcastAction.Listening.Pause)
messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString()))
}
}
@ -684,7 +685,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
locationOwnerId = session.myUserId
)
}
AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Start)
AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start)
}
}

View file

@ -42,6 +42,7 @@ import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
@ -84,6 +85,7 @@ class MessageComposerViewModel @AssistedInject constructor(
private val rainbowGenerator: RainbowGenerator,
private val audioMessageHelper: AudioMessageHelper,
private val analyticsTracker: AnalyticsTracker,
private val voiceBroadcastHelper: VoiceBroadcastHelper,
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
@ -981,6 +983,8 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleEntersBackground(composerText: String) {
// Always stop all voice actions. It may be playing in timeline or active recording
val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false)
// TODO remove this when there will be a listening indicator outside of the timeline
voiceBroadcastHelper.pausePlayback()
val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording }
if (isVoiceRecording) {

View file

@ -43,9 +43,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
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.MessageAudioItem
import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem_
@ -58,8 +56,6 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
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.MessageVoiceItem
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_
import im.vector.app.features.home.room.detail.timeline.item.PollItem
@ -82,8 +78,8 @@ import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.AudioWaveformView
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
@ -107,6 +103,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.settings.LightweightSettingsStorage
@ -141,6 +138,7 @@ class MessageItemFactory @Inject constructor(
private val urlMapProvider: UrlMapProvider,
private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
private val pollItemViewStateFactory: PollItemViewStateFactory,
private val voiceBroadcastItemFactory: VoiceBroadcastItemFactory,
) {
// TODO inject this properly?
@ -203,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 -> buildVoiceBroadcastItem(messageContent, params.eventsGroup, highlight, callback, attributes)
is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(messageContent, params.eventsGroup, highlight, callback, attributes)
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
return messageItem?.apply {
@ -323,7 +321,10 @@ class MessageItemFactory @Inject constructor(
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes
): MessageVoiceItem {
): MessageVoiceItem? {
// Do not display voice broadcast messages
if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) return null
val fileUrl = getAudioFileUrl(messageContent, informationData)
val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
@ -713,25 +714,6 @@ class MessageItemFactory @Inject constructor(
.highlighted(highlight)
}
private fun buildVoiceBroadcastItem(
messageContent: MessageVoiceBroadcastInfoContent,
eventsGroup: TimelineEventsGroup?,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): MessageVoiceBroadcastItem? {
if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null
val voiceBroadcastEventsGroup = eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null
val mostRecentEvent = voiceBroadcastEventsGroup.getLastEvent()
val mostRecentMessageContent = (mostRecentEvent.getVectorLastMessageContent() as? MessageVoiceBroadcastInfoContent) ?: return null
return MessageVoiceBroadcastItem_()
.attributes(attributes)
.highlighted(highlight)
.voiceBroadcastState(mostRecentMessageContent.voiceBroadcastState)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
private fun List<Int?>?.toFft(): List<Int>? {
return this
?.filterNotNull()

View file

@ -0,0 +1,61 @@
/*
* 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.features.home.room.detail.timeline.factory
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.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 javax.inject.Inject
class VoiceBroadcastItemFactory @Inject constructor(
private val session: Session,
private val avatarSizeProvider: AvatarSizeProvider,
private val audioMessagePlaybackTracker: AudioMessagePlaybackTracker,
) {
fun create(
messageContent: MessageVoiceBroadcastInfoContent,
eventsGroup: TimelineEventsGroup?,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): MessageVoiceBroadcastItem? {
// 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 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_()
.attributes(attributes)
.highlighted(highlight)
.voiceBroadcastState(mostRecentMessageContent.voiceBroadcastState)
.recording(isRecording)
.audioMessagePlaybackTracker(audioMessagePlaybackTracker)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
}

View file

@ -18,12 +18,15 @@ package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.widgets.model.WidgetContent
import org.threeten.bp.Duration
@ -61,6 +64,10 @@ class TimelineEventsGroups {
EventType.isCallEvent(type) -> (content?.get("call_id") as? String)
type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> root.asVoiceBroadcastEvent()?.reference?.eventId
type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY -> root.stateKey
type == EventType.MESSAGE && root.asMessageAudioEvent().isVoiceBroadcast() -> {
// Group voice messages with a reference to an eventId
root.asMessageAudioEvent()?.getVoiceBroadcastEventId()
}
else -> {
null
}
@ -134,8 +141,8 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) {
}
class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) {
fun getLastEvent(): TimelineEvent {
fun getLastDisplayableEvent(): TimelineEvent {
return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED }
?: group.events.maxBy { it.root.originServerTs ?: 0L }
?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L }
}
}

View file

@ -22,8 +22,10 @@ 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
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
@ -35,6 +37,15 @@ abstract class MessageVoiceBroadcastItem : AbsMessageItem<MessageVoiceBroadcastI
@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) {
@ -44,16 +55,37 @@ abstract class MessageVoiceBroadcastItem : AbsMessageItem<MessageVoiceBroadcastI
@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) {
currentStateText.text = "Voice Broadcast state: ${voiceBroadcastState?.value ?: "None"}"
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(RoomDetailAction.VoiceBroadcastAction.Resume) }
pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Pause) }
stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Stop) }
playButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) }
pauseButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) }
stopButton.setOnClickListener { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) }
}
}

View file

@ -0,0 +1,29 @@
/*
* 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
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.room.model.message.MessageAudioEvent
fun MessageAudioEvent?.isVoiceBroadcast() = this?.getVoiceBroadcastEventId() != null
fun MessageAudioEvent.getVoiceBroadcastEventId(): String? =
// TODO Improve this condition by checking the referenced event type
root.takeIf { content.voiceMessageIndicator != null }
?.getRelationContent()?.takeIf { it.type == RelationType.REFERENCE }
?.eventId

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2021 New Vector Ltd
* 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.
@ -30,6 +30,7 @@ class VoiceBroadcastHelper @Inject constructor(
private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase,
private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase,
private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase,
private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
) {
suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId)
@ -38,4 +39,10 @@ class VoiceBroadcastHelper @Inject constructor(
suspend fun resumeVoiceBroadcast(roomId: String) = resumeVoiceBroadcastUseCase.execute(roomId)
suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId)
fun playOrResumePlayback(roomId: String, eventId: String) = voiceBroadcastPlayer.play(roomId, eventId)
fun pausePlayback() = voiceBroadcastPlayer.pause()
fun stopPlayback() = voiceBroadcastPlayer.stop()
}

View file

@ -0,0 +1,173 @@
/*
* 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
import android.media.AudioAttributes
import android.media.MediaPlayer
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
import im.vector.app.features.voice.VoiceFailure
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VoiceBroadcastPlayer @Inject constructor(
private val session: Session,
private val playbackTracker: AudioMessagePlaybackTracker,
) {
private val mediaPlayerScope = CoroutineScope(Dispatchers.IO)
private var currentMediaPlayer: MediaPlayer? = null
private var currentPlayingIndex: Int = -1
private var playlist = emptyList<MessageAudioEvent>()
private val currentVoiceBroadcastEventId
get() = playlist.firstOrNull()?.root?.getRelationContent()?.eventId
private val mediaPlayerListener = MediaPlayerListener()
fun play(roomId: String, eventId: String) {
val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId")
when {
currentVoiceBroadcastEventId != eventId -> {
stop()
updatePlaylist(room, eventId)
startPlayback()
}
playbackTracker.getPlaybackState(eventId) is State.Playing -> pause()
else -> resumePlayback()
}
}
fun pause() {
currentMediaPlayer?.pause()
currentVoiceBroadcastEventId?.let { playbackTracker.pausePlayback(it) }
}
fun stop() {
currentMediaPlayer?.stop()
currentMediaPlayer?.release()
currentMediaPlayer?.setOnInfoListener(null)
currentMediaPlayer = null
currentVoiceBroadcastEventId?.let { playbackTracker.stopPlayback(it) }
playlist = emptyList()
currentPlayingIndex = -1
}
private fun updatePlaylist(room: Room, eventId: String) {
val timelineEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId)
val audioEvents = timelineEvents.mapNotNull { it.root.asMessageAudioEvent() }
playlist = audioEvents.sortedBy { it.root.originServerTs }
}
private fun startPlayback() {
val content = playlist.firstOrNull()?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return }
mediaPlayerScope.launch {
try {
currentMediaPlayer = prepareMediaPlayer(content)
currentMediaPlayer?.start()
currentPlayingIndex = 0
currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) }
prepareNextFile()
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
throw VoiceFailure.UnableToPlay(failure)
}
}
}
private fun resumePlayback() {
currentMediaPlayer?.start()
currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) }
}
private suspend fun prepareNextFile() {
val nextContent = playlist.getOrNull(currentPlayingIndex + 1)?.content
if (nextContent == null) {
currentMediaPlayer?.setOnCompletionListener(mediaPlayerListener)
} else {
val nextMediaPlayer = prepareMediaPlayer(nextContent)
currentMediaPlayer?.setNextMediaPlayer(nextMediaPlayer)
}
}
private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer {
// Download can fail
val audioFile = try {
session.fileService().downloadFile(messageAudioContent)
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
throw VoiceFailure.UnableToPlay(failure)
}
return audioFile.inputStream().use { fis ->
MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
// Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(fis.fd)
setOnInfoListener(mediaPlayerListener)
setOnErrorListener(mediaPlayerListener)
prepare()
}
}
}
inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener {
override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean {
when (what) {
MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> {
currentMediaPlayer = mp
currentPlayingIndex++
mediaPlayerScope.launch { prepareNextFile() }
}
}
return false
}
override fun onCompletion(mp: MediaPlayer) {
// Verify that a new media has not been set in the mean time
if (!currentMediaPlayer?.isPlaying.orFalse()) {
stop()
}
}
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
stop()
return true
}
}
}