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/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..f9dfecd1f5 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
@@ -221,7 +221,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/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt
index 09284ea5fc..8adecaad6e 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,
@@ -284,7 +285,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 +293,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 +332,9 @@ class VoiceMessageViews(
fun renderRecordingWaveform(amplitudeList: Array) {
views.voicePlaybackWaveform.doOnLayout { waveFormView ->
+ val waveformColor = ThemeUtils.getColor(waveFormView.context, R.attr.vctr_content_secondary)
amplitudeList.iterator().forEach {
- (waveFormView as AudioRecordView).update(it)
+ (waveFormView as AudioWaveformView).add(AudioWaveformView.FFT(it.toFloat(), waveformColor))
}
}
}
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 0c836748c8..da97cf6984 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
@@ -73,6 +73,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
@@ -688,8 +689,7 @@ 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
+ 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..076c05b9c4 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() {
}
}
+ 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 e9f728d976..82400a431d 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
@@ -26,7 +26,6 @@ import android.widget.TextView
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,6 +33,7 @@ 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() {
@@ -78,11 +78,15 @@ abstract class MessageVoiceItem : AbsMessageItem() {
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.post {
- holder.voicePlaybackWaveform.recreate()
+ holder.voicePlaybackWaveform.clear()
waveform.forEach { amplitude ->
- holder.voicePlaybackWaveform.update(amplitude)
+ holder.voicePlaybackWaveform.add(AudioWaveformView.FFT(amplitude.toFloat(), waveformColorIdle))
}
+ holder.voicePlaybackWaveform.summarize()
}
val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) {
@@ -93,33 +97,39 @@ abstract class MessageVoiceItem : AbsMessageItem() {
holder.voicePlaybackLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
- 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)
+ // Don't track and don't try to update UI before view is present
+ holder.view.post {
+ voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener {
+ override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
+ when (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)
+ }
}
- }
- })
+ })
+ }
}
- private fun renderIdleState(holder: Holder) {
+ 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())
@@ -138,7 +148,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..9ba7597e60
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voice/AudioWaveformView.kt
@@ -0,0 +1,199 @@
+/*
+ * 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 {
+ 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
+ visibleBarHeights.forEach {
+ barPaint.color = it.color
+ // TODO. Support flow
+ 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 {
+ private const val MAX_FFT = 32760f
+ }
+}
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" />
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file