Voice message playback implementation.
This commit is contained in:
parent
5676226f42
commit
9d48b399df
|
@ -35,7 +35,8 @@ data class ContentAttachmentData(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val queryUri: Uri,
|
val queryUri: Uri,
|
||||||
val mimeType: String?,
|
val mimeType: String?,
|
||||||
val type: Type
|
val type: Type,
|
||||||
|
val waveform: List<Int>? = null
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
|
|
|
@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class AudioWaveformInfo(
|
data class AudioWaveformInfo(
|
||||||
@Json(name = "duration")
|
@Json(name = "duration")
|
||||||
val duration: Long? = null,
|
val duration: Int? = null,
|
||||||
|
|
||||||
@Json(name = "waveform")
|
@Json(name = "waveform")
|
||||||
val waveform: List<Int>? = null
|
val waveform: List<Int>? = null
|
||||||
|
|
|
@ -300,8 +300,8 @@ internal class LocalEchoEventFactory @Inject constructor(
|
||||||
),
|
),
|
||||||
url = attachment.queryUri.toString(),
|
url = attachment.queryUri.toString(),
|
||||||
audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo(
|
audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo(
|
||||||
duration = attachment.duration,
|
duration = attachment.duration?.toInt(),
|
||||||
waveform = null // TODO.
|
waveform = attachment.waveform
|
||||||
),
|
),
|
||||||
voiceMessageIndicator = if (!isVoiceMessage) null else Any()
|
voiceMessageIndicator = if (!isVoiceMessage) null else Any()
|
||||||
)
|
)
|
||||||
|
|
|
@ -23,5 +23,6 @@ data class MultiPickerAudioType(
|
||||||
override val size: Long,
|
override val size: Long,
|
||||||
override val mimeType: String?,
|
override val mimeType: String?,
|
||||||
override val contentUri: Uri,
|
override val contentUri: Uri,
|
||||||
val duration: Long
|
val duration: Long,
|
||||||
|
var waveform: List<Int>? = null
|
||||||
) : MultiPickerBaseType
|
) : MultiPickerBaseType
|
||||||
|
|
|
@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
|
||||||
size = size,
|
size = size,
|
||||||
name = displayName,
|
name = displayName,
|
||||||
duration = duration,
|
duration = duration,
|
||||||
queryUri = contentUri
|
queryUri = contentUri,
|
||||||
|
waveform = waveform
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import android.view.View
|
||||||
import im.vector.app.core.platform.VectorViewModelAction
|
import im.vector.app.core.platform.VectorViewModelAction
|
||||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||||
import org.matrix.android.sdk.api.session.events.model.Event
|
import org.matrix.android.sdk.api.session.events.model.Event
|
||||||
|
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
|
||||||
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
||||||
|
@ -112,5 +113,9 @@ sealed class RoomDetailAction : VectorViewModelAction {
|
||||||
|
|
||||||
// Voice Message
|
// Voice Message
|
||||||
object StartRecordingVoiceMessage : RoomDetailAction()
|
object StartRecordingVoiceMessage : RoomDetailAction()
|
||||||
data class EndRecordingVoiceMessage(val recordTime: Long) : RoomDetailAction()
|
data class EndRecordingVoiceMessage(val isCancelled: Boolean) : RoomDetailAction()
|
||||||
|
object PauseRecordingVoiceMessage : RoomDetailAction()
|
||||||
|
data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : RoomDetailAction()
|
||||||
|
object PlayOrPauseRecordingPlayback : RoomDetailAction()
|
||||||
|
object EndAllVoiceActions : RoomDetailAction()
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,6 +132,7 @@ import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivit
|
||||||
import im.vector.app.features.crypto.verification.VerificationBottomSheet
|
import im.vector.app.features.crypto.verification.VerificationBottomSheet
|
||||||
import im.vector.app.features.home.AvatarRenderer
|
import im.vector.app.features.home.AvatarRenderer
|
||||||
import im.vector.app.features.home.room.detail.composer.TextComposerView
|
import im.vector.app.features.home.room.detail.composer.TextComposerView
|
||||||
|
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
|
||||||
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
|
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
|
||||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||||
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
|
||||||
|
@ -139,6 +140,7 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBot
|
||||||
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
|
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
|
||||||
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
|
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
|
||||||
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
|
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
|
||||||
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
|
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
|
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
|
||||||
|
@ -185,6 +187,7 @@ import org.matrix.android.sdk.api.session.events.model.Event
|
||||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||||
import org.matrix.android.sdk.api.session.room.model.Membership
|
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
||||||
|
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
|
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
||||||
|
@ -235,7 +238,8 @@ class RoomDetailFragment @Inject constructor(
|
||||||
private val imageContentRenderer: ImageContentRenderer,
|
private val imageContentRenderer: ImageContentRenderer,
|
||||||
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
|
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
|
||||||
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
||||||
private val callManager: WebRtcCallManager
|
private val callManager: WebRtcCallManager,
|
||||||
|
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
|
||||||
) :
|
) :
|
||||||
VectorBaseFragment<FragmentRoomDetailBinding>(),
|
VectorBaseFragment<FragmentRoomDetailBinding>(),
|
||||||
TimelineEventController.Callback,
|
TimelineEventController.Callback,
|
||||||
|
@ -334,6 +338,7 @@ class RoomDetailFragment @Inject constructor(
|
||||||
setupConfBannerView()
|
setupConfBannerView()
|
||||||
setupEmojiPopup()
|
setupEmojiPopup()
|
||||||
setupFailedMessagesWarningView()
|
setupFailedMessagesWarningView()
|
||||||
|
setupVoiceMessageView()
|
||||||
|
|
||||||
views.roomToolbarContentView.debouncedClicks {
|
views.roomToolbarContentView.debouncedClicks {
|
||||||
navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId)
|
navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId)
|
||||||
|
@ -585,6 +590,33 @@ class RoomDetailFragment @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupVoiceMessageView() {
|
||||||
|
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
|
||||||
|
|
||||||
|
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
|
||||||
|
override fun onVoiceRecordingStarted() {
|
||||||
|
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) {
|
||||||
|
views.composerLayout.isInvisible = true
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
||||||
|
context?.toast(R.string.voice_message_release_to_send_toast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
|
||||||
|
views.composerLayout.isInvisible = false
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoiceRecordingPlaybackModeOn() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.PauseRecordingVoiceMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVoicePlaybackButtonClicked() {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseRecordingPlayback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
|
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
|
||||||
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
|
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
|
||||||
}
|
}
|
||||||
|
@ -969,6 +1001,10 @@ class RoomDetailFragment @Inject constructor(
|
||||||
notificationDrawerManager.setCurrentRoom(null)
|
notificationDrawerManager.setCurrentRoom(null)
|
||||||
|
|
||||||
roomDetailViewModel.handle(RoomDetailAction.SaveDraft(views.composerLayout.text.toString()))
|
roomDetailViewModel.handle(RoomDetailAction.SaveDraft(views.composerLayout.text.toString()))
|
||||||
|
|
||||||
|
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions)
|
||||||
|
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
||||||
|
@ -1191,19 +1227,14 @@ class RoomDetailFragment @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTextEmptyStateChanged(isEmpty: Boolean) {
|
override fun onTextEmptyStateChanged(isEmpty: Boolean) {
|
||||||
// No op
|
views.voiceMessageRecorderView.isVisible = !views.composerLayout.views.sendButton.isVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoiceRecordingStarted() {
|
override fun onTouchVoiceRecording() {
|
||||||
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) {
|
||||||
}
|
views.composerLayout.isInvisible = true
|
||||||
|
views.voiceMessageRecorderView.isVisible = true
|
||||||
override fun onVoiceRecordingEnded(recordTime: Long) {
|
}
|
||||||
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(recordTime))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkVoiceRecordingPermission(): Boolean {
|
|
||||||
return checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1720,14 +1751,18 @@ class RoomDetailFragment @Inject constructor(
|
||||||
onUrlClicked(url, url)
|
onUrlClicked(url, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreviewUrlCloseClicked(eventId: String, url: String) {
|
override fun onPreviewUrlCloseClicked(eventId: String, fileUrl: String) {
|
||||||
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url))
|
roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, fileUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) {
|
override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) {
|
||||||
navigator.openBigImageViewer(requireActivity(), sharedView, mxcUrl, title)
|
navigator.openBigImageViewer(requireActivity(), sharedView, mxcUrl, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) {
|
||||||
|
roomDetailViewModel.handle(RoomDetailAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent))
|
||||||
|
}
|
||||||
|
|
||||||
private fun onShareActionClicked(action: EventSharedAction.Share) {
|
private fun onShareActionClicked(action: EventSharedAction.Share) {
|
||||||
if (action.messageContent is MessageTextContent) {
|
if (action.messageContent is MessageTextContent) {
|
||||||
shareText(requireContext(), action.messageContent.body)
|
shareText(requireContext(), action.messageContent.body)
|
||||||
|
|
|
@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.IdRes
|
import androidx.annotation.IdRes
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.airbnb.mvrx.Async
|
import com.airbnb.mvrx.Async
|
||||||
import com.airbnb.mvrx.Fail
|
import com.airbnb.mvrx.Fail
|
||||||
|
@ -49,7 +48,7 @@ import im.vector.app.features.command.ParsedCommand
|
||||||
import im.vector.app.features.createdirect.DirectRoomHelper
|
import im.vector.app.features.createdirect.DirectRoomHelper
|
||||||
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
|
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
|
||||||
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
|
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
|
||||||
import im.vector.app.features.home.room.detail.composer.VoiceMessageRecordingHelper
|
import im.vector.app.features.home.room.detail.composer.VoiceMessageHelper
|
||||||
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
|
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
|
||||||
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
|
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
|
||||||
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
|
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
|
||||||
|
@ -59,7 +58,6 @@ import im.vector.app.features.home.room.typing.TypingHelper
|
||||||
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
|
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
|
||||||
import im.vector.app.features.session.coroutineScope
|
import im.vector.app.features.session.coroutineScope
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
|
|
||||||
import io.reactivex.Observable
|
import io.reactivex.Observable
|
||||||
import io.reactivex.rxkotlin.subscribeBy
|
import io.reactivex.rxkotlin.subscribeBy
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
@ -123,7 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||||
private val chatEffectManager: ChatEffectManager,
|
private val chatEffectManager: ChatEffectManager,
|
||||||
private val directRoomHelper: DirectRoomHelper,
|
private val directRoomHelper: DirectRoomHelper,
|
||||||
private val jitsiService: JitsiService,
|
private val jitsiService: JitsiService,
|
||||||
private val voiceMessageRecordingHelper: VoiceMessageRecordingHelper,
|
private val voiceMessageHelper: VoiceMessageHelper,
|
||||||
timelineSettingsFactory: TimelineSettingsFactory
|
timelineSettingsFactory: TimelineSettingsFactory
|
||||||
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
|
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
|
||||||
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
|
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener {
|
||||||
|
@ -321,7 +319,7 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||||
RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar()
|
RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar()
|
||||||
is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action)
|
is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action)
|
||||||
RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings)
|
RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings)
|
||||||
is RoomDetailAction.ShowRoomAvatarFullScreen -> {
|
is RoomDetailAction.ShowRoomAvatarFullScreen -> {
|
||||||
_viewEvents.post(
|
_viewEvents.post(
|
||||||
RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView)
|
RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView)
|
||||||
)
|
)
|
||||||
|
@ -330,7 +328,11 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||||
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
|
RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages()
|
||||||
RoomDetailAction.ResendAll -> handleResendAll()
|
RoomDetailAction.ResendAll -> handleResendAll()
|
||||||
RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
|
RoomDetailAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage()
|
||||||
is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.recordTime)
|
is RoomDetailAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled)
|
||||||
|
is RoomDetailAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action)
|
||||||
|
RoomDetailAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage()
|
||||||
|
RoomDetailAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback()
|
||||||
|
RoomDetailAction.EndAllVoiceActions -> handleEndAllVoiceActions()
|
||||||
}.exhaustive
|
}.exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -619,21 +621,39 @@ class RoomDetailViewModel @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleStartRecordingVoiceMessage() {
|
private fun handleStartRecordingVoiceMessage() {
|
||||||
voiceMessageRecordingHelper.startRecording()
|
voiceMessageHelper.startRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEndRecordingVoiceMessage(recordTime: Long) {
|
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) {
|
||||||
if (recordTime == 0L) {
|
if (isCancelled) {
|
||||||
voiceMessageRecordingHelper.deleteRecording()
|
voiceMessageHelper.deleteRecording()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
voiceMessageRecordingHelper.stopRecording(recordTime)?.let { audioType ->
|
voiceMessageHelper.stopRecording()?.let { audioType ->
|
||||||
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
|
room.sendMedia(audioType.toContentAttachmentData(), false, emptySet())
|
||||||
room
|
|
||||||
//voiceMessageRecordingHelper.deleteRecording()
|
//voiceMessageRecordingHelper.deleteRecording()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePlayOrPauseVoicePlayback(action: RoomDetailAction.PlayOrPauseVoicePlayback) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val audioFile = session.fileService().downloadFile(action.messageAudioContent)
|
||||||
|
voiceMessageHelper.startOrPausePlayback(action.eventId, audioFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePlayOrPauseRecordingPlayback() {
|
||||||
|
voiceMessageHelper.startOrPauseRecordingPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEndAllVoiceActions() {
|
||||||
|
voiceMessageHelper.stopAllVoiceActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePauseRecordingVoiceMessage() {
|
||||||
|
voiceMessageHelper.pauseRecording()
|
||||||
|
}
|
||||||
|
|
||||||
private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
|
private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled()
|
||||||
|
|
||||||
fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->
|
fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state ->
|
||||||
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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.composer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import im.vector.app.BuildConfig
|
||||||
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
|
import im.vector.lib.multipicker.entity.MultiPickerAudioType
|
||||||
|
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
|
||||||
|
import org.matrix.android.sdk.api.extensions.orFalse
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to record audio for voice messages.
|
||||||
|
*/
|
||||||
|
class VoiceMessageHelper @Inject constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val playbackTracker: VoiceMessagePlaybackTracker
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
private lateinit var mediaRecorder: MediaRecorder
|
||||||
|
private val outputDirectory = File(context.cacheDir, "downloads")
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var lastRecordingFile: File? = null // In case of user pauses recording, plays another one in timeline
|
||||||
|
|
||||||
|
private val amplitudeList = mutableListOf<Int>()
|
||||||
|
|
||||||
|
private val amplitudeTimer = Timer()
|
||||||
|
private var amplitudeTimerTask: TimerTask? = null
|
||||||
|
|
||||||
|
private val playbackTimer = Timer()
|
||||||
|
private var playbackTimerTask: TimerTask? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!outputDirectory.exists()) {
|
||||||
|
outputDirectory.mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMediaRecorder() {
|
||||||
|
mediaRecorder = MediaRecorder().apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.DEFAULT)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.OGG)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||||
|
setAudioEncodingBitRate(24000)
|
||||||
|
setAudioSamplingRate(48000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRecording() {
|
||||||
|
stopPlayback()
|
||||||
|
playbackTracker.makeAllPlaybacksIdle()
|
||||||
|
|
||||||
|
outputFile = File(outputDirectory, UUID.randomUUID().toString() + ".ogg")
|
||||||
|
lastRecordingFile = outputFile
|
||||||
|
amplitudeList.clear()
|
||||||
|
FileOutputStream(outputFile).use { fos ->
|
||||||
|
refreshMediaRecorder()
|
||||||
|
mediaRecorder.setOutputFile(fos.fd)
|
||||||
|
mediaRecorder.prepare()
|
||||||
|
mediaRecorder.start()
|
||||||
|
startRecordingAmplitudes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopRecording(): MultiPickerAudioType? {
|
||||||
|
try {
|
||||||
|
stopRecordingAmplitudes()
|
||||||
|
releaseMediaRecorder()
|
||||||
|
} catch (e: RuntimeException) { // Usually thrown when the record is less than 1 second.
|
||||||
|
Timber.e(e, "Cannot stop media recorder!")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
outputFile?.let {
|
||||||
|
val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it)
|
||||||
|
return outputFileUri
|
||||||
|
?.toMultiPickerAudioType(context)
|
||||||
|
?.apply {
|
||||||
|
waveform = amplitudeList
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Timber.e(e, "Cannot stop voice recording")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseMediaRecorder() {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
mediaRecorder.reset()
|
||||||
|
mediaRecorder.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseRecording() {
|
||||||
|
releaseMediaRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteRecording() {
|
||||||
|
outputFile?.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startOrPauseRecordingPlayback() {
|
||||||
|
lastRecordingFile?.let {
|
||||||
|
startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startOrPausePlayback(id: String, file: File) {
|
||||||
|
stopPlayback()
|
||||||
|
if (playbackTracker.getPlaybackState(id) is VoiceMessagePlaybackTracker.Listener.State.Playing) {
|
||||||
|
playbackTracker.stopPlayback(id)
|
||||||
|
} else {
|
||||||
|
playbackTracker.startPlayback(id)
|
||||||
|
startPlayback(id, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPlayback(id: String, file: File) {
|
||||||
|
val currentPlaybackTime = playbackTracker.getPlaybackTime(id)
|
||||||
|
|
||||||
|
FileInputStream(file).use { fis ->
|
||||||
|
mediaPlayer = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setDataSource(fis.fd)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
seekTo(currentPlaybackTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startPlaybackTimer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPlayback() {
|
||||||
|
mediaPlayer?.stop()
|
||||||
|
stopPlaybackTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecordingAmplitudes() {
|
||||||
|
amplitudeTimerTask = object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
try {
|
||||||
|
val maxAmplitude = mediaRecorder.maxAmplitude
|
||||||
|
amplitudeList.add(maxAmplitude)
|
||||||
|
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Timber.e(e, "Cannot get max amplitude. Amplitude recording timer will be stopped.")
|
||||||
|
amplitudeTimerTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amplitudeTimer.scheduleAtFixedRate(amplitudeTimerTask, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecordingAmplitudes() {
|
||||||
|
amplitudeTimerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPlaybackTimer(id: String) {
|
||||||
|
playbackTimerTask = object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
if (mediaPlayer?.isPlaying.orFalse()) {
|
||||||
|
val currentPosition = mediaPlayer?.currentPosition ?: 0
|
||||||
|
playbackTracker.updateCurrentPlaybackTime(id, currentPosition)
|
||||||
|
} else {
|
||||||
|
playbackTracker.stopPlayback(id = id, rememberPlaybackTime = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playbackTimer.scheduleAtFixedRate(playbackTimerTask, 0, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPlaybackTimer() {
|
||||||
|
playbackTimerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopAllVoiceActions() {
|
||||||
|
stopRecording()
|
||||||
|
stopPlayback()
|
||||||
|
deleteRecording()
|
||||||
|
playbackTracker.clear()
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,15 +17,26 @@
|
||||||
package im.vector.app.features.home.room.detail.composer
|
package im.vector.app.features.home.room.detail.composer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.Px
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.devlomi.record_view.OnRecordListener
|
import com.visualizer.amplitude.AudioRecordView
|
||||||
import im.vector.app.BuildConfig
|
import im.vector.app.BuildConfig
|
||||||
import im.vector.app.R
|
import im.vector.app.R
|
||||||
|
import im.vector.app.core.utils.toast
|
||||||
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
||||||
import org.matrix.android.sdk.api.extensions.orFalse
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates the voice message recording view and animations.
|
* Encapsulates the voice message recording view and animations.
|
||||||
|
@ -34,59 +45,320 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = 0
|
defStyleAttr: Int = 0
|
||||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onVoiceRecordingStarted()
|
fun onVoiceRecordingStarted()
|
||||||
fun onVoiceRecordingEnded(recordTime: Long)
|
fun onVoiceRecordingEnded(isCancelled: Boolean)
|
||||||
fun checkVoiceRecordingPermission(): Boolean
|
fun onVoiceRecordingPlaybackModeOn()
|
||||||
|
fun onVoicePlaybackButtonClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val views: ViewVoiceMessageRecorderBinding
|
private val views: ViewVoiceMessageRecorderBinding
|
||||||
|
|
||||||
var callback: Callback? = null
|
var callback: Callback? = null
|
||||||
|
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordingState: RecordingState = RecordingState.NONE
|
||||||
|
|
||||||
|
private var firstX: Float = 0f
|
||||||
|
private var firstY: Float = 0f
|
||||||
|
private var lastX: Float = 0f
|
||||||
|
private var lastY: Float = 0f
|
||||||
|
|
||||||
|
private var recordingTime: Int = 0
|
||||||
|
private var amplitudeList = emptyList<Int>()
|
||||||
|
private val recordingTimer = Timer()
|
||||||
|
private var recordingTimerTask: TimerTask? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
inflate(context, R.layout.view_voice_message_recorder, this)
|
inflate(context, R.layout.view_voice_message_recorder, this)
|
||||||
views = ViewVoiceMessageRecorderBinding.bind(this)
|
views = ViewVoiceMessageRecorderBinding.bind(this)
|
||||||
|
|
||||||
views.voiceMessageButton.setRecordView(views.voiceMessageRecordView)
|
initVoiceRecordingViews()
|
||||||
views.voiceMessageRecordView.timeLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS
|
|
||||||
|
|
||||||
views.voiceMessageRecordView.setRecordPermissionHandler { callback?.checkVoiceRecordingPermission().orFalse() }
|
|
||||||
|
|
||||||
views.voiceMessageRecordView.setOnRecordListener(object : OnRecordListener {
|
|
||||||
override fun onStart() {
|
|
||||||
onVoiceRecordingStarted()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel() {
|
|
||||||
onVoiceRecordingEnded(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFinish(recordTime: Long, limitReached: Boolean) {
|
|
||||||
onVoiceRecordingEnded(recordTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLessThanSecond() {
|
|
||||||
onVoiceRecordingEnded(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onVoiceRecordingStarted() {
|
fun initVoiceRecordingViews() {
|
||||||
|
hideRecordingViews(animationDuration = 0)
|
||||||
|
stopRecordingTimer()
|
||||||
|
|
||||||
|
views.voiceMessageMicButton.isVisible = true
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
|
||||||
|
views.voiceMessageSendButton.setOnClickListener {
|
||||||
|
stopRecordingTimer()
|
||||||
|
hideRecordingViews(animationDuration = 0)
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
recordingState = RecordingState.NONE
|
||||||
|
callback?.onVoiceRecordingEnded(isCancelled = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voiceMessageDeletePlayback).setOnClickListener {
|
||||||
|
stopRecordingTimer()
|
||||||
|
hideRecordingViews(animationDuration = 0)
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
recordingState = RecordingState.NONE
|
||||||
|
callback?.onVoiceRecordingEnded(isCancelled = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<AudioRecordView>(R.id.voicePlaybackWaveform).setOnClickListener {
|
||||||
|
if (recordingState !== RecordingState.PLAYBACK) {
|
||||||
|
recordingState = RecordingState.PLAYBACK
|
||||||
|
showPlaybackViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton).setOnClickListener {
|
||||||
|
callback?.onVoicePlaybackButtonClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
views.voiceMessageMicButton.setOnTouchListener { _, event ->
|
||||||
|
return@setOnTouchListener when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
startRecordingTimer()
|
||||||
|
callback?.onVoiceRecordingStarted()
|
||||||
|
recordingState = RecordingState.STARTED
|
||||||
|
showRecordingViews()
|
||||||
|
|
||||||
|
firstX = event.rawX
|
||||||
|
firstY = event.rawY
|
||||||
|
lastX = firstX
|
||||||
|
lastY = firstY
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (recordingState != RecordingState.LOCKED) {
|
||||||
|
stopRecordingTimer()
|
||||||
|
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
|
||||||
|
callback?.onVoiceRecordingEnded(isCancelled)
|
||||||
|
recordingState = RecordingState.NONE
|
||||||
|
hideRecordingViews()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
handleMoveAction(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMoveAction(event: MotionEvent) {
|
||||||
|
val currentX = event.rawX
|
||||||
|
val currentY = event.rawY
|
||||||
|
updateRecordingState(currentX, currentY)
|
||||||
|
|
||||||
|
when (recordingState) {
|
||||||
|
RecordingState.CANCELLING -> {
|
||||||
|
val translationAmount = currentX - firstX
|
||||||
|
views.voiceMessageMicButton.translationX = translationAmount
|
||||||
|
views.voiceMessageSlideToCancel.translationX = translationAmount
|
||||||
|
views.voiceMessageLockBackground.isVisible = false
|
||||||
|
views.voiceMessageLockImage.isVisible = false
|
||||||
|
views.voiceMessageLockArrow.isVisible = false
|
||||||
|
}
|
||||||
|
RecordingState.LOCKING -> {
|
||||||
|
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
|
||||||
|
val translationAmount = currentY - firstY
|
||||||
|
views.voiceMessageMicButton.translationY = translationAmount
|
||||||
|
views.voiceMessageLockArrow.translationY = translationAmount
|
||||||
|
}
|
||||||
|
RecordingState.CANCELLED -> {
|
||||||
|
callback?.onVoiceRecordingEnded(true)
|
||||||
|
hideRecordingViews()
|
||||||
|
}
|
||||||
|
RecordingState.LOCKED -> {
|
||||||
|
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
|
||||||
|
views.voiceMessageLockImage.postDelayed( {
|
||||||
|
showRecordingLockedViews()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
RecordingState.STARTED -> {
|
||||||
|
showRecordingViews()
|
||||||
|
}
|
||||||
|
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
|
||||||
|
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
|
||||||
|
}
|
||||||
|
lastX = currentX
|
||||||
|
lastY = currentY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRecordingState(currentX: Float, currentY: Float) {
|
||||||
|
val distanceX = abs(firstX - currentX)
|
||||||
|
val distanceY = abs(firstY - currentY)
|
||||||
|
if (recordingState == RecordingState.STARTED) { // Determine if cancelling or locking for the first move action.
|
||||||
|
if (currentX < firstX && distanceX > distanceY) {
|
||||||
|
recordingState = RecordingState.CANCELLING
|
||||||
|
} else if (currentY < firstY && distanceY > distanceX) {
|
||||||
|
recordingState = RecordingState.LOCKING
|
||||||
|
}
|
||||||
|
} else if (recordingState == RecordingState.CANCELLING) { // Check if cancelling conditions met, also check if it should be initial state
|
||||||
|
if (abs(currentX - firstX) < 10 && lastX < currentX) {
|
||||||
|
recordingState = RecordingState.STARTED
|
||||||
|
} else if (shouldCancelRecording()) {
|
||||||
|
recordingState = RecordingState.CANCELLED
|
||||||
|
}
|
||||||
|
} else if (recordingState == RecordingState.LOCKING) { // Check if locking conditions met, also check if it should be initial state
|
||||||
|
if (abs(currentY - firstY) < 10 && lastY < currentY) {
|
||||||
|
recordingState = RecordingState.STARTED
|
||||||
|
} else if (shouldLockRecording()) {
|
||||||
|
recordingState = RecordingState.LOCKED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldCancelRecording(): Boolean {
|
||||||
|
return abs(views.voiceMessageTimer.x + views.voiceMessageTimer.width - views.voiceMessageSlideToCancel.x) < 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldLockRecording(): Boolean {
|
||||||
|
return abs(views.voiceMessageLockImage.y + views.voiceMessageLockImage.height - views.voiceMessageLockArrow.y) < 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecordingTimer() {
|
||||||
|
recordingTimerTask = object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
recordingTime++
|
||||||
|
showRecordingTimer()
|
||||||
|
showRecordingWaveform()
|
||||||
|
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - recordingTime * 1000
|
||||||
|
if (timeDiffToRecordingLimit <= 0) {
|
||||||
|
views.voiceMessageRecordingLayout.post {
|
||||||
|
callback?.onVoiceRecordingEnded(false)
|
||||||
|
recordingState = RecordingState.NONE
|
||||||
|
stopRecordingTimer()
|
||||||
|
hideRecordingViews(animationDuration = 0)
|
||||||
|
}
|
||||||
|
} else if (timeDiffToRecordingLimit in 10000..11000) {
|
||||||
|
views.voiceMessageRecordingLayout.post {
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
|
context.toast(context.getString(R.string.voice_message_n_seconds_warning_toast, (timeDiffToRecordingLimit/1000).toInt()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordingTimer.scheduleAtFixedRate(recordingTimerTask, 0, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecordingTimer() {
|
||||||
|
val formattedTimerText = DateUtils.formatElapsedTime((recordingTime).toLong())
|
||||||
|
if (recordingState == RecordingState.LOCKED) {
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<TextView>(R.id.voicePlaybackTime).apply {
|
||||||
|
post {
|
||||||
|
text = formattedTimerText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
views.voiceMessageTimer.post {
|
||||||
|
views.voiceMessageTimer.text = formattedTimerText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecordingWaveform() {
|
||||||
|
val audioRecordView = views.voiceMessagePlaybackLayout.findViewById<AudioRecordView>(R.id.voicePlaybackWaveform)
|
||||||
|
audioRecordView.apply {
|
||||||
|
post {
|
||||||
|
recreate()
|
||||||
|
amplitudeList.toMutableList().forEach { amplitude ->
|
||||||
|
update(amplitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecordingTimer() {
|
||||||
|
recordingTimerTask?.cancel()
|
||||||
|
recordingTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecordingViews() {
|
||||||
|
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
|
||||||
|
(views.voiceMessageMicButton.layoutParams as MarginLayoutParams).apply { setMargins(0, 0, 0, 0) }
|
||||||
views.voiceMessageLockBackground.isVisible = true
|
views.voiceMessageLockBackground.isVisible = true
|
||||||
views.voiceMessageLockArrow.isVisible = true
|
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dpToPx(148)).start()
|
||||||
views.voiceMessageLockImage.isVisible = true
|
views.voiceMessageLockImage.isVisible = true
|
||||||
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic_recording))
|
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
|
||||||
callback?.onVoiceRecordingStarted()
|
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dpToPx(148)).start()
|
||||||
|
views.voiceMessageLockArrow.isVisible = true
|
||||||
|
views.voiceMessageSlideToCancel.isVisible = true
|
||||||
|
views.voiceMessageTimerIndicator.isVisible = true
|
||||||
|
views.voiceMessageTimer.isVisible = true
|
||||||
|
views.voiceMessageSendButton.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onVoiceRecordingEnded(recordTime: Long) {
|
fun hideRecordingViews(animationDuration: Int = 300) {
|
||||||
|
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
|
||||||
|
views.voiceMessageMicButton.animate().translationX(0f).translationY(0f).setDuration(animationDuration.toLong()).start()
|
||||||
|
(views.voiceMessageMicButton.layoutParams as MarginLayoutParams).apply { setMargins(0, 0, dpToPx(12).toInt(), dpToPx(12).toInt()) }
|
||||||
views.voiceMessageLockBackground.isVisible = false
|
views.voiceMessageLockBackground.isVisible = false
|
||||||
views.voiceMessageLockArrow.isVisible = false
|
views.voiceMessageLockBackground.animate().translationY(dpToPx(0)).start()
|
||||||
views.voiceMessageLockImage.isVisible = false
|
views.voiceMessageLockImage.isVisible = false
|
||||||
views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic))
|
views.voiceMessageLockImage.animate().translationY(dpToPx(0)).start()
|
||||||
callback?.onVoiceRecordingEnded(recordTime)
|
views.voiceMessageLockArrow.isVisible = false
|
||||||
|
views.voiceMessageLockArrow.animate().translationY(0f).start()
|
||||||
|
views.voiceMessageSlideToCancel.isVisible = false
|
||||||
|
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
|
||||||
|
views.voiceMessageTimerIndicator.isVisible = false
|
||||||
|
views.voiceMessageTimer.isVisible = false
|
||||||
|
views.voiceMessagePlaybackLayout.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecordingLockedViews() {
|
||||||
|
hideRecordingViews(animationDuration = 0)
|
||||||
|
views.voiceMessagePlaybackLayout.isVisible = true
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voiceMessagePlaybackTimerIndicator).isVisible = true
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voicePlaybackControlButton).isVisible = false
|
||||||
|
views.voiceMessageSendButton.isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPlaybackViews() {
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voiceMessagePlaybackTimerIndicator).isVisible = false
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageView>(R.id.voicePlaybackControlButton).isVisible = true
|
||||||
|
callback?.onVoiceRecordingPlaybackModeOn()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Px
|
||||||
|
private fun dpToPx(dp: Int): Float {
|
||||||
|
return TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP,
|
||||||
|
dp.toFloat(),
|
||||||
|
resources.displayMetrics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class RecordingState {
|
||||||
|
NONE,
|
||||||
|
STARTED,
|
||||||
|
CANCELLING,
|
||||||
|
CANCELLED,
|
||||||
|
LOCKING,
|
||||||
|
LOCKED,
|
||||||
|
PLAYBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
||||||
|
when (state) {
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
|
||||||
|
this.amplitudeList = state.amplitudeList
|
||||||
|
}
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton)
|
||||||
|
.setImageResource(R.drawable.ic_voice_pause)
|
||||||
|
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<TextView>(R.id.voicePlaybackTime)
|
||||||
|
.setText(formattedTimerText)
|
||||||
|
}
|
||||||
|
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
|
||||||
|
views.voiceMessagePlaybackLayout.findViewById<ImageButton>(R.id.voicePlaybackControlButton)
|
||||||
|
.setImageResource(R.drawable.ic_voice_play)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
|
||||||
import org.matrix.android.sdk.api.session.room.model.Membership
|
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||||
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
|
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
|
||||||
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
|
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
|
||||||
|
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||||
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
||||||
|
@ -111,6 +112,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||||
|
|
||||||
// Introduce ViewModel scoped component (or Hilt?)
|
// Introduce ViewModel scoped component (or Hilt?)
|
||||||
fun getPreviewUrlRetriever(): PreviewUrlRetriever
|
fun getPreviewUrlRetriever(): PreviewUrlRetriever
|
||||||
|
|
||||||
|
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReactionPillCallback {
|
interface ReactionPillCallback {
|
||||||
|
|
|
@ -25,6 +25,7 @@ import android.text.style.ForegroundColorSpan
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import im.vector.app.R
|
import im.vector.app.R
|
||||||
|
import im.vector.app.core.epoxy.ClickListener
|
||||||
import im.vector.app.core.epoxy.VectorEpoxyModel
|
import im.vector.app.core.epoxy.VectorEpoxyModel
|
||||||
import im.vector.app.core.files.LocalFilesHelper
|
import im.vector.app.core.files.LocalFilesHelper
|
||||||
import im.vector.app.core.resources.ColorProvider
|
import im.vector.app.core.resources.ColorProvider
|
||||||
|
@ -38,6 +39,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat
|
||||||
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
|
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.MessageItemAttributesFactory
|
||||||
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
|
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
|
||||||
|
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem
|
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_
|
import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem_
|
||||||
|
@ -50,6 +52,8 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageOptionsItem_
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_
|
import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_
|
||||||
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.MessageTextItem_
|
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
|
||||||
|
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.RedactedMessageItem
|
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
|
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
|
||||||
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
|
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
|
||||||
|
@ -110,7 +114,8 @@ class MessageItemFactory @Inject constructor(
|
||||||
private val avatarSizeProvider: AvatarSizeProvider,
|
private val avatarSizeProvider: AvatarSizeProvider,
|
||||||
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
|
||||||
private val spanUtils: SpanUtils,
|
private val spanUtils: SpanUtils,
|
||||||
private val session: Session) {
|
private val session: Session,
|
||||||
|
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) {
|
||||||
|
|
||||||
// TODO inject this properly?
|
// TODO inject this properly?
|
||||||
private var roomId: String = ""
|
private var roomId: String = ""
|
||||||
|
@ -154,7 +159,13 @@ class MessageItemFactory @Inject constructor(
|
||||||
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
|
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
|
||||||
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
|
is MessageAudioContent -> {
|
||||||
|
if (messageContent.voiceMessageIndicator != null) {
|
||||||
|
buildVoiceMessageItem(params, messageContent, informationData, highlight, attributes)
|
||||||
|
} else {
|
||||||
|
buildAudioMessageItem(messageContent, informationData, highlight, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessagePollResponseContent -> noticeItemFactory.create(params)
|
is MessagePollResponseContent -> noticeItemFactory.create(params)
|
||||||
|
@ -223,6 +234,46 @@ class MessageItemFactory @Inject constructor(
|
||||||
.iconRes(R.drawable.ic_headphones)
|
.iconRes(R.drawable.ic_headphones)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildVoiceMessageItem(params: TimelineItemFactoryParams,
|
||||||
|
messageContent: MessageAudioContent,
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
informationData: MessageInformationData,
|
||||||
|
highlight: Boolean,
|
||||||
|
attributes: AbsMessageItem.Attributes): MessageVoiceItem? {
|
||||||
|
val fileUrl = messageContent.getFileUrl()?.let {
|
||||||
|
if (informationData.sentByMe && !informationData.sendState.isSent()) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
it.takeIf { it.startsWith("mxc://") }
|
||||||
|
}
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
val playbackControlButtonClickListener: ClickListener = object : ClickListener {
|
||||||
|
override fun invoke(view: View) {
|
||||||
|
params.callback?.onVoiceControlButtonClicked(informationData.eventId, messageContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageVoiceItem_()
|
||||||
|
.attributes(attributes)
|
||||||
|
.duration(messageContent.audioWaveformInfo?.duration ?: 0)
|
||||||
|
.waveform(messageContent.audioWaveformInfo?.waveform ?: emptyList())
|
||||||
|
.playbackControlButtonClickListener(playbackControlButtonClickListener)
|
||||||
|
.voiceMessagePlaybackTracker(voiceMessagePlaybackTracker)
|
||||||
|
.izLocalFile(localFilesHelper.isLocalFile(fileUrl))
|
||||||
|
.izDownloaded(session.fileService().isFileInCache(
|
||||||
|
fileUrl,
|
||||||
|
messageContent.getFileName(),
|
||||||
|
messageContent.mimeType,
|
||||||
|
messageContent.encryptedFileInfo?.toElementToDecrypt())
|
||||||
|
)
|
||||||
|
.mxcUrl(fileUrl)
|
||||||
|
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||||
|
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
|
||||||
|
.highlighted(highlight)
|
||||||
|
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
|
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
informationData: MessageInformationData,
|
informationData: MessageInformationData,
|
||||||
|
|
|
@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider
|
||||||
import me.gujun.android.span.span
|
import me.gujun.android.span.span
|
||||||
import org.matrix.android.sdk.api.session.events.model.EventType
|
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.events.model.toModel
|
||||||
|
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
|
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.MessageType
|
import org.matrix.android.sdk.api.session.room.model.message.MessageType
|
||||||
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
|
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
|
||||||
|
@ -72,7 +73,11 @@ class DisplayableEventFormatter @Inject constructor(
|
||||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
|
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
|
||||||
}
|
}
|
||||||
MessageType.MSGTYPE_AUDIO -> {
|
MessageType.MSGTYPE_AUDIO -> {
|
||||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
|
if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) {
|
||||||
|
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor)
|
||||||
|
} else {
|
||||||
|
return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageType.MSGTYPE_VIDEO -> {
|
MessageType.MSGTYPE_VIDEO -> {
|
||||||
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor)
|
return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor)
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 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.helper
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import im.vector.app.core.di.ScreenScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ScreenScope
|
||||||
|
class VoiceMessagePlaybackTracker @Inject constructor() {
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val listeners = mutableMapOf<String, Listener>()
|
||||||
|
private val states = mutableMapOf<String, Listener.State>()
|
||||||
|
|
||||||
|
fun track(id: String, listener: Listener) {
|
||||||
|
listeners[id] = listener
|
||||||
|
|
||||||
|
val currentState = states[id] ?: Listener.State.Idle(0)
|
||||||
|
mainHandler.post {
|
||||||
|
listener.onUpdate(currentState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeAllPlaybacksIdle() {
|
||||||
|
listeners.keys.forEach { key ->
|
||||||
|
val currentPlaybackTime = getPlaybackTime(key)
|
||||||
|
states[key] = Listener.State.Idle(currentPlaybackTime)
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[key]?.onUpdate(Listener.State.Idle(currentPlaybackTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startPlayback(id: String) {
|
||||||
|
val currentPlaybackTime = getPlaybackTime(id)
|
||||||
|
val currentState = Listener.State.Playing(currentPlaybackTime)
|
||||||
|
states[id] = currentState
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[id]?.onUpdate(currentState)
|
||||||
|
}
|
||||||
|
// Make active playback IDLE
|
||||||
|
states
|
||||||
|
.filter { it.key != id }
|
||||||
|
.filter { it.value is Listener.State.Playing }
|
||||||
|
.keys
|
||||||
|
.forEach { key ->
|
||||||
|
val playbackTime = getPlaybackTime(key)
|
||||||
|
val state = Listener.State.Idle(playbackTime)
|
||||||
|
states[key] = state
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[key]?.onUpdate(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPlayback(id: String, rememberPlaybackTime: Boolean = true) {
|
||||||
|
val currentPlaybackTime = if (rememberPlaybackTime) getPlaybackTime(id) else 0
|
||||||
|
states[id] = Listener.State.Idle(currentPlaybackTime)
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[id]?.onUpdate(states[id]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCurrentPlaybackTime(id: String, time: Int) {
|
||||||
|
states[id] = Listener.State.Playing(time)
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[id]?.onUpdate(states[id]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
|
||||||
|
states[id] = Listener.State.Recording(amplitudeList)
|
||||||
|
mainHandler.post {
|
||||||
|
listeners[id]?.onUpdate(states[id]!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPlaybackState(id: String) = states[id]
|
||||||
|
|
||||||
|
fun getPlaybackTime(id: String): Int {
|
||||||
|
return when (val state = states[id]) {
|
||||||
|
is Listener.State.Playing -> state.playbackTime
|
||||||
|
is Listener.State.Idle -> state.playbackTime
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
listeners.forEach {
|
||||||
|
it.value.onUpdate(Listener.State.Idle(0))
|
||||||
|
}
|
||||||
|
listeners.clear()
|
||||||
|
states.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var RECORDING_ID = "RECORDING_ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
|
||||||
|
fun onUpdate(state: State)
|
||||||
|
|
||||||
|
sealed class State {
|
||||||
|
data class Idle(val playbackTime: Int): State()
|
||||||
|
data class Playing(val playbackTime: Int) : State()
|
||||||
|
data class Recording(val amplitudeList: List<Int>) : State()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue