Browse Source

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

Waveform SeekBar for Voice Messages
Andy Scherzinger 1 year ago
parent
commit
3ab4d99c4a

+ 10 - 4
app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt

@@ -101,8 +101,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) :
         setParentMessageDataOnMessageItem(message)
         setParentMessageDataOnMessageItem(message)
 
 
         updateDownloadState(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)
         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
     }
     }
 }
 }

+ 10 - 4
app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt

@@ -98,8 +98,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) :
         setParentMessageDataOnMessageItem(message)
         setParentMessageDataOnMessageItem(message)
 
 
         updateDownloadState(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)
         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
     }
     }
 }
 }

+ 63 - 13
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

@@ -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,13 +1832,22 @@ class ChatActivity :
                 mediaPlayer = MediaPlayer().apply {
                 mediaPlayer = MediaPlayer().apply {
                     setDataSource(absolutePath)
                     setDataSource(absolutePath)
                     prepare()
                     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) {
             } catch (e: Exception) {
                 Log.e(TAG, "failed to initialize mediaPlayer", e)
                 Log.e(TAG, "failed to initialize mediaPlayer", e)
@@ -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
     }
     }
 }
 }

+ 17 - 3
app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.kt

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

+ 115 - 0
app/src/main/java/com/nextcloud/talk/ui/WaveformSeekBar.kt

@@ -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()
+    }
+}

+ 13 - 0
app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt

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

+ 167 - 0
app/src/main/java/com/nextcloud/talk/utils/AudioUtils.kt

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

+ 38 - 15
app/src/main/res/layout/item_custom_incoming_voice_message.xml

@@ -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:orientation="horizontal">
+
+            <TextView
+                android:id="@+id/voiceMessageDuration"
+                android:layout_width="0dp"
                 android:layout_height="wrap_content"
                 android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/standard_margin"
+                android:layout_gravity="center"
                 android:layout_weight="1"
                 android:layout_weight="1"
-                tools:progress="50" />
+                android:visibility="invisible"
+                tools:text="02:30"
+                tools:visibility="visible" />
 
 
             <TextView
             <TextView
-                android:id="@+id/voiceMessageDuration"
+                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:visibility="gone"
-                tools:text="00:00" />
+                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>
         </LinearLayout>
 
 
-        <TextView
-            android:id="@id/messageTime"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            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" />
 
 
         <include
         <include
             android:id="@+id/reactions"
             android:id="@+id/reactions"

+ 35 - 24
app/src/main/res/layout/item_custom_outcoming_voice_message.xml

@@ -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="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_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_height="wrap_content"
-                android:visibility="gone" />
+                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>
         </LinearLayout>
 
 
-        <TextView
-            android:id="@id/messageTime"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            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" />
-
-        <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
         <include
             android:id="@+id/reactions"
             android:id="@+id/reactions"