Merge pull request #3202 from nextcloud/seekbar-waveform-for-audio-messages

Waveform SeekBar for Voice Messages
This commit is contained in:
Andy Scherzinger 2023-08-02 15:29:24 +02:00 committed by GitHub
commit 3ab4d99c4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 475 additions and 70 deletions

View file

@ -101,8 +101,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
setParentMessageDataOnMessageItem(message) setParentMessageDataOnMessageItem(message)
updateDownloadState(message) updateDownloadState(message)
binding.seekbar.max = message.voiceMessageDuration - 1 binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar) viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT) viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
if (message.isPlayingVoiceMessage) { if (message.isPlayingVoiceMessage) {
@ -115,7 +115,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
val t = message.voiceMessagePlayedSeconds.toLong() val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true) binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else { } else {
binding.playPauseBtn.visibility = View.VISIBLE binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable( binding.playPauseBtn.icon = ContextCompat.getDrawable(
@ -127,6 +127,11 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
if (message.isDownloadingVoiceMessage) { if (message.isDownloadingVoiceMessage) {
showVoiceMessageLoading() showVoiceMessageLoading()
} else { } else {
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
binding.seekbar.setWaveData(FloatArray(0))
} else {
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
} }
@ -139,7 +144,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
binding.seekbar.progress = SEEKBAR_START binding.seekbar.progress = SEEKBAR_START
message.resetVoiceMessage = false message.resetVoiceMessage = false
message.voiceMessagePlayedSeconds = 0 message.voiceMessagePlayedSeconds = 0
binding.voiceMessageDuration.visibility = View.GONE binding.voiceMessageDuration.visibility = View.INVISIBLE
} }
binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { binding.seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
@ -330,5 +335,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
companion object { companion object {
private const val TAG = "VoiceInMessageView" private const val TAG = "VoiceInMessageView"
private const val SEEKBAR_START: Int = 0 private const val SEEKBAR_START: Int = 0
private const val ONE_SEC: Int = 1000
} }
} }

View file

@ -98,8 +98,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
setParentMessageDataOnMessageItem(message) setParentMessageDataOnMessageItem(message)
updateDownloadState(message) updateDownloadState(message)
binding.seekbar.max = message.voiceMessageDuration - 1 binding.seekbar.max = message.voiceMessageDuration * ONE_SEC
viewThemeUtils.platform.themeHorizontalSeekBar(binding.seekbar) viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar)
viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT) viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT)
handleIsPlayingVoiceMessageState(message) handleIsPlayingVoiceMessageState(message)
@ -176,7 +176,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
) )
binding.seekbar.progress = SEEKBAR_START binding.seekbar.progress = SEEKBAR_START
message.voiceMessagePlayedSeconds = 0 message.voiceMessagePlayedSeconds = 0
binding.voiceMessageDuration.visibility = View.GONE binding.voiceMessageDuration.visibility = View.INVISIBLE
message.resetVoiceMessage = false message.resetVoiceMessage = false
} }
} }
@ -185,6 +185,11 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
if (message.isDownloadingVoiceMessage) { if (message.isDownloadingVoiceMessage) {
showVoiceMessageLoading() showVoiceMessageLoading()
} else { } else {
if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) {
binding.seekbar.setWaveData(FloatArray(0))
} else {
binding.seekbar.setWaveData(message.voiceMessageFloatArray!!)
}
binding.progressBar.visibility = View.GONE binding.progressBar.visibility = View.GONE
} }
} }
@ -201,7 +206,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
val t = message.voiceMessagePlayedSeconds.toLong() val t = message.voiceMessagePlayedSeconds.toLong()
binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t)
binding.voiceMessageDuration.visibility = View.VISIBLE binding.voiceMessageDuration.visibility = View.VISIBLE
binding.seekbar.setProgress(message.voiceMessagePlayedSeconds, true) binding.seekbar.progress = message.voiceMessageSeekbarProgress
} else { } else {
binding.playPauseBtn.visibility = View.VISIBLE binding.playPauseBtn.visibility = View.VISIBLE
binding.playPauseBtn.icon = ContextCompat.getDrawable( binding.playPauseBtn.icon = ContextCompat.getDrawable(
@ -313,5 +318,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
companion object { companion object {
private const val TAG = "VoiceOutMessageView" private const val TAG = "VoiceOutMessageView"
private const val SEEKBAR_START: Int = 0 private const val SEEKBAR_START: Int = 0
private const val ONE_SEC: Int = 1000
} }
} }

View file

@ -188,6 +188,7 @@ import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.AudioUtils
import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.ContactUtils
import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.ConversationUtils
import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateConstants
@ -232,6 +233,10 @@ import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers 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.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException import retrofit2.HttpException
@ -343,6 +348,11 @@ class ChatActivity :
AudioFormat.CHANNEL_IN_MONO, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT 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 lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null private var videoURI: Uri? = null
@ -861,21 +871,45 @@ class ChatActivity :
adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) } adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
adapter?.registerViewClickListener( adapter?.registerViewClickListener(
R.id.playPauseBtn R.id.playPauseBtn
) { view, message -> ) { _, message ->
val filename = message.selectedIndividualHashMap!!["name"] val filename = message.selectedIndividualHashMap!!["name"]
val file = File(context.cacheDir, filename!!) val file = File(context.cacheDir, filename!!)
if (file.exists()) { if (file.exists()) {
if (message.isPlayingVoiceMessage) { if (message.isPlayingVoiceMessage) {
pausePlayback(message) pausePlayback(message)
} else { } else {
startPlayback(message) setUpWaveform(message)
} }
} else { } else {
Log.d(TAG, "Downloaded to cache")
downloadFileToCache(message) 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 { private fun initMessageHolders(): MessageHolders {
val messageHolders = MessageHolders() val messageHolders = MessageHolders()
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!) val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!)
@ -1215,7 +1249,6 @@ class ChatActivity :
setDataSource(currentVoiceRecordFile) setDataSource(currentVoiceRecordFile)
prepare() prepare()
setOnPreparedListener { setOnPreparedListener {
Log.d(TAG, "Julius the duration is ${it.duration}")
binding.messageInputView.seekBar.progress = 0 binding.messageInputView.seekBar.progress = 0
binding.messageInputView.seekBar.max = it.duration binding.messageInputView.seekBar.max = it.duration
voicePreviewObjectAnimator = ObjectAnimator.ofInt( voicePreviewObjectAnimator = ObjectAnimator.ofInt(
@ -1742,6 +1775,7 @@ class ChatActivity :
mediaPlayer?.let { mediaPlayer?.let {
if (!it.isPlaying) { if (!it.isPlaying) {
it.start() it.start()
Log.d(TAG, "MediaPlayer has Started")
} }
mediaPlayerHandler = Handler() mediaPlayerHandler = Handler()
@ -1751,17 +1785,20 @@ class ChatActivity :
if (message.isPlayingVoiceMessage) { if (message.isPlayingVoiceMessage) {
val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE
if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) { if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) {
lastRecordMediaPosition = mediaPlayer!!.currentPosition
message.voiceMessagePlayedSeconds = pos message.voiceMessagePlayedSeconds = pos
message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition
adapter?.update(message) adapter?.update(message)
} else { } else {
message.resetVoiceMessage = true message.resetVoiceMessage = true
message.voiceMessagePlayedSeconds = 0 message.voiceMessagePlayedSeconds = 0
message.voiceMessageSeekbarProgress = 0
adapter?.update(message) adapter?.update(message)
stopMediaPlayer(message) stopMediaPlayer(message)
} }
} }
} }
mediaPlayerHandler.postDelayed(this, SECOND) mediaPlayerHandler.postDelayed(this, 15)
} }
}) })
@ -1774,6 +1811,7 @@ class ChatActivity :
private fun pausePlayback(message: ChatMessage) { private fun pausePlayback(message: ChatMessage) {
if (mediaPlayer!!.isPlaying) { if (mediaPlayer!!.isPlaying) {
mediaPlayer!!.pause() mediaPlayer!!.pause()
Log.d(TAG, "MediaPlayer is paused")
} }
message.isPlayingVoiceMessage = false message.isPlayingVoiceMessage = false
@ -1794,14 +1832,23 @@ class ChatActivity :
mediaPlayer = MediaPlayer().apply { mediaPlayer = MediaPlayer().apply {
setDataSource(absolutePath) setDataSource(absolutePath)
prepare() prepare()
} setOnPreparedListener {
currentlyPlayedVoiceMessage = message currentlyPlayedVoiceMessage = message
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
lastRecordedSeeked = false
mediaPlayer!!.setOnCompletionListener { }
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) stopMediaPlayer(message)
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "failed to initialize mediaPlayer", e) Log.e(TAG, "failed to initialize mediaPlayer", e)
Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
@ -1836,7 +1883,7 @@ class ChatActivity :
override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) { override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) {
if (mediaPlayer != null) { if (mediaPlayer != null) {
if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) { 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) WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
.observeForever { workInfo: WorkInfo -> .observeForever { workInfo: WorkInfo ->
if (workInfo.state == WorkInfo.State.SUCCEEDED) { 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_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping" private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping" 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
} }
} }

View file

@ -42,7 +42,6 @@ import com.stfalcon.chatkit.commons.models.IUser
import com.stfalcon.chatkit.commons.models.MessageContentType import com.stfalcon.chatkit.commons.models.MessageContentType
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Arrays
import java.util.Date import java.util.Date
@Parcelize @Parcelize
@ -132,7 +131,11 @@ data class ChatMessage(
var voiceMessagePlayedSeconds: Int = 0, var voiceMessagePlayedSeconds: Int = 0,
var voiceMessageDownloadProgress: Int = 0 var voiceMessageDownloadProgress: Int = 0,
var voiceMessageSeekbarProgress: Int = 0,
var voiceMessageFloatArray: FloatArray? = null
) : Parcelable, MessageContentType, MessageContentType.Image { ) : Parcelable, MessageContentType, MessageContentType.Image {
@ -140,7 +143,7 @@ data class ChatMessage(
// messageTypesToIgnore is weird. must be deleted by refactoring!!! // messageTypesToIgnore is weird. must be deleted by refactoring!!!
@JsonIgnore @JsonIgnore
var messageTypesToIgnore = Arrays.asList( var messageTypesToIgnore = listOf(
MessageType.REGULAR_TEXT_MESSAGE, MessageType.REGULAR_TEXT_MESSAGE,
MessageType.SYSTEM_MESSAGE, MessageType.SYSTEM_MESSAGE,
MessageType.SINGLE_LINK_VIDEO_MESSAGE, MessageType.SINGLE_LINK_VIDEO_MESSAGE,
@ -417,6 +420,17 @@ data class ChatMessage(
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray()) 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 val isVoiceMessage: Boolean
get() = "voice-message" == messageType get() = "voice-message" == messageType
val isCommandMessage: Boolean val isCommandMessage: Boolean

View 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()
}
}

View file

@ -24,6 +24,8 @@ package com.nextcloud.talk.ui.theme
import android.annotation.TargetApi import android.annotation.TargetApi
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.os.Build 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.android.common.ui.theme.utils.AndroidXViewThemeUtils
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.MicInputCloud
import com.nextcloud.talk.ui.WaveformSeekBar
import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.DrawableUtils
import com.vanniktech.emoji.EmojiTextView 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 { companion object {
private val THEMEABLE_PLACEHOLDER_IDS = listOf( private val THEMEABLE_PLACEHOLDER_IDS = listOf(
R.drawable.ic_mimetype_package_x_generic, R.drawable.ic_mimetype_package_x_generic,

View 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
}
}

View file

@ -91,32 +91,55 @@
app:iconSize="40dp" app:iconSize="40dp"
app:iconTint="@color/nc_incoming_text_default" /> app:iconTint="@color/nc_incoming_text_default" />
<SeekBar
<com.nextcloud.talk.ui.WaveformSeekBar
android:id="@+id/seekbar" android:id="@+id/seekbar"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="70dp"
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
tools:progress="50"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_below="@id/messageText"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:orientation="horizontal">
tools:progress="50" />
<TextView <TextView
android:id="@+id/voiceMessageDuration" android:id="@+id/voiceMessageDuration"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:layout_marginStart="@dimen/standard_margin"
tools:text="00:00" /> android:layout_gravity="center"
android:layout_weight="1"
</LinearLayout> android:visibility="invisible"
tools:text="02:30"
tools:visibility="visible" />
<TextView <TextView
android:id="@id/messageTime" android:id="@id/messageTime"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/messageText"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:alpha="0.6" android:alpha="0.6"
android:textColor="@color/no_emphasis_text" android:textColor="@color/no_emphasis_text"
app:layout_alignSelf="center" android:layout_gravity="center"
tools:text="12:38" /> 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 <include
android:id="@+id/reactions" android:id="@+id/reactions"

View file

@ -76,43 +76,54 @@
app:iconTint="@color/high_emphasis_text" app:iconTint="@color/high_emphasis_text"
app:rippleColor="#1FFFFFFF" /> app:rippleColor="#1FFFFFFF" />
<SeekBar
<com.nextcloud.talk.ui.WaveformSeekBar
android:id="@+id/seekbar" android:id="@+id/seekbar"
style="@style/Nextcloud.Material.Outgoing.SeekBar"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="40dp" android:layout_height="70dp"
android:thumb="@drawable/voice_message_outgoing_seek_bar_slider" android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
tools:progress="50" tools:progress="50"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_below="@id/messageText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView <TextView
android:id="@+id/voiceMessageDuration" android:id="@+id/voiceMessageDuration"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" /> android:layout_marginStart="@dimen/standard_margin"
android:layout_gravity="center"
</LinearLayout> android:layout_weight="1"
android:visibility="invisible"
tools:text="02:30"
tools:visibility="visible" />
<TextView <TextView
android:id="@id/messageTime" android:id="@id/messageTime"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/messageText"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:alpha="0.6" android:alpha="0.6"
android:textColor="@color/no_emphasis_text" android:textColor="@color/no_emphasis_text"
app:layout_alignSelf="center" android:layout_gravity="center"
tools:text="10:35" /> tools:text="10:35" />
<ImageView <ImageView
android:id="@+id/checkMark" android:id="@+id/checkMark"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="@dimen/message_bubble_checkmark_height" android:layout_height="@dimen/message_bubble_checkmark_height"
android:layout_below="@id/messageTime"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:contentDescription="@null" android:contentDescription="@null"
app:layout_alignSelf="center" android:layout_gravity="center"
app:tint="@color/high_emphasis_text" /> app:tint="@color/high_emphasis_text" />
</LinearLayout>
<include <include
android:id="@+id/reactions" android:id="@+id/reactions"