mirror of
https://github.com/nextcloud/talk-android
synced 2025-03-06 14:27:24 +00:00
Issue 2790, allows for continuous voice recording on swipe up, and for previewing messages
- I did a bunch of UI logic in ChatActivity and view_message_input, mainly in setting up the recording interface - I created a custom component, MicInputCloud, under the hood it's 3 ovals, with a hole cut in the center for the icon. The ovals are at around 50% opacity, and they each have their own rotations and size changes animated. General rotation speed and colors can be overridden by the activity implementing it. - I also added a floating action button to activity_chat, to show when the voice recording is locked or not. - I can replay or pause preview voice recordings before sending or deleting - Preview voice recording is now smoother and click boxes are bigger and well defined Signed-off-by: Julius Linus <julius.linus@nextcloud.com>
This commit is contained in:
parent
a4e7a278cf
commit
f0ba16a275
@ -29,6 +29,7 @@
|
||||
package com.nextcloud.talk.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
@ -41,6 +42,8 @@ import android.database.Cursor
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaPlayer
|
||||
import android.media.MediaRecorder
|
||||
import android.net.Uri
|
||||
@ -63,15 +66,21 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.AlphaAnimation
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.widget.AbsListView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.RelativeLayout.BELOW
|
||||
import android.widget.RelativeLayout.LayoutParams
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
@ -79,6 +88,7 @@ import androidx.appcompat.view.ContextThemeWrapper
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.text.bold
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
@ -100,6 +110,7 @@ import coil.request.ImageRequest
|
||||
import coil.target.Target
|
||||
import coil.transform.CircleCropTransformation
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.nextcloud.android.common.ui.theme.utils.ColorRole
|
||||
import com.nextcloud.talk.BuildConfig
|
||||
@ -166,6 +177,7 @@ import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
|
||||
import com.nextcloud.talk.signaling.SignalingMessageReceiver
|
||||
import com.nextcloud.talk.signaling.SignalingMessageSender
|
||||
import com.nextcloud.talk.translate.ui.TranslateActivity
|
||||
import com.nextcloud.talk.ui.MicInputCloud
|
||||
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
|
||||
import com.nextcloud.talk.ui.dialog.AttachmentDialog
|
||||
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
|
||||
@ -233,6 +245,8 @@ import javax.inject.Inject
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val l = 50
|
||||
|
||||
@AutoInjector(NextcloudTalkApplication::class)
|
||||
class ChatActivity :
|
||||
BaseActivity(),
|
||||
@ -312,13 +326,22 @@ class ChatActivity :
|
||||
private lateinit var sharedText: String
|
||||
var isVoiceRecordingInProgress: Boolean = false
|
||||
var currentVoiceRecordFile: String = ""
|
||||
|
||||
var isVoiceRecordingLocked: Boolean = false
|
||||
private var isVoicePreviewPlaying: Boolean = false
|
||||
private var recorder: MediaRecorder? = null
|
||||
|
||||
private var voicePreviewMediaPlayer: MediaPlayer? = null
|
||||
private var voicePreviewObjectAnimator: ObjectAnimator? = null
|
||||
var mediaPlayer: MediaPlayer? = null
|
||||
lateinit var mediaPlayerHandler: Handler
|
||||
private var currentlyPlayedVoiceMessage: ChatMessage? = null
|
||||
|
||||
private lateinit var micInputAudioRecorder: AudioRecord
|
||||
private var micInputAudioRecordThread: Thread? = null
|
||||
private var isMicInputAudioThreadRunning: Boolean = false
|
||||
private val BUFFER_SIZE = AudioRecord.getMinBufferSize(
|
||||
8000,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
private lateinit var participantPermissions: ParticipantPermissions
|
||||
|
||||
private var videoURI: Uri? = null
|
||||
@ -461,6 +484,16 @@ class ChatActivity :
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
active = false
|
||||
stopPreviewVoicePlaying()
|
||||
if (isMicInputAudioThreadRunning) {
|
||||
stopMicInputRecordingAnimation()
|
||||
}
|
||||
if (isVoiceRecordingInProgress) {
|
||||
stopAudioRecording()
|
||||
}
|
||||
if (currentlyPlayedVoiceMessage != null) {
|
||||
stopMediaPlayer(currentlyPlayedVoiceMessage!!)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@ -593,6 +626,33 @@ class ChatActivity :
|
||||
.themeImageButton(it)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<MaterialButton>(R.id.playPauseBtn)?.let {
|
||||
viewThemeUtils.material.colorMaterialButtonText(it)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<SeekBar>(R.id.seekbar)?.let {
|
||||
viewThemeUtils.platform.themeHorizontalSeekBar(it)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<ImageView>(R.id.deleteVoiceRecording)?.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
|
||||
}
|
||||
binding.messageInputView.findViewById<ImageView>(R.id.sendVoiceRecording)?.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<ImageView>(R.id.microphoneEnabledInfo)?.let {
|
||||
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<LinearLayout>(R.id.voice_preview_container)?.let {
|
||||
viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false)
|
||||
}
|
||||
|
||||
binding.messageInputView.findViewById<MicInputCloud>(R.id.micInputCloud)?.let {
|
||||
viewThemeUtils.talk.themeMicInputCloud(it)
|
||||
}
|
||||
|
||||
cancelNotificationsForCurrentConversation()
|
||||
|
||||
chatViewModel.getRoom(conversationUser!!, roomToken)
|
||||
@ -626,6 +686,8 @@ class ChatActivity :
|
||||
|
||||
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) }
|
||||
|
||||
binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) }
|
||||
|
||||
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.popupBubbleView) }
|
||||
|
||||
binding.messageInputView.setPadding(0, 0, 0, 0)
|
||||
@ -890,10 +952,18 @@ class ChatActivity :
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun initVoiceRecordButton() {
|
||||
showMicrophoneButton(true)
|
||||
if (!isVoiceRecordingLocked) {
|
||||
showMicrophoneButton(true)
|
||||
} else if (isVoiceRecordingInProgress) {
|
||||
binding.messageInputView.playPauseBtn.visibility = View.GONE
|
||||
binding.messageInputView.seekBar.visibility = View.GONE
|
||||
} else {
|
||||
binding.messageInputView.micInputCloud.setState(MicInputCloud.ViewState.PAUSED_STATE)
|
||||
}
|
||||
|
||||
binding.messageInputView.messageInput?.doAfterTextChanged {
|
||||
if (binding.messageInputView.messageInput?.text?.isEmpty() == true) {
|
||||
isVoicePreviewPlaying = false
|
||||
binding.messageInputView.messageInput.doAfterTextChanged {
|
||||
if (binding.messageInputView.messageInput.text?.isEmpty() == true) {
|
||||
showMicrophoneButton(true)
|
||||
} else {
|
||||
showMicrophoneButton(false)
|
||||
@ -902,12 +972,103 @@ class ChatActivity :
|
||||
|
||||
var sliderInitX = 0F
|
||||
var downX = 0f
|
||||
var originY = 0f
|
||||
var deltaX = 0f
|
||||
var deltaY = 0f
|
||||
|
||||
var voiceRecordStartTime = 0L
|
||||
var voiceRecordEndTime = 0L
|
||||
var voiceRecordPauseTime = 0L
|
||||
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
binding.messageInputView.recordAudioButton?.setOnTouchListener(object : View.OnTouchListener {
|
||||
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
// this is so that the seekbar is no longer draggable
|
||||
binding.messageInputView.seekBar.setOnTouchListener(OnTouchListener { _, _ -> true })
|
||||
|
||||
binding.messageInputView.micInputCloud.setOnClickListener {
|
||||
if (isVoiceRecordingInProgress) {
|
||||
recorder?.stop()
|
||||
stopMicInputRecordingAnimation()
|
||||
voiceRecordPauseTime = binding.messageInputView.audioRecordDuration.base - SystemClock.elapsedRealtime()
|
||||
binding.messageInputView.audioRecordDuration.stop()
|
||||
binding.messageInputView.audioRecordDuration.visibility = View.GONE
|
||||
binding.messageInputView.playPauseBtn.visibility = View.VISIBLE
|
||||
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
context,
|
||||
R.drawable.ic_baseline_play_arrow_voice_message_24
|
||||
)
|
||||
binding.messageInputView.seekBar.visibility = View.VISIBLE
|
||||
binding.messageInputView.seekBar.progress = 0
|
||||
binding.messageInputView.seekBar.max = 0
|
||||
micInputCloudLayoutParams.removeRule(BELOW)
|
||||
micInputCloudLayoutParams.addRule(BELOW, R.id.voice_preview_container)
|
||||
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
|
||||
sendVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
|
||||
} else {
|
||||
restartAudio()
|
||||
startMicInputRecordingAnimation()
|
||||
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
|
||||
binding.messageInputView.audioRecordDuration.start()
|
||||
binding.messageInputView.playPauseBtn.visibility = View.GONE
|
||||
binding.messageInputView.seekBar.visibility = View.GONE
|
||||
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
|
||||
micInputCloudLayoutParams.removeRule(BELOW)
|
||||
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
sendVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
}
|
||||
|
||||
isVoiceRecordingInProgress = !isVoiceRecordingInProgress
|
||||
}
|
||||
|
||||
binding.messageInputView.deleteVoiceRecording.setOnClickListener {
|
||||
stopAndDiscardAudioRecording()
|
||||
endVoiceRecordingUI()
|
||||
stopMicInputRecordingAnimation()
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
}
|
||||
|
||||
binding.messageInputView.sendVoiceRecording.setOnClickListener {
|
||||
stopAndSendAudioRecording()
|
||||
endVoiceRecordingUI()
|
||||
stopMicInputRecordingAnimation()
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
}
|
||||
|
||||
binding.messageInputView.playPauseBtn.setOnClickListener {
|
||||
Log.d(TAG, "is voice preview playing $isVoicePreviewPlaying")
|
||||
if (isVoicePreviewPlaying) {
|
||||
Log.d(TAG, "Paused")
|
||||
pausePreviewVoicePlaying()
|
||||
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
context,
|
||||
R.drawable
|
||||
.ic_baseline_play_arrow_voice_message_24
|
||||
)
|
||||
isVoicePreviewPlaying = false
|
||||
} else {
|
||||
Log.d(TAG, "Started")
|
||||
startPreviewVoicePlaying()
|
||||
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
context,
|
||||
R.drawable
|
||||
.ic_baseline_pause_voice_message_24
|
||||
)
|
||||
isVoicePreviewPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
binding.messageInputView.recordAudioButton.setOnTouchListener(object : OnTouchListener {
|
||||
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
|
||||
v?.performClick() // ?????????
|
||||
when (event?.action) {
|
||||
@ -926,6 +1087,7 @@ class ChatActivity :
|
||||
setVoiceRecordFileName()
|
||||
startAudioRecording(currentVoiceRecordFile)
|
||||
downX = event.x
|
||||
originY = event.y
|
||||
showRecordAudioUi(true)
|
||||
}
|
||||
|
||||
@ -936,13 +1098,16 @@ class ChatActivity :
|
||||
}
|
||||
|
||||
stopAndDiscardAudioRecording()
|
||||
showRecordAudioUi(false)
|
||||
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
|
||||
endVoiceRecordingUI()
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
Log.d(TAG, "ACTION_UP. stop recording??")
|
||||
if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) {
|
||||
if (!isVoiceRecordingInProgress ||
|
||||
!isRecordAudioPermissionGranted() ||
|
||||
isVoiceRecordingLocked
|
||||
) {
|
||||
return true
|
||||
}
|
||||
showRecordAudioUi(false)
|
||||
@ -964,7 +1129,7 @@ class ChatActivity :
|
||||
stopAndSendAudioRecording()
|
||||
}
|
||||
|
||||
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
@ -977,28 +1142,41 @@ class ChatActivity :
|
||||
showRecordAudioUi(true)
|
||||
|
||||
val movedX: Float = event.x
|
||||
val movedY: Float = event.y
|
||||
deltaX = movedX - downX
|
||||
deltaY = movedY - originY
|
||||
|
||||
binding.voiceRecordingLock.translationY.let {
|
||||
if (it < VOICE_RECORD_LOCK_BUTTON_Y) {
|
||||
Log.d(TAG, "Voice Recording Locked")
|
||||
isVoiceRecordingLocked = true
|
||||
showVoiceRecordingLocked(true)
|
||||
showVoiceRecordingLockedInterface(true)
|
||||
} else if (deltaY < 0f) {
|
||||
binding.voiceRecordingLock.translationY = deltaY
|
||||
}
|
||||
}
|
||||
|
||||
// only allow slide to left
|
||||
binding.messageInputView.slideToCancelDescription?.x?.let {
|
||||
binding.messageInputView.slideToCancelDescription.x.let {
|
||||
if (sliderInitX == 0.0F) {
|
||||
sliderInitX = it
|
||||
}
|
||||
|
||||
if (it > sliderInitX) {
|
||||
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
}
|
||||
}
|
||||
|
||||
binding.messageInputView.slideToCancelDescription?.x?.let {
|
||||
binding.messageInputView.slideToCancelDescription.x.let {
|
||||
if (it < VOICE_RECORD_CANCEL_SLIDER_X) {
|
||||
Log.d(TAG, "stopping recording because slider was moved to left")
|
||||
stopAndDiscardAudioRecording()
|
||||
showRecordAudioUi(false)
|
||||
binding.messageInputView.slideToCancelDescription?.x = sliderInitX
|
||||
endVoiceRecordingUI()
|
||||
binding.messageInputView.slideToCancelDescription.x = sliderInitX
|
||||
return true
|
||||
} else {
|
||||
binding.messageInputView.slideToCancelDescription?.x = it + deltaX
|
||||
binding.messageInputView.slideToCancelDescription.x = it + deltaX
|
||||
downX = movedX
|
||||
}
|
||||
}
|
||||
@ -1010,6 +1188,161 @@ class ChatActivity :
|
||||
})
|
||||
}
|
||||
|
||||
private fun initPreviewVoiceRecording() {
|
||||
voicePreviewMediaPlayer = MediaPlayer().apply {
|
||||
Log.e(TAG, currentVoiceRecordFile)
|
||||
setDataSource(currentVoiceRecordFile)
|
||||
prepare()
|
||||
setOnPreparedListener {
|
||||
Log.d(TAG, "Julius the duration is ${it.duration}")
|
||||
binding.messageInputView.seekBar.progress = 0
|
||||
binding.messageInputView.seekBar.max = it.duration
|
||||
voicePreviewObjectAnimator = ObjectAnimator.ofInt(
|
||||
binding.messageInputView.seekBar,
|
||||
"progress",
|
||||
0,
|
||||
it.duration
|
||||
).apply {
|
||||
duration = it.duration.toLong()
|
||||
interpolator = LinearInterpolator()
|
||||
}
|
||||
voicePreviewMediaPlayer!!.start()
|
||||
voicePreviewObjectAnimator!!.start()
|
||||
}
|
||||
|
||||
setOnCompletionListener {
|
||||
stopPreviewVoicePlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPreviewVoicePlaying() {
|
||||
Log.d(TAG, "started preview voice recording")
|
||||
if (voicePreviewMediaPlayer == null) {
|
||||
initPreviewVoiceRecording()
|
||||
} else {
|
||||
voicePreviewMediaPlayer!!.start()
|
||||
voicePreviewObjectAnimator!!.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pausePreviewVoicePlaying() {
|
||||
Log.d(TAG, "paused preview voice recording")
|
||||
voicePreviewMediaPlayer!!.pause()
|
||||
voicePreviewObjectAnimator!!.pause()
|
||||
}
|
||||
|
||||
private fun stopPreviewVoicePlaying() {
|
||||
if (voicePreviewMediaPlayer != null) {
|
||||
isVoicePreviewPlaying = false
|
||||
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(context, R.drawable.ic_refresh)
|
||||
voicePreviewObjectAnimator!!.end()
|
||||
voicePreviewObjectAnimator = null
|
||||
binding.messageInputView.seekBar.clearAnimation()
|
||||
voicePreviewMediaPlayer!!.stop()
|
||||
voicePreviewMediaPlayer!!.release()
|
||||
voicePreviewMediaPlayer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartAudio() {
|
||||
recorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setOutputFile(currentVoiceRecordFile)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(VOICE_MESSAGE_SAMPLING_RATE)
|
||||
setAudioEncodingBitRate(VOICE_MESSAGE_ENCODING_BIT_RATE)
|
||||
setAudioChannels(VOICE_MESSAGE_CHANNELS)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun endVoiceRecordingUI() {
|
||||
stopPreviewVoicePlaying()
|
||||
showRecordAudioUi(false)
|
||||
binding.voiceRecordingLock.translationY = 0f
|
||||
isVoiceRecordingLocked = false
|
||||
showVoiceRecordingLocked(false)
|
||||
showVoiceRecordingLockedInterface(false)
|
||||
}
|
||||
|
||||
private fun showVoiceRecordingLocked(value: Boolean) {
|
||||
if (value) {
|
||||
binding.voiceRecordingLock.setImageDrawable(
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_lock_grey600_24px)
|
||||
)
|
||||
|
||||
binding.voiceRecordingLock.alpha = 1f
|
||||
binding.voiceRecordingLock.animate().alpha(0f).setDuration(500)
|
||||
.setInterpolator(AccelerateInterpolator()).start()
|
||||
} else {
|
||||
binding.voiceRecordingLock.setImageDrawable(
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_lock_open_grey600_24dp)
|
||||
)
|
||||
binding.voiceRecordingLock.alpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
private fun showVoiceRecordingLockedInterface(value: Boolean) {
|
||||
val audioDurationLayoutParams: LayoutParams = binding.messageInputView.audioRecordDuration
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
|
||||
.layoutParams as LayoutParams
|
||||
|
||||
val standardQuarterMargin = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
resources.getDimension(R.dimen.standard_quarter_margin),
|
||||
resources
|
||||
.displayMetrics
|
||||
).toInt()
|
||||
|
||||
binding.messageInputView.button.isEnabled = true
|
||||
if (value) {
|
||||
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
|
||||
binding.messageInputView.deleteVoiceRecording.visibility = View.VISIBLE
|
||||
binding.messageInputView.sendVoiceRecording.visibility = View.VISIBLE
|
||||
binding.messageInputView.micInputCloud.visibility = View.VISIBLE
|
||||
binding.messageInputView.recordAudioButton.visibility = View.GONE
|
||||
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
|
||||
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
|
||||
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
|
||||
binding.messageInputView.recordAudioButton.visibility = View.GONE
|
||||
micInputCloudLayoutParams.removeRule(BELOW)
|
||||
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
sendVoiceRecordingLayoutParams.removeRule(BELOW)
|
||||
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
|
||||
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_VERTICAL)
|
||||
audioDurationLayoutParams.removeRule(RelativeLayout.END_OF)
|
||||
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, R.bool.value_true)
|
||||
audioDurationLayoutParams.setMargins(0, standardQuarterMargin, 0, 0)
|
||||
startMicInputRecordingAnimation()
|
||||
Log.d(TAG, "MicInputRecording Started")
|
||||
} else {
|
||||
stopMicInputRecordingAnimation()
|
||||
binding.messageInputView.deleteVoiceRecording.visibility = View.GONE
|
||||
binding.messageInputView.micInputCloud.visibility = View.GONE
|
||||
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
|
||||
binding.messageInputView.sendVoiceRecording.visibility = View.GONE
|
||||
binding.messageInputView.playPauseBtn.visibility = View.GONE
|
||||
binding.messageInputView.seekBar.visibility = View.GONE
|
||||
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL, R.bool.value_true)
|
||||
audioDurationLayoutParams.addRule(RelativeLayout.END_OF, R.id.microphoneEnabledInfo)
|
||||
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_HORIZONTAL)
|
||||
audioDurationLayoutParams.setMargins(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSmileyKeyboardToggler() {
|
||||
val smileyButton = binding.messageInputView.findViewById<ImageButton>(R.id.smileyButton)
|
||||
|
||||
@ -1452,6 +1785,7 @@ class ChatActivity :
|
||||
try {
|
||||
mediaPlayer?.let {
|
||||
if (it.isPlaying) {
|
||||
Log.d(TAG, "media player is stopped")
|
||||
it.stop()
|
||||
}
|
||||
}
|
||||
@ -1546,24 +1880,83 @@ class ChatActivity :
|
||||
|
||||
private fun showRecordAudioUi(show: Boolean) {
|
||||
if (show) {
|
||||
binding.messageInputView.microphoneEnabledInfo?.visibility = View.VISIBLE
|
||||
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.VISIBLE
|
||||
binding.messageInputView.audioRecordDuration?.visibility = View.VISIBLE
|
||||
binding.messageInputView.slideToCancelDescription?.visibility = View.VISIBLE
|
||||
binding.messageInputView.attachmentButton?.visibility = View.GONE
|
||||
binding.messageInputView.smileyButton?.visibility = View.GONE
|
||||
binding.messageInputView.messageInput?.visibility = View.GONE
|
||||
binding.messageInputView.messageInput?.hint = ""
|
||||
binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE
|
||||
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE
|
||||
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
|
||||
binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE
|
||||
binding.messageInputView.attachmentButton.visibility = View.GONE
|
||||
binding.messageInputView.smileyButton.visibility = View.GONE
|
||||
binding.messageInputView.messageInput.visibility = View.GONE
|
||||
binding.messageInputView.messageInput.hint = ""
|
||||
binding.voiceRecordingLock.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.messageInputView.microphoneEnabledInfo?.visibility = View.GONE
|
||||
binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.GONE
|
||||
binding.messageInputView.audioRecordDuration?.visibility = View.GONE
|
||||
binding.messageInputView.slideToCancelDescription?.visibility = View.GONE
|
||||
binding.messageInputView.attachmentButton?.visibility = View.VISIBLE
|
||||
binding.messageInputView.smileyButton?.visibility = View.VISIBLE
|
||||
binding.messageInputView.messageInput?.visibility = View.VISIBLE
|
||||
binding.messageInputView.messageInput?.hint =
|
||||
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
|
||||
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
|
||||
binding.messageInputView.audioRecordDuration.visibility = View.GONE
|
||||
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
|
||||
binding.messageInputView.attachmentButton.visibility = View.VISIBLE
|
||||
binding.messageInputView.smileyButton.visibility = View.VISIBLE
|
||||
binding.messageInputView.messageInput.visibility = View.VISIBLE
|
||||
binding.messageInputView.messageInput.hint =
|
||||
context.resources?.getString(R.string.nc_hint_enter_a_message)
|
||||
binding.voiceRecordingLock.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMicInputRecordingAnimation() {
|
||||
val permissionCheck = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
|
||||
if (micInputAudioRecordThread == null && permissionCheck == PERMISSION_GRANTED) {
|
||||
Log.d(TAG, "Mic Animation Started")
|
||||
micInputAudioRecorder = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
8000,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
BUFFER_SIZE
|
||||
)
|
||||
isMicInputAudioThreadRunning = true
|
||||
micInputAudioRecorder.startRecording()
|
||||
micInputAudioRecordThread = Thread(
|
||||
Runnable {
|
||||
while (isMicInputAudioThreadRunning) {
|
||||
val byteArr = ByteArray(BUFFER_SIZE / 2)
|
||||
micInputAudioRecorder.read(byteArr, 0, byteArr.size)
|
||||
val d = Math.abs(byteArr[0].toDouble())
|
||||
if (d > 40) {
|
||||
binding.messageInputView.micInputCloud.setRotationSpeed(
|
||||
Math.log10(d).toFloat(),
|
||||
MicInputCloud.MAXIMUM_RADIUS
|
||||
)
|
||||
} else if (d > 20) {
|
||||
binding.messageInputView.micInputCloud.setRotationSpeed(
|
||||
Math.log10(d).toFloat(),
|
||||
MicInputCloud.EXTENDED_RADIUS
|
||||
)
|
||||
} else {
|
||||
binding.messageInputView.micInputCloud.setRotationSpeed(
|
||||
1f,
|
||||
MicInputCloud.DEFAULT_RADIUS
|
||||
)
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
)
|
||||
micInputAudioRecordThread!!.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopMicInputRecordingAnimation() {
|
||||
if (micInputAudioRecordThread != null) {
|
||||
Log.d(TAG, "Mic Animation Ended")
|
||||
micInputAudioRecorder.stop()
|
||||
micInputAudioRecorder.release()
|
||||
isMicInputAudioThreadRunning = false
|
||||
micInputAudioRecordThread = null
|
||||
}
|
||||
}
|
||||
|
||||
@ -1571,19 +1964,19 @@ class ChatActivity :
|
||||
return PermissionChecker.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) == PermissionChecker.PERMISSION_GRANTED
|
||||
) == PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun startAudioRecording(file: String) {
|
||||
binding.messageInputView.audioRecordDuration?.base = SystemClock.elapsedRealtime()
|
||||
binding.messageInputView.audioRecordDuration?.start()
|
||||
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
|
||||
binding.messageInputView.audioRecordDuration.start()
|
||||
|
||||
val animation: Animation = AlphaAnimation(1.0f, 0.0f)
|
||||
animation.duration = ANIMATION_DURATION
|
||||
animation.interpolator = LinearInterpolator()
|
||||
animation.repeatCount = Animation.INFINITE
|
||||
animation.repeatMode = Animation.REVERSE
|
||||
binding.messageInputView.microphoneEnabledInfo?.startAnimation(animation)
|
||||
binding.messageInputView.microphoneEnabledInfo.startAnimation(animation)
|
||||
|
||||
recorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
@ -1612,13 +2005,18 @@ class ChatActivity :
|
||||
}
|
||||
|
||||
private fun stopAndSendAudioRecording() {
|
||||
stopAudioRecording()
|
||||
if (isVoiceRecordingInProgress) {
|
||||
stopAudioRecording()
|
||||
}
|
||||
|
||||
val uri = Uri.fromFile(File(currentVoiceRecordFile))
|
||||
uploadFile(uri.toString(), true)
|
||||
}
|
||||
|
||||
private fun stopAndDiscardAudioRecording() {
|
||||
stopAudioRecording()
|
||||
if (isVoiceRecordingInProgress) {
|
||||
stopAudioRecording()
|
||||
}
|
||||
|
||||
val cachedFile = File(currentVoiceRecordFile)
|
||||
cachedFile.delete()
|
||||
@ -1626,26 +2024,22 @@ class ChatActivity :
|
||||
|
||||
@Suppress("Detekt.TooGenericExceptionCaught")
|
||||
private fun stopAudioRecording() {
|
||||
binding.messageInputView.audioRecordDuration?.stop()
|
||||
binding.messageInputView.microphoneEnabledInfo?.clearAnimation()
|
||||
binding.messageInputView.audioRecordDuration.stop()
|
||||
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
|
||||
|
||||
if (isVoiceRecordingInProgress) {
|
||||
recorder?.apply {
|
||||
try {
|
||||
stop()
|
||||
release()
|
||||
isVoiceRecordingInProgress = false
|
||||
Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "error while stopping recorder!")
|
||||
}
|
||||
|
||||
VibrationUtils.vibrateShort(context)
|
||||
recorder?.apply {
|
||||
try {
|
||||
stop()
|
||||
release()
|
||||
isVoiceRecordingInProgress = false
|
||||
Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "error while stopping recorder!")
|
||||
}
|
||||
recorder = null
|
||||
} else {
|
||||
Log.e(TAG, "tried to stop audio recorder but it was not recording")
|
||||
|
||||
VibrationUtils.vibrateShort(context)
|
||||
}
|
||||
recorder = null
|
||||
}
|
||||
|
||||
private fun requestRecordAudioPermissions() {
|
||||
@ -1761,7 +2155,7 @@ class ChatActivity :
|
||||
ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!)
|
||||
) {
|
||||
if (shouldShowLobby()) {
|
||||
binding.lobby.lobbyView?.visibility = View.VISIBLE
|
||||
binding.lobby.lobbyView.visibility = View.VISIBLE
|
||||
binding.messagesListView.visibility = View.GONE
|
||||
binding.messageInputView.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.GONE
|
||||
@ -1785,9 +2179,9 @@ class ChatActivity :
|
||||
}
|
||||
|
||||
sb.append(currentConversation!!.description)
|
||||
binding.lobby.lobbyTextView?.text = sb.toString()
|
||||
binding.lobby.lobbyTextView.text = sb.toString()
|
||||
} else {
|
||||
binding.lobby.lobbyView?.visibility = View.GONE
|
||||
binding.lobby.lobbyView.visibility = View.GONE
|
||||
binding.messagesListView.visibility = View.VISIBLE
|
||||
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
|
||||
if (isFirstMessagesProcessing && pastPreconditionFailed) {
|
||||
@ -1799,7 +2193,7 @@ class ChatActivity :
|
||||
}
|
||||
}
|
||||
} else {
|
||||
binding.lobby.lobbyView?.visibility = View.GONE
|
||||
binding.lobby.lobbyView.visibility = View.GONE
|
||||
binding.messagesListView.visibility = View.VISIBLE
|
||||
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
|
||||
}
|
||||
@ -2774,7 +3168,7 @@ class ChatActivity :
|
||||
|
||||
private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) {
|
||||
if (!shouldAddNewMessagesNotice && !shouldScroll) {
|
||||
binding.popupBubbleView.isShown?.let {
|
||||
binding.popupBubbleView.isShown.let {
|
||||
if (it) {
|
||||
newMessagesCount++
|
||||
} else {
|
||||
@ -3455,11 +3849,11 @@ class ChatActivity :
|
||||
|
||||
private fun showMicrophoneButton(show: Boolean) {
|
||||
if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) {
|
||||
binding.messageInputView.messageSendButton?.visibility = View.GONE
|
||||
binding.messageInputView.recordAudioButton?.visibility = View.VISIBLE
|
||||
binding.messageInputView.messageSendButton.visibility = View.GONE
|
||||
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.messageInputView.messageSendButton?.visibility = View.VISIBLE
|
||||
binding.messageInputView.recordAudioButton?.visibility = View.GONE
|
||||
binding.messageInputView.messageSendButton.visibility = View.VISIBLE
|
||||
binding.messageInputView.recordAudioButton.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
@ -3727,6 +4121,7 @@ class ChatActivity :
|
||||
private const val OBJECT_MESSAGE: String = "{object}"
|
||||
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
|
||||
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50
|
||||
private const val VOICE_RECORD_LOCK_BUTTON_Y: Int = -130
|
||||
private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
|
||||
private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3"
|
||||
|
||||
|
@ -25,8 +25,10 @@ import android.util.AttributeSet
|
||||
import android.widget.Chronometer
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import androidx.emoji2.widget.EmojiEditText
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.nextcloud.talk.R
|
||||
import com.stfalcon.chatkit.messages.MessageInput
|
||||
|
||||
@ -37,6 +39,11 @@ class MessageInput : MessageInput {
|
||||
lateinit var microphoneEnabledInfo: ImageView
|
||||
lateinit var microphoneEnabledInfoBackground: ImageView
|
||||
lateinit var smileyButton: ImageButton
|
||||
lateinit var deleteVoiceRecording: ImageView
|
||||
lateinit var sendVoiceRecording: ImageView
|
||||
lateinit var micInputCloud: MicInputCloud
|
||||
lateinit var playPauseBtn: MaterialButton
|
||||
lateinit var seekBar: SeekBar
|
||||
|
||||
constructor(context: Context?) : super(context) {
|
||||
init()
|
||||
@ -57,6 +64,11 @@ class MessageInput : MessageInput {
|
||||
microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo)
|
||||
microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground)
|
||||
smileyButton = findViewById(R.id.smileyButton)
|
||||
deleteVoiceRecording = findViewById(R.id.deleteVoiceRecording)
|
||||
sendVoiceRecording = findViewById(R.id.sendVoiceRecording)
|
||||
micInputCloud = findViewById(R.id.micInputCloud)
|
||||
playPauseBtn = findViewById(R.id.playPauseBtn)
|
||||
seekBar = findViewById(R.id.seekbar)
|
||||
}
|
||||
|
||||
var messageInput: EmojiEditText
|
||||
|
369
app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt
Normal file
369
app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt
Normal file
@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Nextcloud Talk application
|
||||
*
|
||||
* @author Julius Linus
|
||||
* Copyright (C) 2023 Julius Linus <julius.linus@nextcloud.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.ui
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Paint.ANTI_ALIAS_FLAG
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.VectorDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.LinearInterpolator
|
||||
import androidx.annotation.ColorInt
|
||||
import com.nextcloud.talk.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MicInputCloud(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
||||
/**
|
||||
* State Descriptions:
|
||||
* - PAUSED_STATE: Animation speed is set to zero
|
||||
* - PLAY_STATE: Animation speed is set to default, but can be overridden
|
||||
*/
|
||||
enum class ViewState {
|
||||
PAUSED_STATE,
|
||||
PLAY_STATE
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private var primaryColor: Int = Color.WHITE
|
||||
|
||||
private var pauseIcon: VectorDrawable? = null
|
||||
|
||||
private var playIcon: VectorDrawable? = null
|
||||
|
||||
init {
|
||||
context.theme.obtainStyledAttributes(
|
||||
attrs,
|
||||
R.styleable.MicInputCloud,
|
||||
0,
|
||||
0
|
||||
).apply {
|
||||
|
||||
try {
|
||||
pauseIcon = getDrawable(R.styleable.MicInputCloud_pauseIcon) as VectorDrawable
|
||||
playIcon = getDrawable(R.styleable.MicInputCloud_playIcon) as VectorDrawable
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var state: ViewState = ViewState.PLAY_STATE
|
||||
private var ovalOneAnimator: ValueAnimator? = null
|
||||
private var ovalTwoAnimator: ValueAnimator? = null
|
||||
private var ovalThreeAnimator: ValueAnimator? = null
|
||||
private var r1 = OVAL_ONE_DEFAULT_ROTATION
|
||||
private var r2 = OVAL_TWO_DEFAULT_ROTATION
|
||||
private var r3 = OVAL_THREE_DEFAULT_ROTATION
|
||||
private var o1h = OVAL_ONE_DEFAULT_HEIGHT
|
||||
private var o1w = OVAL_ONE_DEFAULT_WIDTH
|
||||
private var o2h = OVAL_TWO_DEFAULT_HEIGHT
|
||||
private var o2w = OVAL_TWO_DEFAULT_WIDTH
|
||||
private var o3h = OVAL_THREE_DEFAULT_HEIGHT
|
||||
private var o3w = OVAL_THREE_DEFAULT_WIDTH
|
||||
private var rotationSpeedMultiplier: Float = DEFAULT_ROTATION_SPEED_MULTIPLIER
|
||||
private var radius: Float = DEFAULT_RADIUS
|
||||
private var centerX: Float = 0f
|
||||
private var centerY: Float = 0f
|
||||
|
||||
private val bottomCirclePaint = Paint(ANTI_ALIAS_FLAG).apply {
|
||||
color = primaryColor
|
||||
style = Paint.Style.FILL
|
||||
alpha = DEFAULT_OPACITY
|
||||
}
|
||||
|
||||
private val topCircleBounds = Rect(0, 0, 0, 0)
|
||||
|
||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||
super.onVisibilityChanged(changedView, visibility)
|
||||
if (visibility == VISIBLE) {
|
||||
createAnimators()
|
||||
} else {
|
||||
state = ViewState.PLAY_STATE
|
||||
destroyAnimators()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAnimators() {
|
||||
ovalOneAnimator = ValueAnimator.ofInt(
|
||||
o1h,
|
||||
OVAL_ONE_DEFAULT_HEIGHT + ANIMATION_CAP,
|
||||
o1h
|
||||
).apply {
|
||||
duration = OVAL_ONE_ANIMATION_LENGTH
|
||||
interpolator = LinearInterpolator()
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
addUpdateListener { valueAnimator ->
|
||||
o1h = valueAnimator.animatedValue as Int
|
||||
}
|
||||
}
|
||||
|
||||
ovalTwoAnimator = ValueAnimator.ofInt(
|
||||
o2h,
|
||||
OVAL_TWO_DEFAULT_HEIGHT + ANIMATION_CAP,
|
||||
o2h
|
||||
).apply {
|
||||
duration = OVAL_TWO_ANIMATION_LENGTH
|
||||
interpolator = LinearInterpolator()
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
addUpdateListener { valueAnimator ->
|
||||
o2h = valueAnimator.animatedValue as Int
|
||||
}
|
||||
}
|
||||
|
||||
ovalThreeAnimator = ValueAnimator.ofInt(
|
||||
o3h,
|
||||
OVAL_THREE_DEFAULT_HEIGHT + ANIMATION_CAP,
|
||||
o3h
|
||||
).apply {
|
||||
duration = OVAL_THREE_ANIMATION_LENGTH
|
||||
interpolator = LinearInterpolator()
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
addUpdateListener { valueAnimator ->
|
||||
o3h = valueAnimator.animatedValue as Int
|
||||
invalidate() // needed to animate the other listeners as well
|
||||
}
|
||||
}
|
||||
|
||||
ovalOneAnimator?.start()
|
||||
ovalTwoAnimator?.start()
|
||||
ovalThreeAnimator?.start()
|
||||
}
|
||||
|
||||
private fun destroyAnimators() {
|
||||
ovalOneAnimator?.cancel()
|
||||
ovalOneAnimator?.removeAllUpdateListeners()
|
||||
ovalTwoAnimator?.cancel()
|
||||
ovalTwoAnimator?.removeAllUpdateListeners()
|
||||
ovalThreeAnimator?.cancel()
|
||||
ovalThreeAnimator?.removeAllUpdateListeners()
|
||||
}
|
||||
|
||||
private val circlePath: Path = Path()
|
||||
private val ovalOnePath: Path = Path()
|
||||
private val ovalTwoPath: Path = Path()
|
||||
private val ovalThreePath: Path = Path()
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
super.onDraw(canvas)
|
||||
circlePath.apply {
|
||||
addCircle(centerX, centerY, DEFAULT_RADIUS, Path.Direction.CCW)
|
||||
}
|
||||
ovalOnePath.apply {
|
||||
addOval(
|
||||
centerX - (radius + o1w),
|
||||
centerY - o1h,
|
||||
centerX + (radius + o1w),
|
||||
centerY + o1h,
|
||||
Path.Direction.CCW
|
||||
)
|
||||
op(this, circlePath, Path.Op.DIFFERENCE)
|
||||
}
|
||||
ovalTwoPath.apply {
|
||||
addOval(
|
||||
centerX - (radius + o2w),
|
||||
centerY - o2h,
|
||||
centerX + (radius + o2w),
|
||||
centerY + o2h,
|
||||
Path.Direction.CCW
|
||||
)
|
||||
op(this, circlePath, Path.Op.DIFFERENCE)
|
||||
}
|
||||
ovalThreePath.apply {
|
||||
addOval(
|
||||
centerX - (radius + o3w),
|
||||
centerY - o3h,
|
||||
centerX + (radius + o3w),
|
||||
centerY + o3h,
|
||||
Path.Direction.CCW
|
||||
)
|
||||
op(this, circlePath, Path.Op.DIFFERENCE)
|
||||
}
|
||||
drawMicInputCloud(canvas)
|
||||
if (state == ViewState.PLAY_STATE) {
|
||||
r1 += OVAL_ONE_ANIMATION_SPEED * rotationSpeedMultiplier
|
||||
r2 -= OVAL_TWO_ANIMATION_SPEED * rotationSpeedMultiplier
|
||||
r3 += OVAL_THREE_ANIMATION_SPEED * rotationSpeedMultiplier
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawMicInputCloud(canvas: Canvas?) {
|
||||
canvas?.apply {
|
||||
save()
|
||||
rotate(r1, centerX, centerY)
|
||||
drawPath(ovalOnePath, bottomCirclePaint)
|
||||
restore()
|
||||
save()
|
||||
rotate(r2, centerX, centerY)
|
||||
drawPath(ovalTwoPath, bottomCirclePaint)
|
||||
restore()
|
||||
save()
|
||||
rotate(r3, centerX, centerY)
|
||||
drawPath(ovalThreePath, bottomCirclePaint)
|
||||
restore()
|
||||
circlePath.reset()
|
||||
ovalOnePath.reset()
|
||||
ovalTwoPath.reset()
|
||||
ovalThreePath.reset()
|
||||
if (state == ViewState.PLAY_STATE) {
|
||||
pauseIcon?.apply {
|
||||
bounds = topCircleBounds
|
||||
setTint(primaryColor)
|
||||
draw(canvas)
|
||||
}
|
||||
} else {
|
||||
playIcon?.apply {
|
||||
bounds = topCircleBounds
|
||||
setTint(primaryColor)
|
||||
draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val desiredWidth = DEFAULT_SIZE.dp
|
||||
val desiredHeight = DEFAULT_SIZE.dp
|
||||
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
val width: Int = when (widthMode) {
|
||||
MeasureSpec.EXACTLY -> {
|
||||
widthSize
|
||||
}
|
||||
|
||||
MeasureSpec.AT_MOST -> {
|
||||
desiredWidth.coerceAtMost(widthSize)
|
||||
}
|
||||
|
||||
else -> {
|
||||
desiredWidth
|
||||
}
|
||||
}
|
||||
|
||||
val height: Int = when (heightMode) {
|
||||
MeasureSpec.EXACTLY -> {
|
||||
heightSize
|
||||
}
|
||||
|
||||
MeasureSpec.AT_MOST -> {
|
||||
desiredHeight.coerceAtMost(heightSize)
|
||||
}
|
||||
|
||||
else -> {
|
||||
desiredHeight
|
||||
}
|
||||
}
|
||||
|
||||
centerX = (width / 2).toFloat()
|
||||
centerY = (height / 2).toFloat()
|
||||
topCircleBounds.left = (centerX - DEFAULT_RADIUS).toInt()
|
||||
topCircleBounds.top = (centerY - DEFAULT_RADIUS).toInt()
|
||||
topCircleBounds.right = (centerX + DEFAULT_RADIUS).toInt()
|
||||
topCircleBounds.bottom = (centerY + DEFAULT_RADIUS).toInt()
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
state = if (state == ViewState.PAUSED_STATE) {
|
||||
ovalOneAnimator?.resume()
|
||||
ovalTwoAnimator?.resume()
|
||||
ovalThreeAnimator?.resume()
|
||||
ViewState.PLAY_STATE
|
||||
} else {
|
||||
ovalOneAnimator?.pause()
|
||||
ovalTwoAnimator?.pause()
|
||||
ovalThreeAnimator?.pause()
|
||||
ViewState.PAUSED_STATE
|
||||
}
|
||||
invalidate()
|
||||
return super.performClick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the color of the cloud to the parameter, opacity is still set to 50%
|
||||
*/
|
||||
fun setColor(primary: Int) {
|
||||
primaryColor = primary
|
||||
bottomCirclePaint.apply {
|
||||
color = primary
|
||||
style = Paint.Style.FILL
|
||||
alpha = DEFAULT_OPACITY
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets state of the component to the parameter
|
||||
*/
|
||||
fun setState(s: ViewState) {
|
||||
state = s
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the rotation speed and radius to the parameters, defaults are left unchanged
|
||||
*/
|
||||
fun setRotationSpeed(speed: Float, r: Float) {
|
||||
rotationSpeedMultiplier = speed
|
||||
radius = r
|
||||
invalidate()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG: String? = MicInputCloud::class.simpleName
|
||||
const val DEFAULT_RADIUS: Float = 70f
|
||||
const val EXTENDED_RADIUS: Float = 75f
|
||||
const val MAXIMUM_RADIUS: Float = 80f
|
||||
private const val DEFAULT_SIZE: Int = 110
|
||||
private const val DEFAULT_OPACITY: Int = 108
|
||||
private const val DEFAULT_ROTATION_SPEED_MULTIPLIER: Float = 0.5f
|
||||
private const val OVAL_ONE_DEFAULT_ROTATION: Float = 105f
|
||||
private const val OVAL_ONE_DEFAULT_HEIGHT: Int = 85
|
||||
private const val OVAL_ONE_DEFAULT_WIDTH: Int = 30
|
||||
private const val OVAL_ONE_ANIMATION_LENGTH: Long = 2000
|
||||
private const val OVAL_ONE_ANIMATION_SPEED: Float = 2.3f
|
||||
private const val OVAL_TWO_DEFAULT_ROTATION: Float = 138f
|
||||
private const val OVAL_TWO_DEFAULT_HEIGHT: Int = 70
|
||||
private const val OVAL_TWO_DEFAULT_WIDTH: Int = 25
|
||||
private const val OVAL_TWO_ANIMATION_LENGTH: Long = 1000
|
||||
private const val OVAL_TWO_ANIMATION_SPEED: Float = 1.75f
|
||||
private const val OVAL_THREE_DEFAULT_ROTATION: Float = 63f
|
||||
private const val OVAL_THREE_DEFAULT_HEIGHT: Int = 80
|
||||
private const val OVAL_THREE_DEFAULT_WIDTH: Int = 40
|
||||
private const val OVAL_THREE_ANIMATION_LENGTH: Long = 1500
|
||||
private const val OVAL_THREE_ANIMATION_SPEED: Float = 1f
|
||||
private const val ANIMATION_CAP: Int = 15
|
||||
private val Int.dp: Int
|
||||
get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
|
||||
}
|
||||
}
|
@ -46,6 +46,7 @@ import com.nextcloud.android.common.ui.theme.MaterialSchemes
|
||||
import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase
|
||||
import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.ui.MicInputCloud
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.DrawableUtils
|
||||
import com.vanniktech.emoji.EmojiTextView
|
||||
@ -243,6 +244,12 @@ class TalkSpecificViewThemeUtils @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun themeMicInputCloud(micInputCloud: MicInputCloud) {
|
||||
withScheme(micInputCloud) { scheme ->
|
||||
micInputCloud.setColor(scheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val THEMEABLE_PLACEHOLDER_IDS = listOf(
|
||||
R.drawable.ic_mimetype_package_x_generic,
|
||||
|
27
app/src/main/res/drawable/baseline_stop_24.xml
Normal file
27
app/src/main/res/drawable/baseline_stop_24.xml
Normal file
@ -0,0 +1,27 @@
|
||||
<!--
|
||||
@author Google LLC
|
||||
Copyright (C) 2023 Google LLC
|
||||
|
||||
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.
|
||||
-->
|
||||
|
||||
<vector android:height="24dp"
|
||||
android:tint="#000000"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6,6h12v12H6z" />
|
||||
</vector>
|
@ -132,6 +132,18 @@
|
||||
app:cornerRadius="@dimen/button_corner_radius"
|
||||
app:icon="@drawable/ic_baseline_arrow_downward_24px" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/voice_recording_lock"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
style="?attr/floatingActionButtonSmallStyle"
|
||||
android:layout_marginEnd="@dimen/standard_margin"
|
||||
android:layout_marginBottom="@dimen/standard_margin"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_alignParentEnd="true"
|
||||
app:srcCompat="@drawable/ic_lock_open_grey600_24dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/scrollDownButton"
|
||||
style="@style/Widget.AppTheme.Button.ElevatedButton"
|
||||
|
@ -109,6 +109,47 @@
|
||||
tools:visibility="gone"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<com.nextcloud.talk.ui.MicInputCloud
|
||||
android:id="@+id/micInputCloud"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/audioRecordDuration"
|
||||
android:layout_centerInParent="true"
|
||||
app:playIcon="@drawable/ic_refresh"
|
||||
app:pauseIcon="@drawable/baseline_stop_24"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/deleteVoiceRecording"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignBottom="@id/micInputCloud"
|
||||
android:layout_below="@id/audioRecordDuration"
|
||||
android:layout_marginVertical="@dimen/standard_margin"
|
||||
android:layout_marginStart="@dimen/standard_double_margin"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ic_delete"
|
||||
android:contentDescription="@null"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/sendVoiceRecording"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_alignBottom="@id/micInputCloud"
|
||||
android:layout_below="@id/audioRecordDuration"
|
||||
android:layout_marginVertical="@dimen/standard_margin"
|
||||
android:layout_marginEnd="@dimen/standard_double_margin"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ic_send"
|
||||
android:contentDescription="@null"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- the height of this ImageView is used to define the overall height of the
|
||||
parent layout whenever the voice recording mode is enabled. parent layout has
|
||||
height=wrap_content because it must enlarge whenever user types a message with
|
||||
@ -123,7 +164,43 @@
|
||||
android:src="@drawable/ic_baseline_mic_red_24"
|
||||
android:contentDescription="@null"
|
||||
android:visibility="gone"
|
||||
tools:visibility="gone"/>
|
||||
tools:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/voice_preview_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/quotedChatMessageView"
|
||||
android:layout_marginTop="@dimen/standard_margin"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginHorizontal="@dimen/standard_margin"
|
||||
android:background="@drawable/shape_grouped_outcoming_message">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/playPauseBtn"
|
||||
style="@style/Widget.AppTheme.Button.IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="@dimen/standard_margin"
|
||||
android:contentDescription="@string/play_pause_voice_message"
|
||||
android:visibility="gone"
|
||||
app:cornerRadius="@dimen/button_corner_radius"
|
||||
app:icon="@drawable/ic_baseline_play_arrow_voice_message_24"
|
||||
app:iconSize="30dp"
|
||||
app:iconTint="@color/high_emphasis_text"
|
||||
app:rippleColor="#1FFFFFFF" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekbar"
|
||||
style="@style/Nextcloud.Material.Outgoing.SeekBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="30dp"
|
||||
android:layout_marginEnd="@dimen/standard_margin"
|
||||
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
||||
android:visibility="gone"
|
||||
tools:progress="50" />
|
||||
</LinearLayout>
|
||||
|
||||
<Chronometer
|
||||
android:id="@+id/audioRecordDuration"
|
||||
|
@ -20,7 +20,9 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<declare-styleable name="MaterialPreferenceCategory">
|
||||
<attr name="mpc_action" format="string" />
|
||||
<declare-styleable name="MicInputCloud">
|
||||
<attr name="primaryColor" format="color" />
|
||||
<attr name="pauseIcon" format="reference" />
|
||||
<attr name="playIcon" format="reference" />
|
||||
</declare-styleable>
|
||||
</resources>
|
Loading…
Reference in New Issue
Block a user