diff options
8 files changed, 391 insertions, 17 deletions
diff --git a/packages/SystemUI/res-keyguard/drawable/media_squiggly_progress.xml b/packages/SystemUI/res-keyguard/drawable/media_squiggly_progress.xml new file mode 100644 index 000000000000..9e61236aa7df --- /dev/null +++ b/packages/SystemUI/res-keyguard/drawable/media_squiggly_progress.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2022 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<com.android.systemui.media.SquigglyProgress />
\ No newline at end of file diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 5ca7285d3f73..c64beb58449c 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -979,6 +979,10 @@ <dimen name="qs_media_session_disabled_seekbar_vertical_padding">16dp</dimen> <dimen name="qs_media_session_height_expanded">184dp</dimen> <dimen name="qs_media_session_height_collapsed">128dp</dimen> + <dimen name="qs_media_seekbar_progress_wavelength">20dp</dimen> + <dimen name="qs_media_seekbar_progress_amplitude">1.5dp</dimen> + <dimen name="qs_media_seekbar_progress_phase">8dp</dimen> + <dimen name="qs_media_seekbar_progress_stroke_width">2dp</dimen> <!-- Size of Smartspace media recommendations cards in the QSPanel carousel --> <dimen name="qs_aa_media_rec_album_size_collapsed">72dp</dimen> diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml index f2eaa75496e8..3ae21e08005d 100644 --- a/packages/SystemUI/res/values/styles.xml +++ b/packages/SystemUI/res/values/styles.xml @@ -590,6 +590,7 @@ <style name="MediaPlayer.ProgressBar" parent="@android:style/Widget.ProgressBar.Horizontal"> <item name="android:thumbTint">?android:attr/textColorPrimary</item> + <item name="android:progressDrawable">@drawable/media_squiggly_progress</item> <item name="android:progressTint">?android:attr/textColorPrimary</item> <item name="android:progressBackgroundTint">?android:attr/textColorTertiary</item> <item name="android:clickable">true</item> diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt index cf997055c692..57701ab618c9 100644 --- a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt +++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt @@ -50,25 +50,46 @@ class SeekBarObserver( .getDimensionPixelSize(R.dimen.qs_media_disabled_seekbar_vertical_padding) } + init { + val seekBarProgressWavelength = holder.seekBar.context.resources + .getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_wavelength).toFloat() + val seekBarProgressAmplitude = holder.seekBar.context.resources + .getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_amplitude).toFloat() + val seekBarProgressPhase = holder.seekBar.context.resources + .getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_phase).toFloat() + val seekBarProgressStrokeWidth = holder.seekBar.context.resources + .getDimensionPixelSize(R.dimen.qs_media_seekbar_progress_stroke_width).toFloat() + val progressDrawable = holder.seekBar.progressDrawable as? SquigglyProgress + progressDrawable?.let { + it.waveLength = seekBarProgressWavelength + it.lineAmplitude = seekBarProgressAmplitude + it.phaseSpeed = seekBarProgressPhase + it.strokeWidth = seekBarProgressStrokeWidth + } + } + /** Updates seek bar views when the data model changes. */ @UiThread override fun onChanged(data: SeekBarViewModel.Progress) { + val progressDrawable = holder.seekBar.progressDrawable as? SquigglyProgress if (!data.enabled) { if (holder.seekBar.maxHeight != seekBarDisabledHeight) { holder.seekBar.maxHeight = seekBarDisabledHeight setVerticalPadding(seekBarDisabledVerticalPadding) } - holder.seekBar.setEnabled(false) - holder.seekBar.getThumb().setAlpha(0) - holder.seekBar.setProgress(0) - holder.elapsedTimeView?.setText("") - holder.totalTimeView?.setText("") + holder.seekBar.isEnabled = false + progressDrawable?.animate = false + holder.seekBar.thumb.alpha = 0 + holder.seekBar.progress = 0 + holder.elapsedTimeView?.text = "" + holder.totalTimeView?.text = "" holder.seekBar.contentDescription = "" return } - holder.seekBar.getThumb().setAlpha(if (data.seekAvailable) 255 else 0) - holder.seekBar.setEnabled(data.seekAvailable) + holder.seekBar.thumb.alpha = if (data.seekAvailable) 255 else 0 + holder.seekBar.isEnabled = data.seekAvailable + progressDrawable?.animate = data.playing if (holder.seekBar.maxHeight != seekBarEnabledMaxHeight) { holder.seekBar.maxHeight = seekBarEnabledMaxHeight diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt index 9cf9c4815b67..329dec24a5c3 100644 --- a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt @@ -31,6 +31,7 @@ import androidx.core.view.GestureDetectorCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.statusbar.NotificationMediaManager import com.android.systemui.util.concurrency.RepeatableExecutor import javax.inject.Inject @@ -73,7 +74,7 @@ private fun PlaybackState.computePosition(duration: Long): Long { class SeekBarViewModel @Inject constructor( @Background private val bgExecutor: RepeatableExecutor ) { - private var _data = Progress(false, false, null, 0) + private var _data = Progress(false, false, false, null, 0) set(value) { field = value _progress.postValue(value) @@ -192,10 +193,12 @@ class SeekBarViewModel @Inject constructor( val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L val position = playbackState?.position?.toInt() val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt() ?: 0 + val playing = NotificationMediaManager + .isPlayingState(playbackState?.state ?: PlaybackState.STATE_NONE) val enabled = if (playbackState == null || playbackState?.getState() == PlaybackState.STATE_NONE || (duration <= 0)) false else true - _data = Progress(enabled, seekAvailable, position, duration) + _data = Progress(enabled, seekAvailable, playing, position, duration) checkIfPollingNeeded() } @@ -412,6 +415,7 @@ class SeekBarViewModel @Inject constructor( data class Progress( val enabled: Boolean, val seekAvailable: Boolean, + val playing: Boolean, val elapsedTime: Int?, val duration: Int ) diff --git a/packages/SystemUI/src/com/android/systemui/media/SquigglyProgress.kt b/packages/SystemUI/src/com/android/systemui/media/SquigglyProgress.kt new file mode 100644 index 000000000000..f1058e28863a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/SquigglyProgress.kt @@ -0,0 +1,184 @@ +package com.android.systemui.media + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable +import android.os.SystemClock +import androidx.annotation.VisibleForTesting +import com.android.systemui.animation.Interpolators +import kotlin.math.abs +import kotlin.math.cos + +private const val TAG = "Squiggly" + +private const val TWO_PI = (Math.PI * 2f).toFloat() +@VisibleForTesting +internal const val DISABLED_ALPHA = 77 + +class SquigglyProgress : Drawable() { + + private val wavePaint = Paint() + private val linePaint = Paint() + private val path = Path() + private var heightFraction = 0f + private var heightAnimator: ValueAnimator? = null + private var phaseOffset = 0f + private var lastFrameTime = -1L + + // Horizontal length of the sine wave + var waveLength = 0f + // Height of each peak of the sine wave + var lineAmplitude = 0f + // Line speed in px per second + var phaseSpeed = 0f + // Progress stroke width, both for wave and solid line + var strokeWidth = 0f + set(value) { + if (field == value) { + return + } + field = value + wavePaint.strokeWidth = value + linePaint.strokeWidth = value + } + + init { + wavePaint.strokeCap = Paint.Cap.ROUND + linePaint.strokeCap = Paint.Cap.ROUND + linePaint.style = Paint.Style.STROKE + wavePaint.style = Paint.Style.STROKE + linePaint.alpha = DISABLED_ALPHA + } + + var animate: Boolean = false + set(value) { + if (field == value) { + return + } + field = value + if (field) { + lastFrameTime = SystemClock.uptimeMillis() + } + heightAnimator?.cancel() + heightAnimator = ValueAnimator.ofFloat(heightFraction, if (animate) 1f else 0f).apply { + if (animate) { + startDelay = 60 + duration = 800 + interpolator = Interpolators.EMPHASIZED_DECELERATE + } else { + duration = 550 + interpolator = Interpolators.STANDARD_DECELERATE + } + addUpdateListener { + heightFraction = it.animatedValue as Float + invalidateSelf() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + heightAnimator = null + } + }) + start() + } + } + + override fun draw(canvas: Canvas) { + if (animate) { + invalidateSelf() + val now = SystemClock.uptimeMillis() + phaseOffset -= (now - lastFrameTime) / 1000f * phaseSpeed + phaseOffset %= waveLength + lastFrameTime = now + } + + val totalProgressPx = (bounds.width() * (level / 10_000f)) + canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.centerY().toFloat()) + // Clip drawing, so we stop at the thumb + canvas.clipRect( + 0f, + -lineAmplitude - strokeWidth, + totalProgressPx, + lineAmplitude + strokeWidth) + + // The squiggly line + val start = phaseOffset + var currentX = start + var waveSign = 1f + path.rewind() + path.moveTo(start, lineAmplitude * heightFraction) + while (currentX < totalProgressPx) { + val nextX = currentX + waveLength / 2f + val nextWaveSign = waveSign * -1 + path.cubicTo( + currentX + waveLength / 4f, lineAmplitude * waveSign * heightFraction, + nextX - waveLength / 4f, lineAmplitude * nextWaveSign * heightFraction, + nextX, lineAmplitude * nextWaveSign * heightFraction) + currentX = nextX + waveSign = nextWaveSign + } + wavePaint.style = Paint.Style.STROKE + canvas.drawPath(path, wavePaint) + canvas.restore() + + // Draw round line cap at the beginning of the wave + val startAmp = cos(abs(phaseOffset) / waveLength * TWO_PI) + val p = Paint() + p.color = Color.WHITE + canvas.drawPoint( + bounds.left.toFloat(), + bounds.centerY() + startAmp * lineAmplitude * heightFraction, + wavePaint) + + // Draw continuous line, to the right of the thumb + canvas.drawLine( + bounds.left.toFloat() + totalProgressPx, + bounds.centerY().toFloat(), + bounds.width().toFloat(), + bounds.centerY().toFloat(), + linePaint) + } + + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + wavePaint.colorFilter = colorFilter + linePaint.colorFilter = colorFilter + } + + override fun setAlpha(alpha: Int) { + wavePaint.alpha = alpha + linePaint.alpha = (DISABLED_ALPHA * (alpha / 255f)).toInt() + } + + override fun getAlpha(): Int { + return wavePaint.alpha + } + + override fun setTint(tintColor: Int) { + wavePaint.color = tintColor + linePaint.color = tintColor + } + + override fun onLevelChange(level: Int): Boolean { + return animate + } + + override fun setTintList(tint: ColorStateList?) { + if (tint == null) { + return + } + wavePaint.color = tint.defaultColor + linePaint.color = tint.defaultColor + } +}
\ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt index 3c2392a30731..7ac15125ea7e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt @@ -26,29 +26,33 @@ import com.android.systemui.R import com.android.systemui.SysuiTestCase import com.google.common.truth.Truth.assertThat import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit import org.mockito.Mockito.`when` as whenever @SmallTest @RunWith(AndroidTestingRunner::class) @TestableLooper.RunWithLooper -public class SeekBarObserverTest : SysuiTestCase() { +class SeekBarObserverTest : SysuiTestCase() { private val disabledHeight = 1 private val enabledHeight = 2 private lateinit var observer: SeekBarObserver @Mock private lateinit var mockHolder: PlayerViewHolder + @Mock private lateinit var mockSquigglyProgress: SquigglyProgress private lateinit var seekBarView: SeekBar private lateinit var elapsedTimeView: TextView private lateinit var totalTimeView: TextView + @JvmField @Rule val mockitoRule = MockitoJUnit.rule() + @Before fun setUp() { - mockHolder = mock(PlayerViewHolder::class.java) context.orCreateTestableResources .addOverride(R.dimen.qs_media_enabled_seekbar_height, enabledHeight) @@ -56,6 +60,7 @@ public class SeekBarObserverTest : SysuiTestCase() { .addOverride(R.dimen.qs_media_disabled_seekbar_height, disabledHeight) seekBarView = SeekBar(context) + seekBarView.progressDrawable = mockSquigglyProgress elapsedTimeView = TextView(context) totalTimeView = TextView(context) whenever(mockHolder.seekBar).thenReturn(seekBarView) @@ -69,7 +74,7 @@ public class SeekBarObserverTest : SysuiTestCase() { fun seekBarGone() { // WHEN seek bar is disabled val isEnabled = false - val data = SeekBarViewModel.Progress(isEnabled, false, null, 0) + val data = SeekBarViewModel.Progress(isEnabled, false, false, null, 0) observer.onChanged(data) // THEN seek bar shows just a thin line with no text assertThat(seekBarView.isEnabled()).isFalse() @@ -84,7 +89,7 @@ public class SeekBarObserverTest : SysuiTestCase() { fun seekBarVisible() { // WHEN seek bar is enabled val isEnabled = true - val data = SeekBarViewModel.Progress(isEnabled, true, 3000, 12000) + val data = SeekBarViewModel.Progress(isEnabled, true, false, 3000, 12000) observer.onChanged(data) // THEN seek bar is visible and thick assertThat(seekBarView.getVisibility()).isEqualTo(View.VISIBLE) @@ -96,7 +101,7 @@ public class SeekBarObserverTest : SysuiTestCase() { @Test fun seekBarProgress() { // WHEN part of the track has been played - val data = SeekBarViewModel.Progress(true, true, 3000, 120000) + val data = SeekBarViewModel.Progress(true, true, true, 3000, 120000) observer.onChanged(data) // THEN seek bar shows the progress assertThat(seekBarView.progress).isEqualTo(3000) @@ -112,7 +117,7 @@ public class SeekBarObserverTest : SysuiTestCase() { fun seekBarDisabledWhenSeekNotAvailable() { // WHEN seek is not available val isSeekAvailable = false - val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000) + val data = SeekBarViewModel.Progress(true, isSeekAvailable, false, 3000, 120000) observer.onChanged(data) // THEN seek bar is not enabled assertThat(seekBarView.isEnabled()).isFalse() @@ -122,9 +127,29 @@ public class SeekBarObserverTest : SysuiTestCase() { fun seekBarEnabledWhenSeekNotAvailable() { // WHEN seek is available val isSeekAvailable = true - val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000) + val data = SeekBarViewModel.Progress(true, isSeekAvailable, false, 3000, 120000) observer.onChanged(data) // THEN seek bar is not enabled assertThat(seekBarView.isEnabled()).isTrue() } + + @Test + fun seekBarPlaying() { + // WHEN playing + val isPlaying = true + val data = SeekBarViewModel.Progress(true, true, isPlaying, 3000, 120000) + observer.onChanged(data) + // THEN progress drawable is animating + verify(mockSquigglyProgress).animate = true + } + + @Test + fun seekBarNotPlaying() { + // WHEN not playing + val isPlaying = false + val data = SeekBarViewModel.Progress(true, true, isPlaying, 3000, 120000) + observer.onChanged(data) + // THEN progress drawable is not animating + verify(mockSquigglyProgress).animate = false + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SquigglyProgressTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SquigglyProgressTest.kt new file mode 100644 index 000000000000..0787fd65eae1 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/SquigglyProgressTest.kt @@ -0,0 +1,118 @@ +package com.android.systemui.media + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LightingColorFilter +import android.graphics.Paint +import android.graphics.Rect +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.mockito.any +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.anyFloat +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +class SquigglyProgressTest : SysuiTestCase() { + + private val colorFilter = LightingColorFilter(Color.RED, Color.BLUE) + private val strokeWidth = 5f + private val alpha = 128 + private val tint = Color.GREEN + + lateinit var squigglyProgress: SquigglyProgress + @Mock lateinit var canvas: Canvas + @Captor lateinit var wavePaintCaptor: ArgumentCaptor<Paint> + @Captor lateinit var linePaintCaptor: ArgumentCaptor<Paint> + @JvmField @Rule val mockitoRule = MockitoJUnit.rule() + + @Before + fun setup() { + squigglyProgress = SquigglyProgress() + squigglyProgress.waveLength = 30f + squigglyProgress.lineAmplitude = 10f + squigglyProgress.phaseSpeed = 8f + squigglyProgress.strokeWidth = strokeWidth + squigglyProgress.bounds = Rect(0, 0, 300, 30) + } + + @Test + fun testDrawPathAndLine() { + squigglyProgress.draw(canvas) + + verify(canvas).drawPath(any(), wavePaintCaptor.capture()) + verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(), + linePaintCaptor.capture()) + } + + @Test + fun testOnLevelChanged() { + assertThat(squigglyProgress.setLevel(5)).isFalse() + squigglyProgress.animate = true + assertThat(squigglyProgress.setLevel(4)).isTrue() + } + + @Test + fun testStrokeWidth() { + squigglyProgress.draw(canvas) + + verify(canvas).drawPath(any(), wavePaintCaptor.capture()) + verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(), + linePaintCaptor.capture()) + + assertThat(wavePaintCaptor.value.strokeWidth).isEqualTo(strokeWidth) + assertThat(linePaintCaptor.value.strokeWidth).isEqualTo(strokeWidth) + } + + @Test + fun testAlpha() { + squigglyProgress.alpha = alpha + squigglyProgress.draw(canvas) + + verify(canvas).drawPath(any(), wavePaintCaptor.capture()) + verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(), + linePaintCaptor.capture()) + + assertThat(squigglyProgress.alpha).isEqualTo(alpha) + assertThat(wavePaintCaptor.value.alpha).isEqualTo(alpha) + assertThat(linePaintCaptor.value.alpha).isEqualTo((alpha / 255f * DISABLED_ALPHA).toInt()) + } + + @Test + fun testColorFilter() { + squigglyProgress.colorFilter = colorFilter + squigglyProgress.draw(canvas) + + verify(canvas).drawPath(any(), wavePaintCaptor.capture()) + verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(), + linePaintCaptor.capture()) + + assertThat(wavePaintCaptor.value.colorFilter).isEqualTo(colorFilter) + assertThat(linePaintCaptor.value.colorFilter).isEqualTo(colorFilter) + } + + @Test + fun testTint() { + squigglyProgress.setTint(tint) + squigglyProgress.draw(canvas) + + verify(canvas).drawPath(any(), wavePaintCaptor.capture()) + verify(canvas).drawLine(anyFloat(), anyFloat(), anyFloat(), anyFloat(), + linePaintCaptor.capture()) + + assertThat(wavePaintCaptor.value.color).isEqualTo(tint) + assertThat(linePaintCaptor.value.color).isEqualTo(tint) + } +}
\ No newline at end of file |