lifting current recording state out of the view
This commit is contained in:
parent
f2690552a2
commit
40d762c37d
|
@ -506,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
|
||||||
|
|
||||||
private fun onCannotRecord() {
|
private fun onCannotRecord() {
|
||||||
// Update the UI, cancel the animation
|
// Update the UI, cancel the animation
|
||||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
||||||
|
@ -698,12 +698,16 @@ class RoomDetailFragment @Inject constructor(
|
||||||
|
|
||||||
private var currentUiState: RecordingUiState = RecordingUiState.None
|
private var currentUiState: RecordingUiState = RecordingUiState.None
|
||||||
|
|
||||||
|
init {
|
||||||
|
display(currentUiState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVoiceRecordingStarted() {
|
override fun onVoiceRecordingStarted() {
|
||||||
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
||||||
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
||||||
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
|
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
|
||||||
vibrate(requireContext())
|
vibrate(requireContext())
|
||||||
views.voiceMessageRecorderView.display(RecordingUiState.Started)
|
display(RecordingUiState.Started)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -721,27 +725,39 @@ class RoomDetailFragment @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRecordingStopped() {
|
override fun onRecordingStopped() {
|
||||||
if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) {
|
if (currentUiState != RecordingUiState.Locked) {
|
||||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
display(RecordingUiState.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUiStateChanged(state: RecordingUiState) {
|
override fun onUiStateChanged(state: RecordingUiState) {
|
||||||
currentUiState = state
|
display(state)
|
||||||
views.voiceMessageRecorderView.display(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendVoiceMessage() {
|
override fun sendVoiceMessage() {
|
||||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
display(RecordingUiState.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteVoiceMessage() {
|
override fun deleteVoiceMessage() {
|
||||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
display(RecordingUiState.Cancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRecordingLimitReached() {
|
override fun onRecordingLimitReached() {
|
||||||
views.voiceMessageRecorderView.display(RecordingUiState.Playback)
|
display(RecordingUiState.Playback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun recordingWaveformClicked() {
|
||||||
|
display(RecordingUiState.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun display(state: RecordingUiState) {
|
||||||
|
if (currentUiState != state) {
|
||||||
|
views.voiceMessageRecorderView.display(state)
|
||||||
|
}
|
||||||
|
currentUiState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentState() = currentUiState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1132,7 +1148,7 @@ class RoomDetailFragment @Inject constructor(
|
||||||
|
|
||||||
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
|
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
|
||||||
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
|
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
|
||||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
||||||
|
|
|
@ -1,551 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 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.home.room.detail.composer
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.text.format.DateUtils
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
|
||||||
import androidx.core.view.isInvisible
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import im.vector.app.BuildConfig
|
|
||||||
import im.vector.app.R
|
|
||||||
import im.vector.app.core.extensions.setAttributeBackground
|
|
||||||
import im.vector.app.core.extensions.setAttributeTintedBackground
|
|
||||||
import im.vector.app.core.extensions.setAttributeTintedImageResource
|
|
||||||
import im.vector.app.core.hardware.vibrate
|
|
||||||
import im.vector.app.core.utils.CountUpTimer
|
|
||||||
import im.vector.app.core.utils.DimensionConverter
|
|
||||||
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
|
|
||||||
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
|
|
||||||
import org.matrix.android.sdk.api.extensions.orFalse
|
|
||||||
import timber.log.Timber
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.floor
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates the voice message recording view and animations.
|
|
||||||
*/
|
|
||||||
class VoiceMessageRecorderView : ConstraintLayout, VoiceMessagePlaybackTracker.Listener {
|
|
||||||
|
|
||||||
interface Callback {
|
|
||||||
// Return true if the recording is started
|
|
||||||
fun onVoiceRecordingStarted(): Boolean
|
|
||||||
fun onVoiceRecordingEnded(isCancelled: Boolean)
|
|
||||||
fun onVoiceRecordingPlaybackModeOn()
|
|
||||||
fun onVoicePlaybackButtonClicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var views: ViewVoiceMessageRecorderBinding
|
|
||||||
|
|
||||||
var callback: Callback? = null
|
|
||||||
var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
value?.track(VoiceMessagePlaybackTracker.RECORDING_ID, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recordingState: RecordingState = RecordingState.NONE
|
|
||||||
|
|
||||||
private var firstX: Float = 0f
|
|
||||||
private var firstY: Float = 0f
|
|
||||||
private var lastX: Float = 0f
|
|
||||||
private var lastY: Float = 0f
|
|
||||||
private var lastDistanceX: Float = 0f
|
|
||||||
private var lastDistanceY: Float = 0f
|
|
||||||
|
|
||||||
private var recordingTicker: CountUpTimer? = null
|
|
||||||
|
|
||||||
private val dimensionConverter = DimensionConverter(context.resources)
|
|
||||||
private val minimumMove = dimensionConverter.dpToPx(16)
|
|
||||||
private val distanceToLock = dimensionConverter.dpToPx(48).toFloat()
|
|
||||||
private val distanceToCancel = dimensionConverter.dpToPx(120).toFloat()
|
|
||||||
private val rtlXMultiplier = context.resources.getInteger(R.integer.rtl_x_multiplier)
|
|
||||||
|
|
||||||
// Don't convert to primary constructor.
|
|
||||||
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
|
|
||||||
@JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = 0
|
|
||||||
) : super(context, attrs, defStyleAttr) {
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
inflate(context, R.layout.view_voice_message_recorder, this)
|
|
||||||
views = ViewVoiceMessageRecorderBinding.bind(this)
|
|
||||||
|
|
||||||
initVoiceRecordingViews()
|
|
||||||
initListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
|
||||||
super.onVisibilityChanged(changedView, visibility)
|
|
||||||
// onVisibilityChanged is called by constructor on api 21 and 22.
|
|
||||||
if (!this::views.isInitialized) return
|
|
||||||
|
|
||||||
if (changedView == this && visibility == VISIBLE) {
|
|
||||||
views.voiceMessageMicButton.contentDescription = context.getString(R.string.a11y_start_voice_message)
|
|
||||||
} else {
|
|
||||||
views.voiceMessageMicButton.contentDescription = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initVoiceRecordingViews() {
|
|
||||||
recordingState = RecordingState.NONE
|
|
||||||
|
|
||||||
hideRecordingViews(null)
|
|
||||||
stopRecordingTicker()
|
|
||||||
|
|
||||||
views.voiceMessageMicButton.isVisible = true
|
|
||||||
views.voiceMessageSendButton.isVisible = false
|
|
||||||
|
|
||||||
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initListeners() {
|
|
||||||
views.voiceMessageSendButton.setOnClickListener {
|
|
||||||
stopRecordingTicker()
|
|
||||||
hideRecordingViews(isCancelled = false)
|
|
||||||
views.voiceMessageSendButton.isVisible = false
|
|
||||||
recordingState = RecordingState.NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
views.voiceMessageDeletePlayback.setOnClickListener {
|
|
||||||
stopRecordingTicker()
|
|
||||||
hideRecordingViews(isCancelled = true)
|
|
||||||
views.voiceMessageSendButton.isVisible = false
|
|
||||||
recordingState = RecordingState.NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
views.voicePlaybackWaveform.setOnClickListener {
|
|
||||||
if (recordingState != RecordingState.PLAYBACK) {
|
|
||||||
recordingState = RecordingState.PLAYBACK
|
|
||||||
showPlaybackViews()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
views.voicePlaybackControlButton.setOnClickListener {
|
|
||||||
callback?.onVoicePlaybackButtonClicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
views.voiceMessageMicButton.setOnTouchListener { _, event ->
|
|
||||||
when (event.action) {
|
|
||||||
MotionEvent.ACTION_DOWN -> {
|
|
||||||
handleMicActionDown(event)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_UP -> {
|
|
||||||
handleMicActionUp()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_MOVE -> {
|
|
||||||
if (recordingState == RecordingState.CANCELLED) return@setOnTouchListener false
|
|
||||||
handleMicActionMove(event)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMicActionDown(event: MotionEvent) {
|
|
||||||
val recordingStarted = callback?.onVoiceRecordingStarted().orFalse()
|
|
||||||
if (recordingStarted) {
|
|
||||||
startRecordingTicker()
|
|
||||||
renderToast(context.getString(R.string.voice_message_release_to_send_toast))
|
|
||||||
recordingState = RecordingState.STARTED
|
|
||||||
showRecordingViews()
|
|
||||||
|
|
||||||
firstX = event.rawX
|
|
||||||
firstY = event.rawY
|
|
||||||
lastX = firstX
|
|
||||||
lastY = firstY
|
|
||||||
lastDistanceX = 0F
|
|
||||||
lastDistanceY = 0F
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMicActionUp() {
|
|
||||||
if (recordingState != RecordingState.LOCKED && recordingState != RecordingState.NONE) {
|
|
||||||
stopRecordingTicker()
|
|
||||||
val isCancelled = recordingState == RecordingState.NONE || recordingState == RecordingState.CANCELLED
|
|
||||||
recordingState = RecordingState.NONE
|
|
||||||
hideRecordingViews(isCancelled = isCancelled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMicActionMove(event: MotionEvent) {
|
|
||||||
val currentX = event.rawX
|
|
||||||
val currentY = event.rawY
|
|
||||||
|
|
||||||
val distanceX = abs(firstX - currentX)
|
|
||||||
val distanceY = abs(firstY - currentY)
|
|
||||||
|
|
||||||
val isRecordingStateChanged = updateRecordingState(currentX, currentY, distanceX, distanceY)
|
|
||||||
|
|
||||||
when (recordingState) {
|
|
||||||
RecordingState.CANCELLING -> {
|
|
||||||
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
|
|
||||||
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
|
|
||||||
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
|
|
||||||
val reducedAlpha = (1 - translationAmount / distanceToCancel / 1.5).toFloat()
|
|
||||||
views.voiceMessageSlideToCancel.alpha = reducedAlpha
|
|
||||||
views.voiceMessageTimerIndicator.alpha = reducedAlpha
|
|
||||||
views.voiceMessageTimer.alpha = reducedAlpha
|
|
||||||
views.voiceMessageLockBackground.isVisible = false
|
|
||||||
views.voiceMessageLockImage.isVisible = false
|
|
||||||
views.voiceMessageLockArrow.isVisible = false
|
|
||||||
// Reset Y translations
|
|
||||||
views.voiceMessageMicButton.translationY = 0F
|
|
||||||
views.voiceMessageLockArrow.translationY = 0F
|
|
||||||
}
|
|
||||||
RecordingState.LOCKING -> {
|
|
||||||
views.voiceMessageLockImage.setAttributeTintedImageResource(R.drawable.ic_voice_message_locked, R.attr.colorPrimary)
|
|
||||||
val translationAmount = -distanceY.coerceIn(0F, distanceToLock)
|
|
||||||
views.voiceMessageMicButton.translationY = translationAmount
|
|
||||||
views.voiceMessageLockArrow.translationY = translationAmount
|
|
||||||
views.voiceMessageLockArrow.alpha = 1 - (-translationAmount / distanceToLock)
|
|
||||||
// Reset X translations
|
|
||||||
views.voiceMessageMicButton.translationX = 0F
|
|
||||||
views.voiceMessageSlideToCancel.translationX = 0F
|
|
||||||
}
|
|
||||||
RecordingState.CANCELLED -> {
|
|
||||||
hideRecordingViews(isCancelled = true)
|
|
||||||
vibrate(context)
|
|
||||||
}
|
|
||||||
RecordingState.LOCKED -> {
|
|
||||||
if (isRecordingStateChanged) { // Do not update views if it was already in locked state.
|
|
||||||
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_locked)
|
|
||||||
views.voiceMessageLockImage.postDelayed({
|
|
||||||
showRecordingLockedViews()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RecordingState.STARTED -> {
|
|
||||||
showRecordingViews()
|
|
||||||
val translationAmount = distanceX.coerceAtMost(distanceToCancel)
|
|
||||||
views.voiceMessageMicButton.translationX = -translationAmount * rtlXMultiplier
|
|
||||||
views.voiceMessageSlideToCancel.translationX = -translationAmount / 2 * rtlXMultiplier
|
|
||||||
}
|
|
||||||
RecordingState.NONE -> Timber.d("VoiceMessageRecorderView shouldn't be in NONE state while moving.")
|
|
||||||
RecordingState.PLAYBACK -> Timber.d("VoiceMessageRecorderView shouldn't be in PLAYBACK state while moving.")
|
|
||||||
}
|
|
||||||
lastX = currentX
|
|
||||||
lastY = currentY
|
|
||||||
lastDistanceX = distanceX
|
|
||||||
lastDistanceY = distanceY
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRecordingState(currentX: Float, currentY: Float, distanceX: Float, distanceY: Float): Boolean {
|
|
||||||
val previousRecordingState = recordingState
|
|
||||||
if (recordingState == RecordingState.STARTED) {
|
|
||||||
// Determine if cancelling or locking for the first move action.
|
|
||||||
if (((currentX < firstX && rtlXMultiplier == 1) || (currentX > firstX && rtlXMultiplier == -1)) &&
|
|
||||||
distanceX > distanceY && distanceX > lastDistanceX) {
|
|
||||||
recordingState = RecordingState.CANCELLING
|
|
||||||
} else if (currentY < firstY && distanceY > distanceX && distanceY > lastDistanceY) {
|
|
||||||
recordingState = RecordingState.LOCKING
|
|
||||||
}
|
|
||||||
} else if (recordingState == RecordingState.CANCELLING) {
|
|
||||||
// Check if cancelling conditions met, also check if it should be initial state
|
|
||||||
if (distanceX < minimumMove && distanceX < lastDistanceX) {
|
|
||||||
recordingState = RecordingState.STARTED
|
|
||||||
} else if (shouldCancelRecording(distanceX)) {
|
|
||||||
recordingState = RecordingState.CANCELLED
|
|
||||||
}
|
|
||||||
} else if (recordingState == RecordingState.LOCKING) {
|
|
||||||
// Check if locking conditions met, also check if it should be initial state
|
|
||||||
if (distanceY < minimumMove && distanceY < lastDistanceY) {
|
|
||||||
recordingState = RecordingState.STARTED
|
|
||||||
} else if (shouldLockRecording(distanceY)) {
|
|
||||||
recordingState = RecordingState.LOCKED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return previousRecordingState != recordingState
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldCancelRecording(distanceX: Float): Boolean {
|
|
||||||
return distanceX >= distanceToCancel
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldLockRecording(distanceY: Float): Boolean {
|
|
||||||
return distanceY >= distanceToLock
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startRecordingTicker() {
|
|
||||||
recordingTicker?.stop()
|
|
||||||
recordingTicker = CountUpTimer().apply {
|
|
||||||
tickListener = object : CountUpTimer.TickListener {
|
|
||||||
override fun onTick(milliseconds: Long) {
|
|
||||||
onRecordingTick(milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resume()
|
|
||||||
}
|
|
||||||
onRecordingTick(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onRecordingTick(milliseconds: Long) {
|
|
||||||
renderRecordingTimer(milliseconds / 1_000)
|
|
||||||
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
|
||||||
if (timeDiffToRecordingLimit <= 0) {
|
|
||||||
views.voiceMessageRecordingLayout.post {
|
|
||||||
recordingState = RecordingState.PLAYBACK
|
|
||||||
showPlaybackViews()
|
|
||||||
stopRecordingTicker()
|
|
||||||
}
|
|
||||||
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
|
||||||
views.voiceMessageRecordingLayout.post {
|
|
||||||
renderToast(context.getString(R.string.voice_message_n_seconds_warning_toast, floor(timeDiffToRecordingLimit / 1000f).toInt()))
|
|
||||||
vibrate(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderToast(message: String) {
|
|
||||||
views.voiceMessageToast.removeCallbacks(hideToastRunnable)
|
|
||||||
views.voiceMessageToast.text = message
|
|
||||||
views.voiceMessageToast.isVisible = true
|
|
||||||
views.voiceMessageToast.postDelayed(hideToastRunnable, 2_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideToast() {
|
|
||||||
views.voiceMessageToast.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private val hideToastRunnable = Runnable {
|
|
||||||
views.voiceMessageToast.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderRecordingTimer(recordingTimeMillis: Long) {
|
|
||||||
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
|
|
||||||
if (recordingState == RecordingState.LOCKED) {
|
|
||||||
views.voicePlaybackTime.apply {
|
|
||||||
post {
|
|
||||||
text = formattedTimerText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
views.voiceMessageTimer.post {
|
|
||||||
views.voiceMessageTimer.text = formattedTimerText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderRecordingWaveform(amplitudeList: Array<Int>) {
|
|
||||||
post {
|
|
||||||
views.voicePlaybackWaveform.apply {
|
|
||||||
amplitudeList.iterator().forEach {
|
|
||||||
update(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopRecordingTicker() {
|
|
||||||
recordingTicker?.stop()
|
|
||||||
recordingTicker = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showRecordingViews() {
|
|
||||||
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording)
|
|
||||||
views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary)
|
|
||||||
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
|
|
||||||
setMargins(0, 0, 0, 0)
|
|
||||||
}
|
|
||||||
views.voiceMessageMicButton.animate().scaleX(1.5f).scaleY(1.5f).setDuration(300).start()
|
|
||||||
|
|
||||||
views.voiceMessageLockBackground.isVisible = true
|
|
||||||
views.voiceMessageLockBackground.animate().setDuration(300).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
|
|
||||||
views.voiceMessageLockImage.isVisible = true
|
|
||||||
views.voiceMessageLockImage.setImageResource(R.drawable.ic_voice_message_unlocked)
|
|
||||||
views.voiceMessageLockImage.animate().setDuration(500).translationY(-dimensionConverter.dpToPx(180).toFloat()).start()
|
|
||||||
views.voiceMessageLockArrow.isVisible = true
|
|
||||||
views.voiceMessageLockArrow.alpha = 1f
|
|
||||||
views.voiceMessageSlideToCancel.isVisible = true
|
|
||||||
views.voiceMessageTimerIndicator.isVisible = true
|
|
||||||
views.voiceMessageTimer.isVisible = true
|
|
||||||
views.voiceMessageSlideToCancel.alpha = 1f
|
|
||||||
views.voiceMessageTimerIndicator.alpha = 1f
|
|
||||||
views.voiceMessageTimer.alpha = 1f
|
|
||||||
views.voiceMessageSendButton.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hideRecordingViews(isCancelled: Boolean?) {
|
|
||||||
// We need to animate the lock image first
|
|
||||||
if (recordingState != RecordingState.LOCKED || isCancelled.orFalse()) {
|
|
||||||
views.voiceMessageLockImage.isVisible = false
|
|
||||||
views.voiceMessageLockImage.animate().translationY(0f).start()
|
|
||||||
views.voiceMessageLockBackground.isVisible = false
|
|
||||||
views.voiceMessageLockBackground.animate().translationY(0f).start()
|
|
||||||
} else {
|
|
||||||
animateLockImageWithBackground()
|
|
||||||
}
|
|
||||||
views.voiceMessageLockArrow.isVisible = false
|
|
||||||
views.voiceMessageLockArrow.animate().translationY(0f).start()
|
|
||||||
views.voiceMessageSlideToCancel.isVisible = false
|
|
||||||
views.voiceMessageSlideToCancel.animate().translationX(0f).translationY(0f).start()
|
|
||||||
views.voiceMessagePlaybackLayout.isVisible = false
|
|
||||||
|
|
||||||
if (recordingState != RecordingState.LOCKED) {
|
|
||||||
views.voiceMessageMicButton
|
|
||||||
.animate()
|
|
||||||
.scaleX(1f)
|
|
||||||
.scaleY(1f)
|
|
||||||
.translationX(0f)
|
|
||||||
.translationY(0f)
|
|
||||||
.setDuration(150)
|
|
||||||
.withEndAction {
|
|
||||||
views.voiceMessageTimerIndicator.isVisible = false
|
|
||||||
views.voiceMessageTimer.isVisible = false
|
|
||||||
resetMicButtonUi()
|
|
||||||
isCancelled?.let {
|
|
||||||
callback?.onVoiceRecordingEnded(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.start()
|
|
||||||
} else {
|
|
||||||
views.voiceMessageTimerIndicator.isVisible = false
|
|
||||||
views.voiceMessageTimer.isVisible = false
|
|
||||||
views.voiceMessageMicButton.apply {
|
|
||||||
scaleX = 1f
|
|
||||||
scaleY = 1f
|
|
||||||
translationX = 0f
|
|
||||||
translationY = 0f
|
|
||||||
}
|
|
||||||
isCancelled?.let {
|
|
||||||
callback?.onVoiceRecordingEnded(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide toasts if user cancelled recording before the timeout of the toast.
|
|
||||||
if (recordingState == RecordingState.CANCELLED || recordingState == RecordingState.NONE) {
|
|
||||||
hideToast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetMicButtonUi() {
|
|
||||||
views.voiceMessageMicButton.isVisible = true
|
|
||||||
views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic)
|
|
||||||
views.voiceMessageMicButton.setAttributeBackground(android.R.attr.selectableItemBackgroundBorderless)
|
|
||||||
views.voiceMessageMicButton.updateLayoutParams<MarginLayoutParams> {
|
|
||||||
if (rtlXMultiplier == -1) {
|
|
||||||
// RTL
|
|
||||||
setMargins(dimensionConverter.dpToPx(12), 0, 0, dimensionConverter.dpToPx(12))
|
|
||||||
} else {
|
|
||||||
setMargins(0, 0, dimensionConverter.dpToPx(12), dimensionConverter.dpToPx(12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun animateLockImageWithBackground() {
|
|
||||||
views.voiceMessageLockBackground.updateLayoutParams {
|
|
||||||
height = dimensionConverter.dpToPx(78)
|
|
||||||
}
|
|
||||||
views.voiceMessageLockBackground.apply {
|
|
||||||
animate()
|
|
||||||
.scaleX(0f)
|
|
||||||
.scaleY(0f)
|
|
||||||
.setDuration(400L)
|
|
||||||
.withEndAction {
|
|
||||||
updateLayoutParams {
|
|
||||||
height = dimensionConverter.dpToPx(180)
|
|
||||||
}
|
|
||||||
isVisible = false
|
|
||||||
scaleX = 1f
|
|
||||||
scaleY = 1f
|
|
||||||
animate().translationY(0f).start()
|
|
||||||
}
|
|
||||||
.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock image animation
|
|
||||||
views.voiceMessageMicButton.isInvisible = true
|
|
||||||
views.voiceMessageLockImage.apply {
|
|
||||||
isVisible = true
|
|
||||||
animate()
|
|
||||||
.scaleX(0f)
|
|
||||||
.scaleY(0f)
|
|
||||||
.setDuration(400L)
|
|
||||||
.withEndAction {
|
|
||||||
isVisible = false
|
|
||||||
scaleX = 1f
|
|
||||||
scaleY = 1f
|
|
||||||
translationY = 0f
|
|
||||||
resetMicButtonUi()
|
|
||||||
}
|
|
||||||
.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showRecordingLockedViews() {
|
|
||||||
hideRecordingViews(null)
|
|
||||||
views.voiceMessagePlaybackLayout.isVisible = true
|
|
||||||
views.voiceMessagePlaybackTimerIndicator.isVisible = true
|
|
||||||
views.voicePlaybackControlButton.isVisible = false
|
|
||||||
views.voiceMessageSendButton.isVisible = true
|
|
||||||
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
|
|
||||||
renderToast(context.getString(R.string.voice_message_tap_to_stop_toast))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPlaybackViews() {
|
|
||||||
views.voiceMessagePlaybackTimerIndicator.isVisible = false
|
|
||||||
views.voicePlaybackControlButton.isVisible = true
|
|
||||||
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
|
||||||
callback?.onVoiceRecordingPlaybackModeOn()
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class RecordingState {
|
|
||||||
NONE,
|
|
||||||
STARTED,
|
|
||||||
CANCELLING,
|
|
||||||
CANCELLED,
|
|
||||||
LOCKING,
|
|
||||||
LOCKED,
|
|
||||||
PLAYBACK
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the voice message is recording or is in playback mode
|
|
||||||
*/
|
|
||||||
fun isActive() = recordingState !in listOf(RecordingState.NONE, RecordingState.CANCELLED)
|
|
||||||
|
|
||||||
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
|
||||||
when (state) {
|
|
||||||
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
|
|
||||||
renderRecordingWaveform(state.amplitudeList.toTypedArray())
|
|
||||||
}
|
|
||||||
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
|
|
||||||
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
|
|
||||||
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_pause_voice_message)
|
|
||||||
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())
|
|
||||||
views.voicePlaybackTime.text = formattedTimerText
|
|
||||||
}
|
|
||||||
is VoiceMessagePlaybackTracker.Listener.State.Paused,
|
|
||||||
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
|
|
||||||
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
|
|
||||||
views.voicePlaybackControlButton.contentDescription = context.getString(R.string.a11y_play_voice_message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -49,15 +49,15 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
fun sendVoiceMessage()
|
fun sendVoiceMessage()
|
||||||
fun deleteVoiceMessage()
|
fun deleteVoiceMessage()
|
||||||
fun onRecordingLimitReached()
|
fun onRecordingLimitReached()
|
||||||
|
fun recordingWaveformClicked()
|
||||||
|
fun currentState(): RecordingUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
|
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
|
||||||
@Suppress("UNNECESSARY_LATEINIT")
|
@Suppress("UNNECESSARY_LATEINIT")
|
||||||
private lateinit var voiceMessageViews: VoiceMessageViews
|
private lateinit var voiceMessageViews: VoiceMessageViews
|
||||||
|
lateinit var callback: Callback
|
||||||
|
|
||||||
var callback: Callback? = null
|
|
||||||
|
|
||||||
private var currentUiState: RecordingUiState = RecordingUiState.None
|
|
||||||
private var recordingTicker: CountUpTimer? = null
|
private var recordingTicker: CountUpTimer? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -68,7 +68,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
ViewVoiceMessageRecorderBinding.bind(this),
|
ViewVoiceMessageRecorderBinding.bind(this),
|
||||||
dimensionConverter
|
dimensionConverter
|
||||||
)
|
)
|
||||||
initVoiceRecordingViews()
|
|
||||||
initListeners()
|
initListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,65 +79,49 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
|
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initVoiceRecordingViews() {
|
|
||||||
stopRecordingTicker()
|
|
||||||
voiceMessageViews.initViews(onVoiceRecordingEnded = {})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initListeners() {
|
private fun initListeners() {
|
||||||
voiceMessageViews.start(object : VoiceMessageViews.Actions {
|
voiceMessageViews.start(object : VoiceMessageViews.Actions {
|
||||||
override fun onRequestRecording() {
|
override fun onRequestRecording() {
|
||||||
callback?.onVoiceRecordingStarted()
|
callback.onVoiceRecordingStarted()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRecordingStopped() {
|
override fun onRecordingStopped() {
|
||||||
callback?.onRecordingStopped()
|
callback.onRecordingStopped()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isActive() = currentUiState != RecordingUiState.Cancelled
|
override fun isActive() = callback.currentState() != RecordingUiState.Cancelled
|
||||||
|
|
||||||
override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
|
override fun updateState(updater: (RecordingUiState) -> RecordingUiState) {
|
||||||
updater(currentUiState).also { newState ->
|
updater(callback.currentState()).also { newState ->
|
||||||
when (newState) {
|
callback.onUiStateChanged(newState)
|
||||||
is DraggingState -> display(newState)
|
|
||||||
else -> {
|
|
||||||
if (newState != currentUiState) {
|
|
||||||
callback?.onUiStateChanged(newState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendMessage() {
|
override fun sendMessage() {
|
||||||
callback?.sendVoiceMessage()
|
callback.sendVoiceMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete() {
|
override fun delete() {
|
||||||
// this was previously marked as cancelled true
|
// this was previously marked as cancelled true
|
||||||
callback?.deleteVoiceMessage()
|
callback.deleteVoiceMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun waveformClicked() {
|
override fun waveformClicked() {
|
||||||
display(RecordingUiState.Playback)
|
callback.recordingWaveformClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVoicePlaybackButtonClicked() {
|
override fun onVoicePlaybackButtonClicked() {
|
||||||
callback?.onVoicePlaybackButtonClicked()
|
callback.onVoicePlaybackButtonClicked()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun display(recordingState: RecordingUiState) {
|
fun display(recordingState: RecordingUiState) {
|
||||||
if (recordingState == this.currentUiState) return
|
|
||||||
|
|
||||||
val previousState = this.currentUiState
|
|
||||||
this.currentUiState = recordingState
|
|
||||||
when (recordingState) {
|
when (recordingState) {
|
||||||
RecordingUiState.None -> {
|
RecordingUiState.None -> {
|
||||||
val isCancelled = previousState == RecordingUiState.Cancelled
|
|
||||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
|
|
||||||
stopRecordingTicker()
|
stopRecordingTicker()
|
||||||
|
voiceMessageViews.initViews()
|
||||||
|
callback.onVoiceRecordingEnded(false)
|
||||||
}
|
}
|
||||||
RecordingUiState.Started -> {
|
RecordingUiState.Started -> {
|
||||||
startRecordingTicker()
|
startRecordingTicker()
|
||||||
|
@ -146,19 +129,20 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
voiceMessageViews.showRecordingViews()
|
voiceMessageViews.showRecordingViews()
|
||||||
}
|
}
|
||||||
RecordingUiState.Cancelled -> {
|
RecordingUiState.Cancelled -> {
|
||||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
|
stopRecordingTicker()
|
||||||
|
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.onVoiceRecordingEnded(it) }
|
||||||
vibrate(context)
|
vibrate(context)
|
||||||
}
|
}
|
||||||
RecordingUiState.Locked -> {
|
RecordingUiState.Locked -> {
|
||||||
voiceMessageViews.renderLocked()
|
voiceMessageViews.renderLocked()
|
||||||
postDelayed({
|
postDelayed({
|
||||||
voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
|
voiceMessageViews.showRecordingLockedViews(recordingState) { callback.onVoiceRecordingEnded(it) }
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
RecordingUiState.Playback -> {
|
RecordingUiState.Playback -> {
|
||||||
stopRecordingTicker()
|
stopRecordingTicker()
|
||||||
voiceMessageViews.showPlaybackViews()
|
voiceMessageViews.showPlaybackViews()
|
||||||
callback?.onVoiceRecordingPlaybackModeOn()
|
callback.onVoiceRecordingPlaybackModeOn()
|
||||||
}
|
}
|
||||||
is DraggingState -> when (recordingState) {
|
is DraggingState -> when (recordingState) {
|
||||||
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
|
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
|
||||||
|
@ -181,11 +165,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRecordingTick(milliseconds: Long) {
|
private fun onRecordingTick(milliseconds: Long) {
|
||||||
voiceMessageViews.renderRecordingTimer(currentUiState, milliseconds / 1_000)
|
voiceMessageViews.renderRecordingTimer(callback.currentState(), milliseconds / 1_000)
|
||||||
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
|
||||||
if (timeDiffToRecordingLimit <= 0) {
|
if (timeDiffToRecordingLimit <= 0) {
|
||||||
post {
|
post {
|
||||||
callback?.onRecordingLimitReached()
|
callback.onRecordingLimitReached()
|
||||||
}
|
}
|
||||||
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
||||||
post {
|
post {
|
||||||
|
@ -203,7 +187,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
||||||
/**
|
/**
|
||||||
* Returns true if the voice message is recording or is in playback mode
|
* Returns true if the voice message is recording or is in playback mode
|
||||||
*/
|
*/
|
||||||
fun isActive() = currentUiState !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
|
fun isActive() = callback.currentState() !in listOf(RecordingUiState.None, RecordingUiState.Cancelled)
|
||||||
|
|
||||||
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
|
||||||
when (state) {
|
when (state) {
|
||||||
|
|
|
@ -282,8 +282,8 @@ class VoiceMessageViews(
|
||||||
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
|
fun initViews() {
|
||||||
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded)
|
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded = {})
|
||||||
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.recreate() }
|
||||||
|
|
Loading…
Reference in New Issue