diff --git a/changelog.d/5426.feature b/changelog.d/5426.feature
new file mode 100644
index 0000000000..2dee22f07a
--- /dev/null
+++ b/changelog.d/5426.feature
@@ -0,0 +1 @@
+Allow scrolling position of Voice Message playback
\ No newline at end of file
diff --git a/library/ui-styles/build.gradle b/library/ui-styles/build.gradle
index cee58414c7..0ac513b252 100644
--- a/library/ui-styles/build.gradle
+++ b/library/ui-styles/build.gradle
@@ -60,6 +60,4 @@ dependencies {
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
// dialpad dimen
implementation 'im.dlg:android-dialer:1.2.5'
- // AudioRecordView attr
- implementation 'com.github.Armen101:AudioRecordView:1.0.5'
}
\ No newline at end of file
diff --git a/library/ui-styles/src/main/res/values/stylable_audio_waveform_view.xml b/library/ui-styles/src/main/res/values/stylable_audio_waveform_view.xml
new file mode 100644
index 0000000000..f2c703764a
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/stylable_audio_waveform_view.xml
@@ -0,0 +1,22 @@
+
+
Copyright (c) 2017-present, dialog LLC <info@dlg.im>
-
Apache License diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index a0844a9a96..c6d0b45a04 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -783,6 +783,18 @@ class TimelineFragment @Inject constructor( updateRecordingUiState(RecordingUiState.Draft) } + override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(VoiceMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + + override fun onVoiceWaveformMoved(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(VoiceMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + private fun updateRecordingUiState(state: RecordingUiState) { messageComposerViewModel.handle( MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) @@ -2051,6 +2063,14 @@ class TimelineFragment @Inject constructor( messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent)) } + override fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float) { + messageComposerViewModel.handle(MessageComposerAction.VoiceWaveformTouchedUp(eventId, duration, percentage)) + } + + override fun onVoiceWaveformMovedTo(eventId: String, duration: Int, percentage: Float) { + messageComposerViewModel.handle(MessageComposerAction.VoiceWaveformMovedTo(eventId, duration, percentage)) + } + private fun onShareActionClicked(action: EventSharedAction.Share) { when (action.messageContent) { is MessageTextContent -> shareText(requireContext(), action.messageContent.body) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 10cef39942..091e9f7869 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -40,4 +40,6 @@ sealed class MessageComposerAction : VectorViewModelAction { data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : MessageComposerAction() object PlayOrPauseRecordingPlayback : MessageComposerAction() data class EndAllVoiceActions(val deleteRecord: Boolean = true) : MessageComposerAction() + data class VoiceWaveformTouchedUp(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction() + data class VoiceWaveformMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 8b34d9d9a9..976489eec3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -108,6 +108,8 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord) is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData) is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText) + is MessageComposerAction.VoiceWaveformTouchedUp -> handleVoiceWaveformTouchedUp(action) + is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action) } } @@ -868,12 +870,23 @@ class MessageComposerViewModel @AssistedInject constructor( voiceMessageHelper.pauseRecording() } + private fun handleVoiceWaveformTouchedUp(action: MessageComposerAction.VoiceWaveformTouchedUp) { + voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration) + } + + private fun handleVoiceWaveformMovedTo(action: MessageComposerAction.VoiceWaveformMovedTo) { + voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration) + } + private fun handleEntersBackground(composerText: String) { + // Always stop all voice actions. It may be playing in timeline or active recording + val playingAudioContent = voiceMessageHelper.stopAllVoiceActions(deleteRecord = false) + voiceMessageHelper.clearTracker() + val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording } if (isVoiceRecording) { - voiceMessageHelper.clearTracker() viewModelScope.launch { - voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)?.toContentAttachmentData()?.let { voiceDraft -> + playingAudioContent?.toContentAttachmentData()?.let { voiceDraft -> val content = voiceDraft.toJsonString() room.saveDraft(UserDraft.Voice(content)) setState { copy(sendMode = SendMode.Voice(content)) } 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 index 735d356476..c5d8b7a5c1 100644 --- 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 @@ -132,9 +132,11 @@ class VoiceMessageHelper @Inject constructor( } fun startOrPausePlayback(id: String, file: File) { - stopPlayback() + val playbackState = playbackTracker.getPlaybackState(id) + mediaPlayer?.stop() + stopPlaybackTicker() stopRecordingAmplitudes() - if (playbackTracker.getPlaybackState(id) is VoiceMessagePlaybackTracker.Listener.State.Playing) { + if (playbackState is VoiceMessagePlaybackTracker.Listener.State.Playing) { playbackTracker.pausePlayback(id) } else { startPlayback(id, file) @@ -169,11 +171,19 @@ class VoiceMessageHelper @Inject constructor( } fun stopPlayback() { - playbackTracker.stopPlayback(VoiceMessagePlaybackTracker.RECORDING_ID) + playbackTracker.pausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID) mediaPlayer?.stop() stopPlaybackTicker() } + fun movePlaybackTo(id: String, percentage: Float, totalDuration: Int) { + val toMillisecond = (totalDuration * percentage).toInt() + playbackTracker.updateCurrentPlaybackTime(id, toMillisecond, percentage) + + stopPlayback() + playbackTracker.pausePlayback(id) + } + private fun startRecordingAmplitudes() { amplitudeTicker?.stop() amplitudeTicker = CountUpTimer(50).apply { @@ -221,7 +231,9 @@ class VoiceMessageHelper @Inject constructor( private fun onPlaybackTick(id: String) { if (mediaPlayer?.isPlaying.orFalse()) { val currentPosition = mediaPlayer?.currentPosition ?: 0 - playbackTracker.updateCurrentPlaybackTime(id, currentPosition) + val totalDuration = mediaPlayer?.duration ?: 0 + val percentage = currentPosition.toFloat() / totalDuration + playbackTracker.updateCurrentPlaybackTime(id, currentPosition, percentage) } else { playbackTracker.stopPlayback(id) stopPlaybackTicker() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 7cb8bbad95..ab37d1a48c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -52,6 +52,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor( fun onDeleteVoiceMessage() fun onRecordingLimitReached() fun onRecordingWaveformClicked() + fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) + fun onVoiceWaveformMoved(percentage: Float, duration: Int) } @Inject lateinit var clock: Clock @@ -64,6 +66,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor( private var recordingTicker: CountUpTimer? = null private var lastKnownState: RecordingUiState? = null private var dragState: DraggingState = DraggingState.Ignored + private var recordingDuration: Long = 0 init { inflate(this.context, R.layout.view_voice_message_recorder, this) @@ -94,7 +97,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor( override fun onDeleteVoiceMessage() = callback.onDeleteVoiceMessage() override fun onWaveformClicked() { when (lastKnownState) { - RecordingUiState.Draft -> callback.onVoicePlaybackButtonClicked() is RecordingUiState.Recording, is RecordingUiState.Locked -> callback.onRecordingWaveformClicked() else -> Unit @@ -105,6 +107,18 @@ class VoiceMessageRecorderView @JvmOverloads constructor( override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) { onDrag(dragState, newDragState = nextDragStateCreator(dragState)) } + + override fun onVoiceWaveformTouchedUp(percentage: Float) { + if (lastKnownState == RecordingUiState.Draft) { + callback.onVoiceWaveformTouchedUp(percentage, recordingDuration.toInt()) + } + } + + override fun onVoiceWaveformMoved(percentage: Float) { + if (lastKnownState == RecordingUiState.Draft) { + callback.onVoiceWaveformMoved(percentage, recordingDuration.toInt()) + } + } }) } @@ -203,6 +217,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor( } private fun stopRecordingTicker() { + recordingDuration = recordingTicker?.elapsedTime() ?: 0 recordingTicker?.stop() recordingTicker = null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index 09284ea5fc..7a76657923 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -27,7 +27,6 @@ import androidx.core.view.doOnLayout import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams -import com.visualizer.amplitude.AudioRecordView import im.vector.app.R import im.vector.app.core.extensions.setAttributeBackground import im.vector.app.core.extensions.setAttributeTintedBackground @@ -37,6 +36,8 @@ import im.vector.app.databinding.ViewVoiceMessageRecorderBinding import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker +import im.vector.app.features.themes.ThemeUtils +import im.vector.app.features.voice.AudioWaveformView class VoiceMessageViews( private val resources: Resources, @@ -59,8 +60,21 @@ class VoiceMessageViews( actions.onDeleteVoiceMessage() } - views.voicePlaybackWaveform.setOnClickListener { - actions.onWaveformClicked() + views.voicePlaybackWaveform.setOnTouchListener { view, motionEvent -> + when (motionEvent.action) { + MotionEvent.ACTION_DOWN -> { + actions.onWaveformClicked() + } + MotionEvent.ACTION_UP -> { + val percentage = getTouchedPositionPercentage(motionEvent, view) + actions.onVoiceWaveformTouchedUp(percentage) + } + MotionEvent.ACTION_MOVE -> { + val percentage = getTouchedPositionPercentage(motionEvent, view) + actions.onVoiceWaveformMoved(percentage) + } + } + true } views.voicePlaybackControlButton.setOnClickListener { @@ -69,6 +83,8 @@ class VoiceMessageViews( observeMicButton(actions) } + private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f) + @SuppressLint("ClickableViewAccessibility") private fun observeMicButton(actions: Actions) { val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter) @@ -284,7 +300,7 @@ class VoiceMessageViews( hideRecordingViews(RecordingUiState.Idle) views.voiceMessageMicButton.isVisible = true views.voiceMessageSendButton.isVisible = false - views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() } + views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.clear() } } fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) { @@ -292,11 +308,15 @@ class VoiceMessageViews( views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message) val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong()) views.voicePlaybackTime.text = formattedTimerText + val waveformColorIdle = ThemeUtils.getColor(views.voicePlaybackWaveform.context, R.attr.vctr_content_quaternary) + val waveformColorPlayed = ThemeUtils.getColor(views.voicePlaybackWaveform.context, R.attr.vctr_content_secondary) + views.voicePlaybackWaveform.updateColors(state.percentage, waveformColorPlayed, waveformColorIdle) } fun renderIdle() { views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play) views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message) + views.voicePlaybackWaveform.summarize() } fun renderToast(message: String) { @@ -327,8 +347,9 @@ class VoiceMessageViews( fun renderRecordingWaveform(amplitudeList: Array) { views.voicePlaybackWaveform.doOnLayout { waveFormView -> + val waveformColor = ThemeUtils.getColor(waveFormView.context, R.attr.vctr_content_quaternary) amplitudeList.iterator().forEach { - (waveFormView as AudioRecordView).update(it) + (waveFormView as AudioWaveformView).add(AudioWaveformView.FFT(it.toFloat(), waveformColor)) } } } @@ -349,5 +370,7 @@ class VoiceMessageViews( fun onDeleteVoiceMessage() fun onWaveformClicked() fun onVoicePlaybackButtonClicked() + fun onVoiceWaveformTouchedUp(percentage: Float) + fun onVoiceWaveformMoved(percentage: Float) } } 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 a14888362b..023c28cdc7 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 @@ -145,6 +145,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun getPreviewUrlRetriever(): PreviewUrlRetriever fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) + fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float) + fun onVoiceWaveformMovedTo(eventId: String, duration: Int, percentage: Float) fun onAddMoreReaction(event: TimelineEvent) } 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 5ce9589ca3..9c15532376 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 @@ -74,6 +74,7 @@ import im.vector.app.features.location.toLocationData import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voice.AudioWaveformView import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import me.gujun.android.span.span import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -362,11 +363,24 @@ class MessageItemFactory @Inject constructor( } } + val waveformTouchListener: MessageVoiceItem.WaveformTouchListener = object : MessageVoiceItem.WaveformTouchListener { + override fun onWaveformTouchedUp(percentage: Float) { + val duration = messageContent.audioInfo?.duration ?: 0 + params.callback?.onVoiceWaveformTouchedUp(informationData.eventId, duration, percentage) + } + + override fun onWaveformMovedTo(percentage: Float) { + val duration = messageContent.audioInfo?.duration ?: 0 + params.callback?.onVoiceWaveformMovedTo(informationData.eventId, duration, percentage) + } + } + return MessageVoiceItem_() .attributes(attributes) .duration(messageContent.audioWaveformInfo?.duration ?: 0) .waveform(messageContent.audioWaveformInfo?.waveform?.toFft().orEmpty()) .playbackControlButtonClickListener(playbackControlButtonClickListener) + .waveformTouchListener(waveformTouchListener) .voiceMessagePlaybackTracker(voiceMessagePlaybackTracker) .izLocalFile(localFilesHelper.isLocalFile(fileUrl)) .izDownloaded(session.fileService().isFileInCache( @@ -699,8 +713,8 @@ class MessageItemFactory @Inject constructor( return this ?.filterNotNull() ?.map { - // Value comes from AudioRecordView.maxReportableAmp, and 1024 is the max value in the Matrix spec - it * 22760 / 1024 + // Value comes from AudioWaveformView.MAX_FFT, and 1024 is the max value in the Matrix spec + it * AudioWaveformView.MAX_FFT / 1024 } } 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 index c6204bff1c..8167ad94af 100644 --- 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 @@ -70,7 +70,8 @@ class VoiceMessagePlaybackTracker @Inject constructor() { fun startPlayback(id: String) { val currentPlaybackTime = getPlaybackTime(id) - val currentState = Listener.State.Playing(currentPlaybackTime) + val currentPercentage = getPercentage(id) + val currentState = Listener.State.Playing(currentPlaybackTime, currentPercentage) setState(id, currentState) // Pause any active playback states @@ -87,15 +88,16 @@ class VoiceMessagePlaybackTracker @Inject constructor() { fun pausePlayback(id: String) { val currentPlaybackTime = getPlaybackTime(id) - setState(id, Listener.State.Paused(currentPlaybackTime)) + val currentPercentage = getPercentage(id) + setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage)) } fun stopPlayback(id: String) { setState(id, Listener.State.Idle) } - fun updateCurrentPlaybackTime(id: String, time: Int) { - setState(id, Listener.State.Playing(time)) + fun updateCurrentPlaybackTime(id: String, time: Int, percentage: Float) { + setState(id, Listener.State.Playing(time, percentage)) } fun updateCurrentRecording(id: String, amplitudeList: List ) { @@ -113,6 +115,15 @@ class VoiceMessagePlaybackTracker @Inject constructor() { } } + private fun getPercentage(id: String): Float { + return when (val state = states[id]) { + is Listener.State.Playing -> state.percentage + is Listener.State.Paused -> state.percentage + /* Listener.State.Idle, */ + else -> 0f + } + } + fun clear() { listeners.forEach { it.value.onUpdate(Listener.State.Idle) @@ -131,8 +142,8 @@ class VoiceMessagePlaybackTracker @Inject constructor() { sealed class State { object Idle : State() - data class Playing(val playbackTime: Int) : State() - data class Paused(val playbackTime: Int) : State() + data class Playing(val playbackTime: Int, val percentage: Float) : State() + data class Paused(val playbackTime: Int, val percentage: Float) : State() data class Recording(val amplitudeList: List ) : State() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index 98be75027e..aad30ef41e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -19,14 +19,15 @@ package im.vector.app.features.home.room.detail.timeline.item import android.content.res.ColorStateList import android.graphics.Color import android.text.format.DateUtils +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.TextView +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import com.visualizer.amplitude.AudioRecordView import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder @@ -34,10 +35,16 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStat import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.themes.ThemeUtils +import im.vector.app.features.voice.AudioWaveformView @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageVoiceItem : AbsMessageItem () { + interface WaveformTouchListener { + fun onWaveformTouchedUp(percentage: Float) + fun onWaveformMovedTo(percentage: Float) + } + @EpoxyAttribute var mxcUrl: String = "" @@ -62,6 +69,9 @@ abstract class MessageVoiceItem : AbsMessageItem () { @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var playbackControlButtonClickListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var waveformTouchListener: WaveformTouchListener? = null + @EpoxyAttribute lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker @@ -76,13 +86,8 @@ abstract class MessageVoiceItem : AbsMessageItem () { holder.progressLayout.isVisible = false } - holder.voicePlaybackWaveform.setOnLongClickListener(attributes.itemLongClickListener) - - holder.voicePlaybackWaveform.post { - holder.voicePlaybackWaveform.recreate() - waveform.forEach { amplitude -> - holder.voicePlaybackWaveform.update(amplitude) - } + holder.voicePlaybackWaveform.doOnLayout { + onWaveformViewReady(holder) } val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) { @@ -92,35 +97,67 @@ abstract class MessageVoiceItem : AbsMessageItem () { } holder.voicePlaybackLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint) holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) } + } + + private fun onWaveformViewReady(holder: Holder) { + holder.voicePlaybackWaveform.setOnLongClickListener(attributes.itemLongClickListener) + + val waveformColorIdle = ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quaternary) + val waveformColorPlayed = ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_secondary) + + holder.voicePlaybackWaveform.clear() + waveform.forEach { amplitude -> + holder.voicePlaybackWaveform.add(AudioWaveformView.FFT(amplitude.toFloat(), waveformColorIdle)) + } + holder.voicePlaybackWaveform.summarize() + + holder.voicePlaybackWaveform.setOnTouchListener { view, motionEvent -> + when (motionEvent.action) { + MotionEvent.ACTION_UP -> { + val percentage = getTouchedPositionPercentage(motionEvent, view) + waveformTouchListener?.onWaveformTouchedUp(percentage) + } + MotionEvent.ACTION_MOVE -> { + val percentage = getTouchedPositionPercentage(motionEvent, view) + waveformTouchListener?.onWaveformMovedTo(percentage) + } + } + true + } voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener { override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) { when (state) { - is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) - is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) - is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) + is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) + is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) + is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) is VoiceMessagePlaybackTracker.Listener.State.Recording -> Unit } } }) } - private fun renderIdleState(holder: Holder) { + private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f) + + private fun renderIdleState(holder: Holder, idleColor: Int, playedColor: Int) { holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message) holder.voicePlaybackTime.text = formatPlaybackTime(duration) + holder.voicePlaybackWaveform.updateColors(0f, playedColor, idleColor) } - private fun renderPlayingState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Playing) { + private fun renderPlayingState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Playing, idleColor: Int, playedColor: Int) { holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_pause_voice_message) holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) + holder.voicePlaybackWaveform.updateColors(state.percentage, playedColor, idleColor) } - private fun renderPausedState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Paused) { + private fun renderPausedState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Paused, idleColor: Int, playedColor: Int) { holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message) holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) + holder.voicePlaybackWaveform.updateColors(state.percentage, playedColor, idleColor) } private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) @@ -139,7 +176,7 @@ abstract class MessageVoiceItem : AbsMessageItem () { val voiceLayout by bind (R.id.voiceLayout) val voicePlaybackControlButton by bind (R.id.voicePlaybackControlButton) val voicePlaybackTime by bind (R.id.voicePlaybackTime) - val voicePlaybackWaveform by bind (R.id.voicePlaybackWaveform) + val voicePlaybackWaveform by bind (R.id.voicePlaybackWaveform) val progressLayout by bind (R.id.messageFileUploadProgressLayout) } diff --git a/vector/src/main/java/im/vector/app/features/voice/AudioWaveformView.kt b/vector/src/main/java/im/vector/app/features/voice/AudioWaveformView.kt new file mode 100644 index 0000000000..32f30fe458 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voice/AudioWaveformView.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voice + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import im.vector.app.R +import kotlin.math.max +import kotlin.random.Random + +class AudioWaveformView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private enum class Alignment(var value: Int) { + CENTER(0), + BOTTOM(1), + TOP(2) + } + + private enum class Flow(var value: Int) { + LTR(0), + RTL(1) + } + + data class FFT(val value: Float, var color: Int) + + private fun Int.dp() = this * Resources.getSystem().displayMetrics.density + + // Configuration fields + private var alignment = Alignment.CENTER + private var flow = Flow.LTR + private var verticalPadding = 4.dp() + private var horizontalPadding = 4.dp() + private var barWidth = 2.dp() + private var barSpace = 1.dp() + private var barMinHeight = 1.dp() + private var isBarRounded = true + + private val rawFftList = mutableListOf () + private var visibleBarHeights = mutableListOf () + + private val barPaint = Paint() + + init { + attrs?.let { + context + .theme + .obtainStyledAttributes( + attrs, + R.styleable.AudioWaveformView, + 0, + 0 + ) + .apply { + alignment = Alignment.values().find { it.value == getInt(R.styleable.AudioWaveformView_alignment, alignment.value) }!! + flow = Flow.values().find { it.value == getInt(R.styleable.AudioWaveformView_flow, alignment.value) }!! + verticalPadding = getDimension(R.styleable.AudioWaveformView_verticalPadding, verticalPadding) + horizontalPadding = getDimension(R.styleable.AudioWaveformView_horizontalPadding, horizontalPadding) + barWidth = getDimension(R.styleable.AudioWaveformView_barWidth, barWidth) + barSpace = getDimension(R.styleable.AudioWaveformView_barSpace, barSpace) + barMinHeight = getDimension(R.styleable.AudioWaveformView_barMinHeight, barMinHeight) + isBarRounded = getBoolean(R.styleable.AudioWaveformView_isBarRounded, isBarRounded) + setWillNotDraw(false) + barPaint.isAntiAlias = true + } + .apply { recycle() } + .also { + barPaint.strokeWidth = barWidth + barPaint.strokeCap = if (isBarRounded) Paint.Cap.ROUND else Paint.Cap.BUTT + } + } + } + + fun initialize(fftList: List ) { + handleNewFftList(fftList) + invalidate() + } + + fun add(fft: FFT) { + handleNewFftList(listOf(fft)) + invalidate() + } + + fun summarize() { + if (rawFftList.isEmpty()) return + + val maxVisibleBarCount = getMaxVisibleBarCount() + val summarizedFftList = rawFftList.summarize(maxVisibleBarCount) + clear() + handleNewFftList(summarizedFftList) + invalidate() + } + + fun updateColors(limitPercentage: Float, colorBefore: Int, colorAfter: Int) { + val size = visibleBarHeights.size + val limitIndex = (size * limitPercentage).toInt() + visibleBarHeights.forEachIndexed { index, fft -> + fft.color = if (index < limitIndex) { + colorBefore + } else { + colorAfter + } + } + invalidate() + } + + fun clear() { + rawFftList.clear() + visibleBarHeights.clear() + } + + private fun List .summarize(target: Int): List { + flow = Flow.LTR + val result = mutableListOf () + if (size <= target) { + result.addAll(this) + val missingItemCount = target - size + repeat(missingItemCount) { + val index = Random.nextInt(result.size) + result.add(index, result[index]) + } + } else { + val step = (size.toDouble() - 1) / (target - 1) + var index = 0.0 + while (index < size) { + result.add(get(index.toInt())) + index += step + } + } + return result + } + + private fun handleNewFftList(fftList: List ) { + val maxVisibleBarCount = getMaxVisibleBarCount() + fftList.forEach { fft -> + rawFftList.add(fft) + val barHeight = max(fft.value / MAX_FFT * (height - verticalPadding * 2), barMinHeight) + visibleBarHeights.add(FFT(barHeight, fft.color)) + if (visibleBarHeights.size > maxVisibleBarCount) { + visibleBarHeights = visibleBarHeights.subList(visibleBarHeights.size - maxVisibleBarCount, visibleBarHeights.size) + } + } + } + + private fun getMaxVisibleBarCount() = ((width - horizontalPadding * 2) / (barWidth + barSpace)).toInt() + + private fun drawBars(canvas: Canvas) { + var currentX = horizontalPadding + val flowableBarHeights = if (flow == Flow.LTR) visibleBarHeights else visibleBarHeights.reversed() + + flowableBarHeights.forEach { + barPaint.color = it.color + when (alignment) { + Alignment.BOTTOM -> { + val startY = height - verticalPadding + val stopY = startY - it.value + canvas.drawLine(currentX, startY, currentX, stopY, barPaint) + } + Alignment.CENTER -> { + val startY = (height - it.value) / 2 + val stopY = startY + it.value + canvas.drawLine(currentX, startY, currentX, stopY, barPaint) + } + Alignment.TOP -> { + val startY = verticalPadding + val stopY = startY + it.value + canvas.drawLine(currentX, startY, currentX, stopY, barPaint) + } + } + currentX += barWidth + barSpace + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawBars(canvas) + } + + companion object { + const val MAX_FFT = 32760 + } +} diff --git a/vector/src/main/res/layout/item_timeline_event_voice_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_stub.xml index a180afbf8e..0fad714bd4 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_stub.xml @@ -40,7 +40,7 @@ app:layout_constraintTop_toTopOf="@id/voicePlaybackControlButton" tools:text="0:23" /> - -