Use waveform seek bar for audio message view.

This commit is contained in:
Anton Chekulaev 2020-10-07 17:43:14 +11:00
parent fdaadcb2b0
commit e07cb716c0
5 changed files with 447 additions and 89 deletions

View File

@ -70,10 +70,18 @@
</org.thoughtcrime.securesms.components.AnimatingToggle>
<SeekBar android:id="@+id/seek"
<!-- TODO: Extract styling attributes into a theme. -->
<org.thoughtcrime.securesms.components.WaveformSeekBar
android:id="@+id/seek"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"/>
android:layout_height="30dp"
android:layout_gravity="center_vertical"
app:wave_background_color="#bbb"
app:wave_progress_color="?colorPrimary"
app:wave_gravity="center"
app:wave_width="4dp"
app:wave_corner_radius="2dp"
app:wave_gap="1dp"/>
</LinearLayout>

View File

@ -287,4 +287,20 @@
<attr name="labeledEditText_background" format="color" />
<attr name="labeledEditText_textLayout" format="reference" />
</declare-styleable>
<declare-styleable name="WaveformSeekBar">
<attr name="wave_progress" format="float"/>
<attr name="wave_width" format="dimension"/>
<attr name="wave_gap" format="dimension"/>
<attr name="wave_min_height" format="dimension"/>
<attr name="wave_corner_radius" format="dimension"/>
<attr name="wave_background_color" format="color"/>
<attr name="wave_progress_color" format="color"/>
<!-- Corresponds to WaveformSeekBar.WaveGravity enum. -->
<attr name="wave_gravity" format="enum">
<enum name="top" value="1" />
<enum name="center" value="2" />
<enum name="bottom" value="3" />
</attr>
</declare-styleable>
</resources>

View File

