Merge pull request #5404 from vector-im/feature/ons/voice_message_scrubbing

Voice Message Playback Scrolling Support
This commit is contained in:
Onuray Sahin 2022-03-23 14:32:58 +03:00 committed by GitHub
commit 6d0b823b66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 418 additions and 53 deletions

1
changelog.d/5426.feature Normal file
View File

@ -0,0 +1 @@
Allow scrolling position of Voice Message playback

View File

@ -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'
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="AudioWaveformView">
<attr name="alignment" format="enum">
<enum name="center" value="0" />
<enum name="bottom" value="1" />
<enum name="top" value="2" />
</attr>
<attr name="flow" format="enum">
<enum name="leftToRight" value="0" />
<enum name="rightToLeft" value="1" />
</attr>
<attr name="verticalPadding" format="dimension" />
<attr name="horizontalPadding" format="dimension" />
<attr name="barWidth" format="dimension" />
<attr name="barSpace" format="dimension" />
<attr name="barMinHeight" format="dimension" />
<attr name="isBarRounded" format="boolean" />
</declare-styleable>
</resources>

View File

@ -2,14 +2,14 @@
<resources>
<style name="VoicePlaybackWaveform">
<item name="chunkColor">?vctr_content_secondary</item>
<item name="chunkAlignTo">center</item>
<item name="chunkMinHeight">1dp</item>
<item name="chunkRoundedCorners">true</item>
<item name="chunkSoftTransition">true</item>
<item name="chunkSpace">2dp</item>
<item name="chunkWidth">2dp</item>
<item name="direction">rightToLeft</item>
<item name="alignment">center</item>
<item name="flow">leftToRight</item>
<item name="verticalPadding">4dp</item>
<item name="horizontalPadding">4dp</item>
<item name="barWidth">2dp</item>
<item name="barSpace">2dp</item>
<item name="barMinHeight">1dp</item>
<item name="isBarRounded">true</item>
</style>
</resources>

View File

@ -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'

View File

@ -437,11 +437,6 @@ THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
<br/>
Copyright (c) 2017-present, dialog LLC &lt;info@dlg.im&gt;
</li>
<li>
<b>Armen101 / AudioRecordView</b>
<br/>
Copyright 2019 Armen Gevorgyan
</li>
</ul>
<pre>
Apache License

View File

@ -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)

View File

@ -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()
}

View File

@ -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)) }

View File

@ -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()

View File

@ -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
}

View File

@ -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<Int>) {
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)
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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<Int>) {
@ -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<Int>) : State()
}
}

View File

@ -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<MessageVoiceItem.Holder>() {
interface WaveformTouchListener {
fun onWaveformTouchedUp(percentage: Float)
fun onWaveformMovedTo(percentage: Float)
}
@EpoxyAttribute
var mxcUrl: String = ""
@ -62,6 +69,9 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
@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<MessageVoiceItem.Holder>() {
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<MessageVoiceItem.Holder>() {
}
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<MessageVoiceItem.Holder>() {
val voiceLayout by bind<ViewGroup>(R.id.voiceLayout)
val voicePlaybackControlButton by bind<ImageButton>(R.id.voicePlaybackControlButton)
val voicePlaybackTime by bind<TextView>(R.id.voicePlaybackTime)
val voicePlaybackWaveform by bind<AudioRecordView>(R.id.voicePlaybackWaveform)
val voicePlaybackWaveform by bind<AudioWaveformView>(R.id.voicePlaybackWaveform)
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
}

View File

@ -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<FFT>()
private var visibleBarHeights = mutableListOf<FFT>()
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<FFT>) {
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<FFT>.summarize(target: Int): List<FFT> {
flow = Flow.LTR
val result = mutableListOf<FFT>()
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<FFT>) {
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
}
}

View File

@ -40,7 +40,7 @@
app:layout_constraintTop_toTopOf="@id/voicePlaybackControlButton"
tools:text="0:23" />
<com.visualizer.amplitude.AudioRecordView
<im.vector.app.features.voice.AudioWaveformView
android:id="@+id/voicePlaybackWaveform"
style="@style/VoicePlaybackWaveform"
android:layout_width="0dp"

View File

@ -208,7 +208,7 @@
app:layout_goneMarginStart="24dp"
tools:text="0:23" />
<com.visualizer.amplitude.AudioRecordView
<im.vector.app.features.voice.AudioWaveformView
android:id="@+id/voicePlaybackWaveform"
style="@style/VoicePlaybackWaveform"
android:layout_width="0dp"