Merge pull request 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' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
// dialpad dimen // dialpad dimen
implementation 'im.dlg:android-dialer:1.2.5' 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> <resources>
<style name="VoicePlaybackWaveform"> <style name="VoicePlaybackWaveform">
<item name="chunkColor">?vctr_content_secondary</item> <item name="alignment">center</item>
<item name="chunkAlignTo">center</item> <item name="flow">leftToRight</item>
<item name="chunkMinHeight">1dp</item> <item name="verticalPadding">4dp</item>
<item name="chunkRoundedCorners">true</item> <item name="horizontalPadding">4dp</item>
<item name="chunkSoftTransition">true</item> <item name="barWidth">2dp</item>
<item name="chunkSpace">2dp</item> <item name="barSpace">2dp</item>
<item name="chunkWidth">2dp</item> <item name="barMinHeight">1dp</item>
<item name="direction">rightToLeft</item> <item name="isBarRounded">true</item>
</style> </style>
</resources> </resources>

View file

@ -411,7 +411,6 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
implementation 'com.github.hyuwah:DraggableView:1.0.0' implementation 'com.github.hyuwah:DraggableView:1.0.0'
implementation 'com.github.Armen101:AudioRecordView:1.0.5'
// Custom Tab // Custom Tab
implementation 'androidx.browser:browser:1.4.0' 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/> <br/>
Copyright (c) 2017-present, dialog LLC &lt;info@dlg.im&gt; Copyright (c) 2017-present, dialog LLC &lt;info@dlg.im&gt;
</li> </li>
<li>
<b>Armen101 / AudioRecordView</b>
<br/>
Copyright 2019 Armen Gevorgyan
</li>
</ul> </ul>
<pre> <pre>
Apache License Apache License

View file

