From e07cb716c005504e5a1396834cf4bf099bc99cd2 Mon Sep 17 00:00:00 2001 From: Anton Chekulaev Date: Wed, 7 Oct 2020 17:43:14 +1100 Subject: [PATCH] Use waveform seek bar for audio message view. --- res/layout/audio_view.xml | 14 +- res/values/attrs.xml | 16 + .../securesms/audio/AudioSlidePlayer.java | 55 +-- .../securesms/components/AudioView.kt | 134 ++++---- .../securesms/components/WaveformSeekBar.kt | 317 ++++++++++++++++++ 5 files changed, 447 insertions(+), 89 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/components/WaveformSeekBar.kt diff --git a/res/layout/audio_view.xml b/res/layout/audio_view.xml index e5a33d9a41..38687742db 100644 --- a/res/layout/audio_view.xml +++ b/res/layout/audio_view.xml @@ -70,10 +70,18 @@ - + + 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"/> diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 63c47b155d..62fadc1379 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -287,4 +287,20 @@ + + + + + + + + + + + + + + + + diff --git a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java index fa65d129e4..4c7878e543 100644 --- a/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java +++ b/src/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java @@ -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 { diff --git a/src/org/thoughtcrime/securesms/components/AudioView.kt b/src/org/thoughtcrime/securesms/components/AudioView.kt index 6e640662d8..ee47ac0b8a 100644 --- a/src/org/thoughtcrime/securesms/components/AudioView.kt +++ b/src/org/thoughtcrime/securesms/components/AudioView.kt @@ -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(R.id.audio_widget_container) as ViewGroup - controlToggle = findViewById(R.id.control_toggle) as AnimatingToggle - playButton = findViewById(R.id.play) as ImageView - pauseButton = findViewById(R.id.pause) as ImageView - downloadButton = findViewById(R.id.download) as ImageView - downloadProgress = findViewById(R.id.download_progress) as ProgressWheel - seekBar = findViewById(R.id.seek) as SeekBar - timestamp = findViewById(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) { diff --git a/src/org/thoughtcrime/securesms/components/WaveformSeekBar.kt b/src/org/thoughtcrime/securesms/components/WaveformSeekBar.kt new file mode 100644 index 0000000000..19113d9eb7 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/WaveformSeekBar.kt @@ -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) + } +} \ No newline at end of file