Update prev/next media player animation
Fixes: 224976580 Test: Manual & some automated Change-Id: I38249dee3d13c8be5b9768a49cb6041b8ce98b09
This commit is contained in:
31
packages/SystemUI/res/anim/media_metadata_enter.xml
Normal file
31
packages/SystemUI/res/anim/media_metadata_enter.xml
Normal 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>
|
||||
32
packages/SystemUI/res/anim/media_metadata_exit.xml
Normal file
32
packages/SystemUI/res/anim/media_metadata_exit.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user