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