Merge "Update prev/next media player animation" into tm-dev

This commit is contained in:
TreeHugger Robot
2022-04-13 23:15:50 +00:00
committed by Android (Google) Code Review
12 changed files with 1179 additions and 214 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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();
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()));
// 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);
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());
// 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);
// 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;
});
}
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);
}
}
}
// 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
}
final ColorScheme colorScheme = mutableColorScheme;
mMainExecutor.execute(() -> {
// Cancel the request if a later one arrived first
if (reqId < mArtworkBoundId) return;
mArtworkBoundId = reqId;
ColorStateList bgColorList = ColorStateList.valueOf(surfaceColor);
ColorStateList accentColorList = ColorStateList.valueOf(accentPrimary);
ColorStateList textColorList = ColorStateList.valueOf(textPrimary);
// 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;
}
// Gradient and background (visible when there is no art)
albumView.setForegroundTintList(ColorStateList.valueOf(surfaceColor));
albumView.setBackgroundTintList(
ColorStateList.valueOf(surfaceColor));
mMediaViewHolder.getPlayer().setBackgroundTintList(bgColorList);
// 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(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);
}
}
// 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);
}
// 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);
// 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();
}
}
/**

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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()
}
}