Adds proper handling of audio seek bar

This commit is contained in:
ericdecanini 2022-04-04 16:17:41 +01:00
parent 34dcd70a64
commit d0155c9890
9 changed files with 99 additions and 31 deletions

View file

@ -105,7 +105,6 @@ import im.vector.app.core.utils.createJSonViewerStyleProvider
import im.vector.app.core.utils.createUIHandler
import im.vector.app.core.utils.isValidUrl
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.core.utils.openLocation
import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.core.utils.registerForPermissionsResult
@ -2080,6 +2079,10 @@ class TimelineFragment @Inject constructor(
messageComposerViewModel.handle(MessageComposerAction.VoiceWaveformMovedTo(eventId, duration, percentage))
}
override fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float) {
messageComposerViewModel.handle(MessageComposerAction.AudioSeekBarMovedTo(eventId, duration, percentage))
}
private fun onShareActionClicked(action: EventSharedAction.Share) {
when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body)

View file

@ -40,12 +40,13 @@ import javax.inject.Inject
/**
* Helper class to record audio for voice messages.
*/
class VoiceMessageHelper @Inject constructor(
class AudioMessageHelper @Inject constructor(
private val context: Context,
private val playbackTracker: AudioMessagePlaybackTracker,
voiceRecorderProvider: VoiceRecorderProvider
) {
private var mediaPlayer: MediaPlayer? = null
private var currentPlayingId: String? = null
private var voiceRecorder: VoiceRecorder = voiceRecorderProvider.provideVoiceRecorder()
private val amplitudeList = mutableListOf<Int>()
@ -136,6 +137,7 @@ class VoiceMessageHelper @Inject constructor(
mediaPlayer?.stop()
stopPlaybackTicker()
stopRecordingAmplitudes()
currentPlayingId = null
if (playbackState is AudioMessagePlaybackTracker.Listener.State.Playing) {
playbackTracker.pausePlayback(id)
} else {
@ -163,6 +165,7 @@ class VoiceMessageHelper @Inject constructor(
seekTo(currentPlaybackTime)
}
}
currentPlayingId = id
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
throw VoiceFailure.UnableToPlay(failure)
@ -174,14 +177,21 @@ class VoiceMessageHelper @Inject constructor(
playbackTracker.pausePlayback(AudioMessagePlaybackTracker.RECORDING_ID)
mediaPlayer?.stop()
stopPlaybackTicker()
currentPlayingId = null
}
fun movePlaybackTo(id: String, percentage: Float, totalDuration: Int) {
val toMillisecond = (totalDuration * percentage).toInt()
playbackTracker.updateCurrentPlaybackTime(id, toMillisecond, percentage)
playbackTracker.pauseAllPlaybacks()
stopPlayback()
playbackTracker.pausePlayback(id)
if (currentPlayingId == id) {
mediaPlayer?.seekTo(toMillisecond)
playbackTracker.updatePlayingAtPlaybackTime(id, toMillisecond, percentage)
} else {
mediaPlayer?.pause()
playbackTracker.updatePausedAtPlaybackTime(id, toMillisecond, percentage)
stopPlaybackTicker()
}
}
private fun startRecordingAmplitudes() {
@ -233,7 +243,7 @@ class VoiceMessageHelper @Inject constructor(
val currentPosition = mediaPlayer?.currentPosition ?: 0
val totalDuration = mediaPlayer?.duration ?: 0
val percentage = currentPosition.toFloat() / totalDuration
playbackTracker.updateCurrentPlaybackTime(id, currentPosition, percentage)
playbackTracker.updatePlayingAtPlaybackTime(id, currentPosition, percentage)
} else {
playbackTracker.stopPlayback(id)
stopPlaybackTicker()

View file

@ -42,4 +42,5 @@ sealed class MessageComposerAction : VectorViewModelAction {
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()
data class AudioSeekBarMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
}

View file

@ -73,7 +73,7 @@ class MessageComposerViewModel @AssistedInject constructor(
private val vectorPreferences: VectorPreferences,
private val commandParser: CommandParser,
private val rainbowGenerator: RainbowGenerator,
private val voiceMessageHelper: VoiceMessageHelper,
private val audioMessageHelper: AudioMessageHelper,
private val analyticsTracker: AnalyticsTracker,
private val voicePlayerHelper: VoicePlayerHelper
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
@ -90,7 +90,6 @@ class MessageComposerViewModel @AssistedInject constructor(
}
override fun handle(action: MessageComposerAction) {
Timber.v("Handle action: $action")
when (action) {
is MessageComposerAction.EnterEditMode -> handleEnterEditMode(action)
is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
@ -110,6 +109,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText)
is MessageComposerAction.VoiceWaveformTouchedUp -> handleVoiceWaveformTouchedUp(action)
is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action)
is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action)
}
}
@ -811,18 +811,18 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleStartRecordingVoiceMessage() {
try {
voiceMessageHelper.startRecording(room.roomId)
audioMessageHelper.startRecording(room.roomId)
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
}
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) {
voiceMessageHelper.stopPlayback()
audioMessageHelper.stopPlayback()
if (isCancelled) {
voiceMessageHelper.deleteRecording()
audioMessageHelper.deleteRecording()
} else {
voiceMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
audioMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
if (audioType.duration > 1000) {
room.sendMedia(
attachment = audioType.toContentAttachmentData(isVoiceMessage = true),
@ -830,7 +830,7 @@ class MessageComposerViewModel @AssistedInject constructor(
roomIds = emptySet(),
rootThreadEventId = rootThreadEventId)
} else {
voiceMessageHelper.deleteRecording()
audioMessageHelper.deleteRecording()
}
}
}
@ -845,7 +845,7 @@ class MessageComposerViewModel @AssistedInject constructor(
// Conversion can fail, fallback to the original file in this case and let the player fail for us
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
// Play can fail
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
audioMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
@ -853,34 +853,38 @@ class MessageComposerViewModel @AssistedInject constructor(
}
private fun handlePlayOrPauseRecordingPlayback() {
voiceMessageHelper.startOrPauseRecordingPlayback()
audioMessageHelper.startOrPauseRecordingPlayback()
}
private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
voiceMessageHelper.clearTracker()
voiceMessageHelper.stopAllVoiceActions(deleteRecord)
audioMessageHelper.clearTracker()
audioMessageHelper.stopAllVoiceActions(deleteRecord)
}
private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) {
voiceMessageHelper.initializeRecorder(attachmentData)
audioMessageHelper.initializeRecorder(attachmentData)
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) }
}
private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording()
audioMessageHelper.pauseRecording()
}
private fun handleVoiceWaveformTouchedUp(action: MessageComposerAction.VoiceWaveformTouchedUp) {
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleVoiceWaveformMovedTo(action: MessageComposerAction.VoiceWaveformMovedTo) {
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleAudioSeekBarMovedTo(action: MessageComposerAction.AudioSeekBarMovedTo) {
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleEntersBackground(composerText: String) {
// Always stop all voice actions. It may be playing in timeline or active recording
val playingAudioContent = voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)
val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false)
val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording }
if (isVoiceRecording) {

View file

@ -148,6 +148,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float)
fun onVoiceWaveformMovedTo(eventId: String, duration: Int, percentage: Float)
fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float)
fun onAddMoreReaction(event: TimelineEvent)
}