@ -783,6 +783,18 @@ class TimelineFragment @Inject constructor(
updateRecordingUiState(RecordingUiState.Draft) 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) { private fun updateRecordingUiState(state: RecordingUiState) {
messageComposerViewModel.handle( messageComposerViewModel.handle(
MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) MessageComposerAction.OnVoiceRecordingUiStateChanged(state))
@ -2051,6 +2063,14 @@ class TimelineFragment @Inject constructor(
messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseVoicePlayback(eventId, messageAudioContent)) 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) { private fun onShareActionClicked(action: EventSharedAction.Share) {
when (action.messageContent) { when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body) 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() data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : MessageComposerAction()
object PlayOrPauseRecordingPlayback : MessageComposerAction() object PlayOrPauseRecordingPlayback : MessageComposerAction()
data class EndAllVoiceActions(val deleteRecord: Boolean = true) : 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.EndAllVoiceActions -> handleEndAllVoiceActions(action.deleteRecord)
is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData) is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData)
is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText) 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() 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) { 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 } val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording }
if (isVoiceRecording) { if (isVoiceRecording) {
voiceMessageHelper.clearTracker()
viewModelScope.launch { viewModelScope.launch {
voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)?.toContentAttachmentData()?.let { voiceDraft -> playingAudioContent?.toContentAttachmentData()?.let { voiceDraft ->
val content = voiceDraft.toJsonString() val content = voiceDraft.toJsonString()
room.saveDraft(UserDraft.Voice(content)) room.saveDraft(UserDraft.Voice(content))
setState { copy(sendMode = SendMode.Voice(content)) } setState { copy(sendMode = SendMode.Voice(content)) }

View file

@ -132,9 +132,11 @@ class VoiceMessageHelper @Inject constructor(
} }
fun startOrPausePlayback(id: String, file: File) { fun startOrPausePlayback(id: String, file: File) {
stopPlayback() val playbackState = playbackTracker.getPlaybackState(id)
mediaPlayer?.stop()
stopPlaybackTicker()
stopRecordingAmplitudes() stopRecordingAmplitudes()
if (playbackTracker.getPlaybackState(id) is VoiceMessagePlaybackTracker.Listener.State.Playing) { if (playbackState is VoiceMessagePlaybackTracker.Listener.State.Playing) {
playbackTracker.pausePlayback(id) playbackTracker.pausePlayback(id)
} else { } else {
startPlayback(id, file) startPlayback(id, file)
@ -169,11 +171,19 @@ class VoiceMessageHelper @Inject constructor(
} }
fun stopPlayback() { fun stopPlayback() {
playbackTracker.stopPlayback(VoiceMessagePlaybackTracker.RECORDING_ID) playbackTracker.pausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID)
mediaPlayer?.stop() mediaPlayer?.stop()
stopPlaybackTicker() 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() { private fun startRecordingAmplitudes() {
amplitudeTicker?.stop() amplitudeTicker?.stop()
amplitudeTicker = CountUpTimer(50).apply { amplitudeTicker = CountUpTimer(50).apply {
@ -221,7 +231,9 @@ class VoiceMessageHelper @Inject constructor(
private fun onPlaybackTick(id: String) { private fun onPlaybackTick(id: String) {
if (mediaPlayer?.isPlaying.orFalse()) { if (mediaPlayer?.isPlaying.orFalse()) {
val currentPosition = mediaPlayer?.currentPosition ?: 0 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 { } else {
playbackTracker.stopPlayback(id) playbackTracker.stopPlayback(id)
stopPlaybackTicker() stopPlaybackTicker()

View file

@ -52,6 +52,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun onDeleteVoiceMessage() fun onDeleteVoiceMessage()
fun onRecordingLimitReached() fun onRecordingLimitReached()
fun onRecordingWaveformClicked() fun onRecordingWaveformClicked()
fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int)
fun onVoiceWaveformMoved(percentage: Float, duration: Int)
} }
@Inject lateinit var clock: Clock @Inject lateinit var clock: Clock
@ -64,6 +66,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
private var recordingTicker: CountUpTimer? = null private var recordingTicker: CountUpTimer? = null
private var lastKnownState: RecordingUiState? = null private var lastKnownState: RecordingUiState? = null
private var dragState: DraggingState = DraggingState.Ignored private var dragState: DraggingState = DraggingState.Ignored
private var recordingDuration: Long = 0
init { init {
inflate(this.context, R.layout.view_voice_message_recorder, this) 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 onDeleteVoiceMessage() = callback.onDeleteVoiceMessage()
override fun onWaveformClicked() { override fun onWaveformClicked() {
when (lastKnownState) { when (lastKnownState) {
RecordingUiState.Draft -> callback.onVoicePlaybackButtonClicked()
is RecordingUiState.Recording, is RecordingUiState.Recording,
is RecordingUiState.Locked -> callback.onRecordingWaveformClicked() is RecordingUiState.Locked -> callback.onRecordingWaveformClicked()
else -> Unit else -> Unit
@ -105,6 +107,18 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) { override fun onMicButtonDrag(nextDragStateCreator: (DraggingState) -> DraggingState) {
onDrag(dragState, newDragState = nextDragStateCreator(dragState)) 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() { private fun stopRecordingTicker() {
recordingDuration = recordingTicker?.elapsedTime() ?: 0
recordingTicker?.stop() recordingTicker?.stop()
recordingTicker = null recordingTicker = null
} }

View file

@ -27,7 +27,6 @@ import androidx.core.view.doOnLayout
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import com.visualizer.amplitude.AudioRecordView
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.core.extensions.setAttributeTintedBackground 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.DraggingState
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState 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.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.voice.AudioWaveformView
class VoiceMessageViews( class VoiceMessageViews(
private val resources: Resources, private val resources: Resources,
@ -59,8 +60,21 @@ class VoiceMessageViews(
actions.onDeleteVoiceMessage() actions.onDeleteVoiceMessage()
} }
views.voicePlaybackWaveform.setOnClickListener { views.voicePlaybackWaveform.setOnTouchListener { view, motionEvent ->
actions.onWaveformClicked() 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 { views.voicePlaybackControlButton.setOnClickListener {
@ -69,6 +83,8 @@ class VoiceMessageViews(
observeMicButton(actions) observeMicButton(actions)
} }
private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f)
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private fun observeMicButton(actions: Actions) { private fun observeMicButton(actions: Actions) {
val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter) val draggableStateProcessor = DraggableStateProcessor(resources, dimensionConverter)
@ -284,7 +300,7 @@ class VoiceMessageViews(
hideRecordingViews(RecordingUiState.Idle) hideRecordingViews(RecordingUiState.Idle)
views.voiceMessageMicButton.isVisible = true views.voiceMessageMicButton.isVisible = true
views.voiceMessageSendButton.isVisible = false views.voiceMessageSendButton.isVisible = false
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() } views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.clear() }
} }
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) { fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
@ -292,11 +308,15 @@ class VoiceMessageViews(
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message) views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong()) val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
views.voicePlaybackTime.text = formattedTimerText 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() { fun renderIdle() {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play) views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message) views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_play_voice_message)
views.voicePlaybackWaveform.summarize()
} }
fun renderToast(message: String) { fun renderToast(message: String) {
@ -327,8 +347,9 @@ class VoiceMessageViews(
fun renderRecordingWaveform(amplitudeList: Array<Int>) { fun renderRecordingWaveform(amplitudeList: Array<Int>) {
views.voicePlaybackWaveform.doOnLayout { waveFormView -> views.voicePlaybackWaveform.doOnLayout { waveFormView ->
val waveformColor = ThemeUtils.getColor(waveFormView.context, R.attr.vctr_content_quaternary)
amplitudeList.iterator().forEach { 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 onDeleteVoiceMessage()
fun onWaveformClicked() fun onWaveformClicked()
fun onVoicePlaybackButtonClicked() 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 getPreviewUrlRetriever(): PreviewUrlRetriever
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent) 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) 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.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.AudioWaveformView
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span import me.gujun.android.span.span
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl 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_() return MessageVoiceItem_()
.attributes(attributes) .attributes(attributes)
.duration(messageContent.audioWaveformInfo?.duration ?: 0) .duration(messageContent.audioWaveformInfo?.duration ?: 0)
.waveform(messageContent.audioWaveformInfo?.waveform?.toFft().orEmpty()) .waveform(messageContent.audioWaveformInfo?.waveform?.toFft().orEmpty())
.playbackControlButtonClickListener(playbackControlButtonClickListener) .playbackControlButtonClickListener(playbackControlButtonClickListener)
.waveformTouchListener(waveformTouchListener)
.voiceMessagePlaybackTracker(voiceMessagePlaybackTracker) .voiceMessagePlaybackTracker(voiceMessagePlaybackTracker)
.izLocalFile(localFilesHelper.isLocalFile(fileUrl)) .izLocalFile(localFilesHelper.isLocalFile(fileUrl))
.izDownloaded(session.fileService().isFileInCache( .izDownloaded(session.fileService().isFileInCache(
@ -699,8 +713,8 @@ class MessageItemFactory @Inject constructor(
return this return this
?.filterNotNull() ?.filterNotNull()
?.map { ?.map {
// Value comes from AudioRecordView.maxReportableAmp, and 1024 is the max value in the Matrix spec // Value comes from AudioWaveformView.MAX_FFT, and 1024 is the max value in the Matrix spec
it * 22760 / 1024 it * AudioWaveformView.MAX_FFT / 1024
} }
} }

View file

@ -70,7 +70,8 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
fun startPlayback(id: String) { fun startPlayback(id: String) {
val currentPlaybackTime = getPlaybackTime(id) val currentPlaybackTime = getPlaybackTime(id)
val currentState = Listener.State.Playing(currentPlaybackTime) val currentPercentage = getPercentage(id)
val currentState = Listener.State.Playing(currentPlaybackTime, currentPercentage)
setState(id, currentState) setState(id, currentState)
// Pause any active playback // Pause any active playback
states states
@ -87,15 +88,16 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
fun pausePlayback(id: String) { fun pausePlayback(id: String) {
val currentPlaybackTime = getPlaybackTime(id) 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) { fun stopPlayback(id: String) {
setState(id, Listener.State.Idle) setState(id, Listener.State.Idle)
} }
fun updateCurrentPlaybackTime(id: String, time: Int) { fun updateCurrentPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Playing(time)) setState(id, Listener.State.Playing(time, percentage))
} }
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) { 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() { fun clear() {
listeners.forEach { listeners.forEach {
it.value.onUpdate(Listener.State.Idle) it.value.onUpdate(Listener.State.Idle)
@ -131,8 +142,8 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
sealed class State { sealed class State {
object Idle : State() object Idle : State()
data class Playing(val playbackTime: Int) : State() data class Playing(val playbackTime: Int, val percentage: Float) : State()
data class Paused(val playbackTime: Int) : State() data class Paused(val playbackTime: Int, val percentage: Float) : State()
data class Recording(val amplitudeList: List<Int>) : 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.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import com.visualizer.amplitude.AudioRecordView
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.ClickListener
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder 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.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.voice.AudioWaveformView
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() { abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
interface WaveformTouchListener {
fun onWaveformTouchedUp(percentage: Float)
fun onWaveformMovedTo(percentage: Float)
}
@EpoxyAttribute @EpoxyAttribute
var mxcUrl: String = "" var mxcUrl: String = ""
@ -62,6 +69,9 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var playbackControlButtonClickListener: ClickListener? = null var playbackControlButtonClickListener: ClickListener? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var waveformTouchListener: WaveformTouchListener? = null
@EpoxyAttribute @EpoxyAttribute
lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
@ -76,13 +86,8 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
holder.progressLayout.isVisible = false holder.progressLayout.isVisible = false
} }
holder.voicePlaybackWaveform.setOnLongClickListener(attributes.itemLongClickListener) holder.voicePlaybackWaveform.doOnLayout {
onWaveformViewReady(holder)
holder.voicePlaybackWaveform.post {
holder.voicePlaybackWaveform.recreate()
waveform.forEach { amplitude ->
holder.voicePlaybackWaveform.update(amplitude)
}
} }
val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) { 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.voicePlaybackLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) } 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 { voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener {
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) { override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
when (state) { when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Recording -> Unit 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.setImageResource(R.drawable.ic_play_pause_play)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message)
holder.voicePlaybackTime.text = formatPlaybackTime(duration) 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.setImageResource(R.drawable.ic_play_pause_pause)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_pause_voice_message) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_pause_voice_message)
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) 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.setImageResource(R.drawable.ic_play_pause_play)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message) holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message)
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.voicePlaybackWaveform.updateColors(state.percentage, playedColor, idleColor)
} }
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) 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 voiceLayout by bind<ViewGroup>(R.id.voiceLayout)
val voicePlaybackControlButton by bind<ImageButton>(R.id.voicePlaybackControlButton) val voicePlaybackControlButton by bind<ImageButton>(R.id.voicePlaybackControlButton)
val voicePlaybackTime by bind<TextView>(R.id.voicePlaybackTime) 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) 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" app:layout_constraintTop_toTopOf="@id/voicePlaybackControlButton"
tools:text="0:23" /> tools:text="0:23" />
<com.visualizer.amplitude.AudioRecordView <im.vector.app.features.voice.AudioWaveformView
android:id="@+id/voicePlaybackWaveform" android:id="@+id/voicePlaybackWaveform"
style="@style/VoicePlaybackWaveform" style="@style/VoicePlaybackWaveform"
android:layout_width="0dp" android:layout_width="0dp"

View file

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