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