View file

@ -341,6 +341,7 @@ class MessageItemFactory @Inject constructor(
): MessageAudioItem {
val fileUrl = getAudioFileUrl(messageContent, informationData)
val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
val duration = messageContent.audioInfo?.duration ?: 0
return MessageAudioItem_()
.attributes(attributes)
@ -349,6 +350,8 @@ class MessageItemFactory @Inject constructor(
.playbackControlButtonClickListener(playbackControlButtonClickListener)
.audioMessagePlaybackTracker(audioMessagePlaybackTracker)
.isLocalFile(localFilesHelper.isLocalFile(fileUrl))
.fileSize(messageContent.audioInfo?.size ?: 0L)
.onSeek { params.callback?.onAudioSeekBarMovedTo(informationData.eventId, duration, it) }
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)

View file

@ -104,10 +104,14 @@ class AudioMessagePlaybackTracker @Inject constructor() {
setState(id, Listener.State.Idle)
}
fun updateCurrentPlaybackTime(id: String, time: Int, percentage: Float) {
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Playing(time, percentage))
}
fun updatePausedAtPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Paused(time, percentage))
}
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
setState(id, Listener.State.Recording(amplitudeList))
}

View file

@ -22,6 +22,7 @@ import android.graphics.Paint
import android.text.format.DateUtils
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
@ -29,6 +30,7 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@ -47,10 +49,16 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
@EpoxyAttribute
var duration: Int = 0
@EpoxyAttribute
var fileSize: Long = 0
@EpoxyAttribute
@JvmField
var isLocalFile = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onSeek: ((percentage: Float) -> Unit)? = null
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@ -63,12 +71,15 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
@EpoxyAttribute
lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker
private var isUserSeeking = false
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.rootLayout, null)
bindFilenameViewAttributes(holder)
bindViewAttributes(holder)
bindUploadState(holder)
applyLayoutTint(holder)
bindSeekBar(holder)
holder.audioPlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
renderStateBasedOnAudioPlayback(holder)
}
@ -93,10 +104,30 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
holder.mainLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
}
private fun bindFilenameViewAttributes(holder: Holder) {
private fun bindViewAttributes(holder: Holder) {
holder.filenameView.text = filename
holder.filenameView.onClick(attributes.itemClickListener)
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
holder.audioPlaybackDuration.text = formatPlaybackTime(duration)
holder.fileSize.text = TextUtils.formatFileSize(holder.rootLayout.context, fileSize, true)
}
private fun bindSeekBar(holder: Holder) {
holder.audioSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
holder.audioPlaybackTime.text = formatPlaybackTime(
(duration * (progress.toFloat() / 100)).toInt()
)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
isUserSeeking = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
isUserSeeking = false
val percentage = seekBar.progress.toFloat() / 100
onSeek?.invoke(percentage)
}
})
}
private fun renderStateBasedOnAudioPlayback(holder: Holder) {
@ -117,13 +148,18 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
holder.audioPlaybackTime.text = formatPlaybackTime(duration)
holder.audioSeekBar.progress = 0
}
private fun renderPlayingState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Playing) {
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_pause_audio_message, filename)
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
if (!isUserSeeking) {
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
}
}
private fun renderPausedState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Paused) {
@ -131,6 +167,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
}
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong())
@ -151,6 +188,9 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
val audioPlaybackControlButton by bind<ImageButton>(R.id.audioPlaybackControlButton)
val audioPlaybackTime by bind<TextView>(R.id.audioPlaybackTime)
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
val fileSize by bind<TextView>(R.id.fileSize)
val audioPlaybackDuration by bind<TextView>(R.id.audioPlaybackDuration)
val audioSeekBar by bind<SeekBar>(R.id.audioSeekBar)
}
companion object {

View file

@ -45,10 +45,11 @@
tools:text="Filename.mp3" />
<TextView
android:id="@+id/audioPlaybackTime"
android:id="@+id/audioPlaybackDuration"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="?vctr_content_secondary"
app:layout_constraintStart_toStartOf="@id/messageFilenameView"
app:layout_constraintTop_toBottomOf="@id/messageFilenameView"
@ -61,8 +62,8 @@
android:layout_height="wrap_content"
android:textColor="?vctr_content_secondary"
android:layout_marginStart="4dp"
app:layout_constraintStart_toEndOf="@id/audioPlaybackTime"
app:layout_constraintBottom_toBottomOf="@id/audioPlaybackTime"
app:layout_constraintStart_toEndOf="@id/audioPlaybackDuration"
app:layout_constraintBottom_toBottomOf="@id/audioPlaybackDuration"
tools:text="(2MB)" />
<SeekBar
@ -74,13 +75,13 @@
android:progressDrawable="@drawable/bg_seek_bar"
android:thumbTint="?vctr_content_tertiary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/audioPlaybackDuration"
app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime"
app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton"
app:layout_constraintBottom_toBottomOf="parent"
tools:progress="40" />
<TextView
android:id="@+id/audioPlaybackDuration"
android:id="@+id/audioPlaybackTime"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"