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 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/styles_voice_message.xml b/library/ui-styles/src/main/res/values/styles_voice_message.xml index 2e87353303..81d2e7581d 100644 --- a/library/ui-styles/src/main/res/values/styles_voice_message.xml +++ b/library/ui-styles/src/main/res/values/styles_voice_message.xml @@ -2,14 +2,14 @@ \ No newline at end of file diff --git a/vector/build.gradle b/vector/build.gradle index aeaad19e02..04b20599d4 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -411,7 +411,6 @@ dependencies { implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' implementation 'com.github.hyuwah:DraggableView:1.0.0' - implementation 'com.github.Armen101:AudioRecordView:1.0.5' // Custom Tab implementation 'androidx.browser:browser:1.4.0' diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index 2c25606f57..0bead1f826 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -437,11 +437,6 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Copyright (c) 2017-present, dialog LLC <info@dlg.im> -
  • - Armen101 / AudioRecordView -
    - Copyright 2019 Armen Gevorgyan -
  •  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" />
     
    -        
     
    -