@ -32,6 +32,7 @@ import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.attachments.AttachmentServer;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.AudioSlide;
@ -150,7 +151,11 @@ public class AudioSlidePlayer implements SensorEventListener {
case Player.STATE_ENDED:
Log.i(TAG, "onComplete");
long millis = mediaPlayer.getDuration();
synchronized (AudioSlidePlayer.this) {
mediaPlayer.release();
mediaPlayer = null;
if (audioAttachmentServer != null) {
@ -167,6 +172,7 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
notifyOnProgress(1.0, millis);
notifyOnStop();
progressEventHandler.removeMessages(0);
}
@ -233,6 +239,22 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
public synchronized boolean isReady() {
if (mediaPlayer == null) return false;
return mediaPlayer.getPlaybackState() == Player.STATE_READY && mediaPlayer.getPlayWhenReady();
}
public synchronized void seekTo(double progress) throws IOException {
if (mediaPlayer == null) return;
if (isReady()) {
mediaPlayer.seekTo((long) (mediaPlayer.getDuration() * progress));
} else {
play(progress);
}
}
public void setListener(@NonNull Listener listener) {
this.listener = new WeakReference<>(listener);
@ -256,30 +278,15 @@ public class AudioSlidePlayer implements SensorEventListener {
}
private void notifyOnStart() {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onStart();
}
});
Util.runOnMain(() -> getListener().onPlayerStart(AudioSlidePlayer.this));
}
private void notifyOnStop() {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onStop();
}
});
Util.runOnMain(() -> getListener().onPlayerStop(AudioSlidePlayer.this));
}
private void notifyOnProgress(final double progress, final long millis) {
Util.runOnMain(new Runnable() {
@Override
public void run() {
getListener().onProgress(progress, millis);
}
});
Util.runOnMain(() -> getListener().onPlayerProgress(AudioSlidePlayer.this, progress, millis));
}
private @NonNull Listener getListener() {
@ -288,11 +295,11 @@ public class AudioSlidePlayer implements SensorEventListener {
if (listener != null) return listener;
else return new Listener() {
@Override
public void onStart() {}
public void onPlayerStart(@NotNull AudioSlidePlayer player) { }
@Override
public void onStop() {}
public void onPlayerStop(@NotNull AudioSlidePlayer player) { }
@Override
public void onProgress(double progress, long millis) {}
public void onPlayerProgress(@NotNull AudioSlidePlayer player, double progress, long millis) { }
};
}
@ -355,9 +362,9 @@ public class AudioSlidePlayer implements SensorEventListener {
}
public interface Listener {
void onStart();
void onStop();
void onProgress(double progress, long millis);
void onPlayerStart(@NonNull AudioSlidePlayer player);
void onPlayerStop(@NonNull AudioSlidePlayer player);
void onPlayerProgress(@NonNull AudioSlidePlayer player, double progress, long millis);
}
private static class ProgressEventHandler extends Handler {

View File

@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.logging.Log
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.SlideClickListener
import java.io.IOException
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.math.floor
@ -45,7 +46,7 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
private val pauseButton: ImageView
private val downloadButton: ImageView
private val downloadProgress: ProgressWheel
private val seekBar: SeekBar
private val seekBar: WaveformSeekBar
private val timestamp: TextView
private var downloadListener: SlideClickListener? = null
@ -58,21 +59,25 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr) {
View.inflate(context, R.layout.audio_view, this)
container = findViewById<View>(R.id.audio_widget_container) as ViewGroup
controlToggle = findViewById<View>(R.id.control_toggle) as AnimatingToggle
playButton = findViewById<View>(R.id.play) as ImageView
pauseButton = findViewById<View>(R.id.pause) as ImageView
downloadButton = findViewById<View>(R.id.download) as ImageView
downloadProgress = findViewById<View>(R.id.download_progress) as ProgressWheel
seekBar = findViewById<View>(R.id.seek) as SeekBar
timestamp = findViewById<View>(R.id.timestamp) as TextView
container = findViewById(R.id.audio_widget_container)
controlToggle = findViewById(R.id.control_toggle)
playButton = findViewById(R.id.play)
pauseButton = findViewById(R.id.pause)
downloadButton = findViewById(R.id.download)
downloadProgress = findViewById(R.id.download_progress)
seekBar = findViewById(R.id.seek)
timestamp = findViewById(R.id.timestamp)
playButton.setOnClickListener {
try {
Log.d(TAG, "playbutton onClick")
if (audioSlidePlayer != null) {
togglePlayToPause()
audioSlidePlayer!!.play(getProgress())
// Restart the playback if progress bar is near at the end.
val progress = if (seekBar.progress < 0.99f) seekBar.progress.toDouble() else 0.0
audioSlidePlayer!!.play(progress)
}
} catch (e: IOException) {
Log.w(TAG, e)
@ -85,7 +90,17 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
audioSlidePlayer!!.stop()
}
}
seekBar.setOnSeekBarChangeListener(SeekBarModifiedListener())
seekBar.progressChangeListener = object : WaveformSeekBar.ProgressChangeListener {
override fun onProgressChanged(waveformSeekBar: WaveformSeekBar, progress: Float, fromUser: Boolean) {
if (fromUser && audioSlidePlayer != null) {
synchronized(audioSlidePlayer!!) {
audioSlidePlayer!!.seekTo(progress.toDouble())
}
}
}
}
//TODO Remove this.
seekBar.sample = Random().let { (0 until 64).map { i -> it.nextFloat() }.toFloatArray() }
playButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.play_icon))
pauseButton.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.pause_icon))
@ -153,25 +168,41 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
downloadProgress.barColor = foregroundTint
timestamp.setTextColor(foregroundTint)
val colorFilter = createBlendModeColorFilterCompat(foregroundTint, BlendModeCompat.SRC_IN)
seekBar.progressDrawable.colorFilter = colorFilter
seekBar.thumb.colorFilter = colorFilter
// val colorFilter = createBlendModeColorFilterCompat(foregroundTint, BlendModeCompat.SRC_IN)
// seekBar.progressDrawable.colorFilter = colorFilter
// seekBar.thumb.colorFilter = colorFilter
}
override fun onStart() {
override fun onPlayerStart(player: AudioSlidePlayer) {
if (pauseButton.visibility != View.VISIBLE) {
togglePlayToPause()
}
}
override fun onStop() {
override fun onPlayerStop(player: AudioSlidePlayer) {
if (playButton.visibility != View.VISIBLE) {
togglePauseToPlay()
}
if (seekBar.progress + 5 >= seekBar.max) {
backwardsCounter = 4
onProgress(0.0, 0)
// if (seekBar.progress + 5 >= seekBar.max) {
// backwardsCounter = 4
// onProgress(0.0, 0)
// }
}
override fun onPlayerProgress(player: AudioSlidePlayer, progress: Double, millis: Long) {
// val seekProgress = floor(progress * seekBar.max).toInt()
//TODO Update text.
seekBar.progress = progress.toFloat()
// if (/*seekProgress > 1f || */backwardsCounter > 3) {
// backwardsCounter = 0
// seekBar.progress = 1f
// timestamp.text = String.format("%02d:%02d",
// TimeUnit.MILLISECONDS.toMinutes(millis),
// TimeUnit.MILLISECONDS.toSeconds(millis))
// } else {
// backwardsCounter++
// }
}
override fun setFocusable(focusable: Boolean) {
@ -201,27 +232,6 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
downloadButton.isEnabled = enabled
}
override fun onProgress(progress: Double, millis: Long) {
val seekProgress = floor(progress * seekBar.max).toInt()
if (seekProgress > seekBar.progress || backwardsCounter > 3) {
backwardsCounter = 0
seekBar.progress = seekProgress
timestamp.text = String.format("%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(millis),
TimeUnit.MILLISECONDS.toSeconds(millis))
} else {
backwardsCounter++
}
}
private fun getProgress(): Double {
return if (seekBar.progress <= 0 || seekBar.max <= 0) {
0.0
} else {
seekBar.progress.toDouble() / seekBar.max.toDouble()
}
}
private fun togglePlayToPause() {
controlToggle.displayQuick(pauseButton)
val playToPauseDrawable = ContextCompat.getDrawable(context, R.drawable.play_to_pause_animation) as AnimatedVectorDrawable
@ -236,27 +246,27 @@ class AudioView: FrameLayout, AudioSlidePlayer.Listener {
pauseToPlayDrawable.start()
}
private inner class SeekBarModifiedListener : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}
@Synchronized
override fun onStartTrackingTouch(seekBar: SeekBar) {
if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
audioSlidePlayer!!.stop()
}
}
@Synchronized
override fun onStopTrackingTouch(seekBar: SeekBar) {
try {
if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
audioSlidePlayer!!.play(getProgress())
}
} catch (e: IOException) {
Log.w(TAG, e)
}
}
}
// private inner class SeekBarModifiedListener : OnSeekBarChangeListener {
// override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {}
//
// @Synchronized
// override fun onStartTrackingTouch(seekBar: SeekBar) {
// if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
// audioSlidePlayer!!.stop()
// }
// }
//
// @Synchronized
// override fun onStopTrackingTouch(seekBar: SeekBar) {
// try {
// if (audioSlidePlayer != null && pauseButton.visibility == View.VISIBLE) {
// audioSlidePlayer!!.play(getProgress())
// }
// } catch (e: IOException) {
// Log.w(TAG, e)
// }
// }
// }
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventAsync(event: PartProgressEvent) {

View File

@ -0,0 +1,317 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.*
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import network.loki.messenger.R
import java.lang.IllegalArgumentException
import java.lang.Math.abs
class WaveformSeekBar : View {
companion object {
@JvmStatic
inline fun dp(context: Context, dp: Float): Float {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp,
context.resources.displayMetrics
)
}
@JvmStatic
inline fun smooth(values: FloatArray, neighborWeight: Float = 1f): FloatArray {
if (values.size < 3) return values
val result = FloatArray(values.size)
result[0] = values[0]
result[values.size - 1] == values[values.size - 1]
for (i in 1 until values.size - 1) {
result[i] =
(values[i] + values[i - 1] * neighborWeight + values[i + 1] * neighborWeight) / (1f + neighborWeight * 2f)
}
return result
}
}
var sample: FloatArray = floatArrayOf(0f)
set(value) {
if (value.isEmpty()) throw IllegalArgumentException("Sample array cannot be empty")
// field = smooth(value, 0.25f)
field = 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 waveBackgroundColor: Int = Color.LTGRAY
set(value) {
field = value
invalidate()
}
var waveProgressColor: Int = Color.WHITE
set(value) {
field = value
invalidate()
}
var waveGap: Float =
dp(
context,
2f
)
set(value) {
field = value
invalidate()
}
var waveWidth: Float =
dp(
context,
5f
)
set(value) {
field = value
invalidate()
}
var waveMinHeight: Float = waveWidth
set(value) {
field = value
invalidate()
}
var waveCornerRadius: Float =
dp(
context,
2.5f
)
set(value) {
field = value
invalidate()
}
var waveGravity: WaveGravity =
WaveGravity.CENTER
set(value) {
field = value
invalidate()
}
var progressChangeListener: ProgressChangeListener? = null
private val postponedProgressUpdateHandler = Handler(Looper.getMainLooper())
private val postponedProgressUpdateRunnable = Runnable {
progressChangeListener?.onProgressChanged(this, progress, true)
}
private val wavePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val waveRect = RectF()
private val progressCanvas = Canvas()
private var canvasWidth = 0
private var canvasHeight = 0
private var maxValue =
dp(
context,
2f
)
private var touchDownX = 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
)
waveWidth = typedAttrs.getDimension(R.styleable.WaveformSeekBar_wave_width, waveWidth)
waveGap = typedAttrs.getDimension(R.styleable.WaveformSeekBar_wave_gap, waveGap)
waveCornerRadius = typedAttrs.getDimension(
R.styleable.WaveformSeekBar_wave_corner_radius,
waveCornerRadius
)
waveMinHeight =
typedAttrs.getDimension(R.styleable.WaveformSeekBar_wave_min_height, waveMinHeight)
waveBackgroundColor = typedAttrs.getColor(
R.styleable.WaveformSeekBar_wave_background_color,
waveBackgroundColor
)
waveProgressColor =
typedAttrs.getColor(R.styleable.WaveformSeekBar_wave_progress_color, waveProgressColor)
progress = typedAttrs.getFloat(R.styleable.WaveformSeekBar_wave_progress, progress)
waveGravity =
WaveGravity.fromString(
typedAttrs.getString(R.styleable.WaveformSeekBar_wave_gravity)
)
typedAttrs.recycle()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
canvasWidth = w
canvasHeight = h
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val totalWidth = getAvailableWith()
maxValue = sample.max()!!
val step = (totalWidth / (waveGap + waveWidth)) / sample.size
var lastWaveRight = paddingLeft.toFloat()
var i = 0f
while (i < sample.size) {
var waveHeight = if (maxValue != 0f) {
getAvailableHeight() * (sample[i.toInt()] / maxValue)
} else {
waveMinHeight
}
if (waveHeight < waveMinHeight) {
waveHeight = waveMinHeight
}
val top: Float = when (waveGravity) {
WaveGravity.TOP -> paddingTop.toFloat()
WaveGravity.CENTER -> paddingTop + getAvailableHeight() / 2f - waveHeight / 2f
WaveGravity.BOTTOM -> canvasHeight - paddingBottom - waveHeight
}
waveRect.set(lastWaveRight, top, lastWaveRight + waveWidth, top + waveHeight)
wavePaint.color = if (waveRect.right <= totalWidth * progress)
waveProgressColor else waveBackgroundColor
canvas.drawRoundRect(waveRect, waveCornerRadius, waveCornerRadius, wavePaint)
lastWaveRight = waveRect.right + waveGap
if (lastWaveRight + waveWidth > totalWidth + paddingLeft)
break
i += 1f / step
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isEnabled) return false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
userSeeking = true
if (isParentScrolling()) {
touchDownX = event.x
} else {
updateProgress(event, true)
}
}
MotionEvent.ACTION_MOVE -> {
updateProgress(event, true)
}
MotionEvent.ACTION_UP -> {
userSeeking = false
if (abs(event.x - touchDownX) > scaledTouchSlop) {
updateProgress(event, false)
}
performClick()
}
}
return true
}
private fun isParentScrolling(): Boolean {
var parent = parent as View
val root = rootView
while (true) {
when {
parent.canScrollHorizontally(+1) -> return true
parent.canScrollHorizontally(-1) -> return true
parent.canScrollVertically(+1) -> return true
parent.canScrollVertically(-1) -> return true
}
if (parent == root) return false
parent = parent.parent as View
}
}
private fun updateProgress(event: MotionEvent, delayNotification: Boolean) {
_progress = event.x / getAvailableWith()
invalidate()
postponedProgressUpdateHandler.removeCallbacks(postponedProgressUpdateRunnable)
if (delayNotification) {
// Re-post delayed user update notification to throttle a bit.
postponedProgressUpdateHandler.postDelayed(postponedProgressUpdateRunnable, 150)
} else {
postponedProgressUpdateRunnable.run()
}
}
override fun performClick(): Boolean {
super.performClick()
return true
}
private fun getAvailableWith() = canvasWidth - paddingLeft - paddingRight
private fun getAvailableHeight() = canvasHeight - paddingTop - paddingBottom
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)
}
}