mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-03-18 20:29:10 +03:00
Voice message playback implementation.
This commit is contained in:
parent
5676226f42
commit
9d48b399df
14 changed files with 808 additions and 72 deletions
|
@ -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<Int>? = null
|
||||
) : Parcelable {
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
|
|
|
@ -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<Int>? = null
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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<Int>? = null
|
||||
) : MultiPickerBaseType
|
||||
|
|
|
@ -57,7 +57,8 @@ fun MultiPickerAudioType.toContentAttachmentData(): ContentAttachmentData {
|
|||
size = size,
|
||||
name = displayName,
|
||||
duration = duration,
|
||||
queryUri = contentUri
|
||||
queryUri = contentUri,
|
||||
waveform = waveform
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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<FragmentRoomDetailBinding>(),
|
||||
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)
|
||||
|
|
|
@ -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<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(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 ->
|
||||
|
|
|
@ -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
|
||||
|
||||
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<Int>()
|
||||
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<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.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<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.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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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…
Add table
Reference in a new issue