summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Hawkwood Glazier <jglazier@google.com> 2022-03-24 22:58:55 +0000
committer Hawkwood Glazier <jglazier@google.com> 2022-04-13 15:14:11 +0000
commit70e724d10db4a89355d989bdf159715f4ed6eba4 (patch)
tree62692327dad6bbe3edef11045a15460f51f523eb
parentff5064e71353fd31e064c1eac4276caebb8ae116 (diff)
Update prev/next media player animation
Fixes: 224976580 Test: Manual & some automated Change-Id: I38249dee3d13c8be5b9768a49cb6041b8ce98b09
-rw-r--r--packages/SystemUI/res/anim/media_metadata_enter.xml31
-rw-r--r--packages/SystemUI/res/anim/media_metadata_exit.xml32
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/AnimationBindHandler.kt81
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/ColorSchemeTransition.kt169
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java396
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt86
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt34
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt144
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt149
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt73
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt177
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt25
12 files changed, 1181 insertions, 216 deletions
diff --git a/packages/SystemUI/res/anim/media_metadata_enter.xml b/packages/SystemUI/res/anim/media_metadata_enter.xml
new file mode 100644
index 000000000000..fccb7667c41c
--- /dev/null
+++ b/packages/SystemUI/res/anim/media_metadata_enter.xml
@@ -0,0 +1,31 @@
+<?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.
+-->
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+ android:ordering="together">
+ <objectAnimator
+ android:propertyName="translationX"
+ android:valueFrom="32dp"
+ android:valueTo="0dp"
+ android:duration="600"
+ android:valueType="floatType" />
+ <objectAnimator
+ android:propertyName="transitionAlpha"
+ android:valueFrom="0"
+ android:valueTo="1"
+ android:duration="167"
+ android:valueType="floatType" />
+</set> \ No newline at end of file
diff --git a/packages/SystemUI/res/anim/media_metadata_exit.xml b/packages/SystemUI/res/anim/media_metadata_exit.xml
new file mode 100644
index 000000000000..0ee1171c3bf0
--- /dev/null
+++ b/packages/SystemUI/res/anim/media_metadata_exit.xml
@@ -0,0 +1,32 @@
+<?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.
+-->
+<set xmlns:android="http://schemas.android.com/apk/res/android"
+ android:ordering="together">
+ <objectAnimator
+ android:propertyName="translationX"
+ android:valueFrom="0dp"
+ android:valueTo="-32dp"
+ android:duration="167"
+ android:valueType="floatType" />
+ <objectAnimator
+ android:propertyName="transitionAlpha"
+ android:valueFrom="1"
+ android:valueTo="0"
+ android:duration="83"
+ android:startOffset="83"
+ android:valueType="floatType" />
+</set>
diff --git a/packages/SystemUI/src/com/android/systemui/media/AnimationBindHandler.kt b/packages/SystemUI/src/com/android/systemui/media/AnimationBindHandler.kt
new file mode 100644
index 000000000000..013683e962a4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/AnimationBindHandler.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import android.graphics.drawable.Animatable2
+import android.graphics.drawable.Drawable
+
+/**
+ * AnimationBindHandler is responsible for tracking the bound animation state and preventing
+ * jank and conflicts due to media notifications arriving at any time during an animation. It
+ * does this in two parts.
+ * - Exit animations fired as a result of user input are tracked. When these are running, any
+ * bind actions are delayed until the animation completes (and then fired in sequence).
+ * - Continuous animations are tracked using their rebind id. Later calls using the same
+ * rebind id will be totally ignored to prevent the continuous animation from restarting.
+ */
+internal class AnimationBindHandler : Animatable2.AnimationCallback() {
+ private val onAnimationsComplete = mutableListOf<() -> Unit>()
+ private val registrations = mutableListOf<Animatable2>()
+ private var rebindId: Int? = null
+
+ val isAnimationRunning: Boolean
+ get() = registrations.any { it.isRunning }
+
+ /**
+ * This check prevents rebinding to the action button if the identifier has not changed. A
+ * null value is always considered to be changed. This is used to prevent the connecting
+ * animation from rebinding (and restarting) if multiple buffer PlaybackStates are pushed by
+ * an application in a row.
+ */
+ fun updateRebindId(newRebindId: Int?): Boolean {
+ if (rebindId == null || newRebindId == null || rebindId != newRebindId) {
+ rebindId = newRebindId
+ return true
+ }
+ return false
+ }
+
+ fun tryRegister(drawable: Drawable?) {
+ if (drawable is Animatable2) {
+ val anim = drawable as Animatable2
+ anim.registerAnimationCallback(this)
+ registrations.add(anim)
+ }
+ }
+
+ fun unregisterAll() {
+ registrations.forEach { it.unregisterAnimationCallback(this) }
+ registrations.clear()
+ }
+
+ fun tryExecute(action: () -> Unit) {
+ if (isAnimationRunning) {
+ onAnimationsComplete.add(action)
+ } else {
+ action()
+ }
+ }
+
+ override fun onAnimationEnd(drawable: Drawable) {
+ super.onAnimationEnd(drawable)
+ if (!isAnimationRunning) {
+ onAnimationsComplete.forEach { it() }
+ onAnimationsComplete.clear()
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/media/ColorSchemeTransition.kt b/packages/SystemUI/src/com/android/systemui/media/ColorSchemeTransition.kt
new file mode 100644
index 000000000000..8f0305f6dc6d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/ColorSchemeTransition.kt
@@ -0,0 +1,169 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import android.animation.ArgbEvaluator
+import android.animation.ValueAnimator.AnimatorUpdateListener
+import android.animation.ValueAnimator
+import android.content.Context
+import android.content.res.ColorStateList
+import com.android.internal.R
+import com.android.internal.annotations.VisibleForTesting
+import com.android.settingslib.Utils
+import com.android.systemui.monet.ColorScheme
+
+/**
+ * ColorTransition is responsible for managing the animation between two specific colors.
+ * It uses a ValueAnimator to execute the animation and interpolate between the source color and
+ * the target color.
+ *
+ * Selection of the target color from the scheme, and application of the interpolated color
+ * are delegated to callbacks.
+ */
+open class ColorTransition(
+ private val defaultColor: Int,
+ private val extractColor: (ColorScheme) -> Int,
+ private val applyColor: (Int) -> Unit
+) : AnimatorUpdateListener {
+
+ private val argbEvaluator = ArgbEvaluator()
+ private val valueAnimator = buildAnimator()
+ var sourceColor: Int = defaultColor
+ var currentColor: Int = defaultColor
+ var targetColor: Int = defaultColor
+
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ currentColor = argbEvaluator.evaluate(
+ animation.animatedFraction, sourceColor, targetColor
+ ) as Int
+ applyColor(currentColor)
+ }
+
+ fun updateColorScheme(scheme: ColorScheme?) {
+ val newTargetColor = if (scheme == null) defaultColor else extractColor(scheme)
+ if (newTargetColor != targetColor) {
+ sourceColor = currentColor
+ targetColor = newTargetColor
+ valueAnimator.cancel()
+ valueAnimator.start()
+ }
+ }
+
+ init {
+ applyColor(defaultColor)
+ }
+
+ @VisibleForTesting
+ open fun buildAnimator(): ValueAnimator {
+ val animator = ValueAnimator.ofFloat(0f, 1f)
+ animator.duration = 333
+ animator.addUpdateListener(this)
+ return animator
+ }
+}
+
+typealias ColorTransitionFactory = (Int, (ColorScheme) -> Int, (Int) -> Unit) -> ColorTransition
+
+/**
+ * ColorSchemeTransition constructs a ColorTransition for each color in the scheme
+ * that needs to be transitioned when changed. It also sets up the assignment functions for sending
+ * the sending the interpolated colors to the appropriate views.
+ */
+class ColorSchemeTransition internal constructor(
+ private val context: Context,
+ bgColor: Int,
+ mediaViewHolder: MediaViewHolder,
+ colorTransitionFactory: ColorTransitionFactory
+) {
+ constructor(context: Context, bgColor: Int, mediaViewHolder: MediaViewHolder) :
+ this(context, bgColor, mediaViewHolder, ::ColorTransition)
+
+ val surfaceColor = colorTransitionFactory(
+ bgColor,
+ { colorScheme -> colorScheme.accent2[9] }, // A2-800
+ { surfaceColor ->
+ val colorList = ColorStateList.valueOf(surfaceColor)
+ mediaViewHolder.player.backgroundTintList = colorList
+ mediaViewHolder.albumView.foregroundTintList = colorList
+ mediaViewHolder.albumView.backgroundTintList = colorList
+ mediaViewHolder.seamlessIcon.imageTintList = colorList
+ mediaViewHolder.seamlessText.setTextColor(surfaceColor)
+ mediaViewHolder.dismissText.setTextColor(surfaceColor)
+ })
+
+ val accentPrimary = colorTransitionFactory(
+ loadDefaultColor(R.attr.textColorPrimary),
+ { colorScheme -> colorScheme.accent1[2] }, // A1-100
+ { accentPrimary ->
+ val accentColorList = ColorStateList.valueOf(accentPrimary)
+ mediaViewHolder.actionPlayPause.backgroundTintList = accentColorList
+ mediaViewHolder.seamlessButton.backgroundTintList = accentColorList
+ mediaViewHolder.settings.imageTintList = accentColorList
+ mediaViewHolder.cancelText.backgroundTintList = accentColorList
+ mediaViewHolder.dismissText.backgroundTintList = accentColorList
+ })
+
+ val textPrimary = colorTransitionFactory(
+ loadDefaultColor(R.attr.textColorPrimary),
+ { colorScheme -> colorScheme.neutral1[1] }, // N1-50
+ { textPrimary ->
+ mediaViewHolder.titleText.setTextColor(textPrimary)
+ val textColorList = ColorStateList.valueOf(textPrimary)
+ mediaViewHolder.seekBar.thumb.setTintList(textColorList)
+ mediaViewHolder.seekBar.progressTintList = textColorList
+ mediaViewHolder.longPressText.setTextColor(textColorList)
+ mediaViewHolder.cancelText.setTextColor(textColorList)
+ mediaViewHolder.scrubbingElapsedTimeView.setTextColor(textColorList)
+ mediaViewHolder.scrubbingTotalTimeView.setTextColor(textColorList)
+ for (button in mediaViewHolder.getTransparentActionButtons()) {
+ button.imageTintList = textColorList
+ }
+ })
+
+ val textPrimaryInverse = colorTransitionFactory(
+ loadDefaultColor(R.attr.textColorPrimaryInverse),
+ { colorScheme -> colorScheme.neutral1[10] }, // N1-900
+ { textPrimaryInverse ->
+ mediaViewHolder.actionPlayPause.imageTintList =
+ ColorStateList.valueOf(textPrimaryInverse)
+ })
+
+ val textSecondary = colorTransitionFactory(
+ loadDefaultColor(R.attr.textColorSecondary),
+ { colorScheme -> colorScheme.neutral2[3] }, // N2-200
+ { textSecondary -> mediaViewHolder.artistText.setTextColor(textSecondary) })
+
+ val textTertiary = colorTransitionFactory(
+ loadDefaultColor(R.attr.textColorTertiary),
+ { colorScheme -> colorScheme.neutral2[5] }, // N2-400
+ { textTertiary ->
+ mediaViewHolder.seekBar.progressBackgroundTintList =
+ ColorStateList.valueOf(textTertiary)
+ })
+
+ val colorTransitions = arrayOf(
+ surfaceColor, accentPrimary, textPrimary,
+ textPrimaryInverse, textSecondary, textTertiary)
+
+ private fun loadDefaultColor(id: Int): Int {
+ return Utils.getColorAttr(context, id).defaultColor
+ }
+
+ fun updateColorScheme(colorScheme: ColorScheme?) {
+ colorTransitions.forEach { it.updateColorScheme(colorScheme) }
+ }
+} \ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index c95678311093..1b6d1d5825da 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -18,6 +18,9 @@ package com.android.systemui.media;
import static android.provider.Settings.ACTION_MEDIA_CONTROLS_SETTINGS;
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.animation.AnimatorSet;
import android.app.PendingIntent;
import android.app.WallpaperColors;
import android.app.smartspace.SmartspaceAction;
@@ -31,20 +34,22 @@ import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
-import android.graphics.drawable.Animatable2;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
+import android.graphics.drawable.TransitionDrawable;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Process;
import android.text.TextUtils;
import android.util.Log;
+import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.animation.Interpolator;
import android.widget.ImageButton;
import android.widget.ImageView;
-import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -52,12 +57,14 @@ import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.constraintlayout.widget.ConstraintSet;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.logging.InstanceId;
import com.android.settingslib.widget.AdaptiveIcon;
import com.android.systemui.R;
import com.android.systemui.animation.ActivityLaunchAnimator;
import com.android.systemui.animation.GhostedViewLaunchAnimatorController;
+import com.android.systemui.animation.Interpolators;
import com.android.systemui.broadcast.BroadcastSender;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
@@ -148,6 +155,11 @@ public class MediaControlPanel {
private MediaCarouselController mMediaCarouselController;
private final MediaOutputDialogFactory mMediaOutputDialogFactory;
private final FalsingManager mFalsingManager;
+ private MetadataAnimationHandler mMetadataAnimationHandler;
+ private ColorSchemeTransition mColorSchemeTransition;
+ private Drawable mPrevArtwork = null;
+ private int mArtworkBoundId = 0;
+ private int mArtworkNextBindRequestId = 0;
// Used for logging.
protected boolean mIsImpressed = false;
@@ -171,15 +183,20 @@ public class MediaControlPanel {
* @param activityStarter activity starter
*/
@Inject
- public MediaControlPanel(Context context,
+ public MediaControlPanel(
+ Context context,
@Background Executor backgroundExecutor,
@Main Executor mainExecutor,
- ActivityStarter activityStarter, BroadcastSender broadcastSender,
- MediaViewController mediaViewController, SeekBarViewModel seekBarViewModel,
+ ActivityStarter activityStarter,
+ BroadcastSender broadcastSender,
+ MediaViewController mediaViewController,
+ SeekBarViewModel seekBarViewModel,
Lazy<MediaDataManager> lazyMediaDataManager,
MediaOutputDialogFactory mediaOutputDialogFactory,
MediaCarouselController mediaCarouselController,
- FalsingManager falsingManager, SystemClock systemClock, MediaUiEventLogger logger) {
+ FalsingManager falsingManager,
+ SystemClock systemClock,
+ MediaUiEventLogger logger) {
mContext = context;
mBackgroundExecutor = backgroundExecutor;
mMainExecutor = mainExecutor;
@@ -306,6 +323,33 @@ public class MediaControlPanel {
mActivityStarter.startActivity(SETTINGS_INTENT, true /* dismissShade */);
}
});
+
+ TextView titleText = mMediaViewHolder.getTitleText();
+ TextView artistText = mMediaViewHolder.getArtistText();
+ AnimatorSet enter = loadAnimator(R.anim.media_metadata_enter,
+ Interpolators.EMPHASIZED_DECELERATE, titleText, artistText);
+ AnimatorSet exit = loadAnimator(R.anim.media_metadata_exit,
+ Interpolators.EMPHASIZED_ACCELERATE, titleText, artistText);
+
+ mColorSchemeTransition = new ColorSchemeTransition(
+ mContext, mBackgroundColor, mMediaViewHolder);
+ mMetadataAnimationHandler = new MetadataAnimationHandler(exit, enter);
+ }
+
+ @VisibleForTesting
+ protected AnimatorSet loadAnimator(int animId, Interpolator motionInterpolator,
+ View... targets) {
+ ArrayList<Animator> animators = new ArrayList<>();
+ for (View target : targets) {
+ AnimatorSet animator = (AnimatorSet) AnimatorInflater.loadAnimator(mContext, animId);
+ animator.getChildAnimations().get(0).setInterpolator(motionInterpolator);
+ animator.setTarget(target);
+ animators.add(animator);
+ }
+
+ AnimatorSet result = new AnimatorSet();
+ result.playTogether(animators);
+ return result;
}
/** Attaches the recommendations to the recommendation view holder. */
@@ -377,20 +421,6 @@ public class MediaControlPanel {
});
}
- // Accessibility label
- mMediaViewHolder.getPlayer().setContentDescription(
- mContext.getString(
- R.string.controls_media_playing_item_description,
- data.getSong(), data.getArtist(), data.getApp()));
-
- // Song name
- TextView titleText = mMediaViewHolder.getTitleText();
- titleText.setText(data.getSong());
-
- // Artist name
- TextView artistText = mMediaViewHolder.getArtistText();
- artistText.setText(data.getArtist());
-
// Seek Bar
final MediaController controller = getController();
mBackgroundExecutor.execute(() -> mSeekBarViewModel.updateController(controller));
@@ -399,11 +429,16 @@ public class MediaControlPanel {
bindLongPressMenu(data);
bindActionButtons(data);
bindScrubbingTime(data);
- bindArtworkAndColors(data);
+
+ boolean isSongUpdated = bindSongMetadata(data);
+ bindArtworkAndColors(data, isSongUpdated);
// TODO: We don't need to refresh this state constantly, only if the state actually changed
// to something which might impact the measurement
- mMediaViewController.refreshState();
+ // State refresh interferes with the translation animation, only run it if it's not running.
+ if (!mMetadataAnimationHandler.isRunning()) {
+ mMediaViewController.refreshState();
+ }
}
private void bindOutputSwitcherChip(MediaData data) {
@@ -494,120 +529,135 @@ public class MediaControlPanel {
});
}
- private void bindArtworkAndColors(MediaData data) {
- // Default colors
- int surfaceColor = mBackgroundColor;
- int accentPrimary = com.android.settingslib.Utils.getColorAttr(mContext,
- com.android.internal.R.attr.textColorPrimary).getDefaultColor();
- int textPrimary = com.android.settingslib.Utils.getColorAttr(mContext,
- com.android.internal.R.attr.textColorPrimary).getDefaultColor();
- int textPrimaryInverse = com.android.settingslib.Utils.getColorAttr(mContext,
- com.android.internal.R.attr.textColorPrimaryInverse).getDefaultColor();
- int textSecondary = com.android.settingslib.Utils.getColorAttr(mContext,
- com.android.internal.R.attr.textColorSecondary).getDefaultColor();
- int textTertiary = com.android.settingslib.Utils.getColorAttr(mContext,
- com.android.internal.R.attr.textColorTertiary).getDefaultColor();
-
- // Album art
- ColorScheme colorScheme = null;
- ImageView albumView = mMediaViewHolder.getAlbumView();
- boolean hasArtwork = data.getArtwork() != null;
- if (hasArtwork) {
- colorScheme = new ColorScheme(WallpaperColors.fromBitmap(data.getArtwork().getBitmap()),
- true);
-
- // Scale artwork to fit background
- int width = mMediaViewHolder.getPlayer().getWidth();
- int height = mMediaViewHolder.getPlayer().getHeight();
- Drawable artwork = getScaledBackground(data.getArtwork(), width, height);
- albumView.setPadding(0, 0, 0, 0);
- albumView.setImageDrawable(artwork);
- albumView.setClipToOutline(true);
- } else {
- // If there's no artwork, use colors from the app icon
- try {
- Drawable icon = mContext.getPackageManager().getApplicationIcon(
- data.getPackageName());
- colorScheme = new ColorScheme(WallpaperColors.fromDrawable(icon), true);
- } catch (PackageManager.NameNotFoundException e) {
- Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
- }
- }
+ private boolean bindSongMetadata(MediaData data) {
+ // Accessibility label
+ mMediaViewHolder.getPlayer().setContentDescription(
+ mContext.getString(
+ R.string.controls_media_playing_item_description,
+ data.getSong(), data.getArtist(), data.getApp()));
- // Get colors for player
- if (colorScheme != null) {
- surfaceColor = colorScheme.getAccent2().get(9); // A2-800
- accentPrimary = colorScheme.getAccent1().get(2); // A1-100
- textPrimary = colorScheme.getNeutral1().get(1); // N1-50
- textPrimaryInverse = colorScheme.getNeutral1().get(10); // N1-900
- textSecondary = colorScheme.getNeutral2().get(3); // N2-200
- textTertiary = colorScheme.getNeutral2().get(5); // N2-400
- }
+ TextView titleText = mMediaViewHolder.getTitleText();
+ TextView artistText = mMediaViewHolder.getArtistText();
+ return mMetadataAnimationHandler.setNext(
+ Pair.create(data.getSong(), data.getArtist()),
+ () -> {
+ titleText.setText(data.getSong());
+ artistText.setText(data.getArtist());
+
+ // refreshState is required here to resize the text views (and prevent ellipsis)
+ mMediaViewController.refreshState();
+
+ // Use OnPreDrawListeners to enforce zero alpha on these views for a frame.
+ // TransitionLayout insists on resetting the alpha of these views to 1 when onLayout
+ // is called which causes the animation to look bad. These suppress that behavior.
+ titleText.getViewTreeObserver().addOnPreDrawListener(
+ new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ titleText.setAlpha(0);
+ titleText.getViewTreeObserver().removeOnPreDrawListener(this);
+ return true;
+ }
+ });
+
+ artistText.getViewTreeObserver().addOnPreDrawListener(
+ new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ artistText.setAlpha(0);
+ artistText.getViewTreeObserver().removeOnPreDrawListener(this);
+ return true;
+ }
+ });
+
+ return Unit.INSTANCE;
+ },
+ () -> {
+ // After finishing the enter animation, we refresh state. This could pop if
+ // something is incorrectly bound, but needs to be run if other elements were
+ // updated while the enter animation was running
+ mMediaViewController.refreshState();
+ return Unit.INSTANCE;
+ });
+ }
- ColorStateList bgColorList = ColorStateList.valueOf(surfaceColor);
- ColorStateList accentColorList = ColorStateList.valueOf(accentPrimary);
- ColorStateList textColorList = ColorStateList.valueOf(textPrimary);
-
- // Gradient and background (visible when there is no art)
- albumView.setForegroundTintList(ColorStateList.valueOf(surfaceColor));
- albumView.setBackgroundTintList(
- ColorStateList.valueOf(surfaceColor));
- mMediaViewHolder.getPlayer().setBackgroundTintList(bgColorList);
-
- // App icon - use notification icon
- ImageView appIconView = mMediaViewHolder.getAppIcon();
- appIconView.clearColorFilter();
- if (data.getAppIcon() != null && !data.getResumption()) {
- appIconView.setImageIcon(data.getAppIcon());
- appIconView.setColorFilter(accentPrimary);
- } else {
- // Resume players use launcher icon
- appIconView.setColorFilter(getGrayscaleFilter());
- try {
- Drawable icon = mContext.getPackageManager().getApplicationIcon(
- data.getPackageName());
- appIconView.setImageDrawable(icon);
- } catch (PackageManager.NameNotFoundException e) {
- Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
- appIconView.setImageResource(R.drawable.ic_music_note);
+ private void bindArtworkAndColors(MediaData data, boolean updateBackground) {
+ final int reqId = mArtworkNextBindRequestId++;
+
+ // Capture width & height from views in foreground for artwork scaling in background
+ int width = mMediaViewHolder.getPlayer().getWidth();
+ int height = mMediaViewHolder.getPlayer().getHeight();
+
+ // WallpaperColors.fromBitmap takes a good amount of time. We do that work
+ // on the background executor to avoid stalling animations on the UI Thread.
+ mBackgroundExecutor.execute(() -> {
+ // Album art
+ ColorScheme mutableColorScheme = null;
+ Drawable artwork;
+ Icon artworkIcon = data.getArtwork();
+ if (artworkIcon != null) {
+ WallpaperColors wallpaperColors = WallpaperColors
+ .fromBitmap(artworkIcon.getBitmap());
+ mutableColorScheme = new ColorScheme(wallpaperColors, true);
+ artwork = getScaledBackground(artworkIcon, width, height);
+ } else {
+ // If there's no artwork, use colors from the app icon
+ artwork = null;
+ try {
+ Drawable icon = mContext.getPackageManager()
+ .getApplicationIcon(data.getPackageName());
+ mutableColorScheme = new ColorScheme(WallpaperColors.fromDrawable(icon), true);
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
+ }
}
- }
- // Metadata text
- mMediaViewHolder.getTitleText().setTextColor(textPrimary);
- mMediaViewHolder.getArtistText().setTextColor(textSecondary);
-
- // Seekbar
- SeekBar seekbar = mMediaViewHolder.getSeekBar();
- seekbar.getThumb().setTintList(textColorList);
- seekbar.setProgressTintList(textColorList);
- seekbar.setProgressBackgroundTintList(ColorStateList.valueOf(textTertiary));
- mMediaViewHolder.getScrubbingElapsedTimeView().setTextColor(textColorList);
- mMediaViewHolder.getScrubbingTotalTimeView().setTextColor(textColorList);
-
- // Action buttons
- mMediaViewHolder.getActionPlayPause().setBackgroundTintList(accentColorList);
- mMediaViewHolder.getActionPlayPause().setImageTintList(
- ColorStateList.valueOf(textPrimaryInverse));
- for (ImageButton button : mMediaViewHolder.getTransparentActionButtons()) {
- button.setImageTintList(textColorList);
- }
+ final ColorScheme colorScheme = mutableColorScheme;
+ mMainExecutor.execute(() -> {
+ // Cancel the request if a later one arrived first
+ if (reqId < mArtworkBoundId) return;
+ mArtworkBoundId = reqId;
+
+ // Bind the album view to the artwork or a transition drawable
+ ImageView albumView = mMediaViewHolder.getAlbumView();
+ albumView.setPadding(0, 0, 0, 0);
+ albumView.setClipToOutline(true);
+ if (updateBackground) {
+ if (mPrevArtwork == null || artwork == null) {
+ albumView.setImageDrawable(artwork);
+ } else {
+ TransitionDrawable transitionDrawable = new TransitionDrawable(
+ new Drawable[] { mPrevArtwork, artwork });
+ albumView.setImageDrawable(transitionDrawable);
+ transitionDrawable.startTransition(333);
+ }
+ mPrevArtwork = artwork;
+ }
- // Output switcher
- View seamlessView = mMediaViewHolder.getSeamlessButton();
- seamlessView.setBackgroundTintList(accentColorList);
- ImageView seamlessIconView = mMediaViewHolder.getSeamlessIcon();
- seamlessIconView.setImageTintList(bgColorList);
- TextView seamlessText = mMediaViewHolder.getSeamlessText();
- seamlessText.setTextColor(surfaceColor);
-
- // Long press buttons
- mMediaViewHolder.getLongPressText().setTextColor(textColorList);
- mMediaViewHolder.getSettings().setImageTintList(accentColorList);
- mMediaViewHolder.getCancelText().setTextColor(textColorList);
- mMediaViewHolder.getCancelText().setBackgroundTintList(accentColorList);
- mMediaViewHolder.getDismissText().setTextColor(surfaceColor);
- mMediaViewHolder.getDismissText().setBackgroundTintList(accentColorList);
+ // Transition Colors to current color scheme
+ mColorSchemeTransition.updateColorScheme(colorScheme);
+
+ // App icon - use notification icon
+ ImageView appIconView = mMediaViewHolder.getAppIcon();
+ appIconView.clearColorFilter();
+ if (data.getAppIcon() != null && !data.getResumption()) {
+ appIconView.setImageIcon(data.getAppIcon());
+ appIconView.setColorFilter(
+ mColorSchemeTransition.getAccentPrimary().getTargetColor());
+ } else {
+ // Resume players use launcher icon
+ appIconView.setColorFilter(getGrayscaleFilter());
+ try {
+ Drawable icon = mContext.getPackageManager()
+ .getApplicationIcon(data.getPackageName());
+ appIconView.setImageDrawable(icon);
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "Cannot find icon for package " + data.getPackageName(), e);
+ appIconView.setImageResource(R.drawable.ic_music_note);
+ }
+ }
+ });
+ });
}
private void bindActionButtons(MediaData data) {
@@ -714,6 +764,7 @@ public class MediaControlPanel {
animHandler.tryExecute(() -> {
bindButtonWithAnimations(button, mediaAction, animHandler);
setSemanticButtonVisibleAndAlpha(button.getId(), mediaAction, semanticActions);
+ return Unit.INSTANCE;
});
}
@@ -795,11 +846,12 @@ public class MediaControlPanel {
private void updateDisplayForScrubbingChange(@NonNull MediaButton semanticActions) {
// Update visibilities of the scrubbing time views and the scrubbing-dependent buttons.
bindScrubbingTime(mMediaData);
- SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.forEach((id) ->
- setSemanticButtonVisibleAndAlpha(
- id, semanticActions.getActionById(id), semanticActions));
- // Trigger a state refresh so that we immediately update visibilities.
- mMediaViewController.refreshState();
+ SEMANTIC_ACTIONS_HIDE_WHEN_SCRUBBING.forEach((id) -> setSemanticButtonVisibleAndAlpha(
+ id, semanticActions.getActionById(id), semanticActions));
+ if (!mMetadataAnimationHandler.isRunning()) {
+ // Trigger a state refresh so that we immediately update visibilities.
+ mMediaViewController.refreshState();
+ }
}
private void bindScrubbingTime(MediaData data) {
@@ -824,74 +876,6 @@ public class MediaControlPanel {
);
}
- // AnimationBindHandler is responsible for tracking the bound animation state and preventing
- // jank and conflicts due to media notifications arriving at any time during an animation. It
- // does this in two parts.
- // - Exit animations fired as a result of user input are tracked. When these are running, any
- // bind actions are delayed until the animation completes (and then fired in sequence).
- // - Continuous animations are tracked using their rebind id. Later calls using the same
- // rebind id will be totally ignored to prevent the continuous animation from restarting.
- private static class AnimationBindHandler extends Animatable2.AnimationCallback {
- private ArrayList<Runnable> mOnAnimationsComplete = new ArrayList<>();
- private ArrayList<Animatable2> mRegistrations = new ArrayList<>();
- private Integer mRebindId = null;
-
- // This check prevents rebinding to the action button if the identifier has not changed. A
- // null value is always considered to be changed. This is used to prevent the connecting
- // animation from rebinding (and restarting) if multiple buffer PlaybackStates are pushed by
- // an application in a row.
- public boolean updateRebindId(Integer rebindId) {
- if (mRebindId == null || rebindId == null || !mRebindId.equals(rebindId)) {
- mRebindId = rebindId;
- return true;
- }
- return false;
- }
-
- public void tryRegister(Drawable drawable) {
- if (drawable instanceof Animatable2) {
- Animatable2 anim = (Animatable2) drawable;
- anim.registerAnimationCallback(this);
- mRegistrations.add(anim);
- }
- }
-
- public void unregisterAll() {
- for (Animatable2 anim : mRegistrations) {
- anim.unregisterAnimationCallback(this);
- }
- mRegistrations.clear();
- }
-
- public boolean isAnimationRunning() {
- for (Animatable2 anim : mRegistrations) {
- if (anim.isRunning()) {
- return true;
- }
- }
- return false;
- }
-
- public void tryExecute(Runnable action) {
- if (isAnimationRunning()) {
- mOnAnimationsComplete.add(action);
- } else {
- action.run();
- }
- }
-
- @Override
- public void onAnimationEnd(Drawable drawable) {
- super.onAnimationEnd(drawable);
- if (!isAnimationRunning()) {
- for (Runnable action : mOnAnimationsComplete) {
- action.run();
- }
- mOnAnimationsComplete.clear();
- }
- }
- }
-
@Nullable
private ActivityLaunchAnimator.Controller buildLaunchAnimatorController(
TransitionLayout player) {
@@ -1097,7 +1081,9 @@ public class MediaControlPanel {
});
mController = null;
- mMediaViewController.refreshState();
+ if (mMetadataAnimationHandler == null || !mMetadataAnimationHandler.isRunning()) {
+ mMediaViewController.refreshState();
+ }
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt b/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt
new file mode 100644
index 000000000000..9a1a6d35e3e3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.AnimatorSet
+import com.android.internal.annotations.VisibleForTesting
+
+/**
+ * MetadataAnimationHandler controls the current state of the MediaControlPanel's transition motion.
+ *
+ * It checks for a changed data object (artist & title from MediaControlPanel) and runs the
+ * animation if necessary. When the motion has fully transitioned the elements out, it runs the
+ * update callback to modify the view data, before the enter animation runs.
+ */
+internal open class MetadataAnimationHandler(
+ private val exitAnimator: Animator,
+ private val enterAnimator: Animator
+) : AnimatorListenerAdapter() {
+
+ private val animator: AnimatorSet
+ private var postExitUpdate: (() -> Unit)? = null
+ private var postEnterUpdate: (() -> Unit)? = null
+ private var targetData: Any? = null
+
+ val isRunning: Boolean
+ get() = animator.isRunning
+
+ fun setNext(targetData: Any, postExit: () -> Unit, postEnter: () -> Unit): Boolean {
+ if (targetData != this.targetData) {
+ this.targetData = targetData
+ postExitUpdate = postExit
+ postEnterUpdate = postEnter
+ if (!animator.isRunning) {
+ animator.start()
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onAnimationEnd(animator: Animator) {
+ if (animator === exitAnimator) {
+ postExitUpdate?.let { it() }
+ postExitUpdate = null
+ }
+
+ if (animator === enterAnimator) {
+ // Another new update appeared while entering
+ if (postExitUpdate != null) {
+ this.animator.start()
+ } else {
+ postEnterUpdate?.let { it() }
+ postEnterUpdate = null
+ }
+ }
+ }
+
+ init {
+ exitAnimator.addListener(this)
+ enterAnimator.addListener(this)
+ animator = buildAnimatorSet(exitAnimator, enterAnimator)
+ }
+
+ @VisibleForTesting
+ protected open fun buildAnimatorSet(exit: Animator, enter: Animator): AnimatorSet {
+ val result = AnimatorSet()
+ result.playSequentially(exitAnimator, enterAnimator)
+ return result
+ }
+} \ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
index 612a7f92fc33..c9d300bad5e0 100644
--- a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
@@ -16,20 +16,29 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.ObjectAnimator
import android.text.format.DateUtils
import androidx.annotation.UiThread
import androidx.lifecycle.Observer
+import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.R
+import com.android.systemui.animation.Interpolators
/**
* Observer for changes from SeekBarViewModel.
*
* <p>Updates the seek bar views in response to changes to the model.
*/
-class SeekBarObserver(
+open class SeekBarObserver(
private val holder: MediaViewHolder
) : Observer<SeekBarViewModel.Progress> {
+ companion object {
+ @JvmStatic val RESET_ANIMATION_DURATION_MS: Int = 750
+ @JvmStatic val RESET_ANIMATION_THRESHOLD_MS: Int = 250
+ }
+
val seekBarEnabledMaxHeight = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_enabled_seekbar_height)
val seekBarDisabledHeight = holder.seekBar.context.resources
@@ -38,6 +47,7 @@ class SeekBarObserver(
.getDimensionPixelSize(R.dimen.qs_media_session_enabled_seekbar_vertical_padding)
val seekBarDisabledVerticalPadding = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_session_disabled_seekbar_vertical_padding)
+ var seekBarResetAnimator: Animator? = null
init {
val seekBarProgressWavelength = holder.seekBar.context.resources
@@ -91,7 +101,17 @@ class SeekBarObserver(
holder.scrubbingTotalTimeView.text = totalTimeString
data.elapsedTime?.let {
- holder.seekBar.setProgress(it)
+ if (!data.scrubbing && !(seekBarResetAnimator?.isRunning ?: false)) {
+ if (it <= RESET_ANIMATION_THRESHOLD_MS &&
+ holder.seekBar.progress > RESET_ANIMATION_THRESHOLD_MS) {
+ // This animation resets for every additional update to zero.
+ val animator = buildResetAnimator(it)
+ animator.start()
+ seekBarResetAnimator = animator
+ } else {
+ holder.seekBar.progress = it
+ }
+ }
val elapsedTimeString = DateUtils.formatElapsedTime(
it / DateUtils.SECOND_IN_MILLIS)
holder.scrubbingElapsedTimeView.text = elapsedTimeString
@@ -104,6 +124,16 @@ class SeekBarObserver(
}
}
+ @VisibleForTesting
+ open fun buildResetAnimator(targetTime: Int): Animator {
+ val animator = ObjectAnimator.ofInt(holder.seekBar, "progress",
+ holder.seekBar.progress, targetTime + RESET_ANIMATION_DURATION_MS)
+ animator.setAutoCancel(true)
+ animator.duration = RESET_ANIMATION_DURATION_MS.toLong()
+ animator.interpolator = Interpolators.EMPHASIZED
+ return animator
+ }
+
@UiThread
fun setVerticalPadding(padding: Int) {
val leftPadding = holder.seekBar.paddingLeft
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt
new file mode 100644
index 000000000000..e4cab1810822
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.graphics.drawable.Animatable2
+import android.graphics.drawable.Drawable
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import junit.framework.Assert.assertTrue
+import junit.framework.Assert.assertFalse
+import org.junit.After
+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.verify
+import org.mockito.Mockito.times
+import org.mockito.Mockito.never
+import org.mockito.junit.MockitoJUnit
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class AnimationBindHandlerTest : SysuiTestCase() {
+
+ private interface Callback : () -> Unit
+ private abstract class AnimatedDrawable : Drawable(), Animatable2
+ private lateinit var handler: AnimationBindHandler
+
+ @Mock private lateinit var animatable: AnimatedDrawable
+ @Mock private lateinit var animatable2: AnimatedDrawable
+ @Mock private lateinit var callback: Callback
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ handler = AnimationBindHandler()
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun registerNoAnimations_executeCallbackImmediately() {
+ handler.tryExecute(callback)
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun registerStoppedAnimations_executeCallbackImmediately() {
+ whenever(animatable.isRunning).thenReturn(false)
+ whenever(animatable2.isRunning).thenReturn(false)
+
+ handler.tryExecute(callback)
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun registerRunningAnimations_executeCallbackDelayed() {
+ whenever(animatable.isRunning).thenReturn(true)
+ whenever(animatable2.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.tryRegister(animatable2)
+ handler.tryExecute(callback)
+
+ verify(callback, never()).invoke()
+
+ whenever(animatable.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable)
+ verify(callback, never()).invoke()
+
+ whenever(animatable2.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable2)
+ verify(callback, times(1)).invoke()
+ }
+
+ @Test
+ fun repeatedEndCallback_executeSingleCallback() {
+ whenever(animatable.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.tryExecute(callback)
+
+ verify(callback, never()).invoke()
+
+ whenever(animatable.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable)
+ handler.onAnimationEnd(animatable)
+ handler.onAnimationEnd(animatable)
+ verify(callback, times(1)).invoke()
+ }
+
+ @Test
+ fun registerUnregister_executeImmediately() {
+ whenever(animatable.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.unregisterAll()
+ handler.tryExecute(callback)
+
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun updateRebindId_returnsAsExpected() {
+ // Previous or current call is null, returns true
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(10))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(20))
+
+ // Different integer from prevoius, returns true
+ assertTrue(handler.updateRebindId(10))
+ assertTrue(handler.updateRebindId(20))
+
+ // Matches previous call, returns false
+ assertFalse(handler.updateRebindId(20))
+ assertFalse(handler.updateRebindId(20))
+ assertTrue(handler.updateRebindId(10))
+ assertFalse(handler.updateRebindId(10))
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt
new file mode 100644
index 000000000000..86527d9558fd
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.animation.ValueAnimator
+import android.graphics.Color
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.monet.ColorScheme
+import junit.framework.Assert.assertEquals
+import org.junit.After
+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.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.junit.MockitoJUnit
+
+private const val DEFAULT_COLOR = Color.RED
+private const val TARGET_COLOR = Color.BLUE
+private const val BG_COLOR = Color.GREEN
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class ColorSchemeTransitionTest : SysuiTestCase() {
+
+ private interface ExtractCB : (ColorScheme) -> Int
+ private interface ApplyCB : (Int) -> Unit
+ private lateinit var colorTransition: ColorTransition
+ private lateinit var colorSchemeTransition: ColorSchemeTransition
+
+ @Mock private lateinit var mockTransition: ColorTransition
+ @Mock private lateinit var valueAnimator: ValueAnimator
+ @Mock private lateinit var colorScheme: ColorScheme
+ @Mock private lateinit var extractColor: ExtractCB
+ @Mock private lateinit var applyColor: ApplyCB
+
+ private lateinit var transitionFactory: ColorTransitionFactory
+ @Mock private lateinit var mediaViewHolder: MediaViewHolder
+
+ @JvmField @Rule val mockitoRule = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ transitionFactory = { default, extractColor, applyColor -> mockTransition }
+ whenever(extractColor.invoke(colorScheme)).thenReturn(TARGET_COLOR)
+
+ colorSchemeTransition = ColorSchemeTransition(context,
+ BG_COLOR, mediaViewHolder, transitionFactory)
+
+ colorTransition = object : ColorTransition(DEFAULT_COLOR, extractColor, applyColor) {
+ override fun buildAnimator(): ValueAnimator {
+ return valueAnimator
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun testColorTransition_nullColorScheme_keepsDefault() {
+ colorTransition.updateColorScheme(null)
+ verify(applyColor, times(1)).invoke(DEFAULT_COLOR)
+ verify(valueAnimator, never()).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(DEFAULT_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_newColor_startsAnimation() {
+ colorTransition.updateColorScheme(colorScheme)
+ verify(applyColor, times(1)).invoke(DEFAULT_COLOR)
+ verify(valueAnimator, times(1)).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(TARGET_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_sameColor_noAnimation() {
+ whenever(extractColor.invoke(colorScheme)).thenReturn(DEFAULT_COLOR)
+ colorTransition.updateColorScheme(colorScheme)
+ verify(valueAnimator, never()).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(DEFAULT_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_startValues() {
+ val expectedColor = DEFAULT_COLOR
+ whenever(valueAnimator.animatedFraction).thenReturn(0f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ assertEquals(expectedColor, colorTransition.sourceColor)
+ verify(applyColor, times(2)).invoke(expectedColor) // applied once in constructor
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_endValues() {
+ val expectedColor = TARGET_COLOR
+ whenever(valueAnimator.animatedFraction).thenReturn(1f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ assertEquals(expectedColor, colorTransition.targetColor)
+ verify(applyColor).invoke(expectedColor)
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_interpolatedMidpoint() {
+ val expectedColor = Color.rgb(186, 0, 186)
+ whenever(valueAnimator.animatedFraction).thenReturn(0.5f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ verify(applyColor).invoke(expectedColor)
+ }
+
+ @Test
+ fun testColorSchemeTransition_update() {
+ colorSchemeTransition.updateColorScheme(colorScheme)
+ verify(mockTransition, times(6)).updateColorScheme(colorScheme)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
index a58a28e3920b..a39ae6c231bd 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
@@ -16,6 +16,8 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.AnimatorSet
import android.app.PendingIntent
import android.app.smartspace.SmartspaceAction
import android.content.Context
@@ -39,6 +41,7 @@ import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.ViewGroup
+import android.view.animation.Interpolator
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
@@ -58,6 +61,7 @@ import com.android.systemui.plugins.FalsingManager
import com.android.systemui.util.animation.TransitionLayout
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.KotlinArgumentCaptor
+import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.withArgCaptor
import com.android.systemui.util.time.FakeSystemClock
@@ -140,6 +144,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
private lateinit var actionsTopBarrier: Barrier
@Mock private lateinit var longPressText: TextView
@Mock private lateinit var handler: Handler
+ @Mock private lateinit var mockAnimator: AnimatorSet
private lateinit var settings: ImageButton
private lateinit var cancel: View
private lateinit var cancelText: TextView
@@ -181,7 +186,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE)
context.setMockPackageManager(packageManager)
- player = MediaControlPanel(
+ player = object : MediaControlPanel(
context,
bgExecutor,
mainExecutor,
@@ -194,8 +199,15 @@ public class MediaControlPanelTest : SysuiTestCase() {
mediaCarouselController,
falsingManager,
clock,
- logger
- )
+ logger) {
+ override fun loadAnimator(
+ animId: Int,
+ otionInterpolator: Interpolator,
+ vararg targets: View
+ ): AnimatorSet {
+ return mockAnimator
+ }
+ }
initMediaViewHolderMocks()
@@ -470,7 +482,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
@@ -504,7 +516,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = null,
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -524,7 +536,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = null,
+ nextOrCustom = null
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -544,7 +556,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -566,7 +578,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
@@ -709,8 +721,53 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindText() {
player.attachPlayer(viewHolder)
player.bindPlayer(mediaData, PACKAGE)
+
+ // Capture animation handler
+ val captor = argumentCaptor<Animator.AnimatorListener>()
+ verify(mockAnimator, times(2)).addListener(captor.capture())
+ val handler = captor.value
+
+ // Validate text views unchanged but animation started
+ assertThat(titleText.getText()).isEqualTo("")
+ assertThat(artistText.getText()).isEqualTo("")
+ verify(mockAnimator, times(1)).start()
+
+ // Binding only after animator runs
+ handler.onAnimationEnd(mockAnimator)
assertThat(titleText.getText()).isEqualTo(TITLE)
assertThat(artistText.getText()).isEqualTo(ARTIST)
+
+ // Rebinding should not trigger animation
+ player.bindPlayer(mediaData, PACKAGE)
+ verify(mockAnimator, times(1)).start()
+ }
+
+ @Test
+ fun bindTextInterrupted() {
+ val data0 = mediaData.copy(artist = "ARTIST_0")
+ val data1 = mediaData.copy(artist = "ARTIST_1")
+ val data2 = mediaData.copy(artist = "ARTIST_2")
+
+ player.attachPlayer(viewHolder)
+ player.bindPlayer(data0, PACKAGE)
+
+ // Capture animation handler
+ val captor = argumentCaptor<Animator.AnimatorListener>()
+ verify(mockAnimator, times(2)).addListener(captor.capture())
+ val handler = captor.value
+
+ handler.onAnimationEnd(mockAnimator)
+ assertThat(artistText.getText()).isEqualTo("ARTIST_0")
+
+ // Bind trigges new animation
+ player.bindPlayer(data1, PACKAGE)
+ verify(mockAnimator, times(2)).start()
+ whenever(mockAnimator.isRunning()).thenReturn(true)
+
+ // Rebind before animation end binds corrct data
+ player.bindPlayer(data2, PACKAGE)
+ handler.onAnimationEnd(mockAnimator)
+ assertThat(artistText.getText()).isEqualTo("ARTIST_2")
}
@Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt
new file mode 100644
index 000000000000..52cb902a4f38
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.
+ */
+
+package com.android.systemui.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.animation.Animator
+import android.animation.AnimatorSet
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import junit.framework.Assert.fail
+import org.junit.After
+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.verify
+import org.mockito.Mockito.times
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.junit.MockitoJUnit
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class MetadataAnimationHandlerTest : SysuiTestCase() {
+
+ private interface Callback : () -> Unit
+ private lateinit var handler: MetadataAnimationHandler
+
+ @Mock private lateinit var animatorSet: AnimatorSet
+ @Mock private lateinit var enterAnimator: Animator
+ @Mock private lateinit var exitAnimator: Animator
+ @Mock private lateinit var postExitCB: Callback
+ @Mock private lateinit var postEnterCB: Callback
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ handler = object : MetadataAnimationHandler(exitAnimator, enterAnimator) {
+ override fun buildAnimatorSet(exit: Animator, enter: Animator): AnimatorSet {
+ return animatorSet
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun firstBind_startsAnimationSet() {
+ val cb = { fail("Unexpected callback") }
+ handler.setNext("data-1", cb, cb)
+
+ verify(animatorSet).start()
+ }
+
+ @Test
+ fun executeAnimationEnd_runsCallacks() {
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, never()).invoke()
+
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postEnterCB, times(1)).invoke()
+ }
+
+ @Test
+ fun rebindSameData_executesFirstCallback() {
+ val postExitCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.setNext("data-1", postExitCB2, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ }
+
+ @Test
+ fun rebindDifferentData_executesSecondCallback() {
+ val postExitCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.setNext("data-2", postExitCB2, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, never()).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ }
+
+ @Test
+ fun rebindBeforeEnterComplete_animationRestarts() {
+ val postExitCB2 = mock(Callback::class.java)
+ val postEnterCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, never()).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ whenever(animatorSet.isRunning()).thenReturn(true)
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.setNext("data-2", postExitCB2, postEnterCB2)
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, times(1)).invoke()
+ }
+
+ @Test
+ fun exitAnimationEndMultipleCalls_singleCallbackExecution() {
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+ handler.onAnimationEnd(exitAnimator)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, times(1)).invoke()
+ }
+
+ @Test
+ fun enterAnimatorEndsWithoutCallback_noAnimatiorStart() {
+ handler.onAnimationEnd(enterAnimator)
+
+ verify(animatorSet, never()).start()
+ }
+}
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 c48d84698b3b..49be669bb4a5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
@@ -16,6 +16,8 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.ObjectAnimator
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
@@ -43,6 +45,7 @@ class SeekBarObserverTest : SysuiTestCase() {
private val enabledHeight = 2
private lateinit var observer: SeekBarObserver
+ @Mock private lateinit var mockSeekbarAnimator: ObjectAnimator
@Mock private lateinit var mockHolder: MediaViewHolder
@Mock private lateinit var mockSquigglyProgress: SquigglyProgress
private lateinit var seekBarView: SeekBar
@@ -66,7 +69,11 @@ class SeekBarObserverTest : SysuiTestCase() {
whenever(mockHolder.scrubbingElapsedTimeView).thenReturn(scrubbingElapsedTimeView)
whenever(mockHolder.scrubbingTotalTimeView).thenReturn(scrubbingTotalTimeView)
- observer = SeekBarObserver(mockHolder)
+ observer = object : SeekBarObserver(mockHolder) {
+ override fun buildResetAnimator(targetTime: Int): Animator {
+ return mockSeekbarAnimator
+ }
+ }
}
@Test
@@ -189,4 +196,20 @@ class SeekBarObserverTest : SysuiTestCase() {
assertThat(scrubbingElapsedTimeView.text).isEqualTo("")
assertThat(scrubbingTotalTimeView.text).isEqualTo("")
}
+
+ @Test
+ fun seekBarJumpAnimation() {
+ val data0 = SeekBarViewModel.Progress(true, true, true, false, 4000, 120000)
+ val data1 = SeekBarViewModel.Progress(true, true, true, false, 10, 120000)
+
+ // Set initial position of progress bar
+ observer.onChanged(data0)
+ assertThat(seekBarView.progress).isEqualTo(4000)
+ assertThat(seekBarView.max).isEqualTo(120000)
+
+ // Change to second data & confirm no change to position (due to animation delay)
+ observer.onChanged(data1)
+ assertThat(seekBarView.progress).isEqualTo(4000)
+ verify(mockSeekbarAnimator).start()
+ }
}