mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-02-20 05:50:03 +03:00
Merge pull request #5404 from vector-im/feature/ons/voice_message_scrubbing
Voice Message Playback Scrolling Support
This commit is contained in:
commit
6d0b823b66
19 changed files with 418 additions and 53 deletions
changelog.d
library/ui-styles
vector
build.gradle
src/main
assets
java/im/vector/app/features
home/room/detail
TimelineFragment.kt
composer
timeline
voice
res/layout
1
changelog.d/5426.feature
Normal file
1
changelog.d/5426.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Allow scrolling position of Voice Message playback
|
|
@ -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'
|
|
||||||
}
|
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
||||||
|
|
|
@ -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 <info@dlg.im>
|
Copyright (c) 2017-present, dialog LLC <info@dlg.im>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<b>Armen101 / AudioRecordView</b>
|
|
||||||
<br/>
|
|
||||||
Copyright 2019 Armen Gevorgyan
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<pre>
|
<pre>
|
||||||
Apache License
|
Apache License
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) }
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Add table
Reference in a new issue