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

View file

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

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

View file

@ -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

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.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,

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: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"

View file

@ -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"