| /* |
| * Copyright (C) 2020 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.controls.ui |
| |
| import android.animation.Animator |
| import android.animation.AnimatorListenerAdapter |
| import android.animation.ValueAnimator |
| import android.annotation.IntDef |
| import android.content.Context |
| import android.content.res.Configuration |
| import android.database.ContentObserver |
| import android.graphics.Rect |
| import android.net.Uri |
| import android.os.Handler |
| import android.os.UserHandle |
| import android.provider.Settings |
| import android.util.Log |
| import android.util.MathUtils |
| import android.view.View |
| import android.view.ViewGroup |
| import android.view.ViewGroupOverlay |
| import androidx.annotation.VisibleForTesting |
| import com.android.keyguard.KeyguardViewController |
| import com.android.systemui.R |
| import com.android.systemui.animation.Interpolators |
| import com.android.systemui.dagger.SysUISingleton |
| import com.android.systemui.dagger.qualifiers.Main |
| import com.android.systemui.dreams.DreamOverlayStateController |
| import com.android.systemui.keyguard.WakefulnessLifecycle |
| import com.android.systemui.media.controls.pipeline.MediaDataManager |
| import com.android.systemui.media.dream.MediaDreamComplication |
| import com.android.systemui.plugins.statusbar.StatusBarStateController |
| import com.android.systemui.shade.ShadeStateEvents |
| import com.android.systemui.shade.ShadeStateEvents.ShadeStateEventsListener |
| import com.android.systemui.statusbar.CrossFadeHelper |
| import com.android.systemui.statusbar.StatusBarState |
| import com.android.systemui.statusbar.SysuiStatusBarStateController |
| import com.android.systemui.statusbar.notification.stack.StackStateAnimator |
| import com.android.systemui.statusbar.phone.KeyguardBypassController |
| import com.android.systemui.statusbar.policy.ConfigurationController |
| import com.android.systemui.statusbar.policy.KeyguardStateController |
| import com.android.systemui.util.LargeScreenUtils |
| import com.android.systemui.util.animation.UniqueObjectHostView |
| import com.android.systemui.util.settings.SecureSettings |
| import com.android.systemui.util.traceSection |
| import javax.inject.Inject |
| |
| private val TAG: String = MediaHierarchyManager::class.java.simpleName |
| |
| /** Similarly to isShown but also excludes views that have 0 alpha */ |
| val View.isShownNotFaded: Boolean |
| get() { |
| var current: View = this |
| while (true) { |
| if (current.visibility != View.VISIBLE) { |
| return false |
| } |
| if (current.alpha == 0.0f) { |
| return false |
| } |
| val parent = current.parent ?: return false // We are not attached to the view root |
| if (parent !is View) { |
| // we reached the viewroot, hurray |
| return true |
| } |
| current = parent |
| } |
| } |
| |
| /** |
| * This manager is responsible for placement of the unique media view between the different hosts |
| * and animate the positions of the views to achieve seamless transitions. |
| */ |
| @SysUISingleton |
| class MediaHierarchyManager |
| @Inject |
| constructor( |
| private val context: Context, |
| private val statusBarStateController: SysuiStatusBarStateController, |
| private val keyguardStateController: KeyguardStateController, |
| private val bypassController: KeyguardBypassController, |
| private val mediaCarouselController: MediaCarouselController, |
| private val mediaManager: MediaDataManager, |
| private val keyguardViewController: KeyguardViewController, |
| private val dreamOverlayStateController: DreamOverlayStateController, |
| configurationController: ConfigurationController, |
| wakefulnessLifecycle: WakefulnessLifecycle, |
| panelEventsEvents: ShadeStateEvents, |
| private val secureSettings: SecureSettings, |
| @Main private val handler: Handler, |
| ) { |
| |
| /** Track the media player setting status on lock screen. */ |
| private var allowMediaPlayerOnLockScreen: Boolean = true |
| private val lockScreenMediaPlayerUri = |
| secureSettings.getUriFor(Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN) |
| |
| /** |
| * Whether we "skip" QQS during panel expansion. |
| * |
| * This means that when expanding the panel we go directly to QS. Also when we are on QS and |
| * start closing the panel, it fully collapses instead of going to QQS. |
| */ |
| private var skipQqsOnExpansion: Boolean = false |
| |
| /** |
| * The root overlay of the hierarchy. This is where the media notification is attached to |
| * whenever the view is transitioning from one host to another. It also make sure that the view |
| * is always in its final state when it is attached to a view host. |
| */ |
| private var rootOverlay: ViewGroupOverlay? = null |
| |
| private var rootView: View? = null |
| private var currentBounds = Rect() |
| private var animationStartBounds: Rect = Rect() |
| |
| private var animationStartClipping = Rect() |
| private var currentClipping = Rect() |
| private var targetClipping = Rect() |
| |
| /** |
| * The cross fade progress at the start of the animation. 0.5f means it's just switching between |
| * the start and the end location and the content is fully faded, while 0.75f means that we're |
| * halfway faded in again in the target state. |
| */ |
| private var animationStartCrossFadeProgress = 0.0f |
| |
| /** The starting alpha of the animation */ |
| private var animationStartAlpha = 0.0f |
| |
| /** The starting location of the cross fade if an animation is running right now. */ |
| @MediaLocation private var crossFadeAnimationStartLocation = -1 |
| |
| /** The end location of the cross fade if an animation is running right now. */ |
| @MediaLocation private var crossFadeAnimationEndLocation = -1 |
| private var targetBounds: Rect = Rect() |
| private val mediaFrame |
| get() = mediaCarouselController.mediaFrame |
| private var statusbarState: Int = statusBarStateController.state |
| private var animator = |
| ValueAnimator.ofFloat(0.0f, 1.0f).apply { |
| interpolator = Interpolators.FAST_OUT_SLOW_IN |
| addUpdateListener { |
| updateTargetState() |
| val currentAlpha: Float |
| var boundsProgress = animatedFraction |
| if (isCrossFadeAnimatorRunning) { |
| animationCrossFadeProgress = |
| MathUtils.lerp(animationStartCrossFadeProgress, 1.0f, animatedFraction) |
| // When crossfading, let's keep the bounds at the right location during fading |
| boundsProgress = if (animationCrossFadeProgress < 0.5f) 0.0f else 1.0f |
| currentAlpha = calculateAlphaFromCrossFade(animationCrossFadeProgress) |
| } else { |
| // If we're not crossfading, let's interpolate from the start alpha to 1.0f |
| currentAlpha = MathUtils.lerp(animationStartAlpha, 1.0f, animatedFraction) |
| } |
| interpolateBounds( |
| animationStartBounds, |
| targetBounds, |
| boundsProgress, |
| result = currentBounds |
| ) |
| resolveClipping(currentClipping) |
| applyState(currentBounds, currentAlpha, clipBounds = currentClipping) |
| } |
| addListener( |
| object : AnimatorListenerAdapter() { |
| private var cancelled: Boolean = false |
| |
| override fun onAnimationCancel(animation: Animator?) { |
| cancelled = true |
| animationPending = false |
| rootView?.removeCallbacks(startAnimation) |
| } |
| |
| override fun onAnimationEnd(animation: Animator?) { |
| isCrossFadeAnimatorRunning = false |
| if (!cancelled) { |
| applyTargetStateIfNotAnimating() |
| } |
| } |
| |
| override fun onAnimationStart(animation: Animator?) { |
| cancelled = false |
| animationPending = false |
| } |
| } |
| ) |
| } |
| |
| private fun resolveClipping(result: Rect) { |
| if (animationStartClipping.isEmpty) result.set(targetClipping) |
| else if (targetClipping.isEmpty) result.set(animationStartClipping) |
| else result.setIntersect(animationStartClipping, targetClipping) |
| } |
| |
| private val mediaHosts = arrayOfNulls<MediaHost>(LOCATION_DREAM_OVERLAY + 1) |
| /** |
| * The last location where this view was at before going to the desired location. This is useful |
| * for guided transitions. |
| */ |
| @MediaLocation private var previousLocation = -1 |
| /** The desired location where the view will be at the end of the transition. */ |
| @MediaLocation private var desiredLocation = -1 |
| |
| /** |
| * The current attachment location where the view is currently attached. Usually this matches |
| * the desired location except for animations whenever a view moves to the new desired location, |
| * during which it is in [IN_OVERLAY]. |
| */ |
| @MediaLocation private var currentAttachmentLocation = -1 |
| |
| private var inSplitShade = false |
| |
| /** Is there any active media or recommendation in the carousel? */ |
| private var hasActiveMediaOrRecommendation: Boolean = false |
| get() = mediaManager.hasActiveMediaOrRecommendation() |
| |
| /** Are we currently waiting on an animation to start? */ |
| private var animationPending: Boolean = false |
| private val startAnimation: Runnable = Runnable { animator.start() } |
| |
| /** The expansion of quick settings */ |
| var qsExpansion: Float = 0.0f |
| set(value) { |
| if (field != value) { |
| field = value |
| updateDesiredLocation() |
| if (getQSTransformationProgress() >= 0) { |
| updateTargetState() |
| applyTargetStateIfNotAnimating() |
| } |
| } |
| } |
| |
| /** Is quick setting expanded? */ |
| var qsExpanded: Boolean = false |
| set(value) { |
| if (field != value) { |
| field = value |
| mediaCarouselController.mediaCarouselScrollHandler.qsExpanded = value |
| } |
| // qs is expanded on LS shade and HS shade |
| if (value && (isLockScreenShadeVisibleToUser() || isHomeScreenShadeVisibleToUser())) { |
| mediaCarouselController.logSmartspaceImpression(value) |
| } |
| mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = isVisibleToUser() |
| } |
| |
| /** |
| * distance that the full shade transition takes in order for media to fully transition to the |
| * shade |
| */ |
| private var distanceForFullShadeTransition = 0 |
| |
| /** |
| * The amount of progress we are currently in if we're transitioning to the full shade. 0.0f |
| * means we're not transitioning yet, while 1 means we're all the way in the full shade. |
| */ |
| private var fullShadeTransitionProgress = 0f |
| set(value) { |
| if (field == value) { |
| return |
| } |
| field = value |
| if (bypassController.bypassEnabled || statusbarState != StatusBarState.KEYGUARD) { |
| // No need to do all the calculations / updates below if we're not on the lockscreen |
| // or if we're bypassing. |
| return |
| } |
| updateDesiredLocation(forceNoAnimation = isCurrentlyFading()) |
| if (value >= 0) { |
| updateTargetState() |
| // Setting the alpha directly, as the below call will use it to update the alpha |
| carouselAlpha = calculateAlphaFromCrossFade(field) |
| applyTargetStateIfNotAnimating() |
| } |
| } |
| |
| /** Is there currently a cross-fade animation running driven by an animator? */ |
| private var isCrossFadeAnimatorRunning = false |
| |
| /** |
| * Are we currently transitionioning from the lockscreen to the full shade |
| * [StatusBarState.SHADE_LOCKED] or [StatusBarState.SHADE]. Once the user has dragged down and |
| * the transition starts, this will no longer return true. |
| */ |
| private val isTransitioningToFullShade: Boolean |
| get() = |
| fullShadeTransitionProgress != 0f && |
| !bypassController.bypassEnabled && |
| statusbarState == StatusBarState.KEYGUARD |
| |
| /** |
| * Set the amount of pixels we have currently dragged down if we're transitioning to the full |
| * shade. 0.0f means we're not transitioning yet. |
| */ |
| fun setTransitionToFullShadeAmount(value: Float) { |
| // If we're transitioning starting on the shade_locked, we don't want any delay and rather |
| // have it aligned with the rest of the animation |
| val progress = MathUtils.saturate(value / distanceForFullShadeTransition) |
| fullShadeTransitionProgress = progress |
| } |
| |
| /** |
| * Returns the amount of translationY of the media container, during the current guided |
| * transformation, if running. If there is no guided transformation running, it will return 0. |
| */ |
| fun getGuidedTransformationTranslationY(): Int { |
| if (!isCurrentlyInGuidedTransformation()) { |
| return -1 |
| } |
| val startHost = getHost(previousLocation) ?: return 0 |
| return targetBounds.top - startHost.currentBounds.top |
| } |
| |
| /** |
| * Is the shade currently collapsing from the expanded qs? If we're on the lockscreen and in qs, |
| * we wouldn't want to transition in that case. |
| */ |
| var collapsingShadeFromQS: Boolean = false |
| set(value) { |
| if (field != value) { |
| field = value |
| updateDesiredLocation(forceNoAnimation = true) |
| } |
| } |
| |
| /** Are location changes currently blocked? */ |
| private val blockLocationChanges: Boolean |
| get() { |
| return goingToSleep || dozeAnimationRunning |
| } |
| |
| /** Are we currently going to sleep */ |
| private var goingToSleep: Boolean = false |
| set(value) { |
| if (field != value) { |
| field = value |
| if (!value) { |
| updateDesiredLocation() |
| } |
| } |
| } |
| |
| /** Are we currently fullyAwake */ |
| private var fullyAwake: Boolean = false |
| set(value) { |
| if (field != value) { |
| field = value |
| if (value) { |
| updateDesiredLocation(forceNoAnimation = true) |
| } |
| } |
| } |
| |
| /** Is the doze animation currently Running */ |
| private var dozeAnimationRunning: Boolean = false |
| private set(value) { |
| if (field != value) { |
| field = value |
| if (!value) { |
| updateDesiredLocation() |
| } |
| } |
| } |
| |
| /** Is the dream overlay currently active */ |
| private var dreamOverlayActive: Boolean = false |
| private set(value) { |
| if (field != value) { |
| field = value |
| updateDesiredLocation(forceNoAnimation = true) |
| } |
| } |
| |
| /** Is the dream media complication currently active */ |
| private var dreamMediaComplicationActive: Boolean = false |
| private set(value) { |
| if (field != value) { |
| field = value |
| updateDesiredLocation(forceNoAnimation = true) |
| } |
| } |
| |
| /** |
| * The current cross fade progress. 0.5f means it's just switching between the start and the end |
| * location and the content is fully faded, while 0.75f means that we're halfway faded in again |
| * in the target state. This is only valid while [isCrossFadeAnimatorRunning] is true. |
| */ |
| private var animationCrossFadeProgress = 1.0f |
| |
| /** The current carousel Alpha. */ |
| private var carouselAlpha: Float = 1.0f |
| set(value) { |
| if (field == value) { |
| return |
| } |
| field = value |
| CrossFadeHelper.fadeIn(mediaFrame, value) |
| } |
| |
| /** |
| * Calculate the alpha of the view when given a cross-fade progress. |
| * |
| * @param crossFadeProgress The current cross fade progress. 0.5f means it's just switching |
| * between the start and the end location and the content is fully faded, while 0.75f means |
| * that we're halfway faded in again in the target state. |
| */ |
| private fun calculateAlphaFromCrossFade(crossFadeProgress: Float): Float { |
| if (crossFadeProgress <= 0.5f) { |
| return 1.0f - crossFadeProgress / 0.5f |
| } else { |
| return (crossFadeProgress - 0.5f) / 0.5f |
| } |
| } |
| |
| init { |
| updateConfiguration() |
| configurationController.addCallback( |
| object : ConfigurationController.ConfigurationListener { |
| override fun onConfigChanged(newConfig: Configuration?) { |
| updateConfiguration() |
| updateDesiredLocation(forceNoAnimation = true, forceStateUpdate = true) |
| } |
| } |
| ) |
| statusBarStateController.addCallback( |
| object : StatusBarStateController.StateListener { |
| override fun onStatePreChange(oldState: Int, newState: Int) { |
| // We're updating the location before the state change happens, since we want |
| // the |
| // location of the previous state to still be up to date when the animation |
| // starts |
| statusbarState = newState |
| updateDesiredLocation() |
| } |
| |
| override fun onStateChanged(newState: Int) { |
| updateTargetState() |
| // Enters shade from lock screen |
| if ( |
| newState == StatusBarState.SHADE_LOCKED && isLockScreenShadeVisibleToUser() |
| ) { |
| mediaCarouselController.logSmartspaceImpression(qsExpanded) |
| } |
| mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = |
| isVisibleToUser() |
| } |
| |
| override fun onDozeAmountChanged(linear: Float, eased: Float) { |
| dozeAnimationRunning = linear != 0.0f && linear != 1.0f |
| } |
| |
| override fun onDozingChanged(isDozing: Boolean) { |
| if (!isDozing) { |
| dozeAnimationRunning = false |
| // Enters lock screen from screen off |
| if (isLockScreenVisibleToUser()) { |
| mediaCarouselController.logSmartspaceImpression(qsExpanded) |
| } |
| } else { |
| updateDesiredLocation() |
| qsExpanded = false |
| closeGuts() |
| } |
| mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = |
| isVisibleToUser() |
| } |
| |
| override fun onExpandedChanged(isExpanded: Boolean) { |
| // Enters shade from home screen |
| if (isHomeScreenShadeVisibleToUser()) { |
| mediaCarouselController.logSmartspaceImpression(qsExpanded) |
| } |
| mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = |
| isVisibleToUser() |
| } |
| } |
| ) |
| |
| dreamOverlayStateController.addCallback( |
| object : DreamOverlayStateController.Callback { |
| override fun onComplicationsChanged() { |
| dreamMediaComplicationActive = |
| dreamOverlayStateController.complications.any { |
| it is MediaDreamComplication |
| } |
| } |
| |
| override fun onStateChanged() { |
| dreamOverlayStateController.isOverlayActive.also { dreamOverlayActive = it } |
| } |
| } |
| ) |
| |
| wakefulnessLifecycle.addObserver( |
| object : WakefulnessLifecycle.Observer { |
| override fun onFinishedGoingToSleep() { |
| goingToSleep = false |
| } |
| |
| override fun onStartedGoingToSleep() { |
| goingToSleep = true |
| fullyAwake = false |
| } |
| |
| override fun onFinishedWakingUp() { |
| goingToSleep = false |
| fullyAwake = true |
| } |
| |
| override fun onStartedWakingUp() { |
| goingToSleep = false |
| } |
| } |
| ) |
| |
| mediaCarouselController.updateUserVisibility = { |
| mediaCarouselController.mediaCarouselScrollHandler.visibleToUser = isVisibleToUser() |
| } |
| mediaCarouselController.updateHostVisibility = { |
| mediaHosts.forEach { it?.updateViewVisibility() } |
| } |
| |
| panelEventsEvents.addShadeStateEventsListener( |
| object : ShadeStateEventsListener { |
| override fun onExpandImmediateChanged(isExpandImmediateEnabled: Boolean) { |
| skipQqsOnExpansion = isExpandImmediateEnabled |
| updateDesiredLocation() |
| } |
| } |
| ) |
| |
| val settingsObserver: ContentObserver = |
| object : ContentObserver(handler) { |
| override fun onChange(selfChange: Boolean, uri: Uri?) { |
| if (uri == lockScreenMediaPlayerUri) { |
| allowMediaPlayerOnLockScreen = |
| secureSettings.getBoolForUser( |
| Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN, |
| true, |
| UserHandle.USER_CURRENT |
| ) |
| } |
| } |
| } |
| secureSettings.registerContentObserverForUser( |
| Settings.Secure.MEDIA_CONTROLS_LOCK_SCREEN, |
| settingsObserver, |
| UserHandle.USER_ALL |
| ) |
| } |
| |
| private fun updateConfiguration() { |
| distanceForFullShadeTransition = |
| context.resources.getDimensionPixelSize( |
| R.dimen.lockscreen_shade_media_transition_distance |
| ) |
| inSplitShade = LargeScreenUtils.shouldUseSplitNotificationShade(context.resources) |
| } |
| |
| /** |
| * Register a media host and create a view can be attached to a view hierarchy and where the |
| * players will be placed in when the host is the currently desired state. |
| * |
| * @return the hostView associated with this location |
| */ |
| fun register(mediaObject: MediaHost): UniqueObjectHostView { |
| val viewHost = createUniqueObjectHost() |
| mediaObject.hostView = viewHost |
| mediaObject.addVisibilityChangeListener { |
| // Never animate because of a visibility change, only state changes should do that |
| updateDesiredLocation(forceNoAnimation = true) |
| } |
| mediaHosts[mediaObject.location] = mediaObject |
| if (mediaObject.location == desiredLocation) { |
| // In case we are overriding a view that is already visible, make sure we attach it |
| // to this new host view in the below call |
| desiredLocation = -1 |
| } |
| if (mediaObject.location == currentAttachmentLocation) { |
| currentAttachmentLocation = -1 |
| } |
| updateDesiredLocation() |
| return viewHost |
| } |
| |
| /** Close the guts in all players in [MediaCarouselController]. */ |
| fun closeGuts() { |
| mediaCarouselController.closeGuts() |
| } |
| |
| private fun createUniqueObjectHost(): UniqueObjectHostView { |
| val viewHost = UniqueObjectHostView(context) |
| viewHost.addOnAttachStateChangeListener( |
| object : View.OnAttachStateChangeListener { |
| override fun onViewAttachedToWindow(p0: View?) { |
| if (rootOverlay == null) { |
| rootView = viewHost.viewRootImpl.view |
| rootOverlay = (rootView!!.overlay as ViewGroupOverlay) |
| } |
| viewHost.removeOnAttachStateChangeListener(this) |
| } |
| |
| override fun onViewDetachedFromWindow(p0: View?) {} |
| } |
| ) |
| return viewHost |
| } |
| |
| /** |
| * Updates the location that the view should be in. If it changes, an animation may be triggered |
| * going from the old desired location to the new one. |
| * |
| * @param forceNoAnimation optional parameter telling the system not to animate |
| * @param forceStateUpdate optional parameter telling the system to update transition state |
| * |
| * ``` |
| * even if location did not change |
| * ``` |
| */ |
| private fun updateDesiredLocation( |
| forceNoAnimation: Boolean = false, |
| forceStateUpdate: Boolean = false |
| ) = |
| traceSection("MediaHierarchyManager#updateDesiredLocation") { |
| val desiredLocation = calculateLocation() |
| if (desiredLocation != this.desiredLocation || forceStateUpdate) { |
| if (this.desiredLocation >= 0 && desiredLocation != this.desiredLocation) { |
| // Only update previous location when it actually changes |
| previousLocation = this.desiredLocation |
| } else if (forceStateUpdate) { |
| val onLockscreen = |
| (!bypassController.bypassEnabled && |
| (statusbarState == StatusBarState.KEYGUARD)) |
| if ( |
| desiredLocation == LOCATION_QS && |
| previousLocation == LOCATION_LOCKSCREEN && |
| !onLockscreen |
| ) { |
| // If media active state changed and the device is now unlocked, update the |
| // previous location so we animate between the correct hosts |
| previousLocation = LOCATION_QQS |
| } |
| } |
| val isNewView = this.desiredLocation == -1 |
| this.desiredLocation = desiredLocation |
| // Let's perform a transition |
| val animate = |
| !forceNoAnimation && shouldAnimateTransition(desiredLocation, previousLocation) |
| val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) |
| val host = getHost(desiredLocation) |
| val willFade = calculateTransformationType() == TRANSFORMATION_TYPE_FADE |
| if (!willFade || isCurrentlyInGuidedTransformation() || !animate) { |
| // if we're fading, we want the desired location / measurement only to change |
| // once fully faded. This is happening in the host attachment |
| mediaCarouselController.onDesiredLocationChanged( |
| desiredLocation, |
| host, |
| animate, |
| animDuration, |
| delay |
| ) |
| } |
| performTransitionToNewLocation(isNewView, animate) |
| } |
| } |
| |
| private fun performTransitionToNewLocation(isNewView: Boolean, animate: Boolean) = |
| traceSection("MediaHierarchyManager#performTransitionToNewLocation") { |
| if (previousLocation < 0 || isNewView) { |
| cancelAnimationAndApplyDesiredState() |
| return |
| } |
| val currentHost = getHost(desiredLocation) |
| val previousHost = getHost(previousLocation) |
| if (currentHost == null || previousHost == null) { |
| cancelAnimationAndApplyDesiredState() |
| return |
| } |
| updateTargetState() |
| if (isCurrentlyInGuidedTransformation()) { |
| applyTargetStateIfNotAnimating() |
| } else if (animate) { |
| val wasCrossFading = isCrossFadeAnimatorRunning |
| val previewsCrossFadeProgress = animationCrossFadeProgress |
| animator.cancel() |
| if ( |
| currentAttachmentLocation != previousLocation || |
| !previousHost.hostView.isAttachedToWindow |
| ) { |
| // Let's animate to the new position, starting from the current position |
| // We also go in here in case the view was detached, since the bounds wouldn't |
| // be correct anymore |
| animationStartBounds.set(currentBounds) |
| animationStartClipping.set(currentClipping) |
| } else { |
| // otherwise, let's take the freshest state, since the current one could |
| // be outdated |
| animationStartBounds.set(previousHost.currentBounds) |
| animationStartClipping.set(previousHost.currentClipping) |
| } |
| val transformationType = calculateTransformationType() |
| var needsCrossFade = transformationType == TRANSFORMATION_TYPE_FADE |
| var crossFadeStartProgress = 0.0f |
| // The alpha is only relevant when not cross fading |
| var newCrossFadeStartLocation = previousLocation |
| if (wasCrossFading) { |
| if (currentAttachmentLocation == crossFadeAnimationEndLocation) { |
| if (needsCrossFade) { |
| // We were previously crossFading and we've already reached |
| // the end view, Let's start crossfading from the same position there |
| crossFadeStartProgress = 1.0f - previewsCrossFadeProgress |
| } |
| // Otherwise let's fade in from the current alpha, but not cross fade |
| } else { |
| // We haven't reached the previous location yet, let's still cross fade from |
| // where we were. |
| newCrossFadeStartLocation = crossFadeAnimationStartLocation |
| if (newCrossFadeStartLocation == desiredLocation) { |
| // we're crossFading back to where we were, let's start at the end |
| // position |
| crossFadeStartProgress = 1.0f - previewsCrossFadeProgress |
| } else { |
| // Let's start from where we are right now |
| crossFadeStartProgress = previewsCrossFadeProgress |
| // We need to force cross fading as we haven't reached the end location |
| // yet |
| needsCrossFade = true |
| } |
| } |
| } else if (needsCrossFade) { |
| // let's not flicker and start with the same alpha |
| crossFadeStartProgress = (1.0f - carouselAlpha) / 2.0f |
| } |
| isCrossFadeAnimatorRunning = needsCrossFade |
| crossFadeAnimationStartLocation = newCrossFadeStartLocation |
| crossFadeAnimationEndLocation = desiredLocation |
| animationStartAlpha = carouselAlpha |
| animationStartCrossFadeProgress = crossFadeStartProgress |
| adjustAnimatorForTransition(desiredLocation, previousLocation) |
| if (!animationPending) { |
| rootView?.let { |
| // Let's delay the animation start until we finished laying out |
| animationPending = true |
| it.postOnAnimation(startAnimation) |
| } |
| } |
| } else { |
| cancelAnimationAndApplyDesiredState() |
| } |
| } |
| |
| private fun shouldAnimateTransition( |
| @MediaLocation currentLocation: Int, |
| @MediaLocation previousLocation: Int |
| ): Boolean { |
| if (isCurrentlyInGuidedTransformation()) { |
| return false |
| } |
| if (skipQqsOnExpansion) { |
| return false |
| } |
| // This is an invalid transition, and can happen when using the camera gesture from the |
| // lock screen. Disallow. |
| if ( |
| previousLocation == LOCATION_LOCKSCREEN && |
| desiredLocation == LOCATION_QQS && |
| statusbarState == StatusBarState.SHADE |
| ) { |
| return false |
| } |
| |
| if ( |
| currentLocation == LOCATION_QQS && |
| previousLocation == LOCATION_LOCKSCREEN && |
| (statusBarStateController.leaveOpenOnKeyguardHide() || |
| statusbarState == StatusBarState.SHADE_LOCKED) |
| ) { |
| // Usually listening to the isShown is enough to determine this, but there is some |
| // non-trivial reattaching logic happening that will make the view not-shown earlier |
| return true |
| } |
| |
| if ( |
| desiredLocation == LOCATION_QS && |
| previousLocation == LOCATION_LOCKSCREEN && |
| statusbarState == StatusBarState.SHADE |
| ) { |
| // This is an invalid transition, can happen when tapping on home control and the UMO |
| // while being on landscape orientation in tablet. |
| return false |
| } |
| |
| if ( |
| statusbarState == StatusBarState.KEYGUARD && |
| (currentLocation == LOCATION_LOCKSCREEN || previousLocation == LOCATION_LOCKSCREEN) |
| ) { |
| // We're always fading from lockscreen to keyguard in situations where the player |
| // is already fully hidden |
| return false |
| } |
| return mediaFrame.isShownNotFaded || animator.isRunning || animationPending |
| } |
| |
| private fun adjustAnimatorForTransition(desiredLocation: Int, previousLocation: Int) { |
| val (animDuration, delay) = getAnimationParams(previousLocation, desiredLocation) |
| animator.apply { |
| duration = animDuration |
| startDelay = delay |
| } |
| } |
| |
| private fun getAnimationParams(previousLocation: Int, desiredLocation: Int): Pair<Long, Long> { |
| var animDuration = 200L |
| var delay = 0L |
| if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) { |
| // Going to the full shade, let's adjust the animation duration |
| if ( |
| statusbarState == StatusBarState.SHADE && |
| keyguardStateController.isKeyguardFadingAway |
| ) { |
| delay = keyguardStateController.keyguardFadingAwayDelay |
| } |
| animDuration = (StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE / 2f).toLong() |
| } else if (previousLocation == LOCATION_QQS && desiredLocation == LOCATION_LOCKSCREEN) { |
| animDuration = StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR.toLong() |
| } |
| return animDuration to delay |
| } |
| |
| private fun applyTargetStateIfNotAnimating() { |
| if (!animator.isRunning) { |
| // Let's immediately apply the target state (which is interpolated) if there is |
| // no animation running. Otherwise the animation update will already update |
| // the location |
| applyState(targetBounds, carouselAlpha, clipBounds = targetClipping) |
| } |
| } |
| |
| /** Updates the bounds that the view wants to be in at the end of the animation. */ |
| private fun updateTargetState() { |
| var starthost = getHost(previousLocation) |
| var endHost = getHost(desiredLocation) |
| if ( |
| isCurrentlyInGuidedTransformation() && |
| !isCurrentlyFading() && |
| starthost != null && |
| endHost != null |
| ) { |
| val progress = getTransformationProgress() |
| // If either of the hosts are invisible, let's keep them at the other host location to |
| // have a nicer disappear animation. Otherwise the currentBounds of the state might |
| // be undefined |
| if (!endHost.visible) { |
| endHost = starthost |
| } else if (!starthost.visible) { |
| starthost = endHost |
| } |
| val newBounds = endHost.currentBounds |
| val previousBounds = starthost.currentBounds |
| targetBounds = interpolateBounds(previousBounds, newBounds, progress) |
| targetClipping = endHost.currentClipping |
| } else if (endHost != null) { |
| val bounds = endHost.currentBounds |
| targetBounds.set(bounds) |
| targetClipping = endHost.currentClipping |
| } |
| } |
| |
| private fun interpolateBounds( |
| startBounds: Rect, |
| endBounds: Rect, |
| progress: Float, |
| result: Rect? = null |
| ): Rect { |
| val left = |
| MathUtils.lerp(startBounds.left.toFloat(), endBounds.left.toFloat(), progress).toInt() |
| val top = |
| MathUtils.lerp(startBounds.top.toFloat(), endBounds.top.toFloat(), progress).toInt() |
| val right = |
| MathUtils.lerp(startBounds.right.toFloat(), endBounds.right.toFloat(), progress).toInt() |
| val bottom = |
| MathUtils.lerp(startBounds.bottom.toFloat(), endBounds.bottom.toFloat(), progress) |
| .toInt() |
| val resultBounds = result ?: Rect() |
| resultBounds.set(left, top, right, bottom) |
| return resultBounds |
| } |
| |
| /** @return true if this transformation is guided by an external progress like a finger */ |
| fun isCurrentlyInGuidedTransformation(): Boolean { |
| return hasValidStartAndEndLocations() && |
| getTransformationProgress() >= 0 && |
| (areGuidedTransitionHostsVisible() || !hasActiveMediaOrRecommendation) |
| } |
| |
| private fun hasValidStartAndEndLocations(): Boolean { |
| return previousLocation != -1 && desiredLocation != -1 |
| } |
| |
| /** Calculate the transformation type for the current animation */ |
| @VisibleForTesting |
| @TransformationType |
| fun calculateTransformationType(): Int { |
| if (isTransitioningToFullShade) { |
| if (inSplitShade && areGuidedTransitionHostsVisible()) { |
| return TRANSFORMATION_TYPE_TRANSITION |
| } |
| return TRANSFORMATION_TYPE_FADE |
| } |
| if ( |
| previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QS || |
| previousLocation == LOCATION_QS && desiredLocation == LOCATION_LOCKSCREEN |
| ) { |
| // animating between ls and qs should fade, as QS is clipped. |
| return TRANSFORMATION_TYPE_FADE |
| } |
| if (previousLocation == LOCATION_LOCKSCREEN && desiredLocation == LOCATION_QQS) { |
| // animating between ls and qqs should fade when dragging down via e.g. expand button |
| return TRANSFORMATION_TYPE_FADE |
| } |
| return TRANSFORMATION_TYPE_TRANSITION |
| } |
| |
| private fun areGuidedTransitionHostsVisible(): Boolean { |
| return getHost(previousLocation)?.visible == true && |
| getHost(desiredLocation)?.visible == true |
| } |
| |
| /** |
| * @return the current transformation progress if we're in a guided transformation and -1 |
| * otherwise |
| */ |
| private fun getTransformationProgress(): Float { |
| if (skipQqsOnExpansion) { |
| return -1.0f |
| } |
| val progress = getQSTransformationProgress() |
| if (statusbarState != StatusBarState.KEYGUARD && progress >= 0) { |
| return progress |
| } |
| if (isTransitioningToFullShade) { |
| return fullShadeTransitionProgress |
| } |
| return -1.0f |
| } |
| |
| private fun getQSTransformationProgress(): Float { |
| val currentHost = getHost(desiredLocation) |
| val previousHost = getHost(previousLocation) |
| if (currentHost?.location == LOCATION_QS && !inSplitShade) { |
| if (previousHost?.location == LOCATION_QQS) { |
| if (previousHost.visible || statusbarState != StatusBarState.KEYGUARD) { |
| return qsExpansion |
| } |
| } |
| } |
| return -1.0f |
| } |
| |
| private fun getHost(@MediaLocation location: Int): MediaHost? { |
| if (location < 0) { |
| return null |
| } |
| return mediaHosts[location] |
| } |
| |
| private fun cancelAnimationAndApplyDesiredState() { |
| animator.cancel() |
| getHost(desiredLocation)?.let { |
| applyState(it.currentBounds, alpha = 1.0f, immediately = true) |
| } |
| } |
| |
| /** Apply the current state to the view, updating it's bounds and desired state */ |
| private fun applyState( |
| bounds: Rect, |
| alpha: Float, |
| immediately: Boolean = false, |
| clipBounds: Rect = EMPTY_RECT |
| ) = |
| traceSection("MediaHierarchyManager#applyState") { |
| currentBounds.set(bounds) |
| currentClipping = clipBounds |
| carouselAlpha = if (isCurrentlyFading()) alpha else 1.0f |
| val onlyUseEndState = !isCurrentlyInGuidedTransformation() || isCurrentlyFading() |
| val startLocation = if (onlyUseEndState) -1 else previousLocation |
| val progress = if (onlyUseEndState) 1.0f else getTransformationProgress() |
| val endLocation = resolveLocationForFading() |
| mediaCarouselController.setCurrentState( |
| startLocation, |
| endLocation, |
| progress, |
| immediately |
| ) |
| updateHostAttachment() |
| if (currentAttachmentLocation == IN_OVERLAY) { |
| // Setting the clipping on the hierarchy of `mediaFrame` does not work |
| if (!currentClipping.isEmpty) { |
| currentBounds.intersect(currentClipping) |
| } |
| mediaFrame.setLeftTopRightBottom( |
| currentBounds.left, |
| currentBounds.top, |
| currentBounds.right, |
| currentBounds.bottom |
| ) |
| } |
| } |
| |
| private fun updateHostAttachment() = |
| traceSection("MediaHierarchyManager#updateHostAttachment") { |
| var newLocation = resolveLocationForFading() |
| // Don't use the overlay when fading or when we don't have active media |
| var canUseOverlay = !isCurrentlyFading() && hasActiveMediaOrRecommendation |
| if (isCrossFadeAnimatorRunning) { |
| if ( |
| getHost(newLocation)?.visible == true && |
| getHost(newLocation)?.hostView?.isShown == false && |
| newLocation != desiredLocation |
| ) { |
| // We're crossfading but the view is already hidden. Let's move to the overlay |
| // instead. This happens when animating to the full shade using a button click. |
| canUseOverlay = true |
| } |
| } |
| val inOverlay = isTransitionRunning() && rootOverlay != null && canUseOverlay |
| newLocation = if (inOverlay) IN_OVERLAY else newLocation |
| if (currentAttachmentLocation != newLocation) { |
| currentAttachmentLocation = newLocation |
| |
| // Remove the carousel from the old host |
| (mediaFrame.parent as ViewGroup?)?.removeView(mediaFrame) |
| |
| // Add it to the new one |
| if (inOverlay) { |
| rootOverlay!!.add(mediaFrame) |
| } else { |
| val targetHost = getHost(newLocation)!!.hostView |
| // This will either do a full layout pass and remeasure, or it will bypass |
| // that and directly set the mediaFrame's bounds within the premeasured host. |
| targetHost.addView(mediaFrame) |
| |
| if (mediaFrame.childCount > 0) { |
| val child = mediaFrame.getChildAt(0) |
| if (mediaFrame.height < child.height) { |
| Log.wtf( |
| TAG, |
| "mediaFrame height is too small for child: " + |
| "${mediaFrame.height} vs ${child.height}" |
| ) |
| } |
| } |
| } |
| if (isCrossFadeAnimatorRunning) { |
| // When cross-fading with an animation, we only notify the media carousel of the |
| // location change, once the view is reattached to the new place and not |
| // immediately |
| // when the desired location changes. This callback will update the measurement |
| // of the carousel, only once we've faded out at the old location and then |
| // reattach |
| // to fade it in at the new location. |
| mediaCarouselController.onDesiredLocationChanged( |
| newLocation, |
| getHost(newLocation), |
| animate = false |
| ) |
| } |
| } |
| } |
| |
| /** |
| * Calculate the location when cross fading between locations. While fading out, the content |
| * should remain in the previous location, while after the switch it should be at the desired |
| * location. |
| */ |
| private fun resolveLocationForFading(): Int { |
| if (isCrossFadeAnimatorRunning) { |
| // When animating between two hosts with a fade, let's keep ourselves in the old |
| // location for the first half, and then switch over to the end location |
| if (animationCrossFadeProgress > 0.5 || previousLocation == -1) { |
| return crossFadeAnimationEndLocation |
| } else { |
| return crossFadeAnimationStartLocation |
| } |
| } |
| return desiredLocation |
| } |
| |
| private fun isTransitionRunning(): Boolean { |
| return isCurrentlyInGuidedTransformation() && getTransformationProgress() != 1.0f || |
| animator.isRunning || |
| animationPending |
| } |
| |
| @MediaLocation |
| private fun calculateLocation(): Int { |
| if (blockLocationChanges) { |
| // Keep the current location until we're allowed to again |
| return desiredLocation |
| } |
| val onLockscreen = |
| (!bypassController.bypassEnabled && (statusbarState == StatusBarState.KEYGUARD)) |
| val location = |
| when { |
| dreamOverlayActive && dreamMediaComplicationActive -> LOCATION_DREAM_OVERLAY |
| (qsExpansion > 0.0f || inSplitShade) && !onLockscreen -> LOCATION_QS |
| qsExpansion > 0.4f && onLockscreen -> LOCATION_QS |
| onLockscreen && isSplitShadeExpanding() -> LOCATION_QS |
| onLockscreen && isTransformingToFullShadeAndInQQS() -> LOCATION_QQS |
| onLockscreen && allowMediaPlayerOnLockScreen -> LOCATION_LOCKSCREEN |
| else -> LOCATION_QQS |
| } |
| // When we're on lock screen and the player is not active, we should keep it in QS. |
| // Otherwise it will try to animate a transition that doesn't make sense. |
| if ( |
| location == LOCATION_LOCKSCREEN && |
| getHost(location)?.visible != true && |
| !statusBarStateController.isDozing |
| ) { |
| return LOCATION_QS |
| } |
| if ( |
| location == LOCATION_LOCKSCREEN && |
| desiredLocation == LOCATION_QS && |
| collapsingShadeFromQS |
| ) { |
| // When collapsing on the lockscreen, we want to remain in QS |
| return LOCATION_QS |
| } |
| if ( |
| location != LOCATION_LOCKSCREEN && desiredLocation == LOCATION_LOCKSCREEN && !fullyAwake |
| ) { |
| // When unlocking from dozing / while waking up, the media shouldn't be transitioning |
| // in an animated way. Let's keep it in the lockscreen until we're fully awake and |
| // reattach it without an animation |
| return LOCATION_LOCKSCREEN |
| } |
| if (skipQqsOnExpansion) { |
| // When doing an immediate expand or collapse, we want to keep it in QS. |
| return LOCATION_QS |
| } |
| return location |
| } |
| |
| private fun isSplitShadeExpanding(): Boolean { |
| return inSplitShade && isTransitioningToFullShade |
| } |
| |
| /** Are we currently transforming to the full shade and already in QQS */ |
| private fun isTransformingToFullShadeAndInQQS(): Boolean { |
| if (!isTransitioningToFullShade) { |
| return false |
| } |
| if (inSplitShade) { |
| // Split shade doesn't use QQS. |
| return false |
| } |
| return fullShadeTransitionProgress > 0.5f |
| } |
| |
| /** Is the current transformationType fading */ |
| private fun isCurrentlyFading(): Boolean { |
| if (isSplitShadeExpanding()) { |
| // Split shade always uses transition instead of fade. |
| return false |
| } |
| if (isTransitioningToFullShade) { |
| return true |
| } |
| return isCrossFadeAnimatorRunning |
| } |
| |
| /** Returns true when the media card could be visible to the user if existed. */ |
| private fun isVisibleToUser(): Boolean { |
| return isLockScreenVisibleToUser() || |
| isLockScreenShadeVisibleToUser() || |
| isHomeScreenShadeVisibleToUser() |
| } |
| |
| private fun isLockScreenVisibleToUser(): Boolean { |
| return !statusBarStateController.isDozing && |
| !keyguardViewController.isBouncerShowing && |
| statusBarStateController.state == StatusBarState.KEYGUARD && |
| allowMediaPlayerOnLockScreen && |
| statusBarStateController.isExpanded && |
| !qsExpanded |
| } |
| |
| private fun isLockScreenShadeVisibleToUser(): Boolean { |
| return !statusBarStateController.isDozing && |
| !keyguardViewController.isBouncerShowing && |
| (statusBarStateController.state == StatusBarState.SHADE_LOCKED || |
| (statusBarStateController.state == StatusBarState.KEYGUARD && qsExpanded)) |
| } |
| |
| private fun isHomeScreenShadeVisibleToUser(): Boolean { |
| return !statusBarStateController.isDozing && |
| statusBarStateController.state == StatusBarState.SHADE && |
| statusBarStateController.isExpanded |
| } |
| |
| companion object { |
| /** Attached in expanded quick settings */ |
| const val LOCATION_QS = 0 |
| |
| /** Attached in the collapsed QS */ |
| const val LOCATION_QQS = 1 |
| |
| /** Attached on the lock screen */ |
| const val LOCATION_LOCKSCREEN = 2 |
| |
| /** Attached on the dream overlay */ |
| const val LOCATION_DREAM_OVERLAY = 3 |
| |
| /** Attached at the root of the hierarchy in an overlay */ |
| const val IN_OVERLAY = -1000 |
| |
| /** |
| * The default transformation type where the hosts transform into each other using a direct |
| * transition |
| */ |
| const val TRANSFORMATION_TYPE_TRANSITION = 0 |
| |
| /** |
| * A transformation type where content fades from one place to another instead of |
| * transitioning |
| */ |
| const val TRANSFORMATION_TYPE_FADE = 1 |
| } |
| } |
| |
| private val EMPTY_RECT = Rect() |
| |
| @IntDef( |
| prefix = ["TRANSFORMATION_TYPE_"], |
| value = |
| [ |
| MediaHierarchyManager.TRANSFORMATION_TYPE_TRANSITION, |
| MediaHierarchyManager.TRANSFORMATION_TYPE_FADE |
| ] |
| ) |
| @Retention(AnnotationRetention.SOURCE) |
| private annotation class TransformationType |
| |
| @IntDef( |
| prefix = ["LOCATION_"], |
| value = |
| [ |
| MediaHierarchyManager.LOCATION_QS, |
| MediaHierarchyManager.LOCATION_QQS, |
| MediaHierarchyManager.LOCATION_LOCKSCREEN, |
| MediaHierarchyManager.LOCATION_DREAM_OVERLAY |
| ] |
| ) |
| @Retention(AnnotationRetention.SOURCE) |
| annotation class MediaLocation |