diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt index 98a84b8b66..7ee26de8db 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentAttachmentData.kt @@ -35,7 +35,8 @@ data class ContentAttachmentData( val name: String? = null, val queryUri: Uri, val mimeType: String?, - val type: Type + val type: Type, + val waveform: List? = null ) : Parcelable { @JsonClass(generateAdapter = false) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/AudioWaveformInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/AudioWaveformInfo.kt index 31a356e164..2098a34960 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/AudioWaveformInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/AudioWaveformInfo.kt @@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AudioWaveformInfo( @Json(name = "duration") - val duration: Long? = null, + val duration: Int? = null, @Json(name = "waveform") val waveform: List? = null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 03afa4beb6..4b9f69501e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -300,8 +300,8 @@ internal class LocalEchoEventFactory @Inject constructor( ), url = attachment.queryUri.toString(), audioWaveformInfo = if (!isVoiceMessage) null else AudioWaveformInfo( - duration = attachment.duration, - waveform = null // TODO. + duration = attachment.duration?.toInt(), + waveform = attachment.waveform ), voiceMessageIndicator = if (!isVoiceMessage) null else Any() ) diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerAudioType.kt b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerAudioType.kt index a779923d46..4d0f14fbfa 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerAudioType.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerAudioType.kt @@ -23,5 +23,6 @@ data class MultiPickerAudioType( override val size: Long, override val mimeType: String?, override val contentUri: Uri, - val duration: Long + val duration: Long, + var waveform: List? = null ) : MultiPickerBaseType diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt index 2229455dfe..420ed7c928 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt @@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData { size = size, name = displayName, duration = duration, - queryUri = contentUri + queryUri = contentUri, + waveform = waveform ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index e63fc9e17b..a8c6f53ebf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -21,6 +21,7 @@ import android.view.View import im.vector.app.core.platform.VectorViewModelAction 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.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.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.timeline.Timeline @@ -112,5 +113,9 @@ sealed class RoomDetailAction : VectorViewModelAction { // Voice Message 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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 024a37067a..102bcb0511 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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.home.AvatarRenderer 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.timeline.TimelineEventController 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.edithistory.ViewEditHistoryBottomSheet 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.item.AbsMessageItem 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.room.model.Membership 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.MessageFormat 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 roomDetailPendingActionStore: RoomDetailPendingActionStore, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, - private val callManager: WebRtcCallManager + private val callManager: WebRtcCallManager, + private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker ) : VectorBaseFragment(), TimelineEventController.Callback, @@ -334,6 +338,7 @@ class RoomDetailFragment @Inject constructor( setupConfBannerView() setupEmojiPopup() setupFailedMessagesWarningView() + setupVoiceMessageView() views.roomToolbarContentView.debouncedClicks { 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) { navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) } @@ -969,6 +1001,10 @@ class RoomDetailFragment @Inject constructor( notificationDrawerManager.setCurrentRoom(null) 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 { @@ -1191,19 +1227,14 @@ class RoomDetailFragment @Inject constructor( } override fun onTextEmptyStateChanged(isEmpty: Boolean) { - // No op + views.voiceMessageRecorderView.isVisible = !views.composerLayout.views.sendButton.isVisible } - override fun onVoiceRecordingStarted() { - roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage) - } - - override fun onVoiceRecordingEnded(recordTime: Long) { - roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(recordTime)) - } - - override fun checkVoiceRecordingPermission(): Boolean { - return checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0) + override fun onTouchVoiceRecording() { + if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, requireActivity(), 0)) { + views.composerLayout.isInvisible = true + views.voiceMessageRecorderView.isVisible = true + } } } } @@ -1720,14 +1751,18 @@ class RoomDetailFragment @Inject constructor( onUrlClicked(url, url) } - override fun onPreviewUrlCloseClicked(eventId: String, url: String) { - roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) + override fun onPreviewUrlCloseClicked(eventId: String, fileUrl: String) { + roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, fileUrl)) } override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) { 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) { if (action.messageContent is MessageTextContent) { shareText(requireContext(), action.messageContent.body) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 55ee63091e..b582d075d2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail import android.net.Uri import androidx.annotation.IdRes -import androidx.core.net.toUri import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.Async 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.crypto.keysrequest.OutboundSessionKeySharingStrategy 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.sticker.StickerPickerActionHandler 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.session.coroutineScope import im.vector.app.features.settings.VectorPreferences -import im.vector.lib.multipicker.utils.toMultiPickerAudioType import io.reactivex.Observable import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers @@ -123,7 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor( private val chatEffectManager: ChatEffectManager, private val directRoomHelper: DirectRoomHelper, private val jitsiService: JitsiService, - private val voiceMessageRecordingHelper: VoiceMessageRecordingHelper, + private val voiceMessageHelper: VoiceMessageHelper, timelineSettingsFactory: TimelineSettingsFactory ) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener { @@ -321,7 +319,7 @@ class RoomDetailViewModel @AssistedInject constructor( RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) - is RoomDetailAction.ShowRoomAvatarFullScreen -> { + is RoomDetailAction.ShowRoomAvatarFullScreen -> { _viewEvents.post( RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) ) @@ -330,7 +328,11 @@ class RoomDetailViewModel @AssistedInject constructor( RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() RoomDetailAction.ResendAll -> handleResendAll() 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 } @@ -619,21 +621,39 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleStartRecordingVoiceMessage() { - voiceMessageRecordingHelper.startRecording() + voiceMessageHelper.startRecording() } - private fun handleEndRecordingVoiceMessage(recordTime: Long) { - if (recordTime == 0L) { - voiceMessageRecordingHelper.deleteRecording() + private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) { + if (isCancelled) { + voiceMessageHelper.deleteRecording() return } - voiceMessageRecordingHelper.stopRecording(recordTime)?.let { audioType -> + voiceMessageHelper.stopRecording()?.let { audioType -> room.sendMedia(audioType.toContentAttachmentData(), false, emptySet()) - room //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() fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state -> diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt new file mode 100644 index 0000000000..1187567b99 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageHelper.kt @@ -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() + + 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() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt index 8ae32fcc69..92671a34bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecorderView.kt @@ -17,15 +17,26 @@ package im.vector.app.features.home.room.detail.composer import android.content.Context +import android.text.format.DateUtils 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.core.content.ContextCompat 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.R +import im.vector.app.core.utils.toast 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. @@ -34,59 +45,320 @@ class VoiceMessageRecorderView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { +) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener { interface Callback { fun onVoiceRecordingStarted() - fun onVoiceRecordingEnded(recordTime: Long) - fun checkVoiceRecordingPermission(): Boolean + fun onVoiceRecordingEnded(isCancelled: Boolean) + fun onVoiceRecordingPlaybackModeOn() + fun onVoicePlaybackButtonClicked() } private val views: ViewVoiceMessageRecorderBinding 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() + private val recordingTimer = Timer() + private var recordingTimerTask: TimerTask? = null init { inflate(context, R.layout.view_voice_message_recorder, this) views = ViewVoiceMessageRecorderBinding.bind(this) - views.voiceMessageButton.setRecordView(views.voiceMessageRecordView) - 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) - } - }) + initVoiceRecordingViews() } - 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(R.id.voiceMessageDeletePlayback).setOnClickListener { + stopRecordingTimer() + hideRecordingViews(animationDuration = 0) + views.voiceMessageSendButton.isVisible = false + recordingState = RecordingState.NONE + callback?.onVoiceRecordingEnded(isCancelled = true) + } + + views.voiceMessagePlaybackLayout.findViewById(R.id.voicePlaybackWaveform).setOnClickListener { + if (recordingState !== RecordingState.PLAYBACK) { + recordingState = RecordingState.PLAYBACK + showPlaybackViews() + } + } + + views.voiceMessagePlaybackLayout.findViewById(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(R.id.voicePlaybackTime).apply { + post { + text = formattedTimerText + } + } + } else { + views.voiceMessageTimer.post { + views.voiceMessageTimer.text = formattedTimerText + } + } + } + + private fun showRecordingWaveform() { + val audioRecordView = views.voiceMessagePlaybackLayout.findViewById(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.voiceMessageLockArrow.isVisible = true + views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dpToPx(148)).start() views.voiceMessageLockImage.isVisible = true - views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic_recording)) - callback?.onVoiceRecordingStarted() + views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked) + 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.voiceMessageLockArrow.isVisible = false + views.voiceMessageLockBackground.animate().translationY(dpToPx(0)).start() views.voiceMessageLockImage.isVisible = false - views.voiceMessageButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_voice_mic)) - callback?.onVoiceRecordingEnded(recordTime) + views.voiceMessageLockImage.animate().translationY(dpToPx(0)).start() + 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(R.id.voiceMessagePlaybackTimerIndicator).isVisible = true + views.voiceMessagePlaybackLayout.findViewById(R.id.voicePlaybackControlButton).isVisible = false + views.voiceMessageSendButton.isVisible = true + } + + private fun showPlaybackViews() { + views.voiceMessagePlaybackLayout.findViewById(R.id.voiceMessagePlaybackTimerIndicator).isVisible = false + views.voiceMessagePlaybackLayout.findViewById(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(R.id.voicePlaybackControlButton) + .setImageResource(R.drawable.ic_voice_pause) + val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong()) + views.voiceMessagePlaybackLayout.findViewById(R.id.voicePlaybackTime) + .setText(formattedTimerText) + } + is VoiceMessagePlaybackTracker.Listener.State.Idle -> { + views.voiceMessagePlaybackLayout.findViewById(R.id.voicePlaybackControlButton) + .setImageResource(R.drawable.ic_voice_play) + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index a0f87b9749..54008c6da3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -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.ReadReceipt 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.MessageVideoContent 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?) fun getPreviewUrlRetriever(): PreviewUrlRetriever + + fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) } interface ReactionPillCallback { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index b1bac3378e..c89753b3cc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -25,6 +25,7 @@ import android.text.style.ForegroundColorSpan import android.view.View import dagger.Lazy import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.files.LocalFilesHelper 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.MessageItemAttributesFactory 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.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.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.VerificationRequestItem @@ -110,7 +114,8 @@ class MessageItemFactory @Inject constructor( private val avatarSizeProvider: AvatarSizeProvider, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val spanUtils: SpanUtils, - private val session: Session) { + private val session: Session, + private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) { // TODO inject this properly? private var roomId: String = "" @@ -154,7 +159,13 @@ class MessageItemFactory @Inject constructor( is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, 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 MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) is MessagePollResponseContent -> noticeItemFactory.create(params) @@ -223,6 +234,46 @@ class MessageItemFactory @Inject constructor( .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, @Suppress("UNUSED_PARAMETER") informationData: MessageInformationData, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index b4fe593ea3..e6fbc5294b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider 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.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.MessageType 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) } 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 -> { return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt new file mode 100644 index 0000000000..2cd2f9c866 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt @@ -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() + private val states = mutableMapOf() + + 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) { + 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) : State() + } + } +}