1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.systemui.media.controls.ui
18 
19 import android.content.Context
20 import android.content.res.Configuration
21 import androidx.annotation.VisibleForTesting
22 import androidx.constraintlayout.widget.ConstraintSet
23 import com.android.systemui.R
24 import com.android.systemui.media.controls.models.GutsViewHolder
25 import com.android.systemui.media.controls.models.player.MediaViewHolder
26 import com.android.systemui.media.controls.models.recommendation.RecommendationViewHolder
27 import com.android.systemui.media.controls.ui.MediaCarouselController.Companion.calculateAlpha
28 import com.android.systemui.media.controls.util.MediaFlags
29 import com.android.systemui.statusbar.policy.ConfigurationController
30 import com.android.systemui.util.animation.MeasurementOutput
31 import com.android.systemui.util.animation.TransitionLayout
32 import com.android.systemui.util.animation.TransitionLayoutController
33 import com.android.systemui.util.animation.TransitionViewState
34 import com.android.systemui.util.traceSection
35 import java.lang.Float.max
36 import java.lang.Float.min
37 import javax.inject.Inject
38 
39 /**
40  * A class responsible for controlling a single instance of a media player handling interactions
41  * with the view instance and keeping the media view states up to date.
42  */
43 class MediaViewController
44 @Inject
45 constructor(
46     private val context: Context,
47     private val configurationController: ConfigurationController,
48     private val mediaHostStatesManager: MediaHostStatesManager,
49     private val logger: MediaViewLogger,
50     private val mediaFlags: MediaFlags,
51 ) {
52 
53     /**
54      * Indicating that the media view controller is for a notification-based player, session-based
55      * player, or recommendation
56      */
57     enum class TYPE {
58         PLAYER,
59         RECOMMENDATION
60     }
61 
62     companion object {
63         @JvmField val GUTS_ANIMATION_DURATION = 500L
64     }
65 
66     /** A listener when the current dimensions of the player change */
67     lateinit var sizeChangedListener: () -> Unit
68     lateinit var configurationChangeListener: () -> Unit
69     private var firstRefresh: Boolean = true
70     @VisibleForTesting private var transitionLayout: TransitionLayout? = null
71     private val layoutController = TransitionLayoutController()
72     private var animationDelay: Long = 0
73     private var animationDuration: Long = 0
74     private var animateNextStateChange: Boolean = false
75     private val measurement = MeasurementOutput(0, 0)
76     private var type: TYPE = TYPE.PLAYER
77 
78     /** A map containing all viewStates for all locations of this mediaState */
79     private val viewStates: MutableMap<CacheKey, TransitionViewState?> = mutableMapOf()
80 
81     /**
82      * The ending location of the view where it ends when all animations and transitions have
83      * finished
84      */
85     @MediaLocation var currentEndLocation: Int = -1
86 
87     /** The starting location of the view where it starts for all animations and transitions */
88     @MediaLocation private var currentStartLocation: Int = -1
89 
90     /** The progress of the transition or 1.0 if there is no transition happening */
91     private var currentTransitionProgress: Float = 1.0f
92 
93     /** A temporary state used to store intermediate measurements. */
94     private val tmpState = TransitionViewState()
95 
96     /** A temporary state used to store intermediate measurements. */
97     private val tmpState2 = TransitionViewState()
98 
99     /** A temporary state used to store intermediate measurements. */
100     private val tmpState3 = TransitionViewState()
101 
102     /** A temporary cache key to be used to look up cache entries */
103     private val tmpKey = CacheKey()
104 
105     /**
106      * The current width of the player. This might not factor in case the player is animating to the
107      * current state, but represents the end state
108      */
109     var currentWidth: Int = 0
110     /**
111      * The current height of the player. This might not factor in case the player is animating to
112      * the current state, but represents the end state
113      */
114     var currentHeight: Int = 0
115 
116     /** Get the translationX of the layout */
117     var translationX: Float = 0.0f
118         private set
119         get() {
120             return transitionLayout?.translationX ?: 0.0f
121         }
122 
123     /** Get the translationY of the layout */
124     var translationY: Float = 0.0f
125         private set
126         get() {
127             return transitionLayout?.translationY ?: 0.0f
128         }
129 
130     /** A callback for config changes */
131     private val configurationListener =
132         object : ConfigurationController.ConfigurationListener {
133             var lastOrientation = -1
134 
135             override fun onConfigChanged(newConfig: Configuration?) {
136                 // Because the TransitionLayout is not always attached (and calculates/caches layout
137                 // results regardless of attach state), we have to force the layoutDirection of the
138                 // view
139                 // to the correct value for the user's current locale to ensure correct
140                 // recalculation
141                 // when/after calling refreshState()
142                 newConfig?.apply {
143                     if (transitionLayout?.rawLayoutDirection != layoutDirection) {
144                         transitionLayout?.layoutDirection = layoutDirection
145                         refreshState()
146                     }
147                     val newOrientation = newConfig.orientation
148                     if (lastOrientation != newOrientation) {
149                         // Layout dimensions are possibly changing, so we need to update them. (at
150                         // least on large screen devices)
151                         lastOrientation = newOrientation
152                         // Update the height of media controls for the expanded layout. it is needed
153                         // for large screen devices.
154                         val backgroundIds =
155                             if (type == TYPE.PLAYER) {
156                                 MediaViewHolder.backgroundIds
157                             } else {
158                                 setOf(RecommendationViewHolder.backgroundId)
159                             }
160                         backgroundIds.forEach { id ->
161                             expandedLayout.getConstraint(id).layout.mHeight =
162                                 context.resources.getDimensionPixelSize(
163                                     R.dimen.qs_media_session_height_expanded
164                                 )
165                         }
166                     }
167                     if (this@MediaViewController::configurationChangeListener.isInitialized) {
168                         configurationChangeListener.invoke()
169                         refreshState()
170                     }
171                 }
172             }
173         }
174 
175     /** A callback for media state changes */
176     val stateCallback =
177         object : MediaHostStatesManager.Callback {
178             override fun onHostStateChanged(
179                 @MediaLocation location: Int,
180                 mediaHostState: MediaHostState
181             ) {
182                 if (location == currentEndLocation || location == currentStartLocation) {
183                     setCurrentState(
184                         currentStartLocation,
185                         currentEndLocation,
186                         currentTransitionProgress,
187                         applyImmediately = false
188                     )
189                 }
190             }
191         }
192 
193     /**
194      * The expanded constraint set used to render a expanded player. If it is modified, make sure to
195      * call [refreshState]
196      */
197     var collapsedLayout = ConstraintSet()
198         @VisibleForTesting set
199     /**
200      * The expanded constraint set used to render a collapsed player. If it is modified, make sure
201      * to call [refreshState]
202      */
203     var expandedLayout = ConstraintSet()
204         @VisibleForTesting set
205 
206     /** Whether the guts are visible for the associated player. */
207     var isGutsVisible = false
208         private set
209 
210     init {
211         mediaHostStatesManager.addController(this)
212         layoutController.sizeChangedListener = { width: Int, height: Int ->
213             currentWidth = width
214             currentHeight = height
215             sizeChangedListener.invoke()
216         }
217         configurationController.addCallback(configurationListener)
218     }
219 
220     /**
221      * Notify this controller that the view has been removed and all listeners should be destroyed
222      */
223     fun onDestroy() {
224         mediaHostStatesManager.removeController(this)
225         configurationController.removeCallback(configurationListener)
226     }
227 
228     /** Show guts with an animated transition. */
229     fun openGuts() {
230         if (isGutsVisible) return
231         isGutsVisible = true
232         animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
233         setCurrentState(
234             currentStartLocation,
235             currentEndLocation,
236             currentTransitionProgress,
237             applyImmediately = false
238         )
239     }
240 
241     /**
242      * Close the guts for the associated player.
243      *
244      * @param immediate if `false`, it will animate the transition.
245      */
246     @JvmOverloads
247     fun closeGuts(immediate: Boolean = false) {
248         if (!isGutsVisible) return
249         isGutsVisible = false
250         if (!immediate) {
251             animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
252         }
253         setCurrentState(
254             currentStartLocation,
255             currentEndLocation,
256             currentTransitionProgress,
257             applyImmediately = immediate
258         )
259     }
260 
261     private fun ensureAllMeasurements() {
262         val mediaStates = mediaHostStatesManager.mediaHostStates
263         for (entry in mediaStates) {
264             obtainViewState(entry.value)
265         }
266     }
267 
268     /** Get the constraintSet for a given expansion */
269     private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
270         if (expansion > 0) expandedLayout else collapsedLayout
271 
272     /**
273      * Set the views to be showing/hidden based on the [isGutsVisible] for a given
274      * [TransitionViewState].
275      */
276     private fun setGutsViewState(viewState: TransitionViewState) {
277         val controlsIds =
278             when (type) {
279                 TYPE.PLAYER -> MediaViewHolder.controlsIds
280                 TYPE.RECOMMENDATION -> RecommendationViewHolder.controlsIds
281             }
282         val gutsIds = GutsViewHolder.ids
283         controlsIds.forEach { id ->
284             viewState.widgetStates.get(id)?.let { state ->
285                 // Make sure to use the unmodified state if guts are not visible.
286                 state.alpha = if (isGutsVisible) 0f else state.alpha
287                 state.gone = if (isGutsVisible) true else state.gone
288             }
289         }
290         gutsIds.forEach { id ->
291             viewState.widgetStates.get(id)?.let { state ->
292                 // Make sure to use the unmodified state if guts are visible
293                 state.alpha = if (isGutsVisible) state.alpha else 0f
294                 state.gone = if (isGutsVisible) state.gone else true
295             }
296         }
297     }
298 
299     /** Apply squishFraction to a copy of viewState such that the cached version is untouched. */
300     internal fun squishViewState(
301         viewState: TransitionViewState,
302         squishFraction: Float
303     ): TransitionViewState {
304         val squishedViewState = viewState.copy()
305         val squishedHeight = (squishedViewState.measureHeight * squishFraction).toInt()
306         squishedViewState.height = squishedHeight
307         // We are not overriding the squishedViewStates height but only the children to avoid
308         // them remeasuring the whole view. Instead it just remains as the original size
309         MediaViewHolder.backgroundIds.forEach { id ->
310             squishedViewState.widgetStates.get(id)?.let { state -> state.height = squishedHeight }
311         }
312 
313         // media player
314         calculateWidgetGroupAlphaForSquishiness(
315             MediaViewHolder.expandedBottomActionIds,
316             squishedViewState.measureHeight.toFloat(),
317             squishedViewState,
318             squishFraction
319         )
320         calculateWidgetGroupAlphaForSquishiness(
321             MediaViewHolder.detailIds,
322             squishedViewState.measureHeight.toFloat(),
323             squishedViewState,
324             squishFraction
325         )
326         // recommendation card
327         val titlesTop =
328             calculateWidgetGroupAlphaForSquishiness(
329                 RecommendationViewHolder.mediaTitlesAndSubtitlesIds,
330                 squishedViewState.measureHeight.toFloat(),
331                 squishedViewState,
332                 squishFraction
333             )
334         calculateWidgetGroupAlphaForSquishiness(
335             RecommendationViewHolder.mediaContainersIds,
336             titlesTop,
337             squishedViewState,
338             squishFraction
339         )
340         return squishedViewState
341     }
342 
343     /**
344      * This function is to make each widget in UMO disappear before being clipped by squished UMO
345      *
346      * The general rule is that widgets in UMO has been divided into several groups, and widgets in
347      * one group have the same alpha during squishing It will change from alpha 0.0 when the visible
348      * bottom of UMO reach the bottom of this group It will change to alpha 1.0 when the visible
349      * bottom of UMO reach the top of the group below e.g.Album title, artist title and play-pause
350      * button will change alpha together.
351      *
352      * ```
353      *     And their alpha becomes 1.0 when the visible bottom of UMO reach the top of controls,
354      *     including progress bar, next button, previous button
355      * ```
356      *
357      * widgetGroupIds: a group of widgets have same state during UMO is squished,
358      * ```
359      *     e.g. Album title, artist title and play-pause button
360      * ```
361      *
362      * groupEndPosition: the height of UMO, when the height reaches this value,
363      * ```
364      *     widgets in this group should have 1.0 as alpha
365      *     e.g., the group of album title, artist title and play-pause button will become fully
366      *         visible when the height of UMO reaches the top of controls group
367      *         (progress bar, previous button and next button)
368      * ```
369      *
370      * squishedViewState: hold the widgetState of each widget, which will be modified
371      * squishFraction: the squishFraction of UMO
372      */
373     private fun calculateWidgetGroupAlphaForSquishiness(
374         widgetGroupIds: Set<Int>,
375         groupEndPosition: Float,
376         squishedViewState: TransitionViewState,
377         squishFraction: Float
378     ): Float {
379         val nonsquishedHeight = squishedViewState.measureHeight
380         var groupTop = squishedViewState.measureHeight.toFloat()
381         var groupBottom = 0F
382         widgetGroupIds.forEach { id ->
383             squishedViewState.widgetStates.get(id)?.let { state ->
384                 groupTop = min(groupTop, state.y)
385                 groupBottom = max(groupBottom, state.y + state.height)
386             }
387         }
388         // startPosition means to the height of squished UMO where the widget alpha should start
389         // changing from 0.0
390         // generally, it equals to the bottom of widgets, so that we can meet the requirement that
391         // widget should not go beyond the bounds of background
392         // endPosition means to the height of squished UMO where the widget alpha should finish
393         // changing alpha to 1.0
394         var startPosition = groupBottom
395         val endPosition = groupEndPosition
396         if (startPosition == endPosition) {
397             startPosition = (endPosition - 0.2 * (groupBottom - groupTop)).toFloat()
398         }
399         widgetGroupIds.forEach { id ->
400             squishedViewState.widgetStates.get(id)?.let { state ->
401                 state.alpha =
402                     calculateAlpha(
403                         squishFraction,
404                         startPosition / nonsquishedHeight,
405                         endPosition / nonsquishedHeight
406                     )
407             }
408         }
409         return groupTop // used for the widget group above this group
410     }
411 
412     /**
413      * Obtain a new viewState for a given media state. This usually returns a cached state, but if
414      * it's not available, it will recreate one by measuring, which may be expensive.
415      */
416     @VisibleForTesting
417     fun obtainViewState(state: MediaHostState?): TransitionViewState? {
418         if (state == null || state.measurementInput == null) {
419             return null
420         }
421         // Only a subset of the state is relevant to get a valid viewState. Let's get the cachekey
422         var cacheKey = getKey(state, isGutsVisible, tmpKey)
423         val viewState = viewStates[cacheKey]
424         if (viewState != null) {
425             // we already have cached this measurement, let's continue
426             if (state.squishFraction <= 1f) {
427                 return squishViewState(viewState, state.squishFraction)
428             }
429             return viewState
430         }
431         // Copy the key since this might call recursively into it and we're using tmpKey
432         cacheKey = cacheKey.copy()
433         val result: TransitionViewState?
434 
435         if (transitionLayout == null) {
436             return null
437         }
438         // Let's create a new measurement
439         if (state.expansion == 0.0f || state.expansion == 1.0f) {
440             result =
441                 transitionLayout!!.calculateViewState(
442                     state.measurementInput!!,
443                     constraintSetForExpansion(state.expansion),
444                     TransitionViewState()
445                 )
446 
447             setGutsViewState(result)
448             // We don't want to cache interpolated or null states as this could quickly fill up
449             // our cache. We only cache the start and the end states since the interpolation
450             // is cheap
451             viewStates[cacheKey] = result
452         } else {
453             // This is an interpolated state
454             val startState = state.copy().also { it.expansion = 0.0f }
455 
456             // Given that we have a measurement and a view, let's get (guaranteed) viewstates
457             // from the start and end state and interpolate them
458             val startViewState = obtainViewState(startState) as TransitionViewState
459             val endState = state.copy().also { it.expansion = 1.0f }
460             val endViewState = obtainViewState(endState) as TransitionViewState
461             result =
462                 layoutController.getInterpolatedState(startViewState, endViewState, state.expansion)
463         }
464         if (state.squishFraction <= 1f) {
465             return squishViewState(result, state.squishFraction)
466         }
467         return result
468     }
469 
470     private fun getKey(state: MediaHostState, guts: Boolean, result: CacheKey): CacheKey {
471         result.apply {
472             heightMeasureSpec = state.measurementInput?.heightMeasureSpec ?: 0
473             widthMeasureSpec = state.measurementInput?.widthMeasureSpec ?: 0
474             expansion = state.expansion
475             gutsVisible = guts
476         }
477         return result
478     }
479 
480     /**
481      * Attach a view to this controller. This may perform measurements if it's not available yet and
482      * should therefore be done carefully.
483      */
484     fun attach(transitionLayout: TransitionLayout, type: TYPE) =
485         traceSection("MediaViewController#attach") {
486             loadLayoutForType(type)
487             logger.logMediaLocation("attach $type", currentStartLocation, currentEndLocation)
488             this.transitionLayout = transitionLayout
489             layoutController.attach(transitionLayout)
490             if (currentEndLocation == -1) {
491                 return
492             }
493             // Set the previously set state immediately to the view, now that it's finally attached
494             setCurrentState(
495                 startLocation = currentStartLocation,
496                 endLocation = currentEndLocation,
497                 transitionProgress = currentTransitionProgress,
498                 applyImmediately = true
499             )
500         }
501 
502     /**
503      * Obtain a measurement for a given location. This makes sure that the state is up to date and
504      * all widgets know their location. Calling this method may create a measurement if we don't
505      * have a cached value available already.
506      */
507     fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? =
508         traceSection("MediaViewController#getMeasurementsForState") {
509             // measurements should never factor in the squish fraction
510             val viewState = obtainViewState(hostState) ?: return null
511             measurement.measuredWidth = viewState.measureWidth
512             measurement.measuredHeight = viewState.measureHeight
513             return measurement
514         }
515 
516     /**
517      * Set a new state for the controlled view which can be an interpolation between multiple
518      * locations.
519      */
520     fun setCurrentState(
521         @MediaLocation startLocation: Int,
522         @MediaLocation endLocation: Int,
523         transitionProgress: Float,
524         applyImmediately: Boolean
525     ) =
526         traceSection("MediaViewController#setCurrentState") {
527             currentEndLocation = endLocation
528             currentStartLocation = startLocation
529             currentTransitionProgress = transitionProgress
530             logger.logMediaLocation("setCurrentState", startLocation, endLocation)
531 
532             val shouldAnimate = animateNextStateChange && !applyImmediately
533 
534             val endHostState = mediaHostStatesManager.mediaHostStates[endLocation] ?: return
535             val startHostState = mediaHostStatesManager.mediaHostStates[startLocation]
536 
537             // Obtain the view state that we'd want to be at the end
538             // The view might not be bound yet or has never been measured and in that case will be
539             // reset once the state is fully available
540             var endViewState = obtainViewState(endHostState) ?: return
541             endViewState = updateViewStateSize(endViewState, endLocation, tmpState2)!!
542             layoutController.setMeasureState(endViewState)
543 
544             // If the view isn't bound, we can drop the animation, otherwise we'll execute it
545             animateNextStateChange = false
546             if (transitionLayout == null) {
547                 return
548             }
549 
550             val result: TransitionViewState
551             var startViewState = obtainViewState(startHostState)
552             startViewState = updateViewStateSize(startViewState, startLocation, tmpState3)
553 
554             if (!endHostState.visible) {
555                 // Let's handle the case where the end is gone first. In this case we take the
556                 // start viewState and will make it gone
557                 if (startViewState == null || startHostState == null || !startHostState.visible) {
558                     // the start isn't a valid state, let's use the endstate directly
559                     result = endViewState
560                 } else {
561                     // Let's get the gone presentation from the start state
562                     result =
563                         layoutController.getGoneState(
564                             startViewState,
565                             startHostState.disappearParameters,
566                             transitionProgress,
567                             tmpState
568                         )
569                 }
570             } else if (startHostState != null && !startHostState.visible) {
571                 // We have a start state and it is gone.
572                 // Let's get presentation from the endState
573                 result =
574                     layoutController.getGoneState(
575                         endViewState,
576                         endHostState.disappearParameters,
577                         1.0f - transitionProgress,
578                         tmpState
579                     )
580             } else if (transitionProgress == 1.0f || startViewState == null) {
581                 // We're at the end. Let's use that state
582                 result = endViewState
583             } else if (transitionProgress == 0.0f) {
584                 // We're at the start. Let's use that state
585                 result = startViewState
586             } else {
587                 result =
588                     layoutController.getInterpolatedState(
589                         startViewState,
590                         endViewState,
591                         transitionProgress,
592                         tmpState
593                     )
594             }
595             logger.logMediaSize(
596                 "setCurrentState (progress $transitionProgress)",
597                 result.width,
598                 result.height
599             )
600             layoutController.setState(
601                 result,
602                 applyImmediately,
603                 shouldAnimate,
604                 animationDuration,
605                 animationDelay
606             )
607         }
608 
609     private fun updateViewStateSize(
610         viewState: TransitionViewState?,
611         location: Int,
612         outState: TransitionViewState
613     ): TransitionViewState? {
614         var result = viewState?.copy(outState) ?: return null
615         val state = mediaHostStatesManager.mediaHostStates[location]
616         val overrideSize = mediaHostStatesManager.carouselSizes[location]
617         var overridden = false
618         overrideSize?.let {
619             // To be safe we're using a maximum here. The override size should always be set
620             // properly though.
621             if (
622                 result.measureHeight != it.measuredHeight || result.measureWidth != it.measuredWidth
623             ) {
624                 result.measureHeight = Math.max(it.measuredHeight, result.measureHeight)
625                 result.measureWidth = Math.max(it.measuredWidth, result.measureWidth)
626                 // The measureHeight and the shown height should both be set to the overridden
627                 // height
628                 result.height = result.measureHeight
629                 result.width = result.measureWidth
630                 // Make sure all background views are also resized such that their size is correct
631                 MediaViewHolder.backgroundIds.forEach { id ->
632                     result.widgetStates.get(id)?.let { state ->
633                         state.height = result.height
634                         state.width = result.width
635                     }
636                 }
637                 overridden = true
638             }
639         }
640         if (overridden && state != null && state.squishFraction <= 1f) {
641             // Let's squish the media player if our size was overridden
642             result = squishViewState(result, state.squishFraction)
643         }
644         logger.logMediaSize("update to carousel", result.width, result.height)
645         return result
646     }
647 
648     private fun loadLayoutForType(type: TYPE) {
649         this.type = type
650 
651         // These XML resources contain ConstraintSets that will apply to this player type's layout
652         when (type) {
653             TYPE.PLAYER -> {
654                 collapsedLayout.load(context, R.xml.media_session_collapsed)
655                 expandedLayout.load(context, R.xml.media_session_expanded)
656             }
657             TYPE.RECOMMENDATION -> {
658                 collapsedLayout.load(context, R.xml.media_recommendations_collapsed)
659                 expandedLayout.load(context, R.xml.media_recommendations_expanded)
660             }
661         }
662         refreshState()
663     }
664 
665     /**
666      * Retrieves the [TransitionViewState] and [MediaHostState] of a [@MediaLocation]. In the event
667      * of [location] not being visible, [locationWhenHidden] will be used instead.
668      *
669      * @param location Target
670      * @param locationWhenHidden Location that will be used when the target is not
671      *   [MediaHost.visible]
672      * @return State require for executing a transition, and also the respective [MediaHost].
673      */
674     private fun obtainViewStateForLocation(@MediaLocation location: Int): TransitionViewState? {
675         val mediaHostState = mediaHostStatesManager.mediaHostStates[location] ?: return null
676         val viewState = obtainViewState(mediaHostState)
677         if (viewState != null) {
678             // update the size of the viewstate for the location with the override
679             updateViewStateSize(viewState, location, tmpState)
680             return tmpState
681         }
682         return viewState
683     }
684 
685     /**
686      * Notify that the location is changing right now and a [setCurrentState] change is imminent.
687      * This updates the width the view will me measured with.
688      */
689     fun onLocationPreChange(@MediaLocation newLocation: Int) {
690         obtainViewStateForLocation(newLocation)?.let { layoutController.setMeasureState(it) }
691     }
692 
693     /** Request that the next state change should be animated with the given parameters. */
694     fun animatePendingStateChange(duration: Long, delay: Long) {
695         animateNextStateChange = true
696         animationDuration = duration
697         animationDelay = delay
698     }
699 
700     /** Clear all existing measurements and refresh the state to match the view. */
701     fun refreshState() =
702         traceSection("MediaViewController#refreshState") {
703             // Let's clear all of our measurements and recreate them!
704             viewStates.clear()
705             if (firstRefresh) {
706                 // This is the first bind, let's ensure we pre-cache all measurements. Otherwise
707                 // We'll just load these on demand.
708                 ensureAllMeasurements()
709                 firstRefresh = false
710             }
711             setCurrentState(
712                 currentStartLocation,
713                 currentEndLocation,
714                 currentTransitionProgress,
715                 applyImmediately = true
716             )
717         }
718 }
719 
720 /** An internal key for the cache of mediaViewStates. This is a subset of the full host state. */
721 private data class CacheKey(
722     var widthMeasureSpec: Int = -1,
723     var heightMeasureSpec: Int = -1,
724     var expansion: Float = 0.0f,
725     var gutsVisible: Boolean = false
726 )
727