315 lines
10 KiB
Kotlin
315 lines
10 KiB
Kotlin
package org.thoughtcrime.securesms.loki.views
|
|
|
|
import android.animation.ValueAnimator
|
|
import android.content.Context
|
|
import android.graphics.Canvas
|
|
import android.graphics.Color
|
|
import android.graphics.Paint
|
|
import android.graphics.RectF
|
|
import android.util.AttributeSet
|
|
import android.util.TypedValue
|
|
import android.view.MotionEvent
|
|
import android.view.View
|
|
import android.view.ViewConfiguration
|
|
import android.view.animation.DecelerateInterpolator
|
|
import androidx.core.math.MathUtils
|
|
import network.loki.messenger.R
|
|
import org.session.libsession.utilities.byteToNormalizedFloat
|
|
import kotlin.math.abs
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlin.math.roundToInt
|
|
|
|
class WaveformSeekBar : View {
|
|
|
|
companion object {
|
|
@JvmStatic
|
|
fun dp(context: Context, dp: Float): Float {
|
|
return TypedValue.applyDimension(
|
|
TypedValue.COMPLEX_UNIT_DIP,
|
|
dp,
|
|
context.resources.displayMetrics
|
|
)
|
|
}
|
|
}
|
|
|
|
private val sampleDataHolder = SampleDataHolder(::invalidate)
|
|
/** An array of signed byte values representing the audio signal. */
|
|
var sampleData: ByteArray?
|
|
get() {
|
|
return sampleDataHolder.getSamples()
|
|
}
|
|
set(value) {
|
|
sampleDataHolder.setSamples(value)
|
|
invalidate()
|
|
}
|
|
|
|
/** Indicates whether the user is currently interacting with the view and performing a seeking gesture. */
|
|
private var userSeeking = false
|
|
private var _progress: Float = 0f
|
|
/** In [0..1] range. */
|
|
var progress: Float
|
|
set(value) {
|
|
// Do not let to modify the progress value from the outside
|
|
// when the user is currently interacting with the view.
|
|
if (userSeeking) return
|
|
|
|
_progress = value
|
|
invalidate()
|
|
progressChangeListener?.onProgressChanged(this, _progress, false)
|
|
}
|
|
get() {
|
|
return _progress
|
|
}
|
|
|
|
var barBackgroundColor: Int = Color.LTGRAY
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barProgressColor: Int = Color.WHITE
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barGap: Float = dp(context, 2f)
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barWidth: Float = dp(context, 5f)
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barMinHeight: Float = barWidth
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barCornerRadius: Float = dp(context, 2.5f)
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var barGravity: WaveGravity = WaveGravity.CENTER
|
|
set(value) {
|
|
field = value
|
|
invalidate()
|
|
}
|
|
|
|
var progressChangeListener: ProgressChangeListener? = null
|
|
|
|
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
private val barRect = RectF()
|
|
|
|
private var canvasWidth = 0
|
|
private var canvasHeight = 0
|
|
|
|
private var touchDownX = 0f
|
|
private var touchDownProgress: Float = 0f
|
|
private var scaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
|
|
constructor(context: Context) : this(context, null)
|
|
|
|
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
|
|
|
|
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
|
|
: super(context, attrs, defStyleAttr) {
|
|
|
|
val typedAttrs = context.obtainStyledAttributes(attrs, R.styleable.WaveformSeekBar)
|
|
barWidth = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_width, barWidth)
|
|
barGap = typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_gap, barGap)
|
|
barCornerRadius = typedAttrs.getDimension(
|
|
R.styleable.WaveformSeekBar_bar_corner_radius,
|
|
barCornerRadius)
|
|
barMinHeight =
|
|
typedAttrs.getDimension(R.styleable.WaveformSeekBar_bar_min_height, barMinHeight)
|
|
barBackgroundColor = typedAttrs.getColor(
|
|
R.styleable.WaveformSeekBar_bar_background_color,
|
|
barBackgroundColor)
|
|
barProgressColor =
|
|
typedAttrs.getColor(R.styleable.WaveformSeekBar_bar_progress_color, barProgressColor)
|
|
progress = typedAttrs.getFloat(R.styleable.WaveformSeekBar_progress, progress)
|
|
barGravity = WaveGravity.fromString(
|
|
typedAttrs.getString(R.styleable.WaveformSeekBar_bar_gravity))
|
|
|
|
typedAttrs.recycle()
|
|
}
|
|
|
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
super.onSizeChanged(w, h, oldw, oldh)
|
|
canvasWidth = w
|
|
canvasHeight = h
|
|
invalidate()
|
|
}
|
|
|
|
override fun onDraw(canvas: Canvas) {
|
|
super.onDraw(canvas)
|
|
|
|
val totalWidth = getAvailableWidth()
|
|
val barAmount = (totalWidth / (barWidth + barGap)).toInt()
|
|
|
|
var lastBarRight = paddingLeft.toFloat()
|
|
|
|
(0 until barAmount).forEach { barIdx ->
|
|
// Convert a signed byte to a [0..1] float.
|
|
val barValue = byteToNormalizedFloat(sampleDataHolder.computeBarValue(barIdx, barAmount))
|
|
|
|
val barHeight = max(barMinHeight, getAvailableHeight() * barValue)
|
|
|
|
val top: Float = when (barGravity) {
|
|
WaveGravity.TOP -> paddingTop.toFloat()
|
|
WaveGravity.CENTER -> paddingTop + getAvailableHeight() * 0.5f - barHeight * 0.5f
|
|
WaveGravity.BOTTOM -> canvasHeight - paddingBottom - barHeight
|
|
}
|
|
|
|
barRect.set(lastBarRight, top, lastBarRight + barWidth, top + barHeight)
|
|
|
|
barPaint.color = if (barRect.right <= totalWidth * progress)
|
|
barProgressColor else barBackgroundColor
|
|
|
|
canvas.drawRoundRect(barRect, barCornerRadius, barCornerRadius, barPaint)
|
|
|
|
lastBarRight = barRect.right + barGap
|
|
}
|
|
}
|
|
|
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
if (!isEnabled) return false
|
|
|
|
when (event.action) {
|
|
MotionEvent.ACTION_DOWN -> {
|
|
userSeeking = true
|
|
touchDownX = event.x
|
|
touchDownProgress = progress
|
|
updateProgress(event, false)
|
|
}
|
|
MotionEvent.ACTION_MOVE -> {
|
|
// Prevent any parent scrolling if the user scrolled more
|
|
// than scaledTouchSlop on horizontal axis.
|
|
if (abs(event.x - touchDownX) > scaledTouchSlop) {
|
|
parent.requestDisallowInterceptTouchEvent(true)
|
|
}
|
|
updateProgress(event, false)
|
|
}
|
|
MotionEvent.ACTION_UP -> {
|
|
userSeeking = false
|
|
updateProgress(event, true)
|
|
performClick()
|
|
}
|
|
MotionEvent.ACTION_CANCEL -> {
|
|
updateProgress(touchDownProgress, false)
|
|
userSeeking = false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private fun updateProgress(event: MotionEvent, notify: Boolean) {
|
|
updateProgress(event.x / getAvailableWidth(), notify)
|
|
}
|
|
|
|
private fun updateProgress(progress: Float, notify: Boolean) {
|
|
_progress = MathUtils.clamp(progress, 0f, 1f)
|
|
invalidate()
|
|
|
|
if (notify) {
|
|
progressChangeListener?.onProgressChanged(this, _progress, true)
|
|
}
|
|
}
|
|
|
|
override fun performClick(): Boolean {
|
|
super.performClick()
|
|
return true
|
|
}
|
|
|
|
private fun getAvailableWidth() = canvasWidth - paddingLeft - paddingRight
|
|
private fun getAvailableHeight() = canvasHeight - paddingTop - paddingBottom
|
|
|
|
private class SampleDataHolder(private val invalidateDelegate: () -> Any) {
|
|
|
|
private var sampleDataFrom: ByteArray? = null
|
|
private var sampleDataTo: ByteArray? = null
|
|
private var progress = 1f // Mix between from and to values.
|
|
|
|
private var animation: ValueAnimator? = null
|
|
|
|
fun computeBarValue(barIdx: Int, barAmount: Int): Byte {
|
|
/** @return The array's value at the interpolated index. */
|
|
fun getSampleValue(sampleData: ByteArray?): Byte {
|
|
if (sampleData == null || sampleData.isEmpty())
|
|
return Byte.MIN_VALUE
|
|
else {
|
|
val sampleIdx = (barIdx * (sampleData.size / barAmount.toFloat())).toInt()
|
|
return sampleData[sampleIdx]
|
|
}
|
|
}
|
|
|
|
if (progress == 1f) {
|
|
return getSampleValue(sampleDataTo)
|
|
}
|
|
|
|
val fromValue = getSampleValue(sampleDataFrom)
|
|
val toValue = getSampleValue(sampleDataTo)
|
|
val rawResultValue = fromValue * (1f - progress) + toValue * progress
|
|
return rawResultValue.roundToInt().toByte()
|
|
}
|
|
|
|
fun setSamples(sampleData: ByteArray?) {
|
|
/** @return a mix between [sampleDataFrom] and [sampleDataTo] arrays according to the current [progress] value. */
|
|
fun computeNewDataFromArray(): ByteArray? {
|
|
if (sampleDataTo == null) return null
|
|
if (sampleDataFrom == null) return sampleDataTo
|
|
|
|
val sampleSize = min(sampleDataFrom!!.size, sampleDataTo!!.size)
|
|
return ByteArray(sampleSize) { i -> computeBarValue(i, sampleSize) }
|
|
}
|
|
|
|
sampleDataFrom = computeNewDataFromArray()
|
|
sampleDataTo = sampleData
|
|
progress = 0f
|
|
|
|
animation?.cancel()
|
|
animation = ValueAnimator.ofFloat(0f, 1f).apply {
|
|
addUpdateListener { animation ->
|
|
progress = animation.animatedValue as Float
|
|
invalidateDelegate()
|
|
}
|
|
interpolator = DecelerateInterpolator(3f)
|
|
duration = 500
|
|
start()
|
|
}
|
|
}
|
|
|
|
fun getSamples(): ByteArray? {
|
|
return sampleDataTo
|
|
}
|
|
}
|
|
|
|
enum class WaveGravity {
|
|
TOP,
|
|
CENTER,
|
|
BOTTOM,
|
|
;
|
|
|
|
companion object {
|
|
@JvmStatic
|
|
fun fromString(gravity: String?): WaveGravity = when (gravity) {
|
|
"1" -> TOP
|
|
"2" -> CENTER
|
|
else -> BOTTOM
|
|
}
|
|
}
|
|
}
|
|
|
|
interface ProgressChangeListener {
|
|
fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean)
|
|
}
|
|
} |