mirror of
https://github.com/nextcloud/talk-android.git
synced 2024-11-26 23:25:20 +03:00
Merge pull request #3202 from nextcloud/seekbar-waveform-for-audio-messages
Waveform SeekBar for Voice Messages
This commit is contained in:
commit
3ab4d99c4a
9 changed files with 475 additions and 70 deletions
|
@ -101,8 +101,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
updateDownloadState(message)
|
||||
binding.seekbar.max = message.voiceMessageDuration - 1
|
||||
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar)
|
||||
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
|
||||
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
if (message.isPlayingVoiceMessage) {
|
||||
|
@ -115,7 +115,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true)
|
||||
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||
} else {
|
||||
binding.playPauseBtn.visibility = View.VISIBLE
|
||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
|
@ -127,6 +127,11 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
if (message.isDownloadingVoiceMessage) {
|
||||
showVoiceMessageLoading()
|
||||
} else {
|
||||
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||
binding.seekbar.setWaveData(FloatArray(0))
|
||||
} else {
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
@ -139,7 +144,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
binding.seekbar.progress = SEEKBAR_START
|
||||
message.resetVoiceMessage = false
|
||||
message.voiceMessagePlayedSeconds = 0
|
||||
binding.voiceMessageDuration.visibility = View.GONE
|
||||
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
|
@ -330,5 +335,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
|
|||
companion object {
|
||||
private const val TAG = "VoiceInMessageView"
|
||||
private const val SEEKBAR_START: Int = 0
|
||||
private const val ONE_SEC: Int = 1000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,8 +98,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
setParentMessageDataOnMessageItem(message)
|
||||
|
||||
updateDownloadState(message)
|
||||
binding.seekbar.max = message.voiceMessageDuration - 1
|
||||
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar)
|
||||
binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
|
||||
viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
|
||||
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
|
||||
|
||||
handleIsPlayingVoiceMessageState(message)
|
||||
|
@ -176,7 +176,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
)
|
||||
binding.seekbar.progress = SEEKBAR_START
|
||||
message.voiceMessagePlayedSeconds = 0
|
||||
binding.voiceMessageDuration.visibility = View.GONE
|
||||
binding.voiceMessageDuration.visibility = View.INVISIBLE
|
||||
message.resetVoiceMessage = false
|
||||
}
|
||||
}
|
||||
|
@ -185,6 +185,11 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
if (message.isDownloadingVoiceMessage) {
|
||||
showVoiceMessageLoading()
|
||||
} else {
|
||||
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
|
||||
binding.seekbar.setWaveData(FloatArray(0))
|
||||
} else {
|
||||
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
|
||||
}
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
@ -201,7 +206,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
val t = message.voiceMessagePlayedSeconds.toLong()
|
||||
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
|
||||
binding.voiceMessageDuration.visibility = View.VISIBLE
|
||||
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true)
|
||||
binding.seekbar.progress = message.voiceMessageSeekbarProgress
|
||||
} else {
|
||||
binding.playPauseBtn.visibility = View.VISIBLE
|
||||
binding.playPauseBtn.icon = ContextCompat.getDrawable(
|
||||
|
@ -313,5 +318,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
|
|||
companion object {
|
||||
private const val TAG = "VoiceOutMessageView"
|
||||
private const val SEEKBAR_START: Int = 0
|
||||
private const val ONE_SEC: Int = 1000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -188,6 +188,7 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
|
|||
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
|
||||
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
|
||||
import com.nextcloud.talk.utils.ApiUtils
|
||||
import com.nextcloud.talk.utils.AudioUtils
|
||||
import com.nextcloud.talk.utils.ContactUtils
|
||||
import com.nextcloud.talk.utils.ConversationUtils
|
||||
import com.nextcloud.talk.utils.DateConstants
|
||||
|
@ -232,6 +233,10 @@ import io.reactivex.Observer
|
|||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.Disposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import retrofit2.HttpException
|
||||
|
@ -343,6 +348,11 @@ class ChatActivity :
|
|||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
// messy workaround for a mediaPlayer bug, don't delete
|
||||
private var lastRecordMediaPosition: Int = 0
|
||||
private var lastRecordedSeeked: Boolean = false
|
||||
|
||||
private lateinit var participantPermissions: ParticipantPermissions
|
||||
|
||||
private var videoURI: Uri? = null
|
||||
|
@ -861,21 +871,45 @@ class ChatActivity :
|
|||
adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
|
||||
adapter?.registerViewClickListener(
|
||||
R.id.playPauseBtn
|
||||
) { view, message ->
|
||||
) { _, message ->
|
||||
val filename = message.selectedIndividualHashMap!!["name"]
|
||||
val file = File(context.cacheDir, filename!!)
|
||||
if (file.exists()) {
|
||||
if (message.isPlayingVoiceMessage) {
|
||||
pausePlayback(message)
|
||||
} else {
|
||||
startPlayback(message)
|
||||
setUpWaveform(message)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Downloaded to cache")
|
||||
downloadFileToCache(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpWaveform(message: ChatMessage) {
|
||||
val filename = message.selectedIndividualHashMap!!["name"]
|
||||
val file = File(context.cacheDir, filename!!)
|
||||
if (file.exists() && message.voiceMessageFloatArray == null) {
|
||||
message.isDownloadingVoiceMessage = true
|
||||
adapter?.update(message)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val bars = if (message.actorDisplayName == conversationUser?.displayName) {
|
||||
NUM_BARS_OUTCOMING
|
||||
} else {
|
||||
NUM_BARS_INCOMING
|
||||
}
|
||||
val r = AudioUtils.audioFileToFloatArray(file, bars)
|
||||
message.voiceMessageFloatArray = r
|
||||
withContext(Dispatchers.Main) {
|
||||
startPlayback(message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startPlayback(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initMessageHolders(): MessageHolders {
|
||||
val messageHolders = MessageHolders()
|
||||
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!)
|
||||
|
@ -1215,7 +1249,6 @@ class ChatActivity :
|
|||
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(
|
||||
|
@ -1742,6 +1775,7 @@ class ChatActivity :
|
|||
mediaPlayer?.let {
|
||||
if (!it.isPlaying) {
|
||||
it.start()
|
||||
Log.d(TAG, "MediaPlayer has Started")
|
||||
}
|
||||
|
||||
mediaPlayerHandler = Handler()
|
||||
|
@ -1751,17 +1785,20 @@ class ChatActivity :
|
|||
if (message.isPlayingVoiceMessage) {
|
||||
val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE
|
||||
if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) {
|
||||
lastRecordMediaPosition = mediaPlayer!!.currentPosition
|
||||
message.voiceMessagePlayedSeconds = pos
|
||||
message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition
|
||||
adapter?.update(message)
|
||||
} else {
|
||||
message.resetVoiceMessage = true
|
||||
message.voiceMessagePlayedSeconds = 0
|
||||
message.voiceMessageSeekbarProgress = 0
|
||||
adapter?.update(message)
|
||||
stopMediaPlayer(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaPlayerHandler.postDelayed(this, SECOND)
|
||||
mediaPlayerHandler.postDelayed(this, 15)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -1774,6 +1811,7 @@ class ChatActivity :
|
|||
private fun pausePlayback(message: ChatMessage) {
|
||||
if (mediaPlayer!!.isPlaying) {
|
||||
mediaPlayer!!.pause()
|
||||
Log.d(TAG, "MediaPlayer is paused")
|
||||
}
|
||||
|
||||
message.isPlayingVoiceMessage = false
|
||||
|
@ -1794,13 +1832,22 @@ class ChatActivity :
|
|||
mediaPlayer = MediaPlayer().apply {
|
||||
setDataSource(absolutePath)
|
||||
prepare()
|
||||
}
|
||||
|
||||
currentlyPlayedVoiceMessage = message
|
||||
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
|
||||
|
||||
mediaPlayer!!.setOnCompletionListener {
|
||||
stopMediaPlayer(message)
|
||||
setOnPreparedListener {
|
||||
currentlyPlayedVoiceMessage = message
|
||||
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
|
||||
lastRecordedSeeked = false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setOnMediaTimeDiscontinuityListener { mp, _ ->
|
||||
if (lastRecordMediaPosition > ONE_SECOND_IN_MILLIS && !lastRecordedSeeked) {
|
||||
mp.seekTo(lastRecordMediaPosition)
|
||||
lastRecordedSeeked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
setOnCompletionListener {
|
||||
stopMediaPlayer(message)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "failed to initialize mediaPlayer", e)
|
||||
|
@ -1836,7 +1883,7 @@ class ChatActivity :
|
|||
override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) {
|
||||
if (mediaPlayer != null) {
|
||||
if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) {
|
||||
mediaPlayer!!.seekTo(progress * VOICE_MESSAGE_SEEKBAR_BASE)
|
||||
mediaPlayer!!.seekTo(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1894,7 +1941,8 @@ class ChatActivity :
|
|||
WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
|
||||
.observeForever { workInfo: WorkInfo ->
|
||||
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
|
||||
startPlayback(message)
|
||||
setUpWaveform(message)
|
||||
// startPlayback(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4227,5 +4275,7 @@ class ChatActivity :
|
|||
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
|
||||
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
|
||||
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
|
||||
private const val NUM_BARS_OUTCOMING: Int = 38
|
||||
private const val NUM_BARS_INCOMING: Int = 50
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ import com.stfalcon.chatkit.commons.models.IUser
|
|||
import com.stfalcon.chatkit.commons.models.MessageContentType
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.security.MessageDigest
|
||||
import java.util.Arrays
|
||||
import java.util.Date
|
||||
|
||||
@Parcelize
|
||||
|
@ -132,7 +131,11 @@ data class ChatMessage(
|
|||
|
||||
var voiceMessagePlayedSeconds: Int = 0,
|
||||
|
||||
var voiceMessageDownloadProgress: Int = 0
|
||||
var voiceMessageDownloadProgress: Int = 0,
|
||||
|
||||
var voiceMessageSeekbarProgress: Int = 0,
|
||||
|
||||
var voiceMessageFloatArray: FloatArray? = null
|
||||
|
||||
) : Parcelable, MessageContentType, MessageContentType.Image {
|
||||
|
||||
|
@ -140,7 +143,7 @@ data class ChatMessage(
|
|||
|
||||
// messageTypesToIgnore is weird. must be deleted by refactoring!!!
|
||||
@JsonIgnore
|
||||
var messageTypesToIgnore = Arrays.asList(
|
||||
var messageTypesToIgnore = listOf(
|
||||
MessageType.REGULAR_TEXT_MESSAGE,
|
||||
MessageType.SYSTEM_MESSAGE,
|
||||
MessageType.SINGLE_LINK_VIDEO_MESSAGE,
|
||||
|
@ -417,6 +420,17 @@ data class ChatMessage(
|
|||
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
|
||||
}
|
||||
|
||||
// needed a equals and hashcode function to fix detekt errors
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
val isVoiceMessage: Boolean
|
||||
get() = "voice-message" == messageType
|
||||
val isCommandMessage: Boolean
|
||||
|
|
115
app/src/main/java/com/nextcloud/talk/ui/WaveformSeekBar.kt
Normal file
115
app/src/main/java/com/nextcloud/talk/ui/WaveformSeekBar.kt
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.widget.AppCompatSeekBar
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class WaveformSeekBar : AppCompatSeekBar {
|
||||
|
||||
@ColorInt
|
||||
private var primary: Int = Color.parseColor("#679ff5")
|
||||
|
||||
@ColorInt
|
||||
private var secondary: Int = Color.parseColor("#a6c6f7")
|
||||
private var waveData: FloatArray = floatArrayOf()
|
||||
private val paint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init()
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
||||
init()
|
||||
}
|
||||
|
||||
fun setColors(@ColorInt p: Int, @ColorInt s: Int) {
|
||||
primary = p
|
||||
secondary = s
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setWaveData(data: FloatArray) {
|
||||
waveData = data
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
paint.apply {
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeWidth = DEFAULT_BAR_WIDTH.dp.toFloat()
|
||||
color = Color.RED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
if (waveData.isEmpty() || waveData[0].toString() == "NaN") {
|
||||
super.onDraw(canvas)
|
||||
} else {
|
||||
if (progressDrawable != null) {
|
||||
super.setProgressDrawable(null)
|
||||
}
|
||||
|
||||
drawWaveformSeekbar(canvas)
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawWaveformSeekbar(canvas: Canvas?) {
|
||||
val usableHeight = height - paddingTop - paddingBottom
|
||||
val usableWidth = width - paddingLeft - paddingRight
|
||||
val midpoint = usableHeight / 2f
|
||||
val maxHeight: Float = usableHeight / MAX_HEIGHT_DIVISOR
|
||||
val barGap: Float = (usableWidth - waveData.size * DEFAULT_BAR_WIDTH) / (waveData.size - 1).toFloat()
|
||||
|
||||
canvas?.apply {
|
||||
save()
|
||||
translate(paddingLeft.toFloat(), paddingTop.toFloat())
|
||||
for (i in waveData.indices) {
|
||||
val x: Float = i * (DEFAULT_BAR_WIDTH + barGap) + DEFAULT_BAR_WIDTH / 2f
|
||||
val y: Float = waveData[i] * maxHeight
|
||||
val progress = (x / usableWidth)
|
||||
paint.color = if (progress * max < getProgress()) primary else secondary
|
||||
canvas.drawLine(x, midpoint - y, x, midpoint + y, paint)
|
||||
}
|
||||
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_BAR_WIDTH: Int = 2
|
||||
private const val MAX_HEIGHT_DIVISOR: Float = 4.0f
|
||||
private val Int.dp: Int
|
||||
get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
|
||||
}
|
||||
}
|
|
@ -24,6 +24,8 @@ package com.nextcloud.talk.ui.theme
|
|||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Build
|
||||
|
@ -47,6 +49,7 @@ 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.ui.WaveformSeekBar
|
||||
import com.nextcloud.talk.utils.DisplayUtils
|
||||
import com.nextcloud.talk.utils.DrawableUtils
|
||||
import com.vanniktech.emoji.EmojiTextView
|
||||
|
@ -250,6 +253,16 @@ class TalkSpecificViewThemeUtils @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun themeWaveFormSeekBar(waveformSeekBar: WaveformSeekBar) {
|
||||
withScheme(waveformSeekBar) { scheme ->
|
||||
waveformSeekBar.thumb.colorFilter =
|
||||
PorterDuffColorFilter(scheme.inversePrimary, PorterDuff.Mode.SRC_IN)
|
||||
waveformSeekBar.setColors(scheme.inversePrimary, scheme.onPrimaryContainer)
|
||||
waveformSeekBar.progressDrawable?.colorFilter =
|
||||
PorterDuffColorFilter(scheme.primary, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val THEMEABLE_PLACEHOLDER_IDS = listOf(
|
||||
R.drawable.ic_mimetype_package_x_generic,
|
||||
|
|
167
app/src/main/java/com/nextcloud/talk/utils/AudioUtils.kt
Normal file
167
app/src/main/java/com/nextcloud/talk/utils/AudioUtils.kt
Normal file
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* 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.utils
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodec.CodecException
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* AudioUtils are for processing raw audio using android's low level APIs, for more information read here
|
||||
* [MediaCodec documentation](https://developer.android.com/reference/android/media/MediaCodec)
|
||||
*/
|
||||
object AudioUtils {
|
||||
private val TAG = AudioUtils::class.java.simpleName
|
||||
private const val VALUE_10 = 10
|
||||
private const val TIME_LIMIT = 5000
|
||||
|
||||
/**
|
||||
* Suspension function, returns a FloatArray containing the values of an audio file squeezed between [0,1)
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun audioFileToFloatArray(file: File, size: Int): FloatArray {
|
||||
return suspendCoroutine {
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
var result = mutableListOf<Float>()
|
||||
val path = file.path
|
||||
val mediaExtractor = MediaExtractor()
|
||||
mediaExtractor.setDataSource(path)
|
||||
|
||||
val mediaFormat = mediaExtractor.getTrackFormat(0)
|
||||
mediaFormat.setString(MediaFormat.KEY_FRAME_RATE, null)
|
||||
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 0)
|
||||
|
||||
mediaExtractor.release()
|
||||
|
||||
val mediaCodecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||
val codecName = mediaCodecList.findDecoderForFormat(mediaFormat)
|
||||
val mediaCodec = MediaCodec.createByCodecName(codecName)
|
||||
mediaCodec.setCallback(object : MediaCodec.Callback() {
|
||||
private var extractor: MediaExtractor? = null
|
||||
val tempList = mutableListOf<Float>()
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||
if (extractor == null) {
|
||||
extractor = MediaExtractor()
|
||||
try {
|
||||
extractor!!.setDataSource(path)
|
||||
extractor!!.selectTrack(0)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
val byteBuffer = codec.getInputBuffer(index)
|
||||
if (byteBuffer != null) {
|
||||
val sampleSize = extractor!!.readSampleData(byteBuffer, 0)
|
||||
if (sampleSize > 0) {
|
||||
val isOver = !extractor!!.advance()
|
||||
codec.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
sampleSize,
|
||||
extractor!!.sampleTime,
|
||||
if (isOver) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
||||
val outputBuffer = codec.getOutputBuffer(index)
|
||||
val bufferFormat = codec.getOutputFormat(index)
|
||||
val samples = outputBuffer!!.order(ByteOrder.nativeOrder()).asShortBuffer()
|
||||
val numChannels = bufferFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (index < 0 || index >= numChannels) {
|
||||
return
|
||||
}
|
||||
val sampleLength = (samples.remaining() / numChannels)
|
||||
// Squeezes the value of each sample between [0,1) using y = (x-1)/x
|
||||
for (i in 0 until sampleLength) {
|
||||
val x = abs(samples[i * numChannels + index].toInt()) / VALUE_10
|
||||
val y = (if (x > 0) ((x - 1) / x.toFloat()) else x.toFloat())
|
||||
tempList.add(y)
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
val currTime = SystemClock.elapsedRealtime() - startTime
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM > 0 || currTime > TIME_LIMIT) {
|
||||
codec.stop()
|
||||
codec.release()
|
||||
extractor!!.release()
|
||||
extractor = null
|
||||
if (currTime < TIME_LIMIT) {
|
||||
result = tempList
|
||||
} else {
|
||||
Log.d(TAG, "time limit exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(codec: MediaCodec, e: CodecException) {
|
||||
Log.e(TAG, "Error in MediaCodec Callback: \n$e")
|
||||
codec.stop()
|
||||
codec.release()
|
||||
extractor!!.release()
|
||||
extractor = null
|
||||
result = tempList
|
||||
}
|
||||
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
||||
// unused atm
|
||||
}
|
||||
})
|
||||
mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
|
||||
mediaCodec.configure(mediaFormat, null, null, 0)
|
||||
mediaCodec.start()
|
||||
while (result.size <= 0) {
|
||||
continue
|
||||
}
|
||||
it.resume(shrinkFloatArray(result.toFloatArray(), size))
|
||||
}
|
||||
}
|
||||
|
||||
private fun shrinkFloatArray(data: FloatArray, size: Int): FloatArray {
|
||||
val result = FloatArray(size)
|
||||
val scale = data.size / size
|
||||
var begin = 0
|
||||
var end = scale
|
||||
for (i in 0 until size) {
|
||||
val arr = data.copyOfRange(begin, end)
|
||||
var sum = 0f
|
||||
for (j in arr.indices) {
|
||||
sum += arr[j]
|
||||
}
|
||||
result[i] = (sum / arr.size)
|
||||
begin += scale
|
||||
end += scale
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
|
@ -91,32 +91,55 @@
|
|||
app:iconSize="40dp"
|
||||
app:iconTint="@color/nc_incoming_text_default" />
|
||||
|
||||
<SeekBar
|
||||
|
||||
<com.nextcloud.talk.ui.WaveformSeekBar
|
||||
android:id="@+id/seekbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
tools:progress="50" />
|
||||
android:layout_height="70dp"
|
||||
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
||||
tools:progress="50"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageDuration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
tools:text="00:00" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@id/messageTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
<LinearLayout
|
||||
android:layout_below="@id/messageText"
|
||||
android:layout_marginStart="8dp"
|
||||
android:alpha="0.6"
|
||||
android:textColor="@color/no_emphasis_text"
|
||||
app:layout_alignSelf="center"
|
||||
tools:text="12:38" />
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageDuration"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/standard_margin"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:visibility="invisible"
|
||||
tools:text="02:30"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@id/messageTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:alpha="0.6"
|
||||
android:textColor="@color/no_emphasis_text"
|
||||
android:layout_gravity="center"
|
||||
tools:text="10:35" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/checkMark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/message_bubble_checkmark_height"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@null"
|
||||
android:layout_gravity="center"
|
||||
app:tint="@color/high_emphasis_text" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<include
|
||||
android:id="@+id/reactions"
|
||||
|
|
|
@ -76,43 +76,54 @@
|
|||
app:iconTint="@color/high_emphasis_text"
|
||||
app:rippleColor="#1FFFFFFF" />
|
||||
|
||||
<SeekBar
|
||||
|
||||
<com.nextcloud.talk.ui.WaveformSeekBar
|
||||
android:id="@+id/seekbar"
|
||||
style="@style/Nextcloud.Material.Outgoing.SeekBar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_height="70dp"
|
||||
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
|
||||
tools:progress="50"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageDuration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@id/messageTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
<LinearLayout
|
||||
android:layout_below="@id/messageText"
|
||||
android:layout_marginStart="8dp"
|
||||
android:alpha="0.6"
|
||||
android:textColor="@color/no_emphasis_text"
|
||||
app:layout_alignSelf="center"
|
||||
tools:text="10:35" />
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/voiceMessageDuration"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/standard_margin"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:visibility="invisible"
|
||||
tools:text="02:30"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@id/messageTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:alpha="0.6"
|
||||
android:textColor="@color/no_emphasis_text"
|
||||
android:layout_gravity="center"
|
||||
tools:text="10:35" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/checkMark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/message_bubble_checkmark_height"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@null"
|
||||
android:layout_gravity="center"
|
||||
app:tint="@color/high_emphasis_text" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/checkMark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/message_bubble_checkmark_height"
|
||||
android:layout_below="@id/messageTime"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@null"
|
||||
app:layout_alignSelf="center"
|
||||
app:tint="@color/high_emphasis_text" />
|
||||
|
||||
<include
|
||||
android:id="@+id/reactions"
|
||||
|
|
Loading…
Reference in a new issue