mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 18:35:40 +03:00
lifting current recording state out of the view
This commit is contained in:
parent
f2690552a2
commit
40d762c37d
4 changed files with 49 additions and 600 deletions
|
@ -506,7 +506,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
|
||||
private fun onCannotRecord() {
|
||||
// Update the UI, cancel the animation
|
||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||
}
|
||||
|
||||
private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) {
|
||||
|
@ -698,12 +698,16 @@ class RoomDetailFragment @Inject constructor(
|
|||
|
||||
private var currentUiState: RecordingUiState = RecordingUiState.None
|
||||
|
||||
init {
|
||||
display(currentUiState)
|
||||
}
|
||||
|
||||
override fun onVoiceRecordingStarted() {
|
||||
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
|
||||
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
|
||||
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
|
||||
vibrate(requireContext())
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.Started)
|
||||
display(RecordingUiState.Started)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -721,27 +725,39 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onRecordingStopped() {
|
||||
if (currentUiState != RecordingUiState.Locked && currentUiState != RecordingUiState.None) {
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||
if (currentUiState != RecordingUiState.Locked) {
|
||||
display(RecordingUiState.None)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUiStateChanged(state: RecordingUiState) {
|
||||
currentUiState = state
|
||||
views.voiceMessageRecorderView.display(state)
|
||||
display(state)
|
||||
}
|
||||
|
||||
override fun sendVoiceMessage() {
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||
display(RecordingUiState.None)
|
||||
}
|
||||
|
||||
override fun deleteVoiceMessage() {
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||
display(RecordingUiState.Cancelled)
|
||||
}
|
||||
|
||||
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.
|
||||
roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions(deleteRecord = false))
|
||||
views.voiceMessageRecorderView.initVoiceRecordingViews()
|
||||
views.voiceMessageRecorderView.display(RecordingUiState.None)
|
||||
}
|
||||
|
||||
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 deleteVoiceMessage()
|
||||
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.
|
||||
@Suppress("UNNECESSARY_LATEINIT")
|
||||
private lateinit var voiceMessageViews: VoiceMessageViews
|
||||
lateinit var callback: Callback
|
||||
|
||||
var callback: Callback? = null
|
||||
|
||||
private var currentUiState: RecordingUiState = RecordingUiState.None
|
||||
private var recordingTicker: CountUpTimer? = null
|
||||
|
||||
init {
|
||||
|
@ -68,7 +68,6 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
|||
ViewVoiceMessageRecorderBinding.bind(this),
|
||||
dimensionConverter
|
||||
)
|
||||
initVoiceRecordingViews()
|
||||
initListeners()
|
||||
}
|
||||
|
||||
|
@ -80,65 +79,49 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
|||
voiceMessageViews.renderVisibilityChanged(parentChanged, visibility)
|
||||
}
|
||||
|
||||
fun initVoiceRecordingViews() {
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.initViews(onVoiceRecordingEnded = {})
|
||||
}
|
||||
|
||||
private fun initListeners() {
|
||||
voiceMessageViews.start(object : VoiceMessageViews.Actions {
|
||||
override fun onRequestRecording() {
|
||||
callback?.onVoiceRecordingStarted()
|
||||
callback.onVoiceRecordingStarted()
|
||||
}
|
||||
|
||||
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) {
|
||||
updater(currentUiState).also { newState ->
|
||||
when (newState) {
|
||||
is DraggingState -> display(newState)
|
||||
else -> {
|
||||
if (newState != currentUiState) {
|
||||
callback?.onUiStateChanged(newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
updater(callback.currentState()).also { newState ->
|
||||
callback.onUiStateChanged(newState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMessage() {
|
||||
callback?.sendVoiceMessage()
|
||||
callback.sendVoiceMessage()
|
||||
}
|
||||
|
||||
override fun delete() {
|
||||
// this was previously marked as cancelled true
|
||||
callback?.deleteVoiceMessage()
|
||||
callback.deleteVoiceMessage()
|
||||
}
|
||||
|
||||
override fun waveformClicked() {
|
||||
display(RecordingUiState.Playback)
|
||||
callback.recordingWaveformClicked()
|
||||
}
|
||||
|
||||
override fun onVoicePlaybackButtonClicked() {
|
||||
callback?.onVoicePlaybackButtonClicked()
|
||||
callback.onVoicePlaybackButtonClicked()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun display(recordingState: RecordingUiState) {
|
||||
if (recordingState == this.currentUiState) return
|
||||
|
||||
val previousState = this.currentUiState
|
||||
this.currentUiState = recordingState
|
||||
when (recordingState) {
|
||||
RecordingUiState.None -> {
|
||||
val isCancelled = previousState == RecordingUiState.Cancelled
|
||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = isCancelled) { callback?.onVoiceRecordingEnded(it) }
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.initViews()
|
||||
callback.onVoiceRecordingEnded(false)
|
||||
}
|
||||
RecordingUiState.Started -> {
|
||||
startRecordingTicker()
|
||||
|
@ -146,19 +129,20 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
|||
voiceMessageViews.showRecordingViews()
|
||||
}
|
||||
RecordingUiState.Cancelled -> {
|
||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback?.onVoiceRecordingEnded(it) }
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.hideRecordingViews(recordingState, isCancelled = true) { callback.onVoiceRecordingEnded(it) }
|
||||
vibrate(context)
|
||||
}
|
||||
RecordingUiState.Locked -> {
|
||||
voiceMessageViews.renderLocked()
|
||||
postDelayed({
|
||||
voiceMessageViews.showRecordingLockedViews(recordingState) { callback?.onVoiceRecordingEnded(it) }
|
||||
voiceMessageViews.showRecordingLockedViews(recordingState) { callback.onVoiceRecordingEnded(it) }
|
||||
}, 500)
|
||||
}
|
||||
RecordingUiState.Playback -> {
|
||||
stopRecordingTicker()
|
||||
voiceMessageViews.showPlaybackViews()
|
||||
callback?.onVoiceRecordingPlaybackModeOn()
|
||||
callback.onVoiceRecordingPlaybackModeOn()
|
||||
}
|
||||
is DraggingState -> when (recordingState) {
|
||||
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(recordingState.distanceX)
|
||||
|
@ -181,11 +165,11 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
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
|
||||
if (timeDiffToRecordingLimit <= 0) {
|
||||
post {
|
||||
callback?.onRecordingLimitReached()
|
||||
callback.onRecordingLimitReached()
|
||||
}
|
||||
} else if (timeDiffToRecordingLimit in 10_000..10_999) {
|
||||
post {
|
||||
|
@ -203,7 +187,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
|
|||
/**
|
||||
* 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) {
|
||||
when (state) {
|
||||
|
|
|
@ -282,8 +282,8 @@ class VoiceMessageViews(
|
|||
views.voicePlaybackWaveform.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
|
||||
fun initViews(onVoiceRecordingEnded: (Boolean) -> Unit) {
|
||||
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded)
|
||||
fun initViews() {
|
||||
hideRecordingViews(RecordingUiState.None, null, onVoiceRecordingEnded = {})
|
||||
views.voiceMessageMicButton.isVisible = true
|
||||
views.voiceMessageSendButton.isVisible = false
|
||||
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.recreate() }
|
||||
|
|
Loading…
Reference in a new issue