mirror of
https://github.com/element-hq/element-android
synced 2024-11-23 18:05:36 +03:00
Merge pull request #7387 from vector-im/feature/fre/voice_broadcast_start_listening
Voice Broadcast - Listening
This commit is contained in:
commit
0dad78a24a
17 changed files with 428 additions and 44 deletions
1
changelog.d/7387.wip
Normal file
1
changelog.d/7387.wip
Normal file
|
@ -0,0 +1 @@
|
|||
[Voice Broadcast] Start listening to a voice broadcast
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue