• 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
18 
19 import android.content.Context
20 import android.content.res.Configuration
21 import androidx.constraintlayout.widget.ConstraintSet
22 import com.android.systemui.R
23 import com.android.systemui.statusbar.policy.ConfigurationController
24 import com.android.systemui.util.animation.MeasurementOutput
25 import com.android.systemui.util.animation.TransitionLayout
26 import com.android.systemui.util.animation.TransitionLayoutController
27 import com.android.systemui.util.animation.TransitionViewState
28 import javax.inject.Inject
29 
30 /**
31  * A class responsible for controlling a single instance of a media player handling interactions
32  * with the view instance and keeping the media view states up to date.
33  */
34 class MediaViewController @Inject constructor(
35     private val context: Context,
36     private val configurationController: ConfigurationController,
37     private val mediaHostStatesManager: MediaHostStatesManager
38 ) {
39 
40     /** Indicating the media view controller is for a player or recommendation. */
41     enum class TYPE {
42         PLAYER, RECOMMENDATION
43     }
44 
45     companion object {
46         @JvmField
47         val GUTS_ANIMATION_DURATION = 500L
48     }
49 
50     /**
51      * A listener when the current dimensions of the player change
52      */
53     lateinit var sizeChangedListener: () -> Unit
54     private var firstRefresh: Boolean = true
55     private var transitionLayout: TransitionLayout? = null
56     private val layoutController = TransitionLayoutController()
57     private var animationDelay: Long = 0
58     private var animationDuration: Long = 0
59     private var animateNextStateChange: Boolean = false
60     private val measurement = MeasurementOutput(0, 0)
61     private var type: TYPE = TYPE.PLAYER
62 
63     /**
64      * A map containing all viewStates for all locations of this mediaState
65      */
66     private val viewStates: MutableMap<CacheKey, TransitionViewState?> = mutableMapOf()
67 
68     /**
69      * The ending location of the view where it ends when all animations and transitions have
70      * finished
71      */
72     @MediaLocation
73     var currentEndLocation: Int = -1
74 
75     /**
76      * The starting location of the view where it starts for all animations and transitions
77      */
78     @MediaLocation
79     private var currentStartLocation: Int = -1
80 
81     /**
82      * The progress of the transition or 1.0 if there is no transition happening
83      */
84     private var currentTransitionProgress: Float = 1.0f
85 
86     /**
87      * A temporary state used to store intermediate measurements.
88      */
89     private val tmpState = TransitionViewState()
90 
91     /**
92      * A temporary state used to store intermediate measurements.
93      */
94     private val tmpState2 = TransitionViewState()
95 
96     /**
97      * A temporary state used to store intermediate measurements.
98      */
99     private val tmpState3 = TransitionViewState()
100 
101     /**
102      * A temporary cache key to be used to look up cache entries
103      */
104     private val tmpKey = CacheKey()
105 
106     /**
107      * The current width of the player. This might not factor in case the player is animating
108      * to the current state, but represents the end state
109      */
110     var currentWidth: Int = 0
111     /**
112      * The current height of the player. This might not factor in case the player is animating
113      * to the current state, but represents the end state
114      */
115     var currentHeight: Int = 0
116 
117     /**
118      * Get the translationX of the layout
119      */
120     var translationX: Float = 0.0f
121         private set
122         get() {
123             return transitionLayout?.translationX ?: 0.0f
124         }
125 
126     /**
127      * Get the translationY of the layout
128      */
129     var translationY: Float = 0.0f
130         private set
131         get() {
132             return transitionLayout?.translationY ?: 0.0f
133         }
134 
135     /**
136      * A callback for RTL config changes
137      */
138     private val configurationListener = object : ConfigurationController.ConfigurationListener {
139         override fun onConfigChanged(newConfig: Configuration?) {
140             // Because the TransitionLayout is not always attached (and calculates/caches layout
141             // results regardless of attach state), we have to force the layoutDirection of the view
142             // to the correct value for the user's current locale to ensure correct recalculation
143             // when/after calling refreshState()
144             newConfig?.apply {
145                 if (transitionLayout?.rawLayoutDirection != layoutDirection) {
146                     transitionLayout?.layoutDirection = layoutDirection
147                     refreshState()
148                 }
149             }
150         }
151     }
152 
153     /**
154      * A callback for media state changes
155      */
156     val stateCallback = object : MediaHostStatesManager.Callback {
157         override fun onHostStateChanged(
158             @MediaLocation location: Int,
159             mediaHostState: MediaHostState
160         ) {
161             if (location == currentEndLocation || location == currentStartLocation) {
162                 setCurrentState(currentStartLocation,
163                         currentEndLocation,
164                         currentTransitionProgress,
165                         applyImmediately = false)
166             }
167         }
168     }
169 
170     /**
171      * The expanded constraint set used to render a expanded player. If it is modified, make sure
172      * to call [refreshState]
173      */
174     val collapsedLayout = ConstraintSet()
175 
176     /**
177      * The expanded constraint set used to render a collapsed player. If it is modified, make sure
178      * to call [refreshState]
179      */
180     val expandedLayout = ConstraintSet()
181 
182     /**
183      * Whether the guts are visible for the associated player.
184      */
185     var isGutsVisible = false
186         private set
187 
188     /**
189      * Whether the settings button in the guts should be visible
190      */
191     var shouldHideGutsSettings = false
192 
193     init {
194         mediaHostStatesManager.addController(this)
195         layoutController.sizeChangedListener = { width: Int, height: Int ->
196             currentWidth = width
197             currentHeight = height
198             sizeChangedListener.invoke()
199         }
200         configurationController.addCallback(configurationListener)
201     }
202 
203     /**
204      * Notify this controller that the view has been removed and all listeners should be destroyed
205      */
206     fun onDestroy() {
207         mediaHostStatesManager.removeController(this)
208         configurationController.removeCallback(configurationListener)
209     }
210 
211     /**
212      * Show guts with an animated transition.
213      */
214     fun openGuts() {
215         if (isGutsVisible) return
216         isGutsVisible = true
217         animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
218         setCurrentState(currentStartLocation,
219                 currentEndLocation,
220                 currentTransitionProgress,
221                 applyImmediately = false)
222     }
223 
224     /**
225      * Close the guts for the associated player.
226      *
227      * @param immediate if `false`, it will animate the transition.
228      */
229     @JvmOverloads
230     fun closeGuts(immediate: Boolean = false) {
231         if (!isGutsVisible) return
232         isGutsVisible = false
233         if (!immediate) {
234             animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
235         }
236         setCurrentState(currentStartLocation,
237                 currentEndLocation,
238                 currentTransitionProgress,
239                 applyImmediately = immediate)
240     }
241 
242     private fun ensureAllMeasurements() {
243         val mediaStates = mediaHostStatesManager.mediaHostStates
244         for (entry in mediaStates) {
245             obtainViewState(entry.value)
246         }
247     }
248 
249     /**
250      * Get the constraintSet for a given expansion
251      */
252     private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
253             if (expansion > 0) expandedLayout else collapsedLayout
254 
255     /**
256      * Set the views to be showing/hidden based on the [isGutsVisible] for a given
257      * [TransitionViewState].
258      */
259     private fun setGutsViewState(viewState: TransitionViewState) {
260         if (type == TYPE.PLAYER) {
261             PlayerViewHolder.controlsIds.forEach { id ->
262                 viewState.widgetStates.get(id)?.let { state ->
263                     // Make sure to use the unmodified state if guts are not visible.
264                     state.alpha = if (isGutsVisible) 0f else state.alpha
265                     state.gone = if (isGutsVisible) true else state.gone
266                 }
267             }
268             PlayerViewHolder.gutsIds.forEach { id ->
269                 viewState.widgetStates.get(id)?.alpha = if (isGutsVisible) 1f else 0f
270                 viewState.widgetStates.get(id)?.gone = !isGutsVisible
271             }
272         } else {
273             RecommendationViewHolder.controlsIds.forEach { id ->
274                 viewState.widgetStates.get(id)?.let { state ->
275                     // Make sure to use the unmodified state if guts are not visible.
276                     state.alpha = if (isGutsVisible) 0f else state.alpha
277                     state.gone = if (isGutsVisible) true else state.gone
278                 }
279             }
280             RecommendationViewHolder.gutsIds.forEach { id ->
281                 viewState.widgetStates.get(id)?.alpha = if (isGutsVisible) 1f else 0f
282                 viewState.widgetStates.get(id)?.gone = !isGutsVisible
283             }
284         }
285         if (shouldHideGutsSettings) {
286             viewState.widgetStates.get(R.id.settings)?.gone = true
287         }
288     }
289 
290     /**
291      * Obtain a new viewState for a given media state. This usually returns a cached state, but if
292      * it's not available, it will recreate one by measuring, which may be expensive.
293      */
294     private fun obtainViewState(state: MediaHostState?): TransitionViewState? {
295         if (state == null || state.measurementInput == null) {
296             return null
297         }
298         // Only a subset of the state is relevant to get a valid viewState. Let's get the cachekey
299         var cacheKey = getKey(state, isGutsVisible, tmpKey)
300         val viewState = viewStates[cacheKey]
301         if (viewState != null) {
302             // we already have cached this measurement, let's continue
303             return viewState
304         }
305         // Copy the key since this might call recursively into it and we're using tmpKey
306         cacheKey = cacheKey.copy()
307         val result: TransitionViewState?
308         if (transitionLayout != null) {
309             // Let's create a new measurement
310             if (state.expansion == 0.0f || state.expansion == 1.0f) {
311                 result = transitionLayout!!.calculateViewState(
312                         state.measurementInput!!,
313                         constraintSetForExpansion(state.expansion),
314                         TransitionViewState())
315 
316                 setGutsViewState(result)
317                 // We don't want to cache interpolated or null states as this could quickly fill up
318                 // our cache. We only cache the start and the end states since the interpolation
319                 // is cheap
320                 viewStates[cacheKey] = result
321             } else {
322                 // This is an interpolated state
323                 val startState = state.copy().also { it.expansion = 0.0f }
324 
325                 // Given that we have a measurement and a view, let's get (guaranteed) viewstates
326                 // from the start and end state and interpolate them
327                 val startViewState = obtainViewState(startState) as TransitionViewState
328                 val endState = state.copy().also { it.expansion = 1.0f }
329                 val endViewState = obtainViewState(endState) as TransitionViewState
330                 result = layoutController.getInterpolatedState(
331                         startViewState,
332                         endViewState,
333                         state.expansion)
334             }
335         } else {
336             result = null
337         }
338         return result
339     }
340 
341     private fun getKey(
342         state: MediaHostState,
343         guts: Boolean,
344         result: CacheKey
345     ): CacheKey {
346         result.apply {
347             heightMeasureSpec = state.measurementInput?.heightMeasureSpec ?: 0
348             widthMeasureSpec = state.measurementInput?.widthMeasureSpec ?: 0
349             expansion = state.expansion
350             gutsVisible = guts
351         }
352         return result
353     }
354 
355     /**
356      * Attach a view to this controller. This may perform measurements if it's not available yet
357      * and should therefore be done carefully.
358      */
359     fun attach(transitionLayout: TransitionLayout, type: TYPE) {
360         updateMediaViewControllerType(type)
361         this.transitionLayout = transitionLayout
362         layoutController.attach(transitionLayout)
363         if (currentEndLocation == -1) {
364             return
365         }
366         // Set the previously set state immediately to the view, now that it's finally attached
367         setCurrentState(
368                 startLocation = currentStartLocation,
369                 endLocation = currentEndLocation,
370                 transitionProgress = currentTransitionProgress,
371                 applyImmediately = true)
372     }
373 
374     /**
375      * Obtain a measurement for a given location. This makes sure that the state is up to date
376      * and all widgets know their location. Calling this method may create a measurement if we
377      * don't have a cached value available already.
378      */
379     fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? {
380         val viewState = obtainViewState(hostState) ?: return null
381         measurement.measuredWidth = viewState.width
382         measurement.measuredHeight = viewState.height
383         return measurement
384     }
385 
386     /**
387      * Set a new state for the controlled view which can be an interpolation between multiple
388      * locations.
389      */
390     fun setCurrentState(
391         @MediaLocation startLocation: Int,
392         @MediaLocation endLocation: Int,
393         transitionProgress: Float,
394         applyImmediately: Boolean
395     ) {
396         currentEndLocation = endLocation
397         currentStartLocation = startLocation
398         currentTransitionProgress = transitionProgress
399 
400         val shouldAnimate = animateNextStateChange && !applyImmediately
401 
402         val endHostState = mediaHostStatesManager.mediaHostStates[endLocation] ?: return
403         val startHostState = mediaHostStatesManager.mediaHostStates[startLocation]
404 
405         // Obtain the view state that we'd want to be at the end
406         // The view might not be bound yet or has never been measured and in that case will be
407         // reset once the state is fully available
408         var endViewState = obtainViewState(endHostState) ?: return
409         endViewState = updateViewStateToCarouselSize(endViewState, endLocation, tmpState2)!!
410         layoutController.setMeasureState(endViewState)
411 
412         // If the view isn't bound, we can drop the animation, otherwise we'll execute it
413         animateNextStateChange = false
414         if (transitionLayout == null) {
415             return
416         }
417 
418         val result: TransitionViewState
419         var startViewState = obtainViewState(startHostState)
420         startViewState = updateViewStateToCarouselSize(startViewState, startLocation, tmpState3)
421 
422         if (!endHostState.visible) {
423             // Let's handle the case where the end is gone first. In this case we take the
424             // start viewState and will make it gone
425             if (startViewState == null || startHostState == null || !startHostState.visible) {
426                 // the start isn't a valid state, let's use the endstate directly
427                 result = endViewState
428             } else {
429                 // Let's get the gone presentation from the start state
430                 result = layoutController.getGoneState(startViewState,
431                         startHostState.disappearParameters,
432                         transitionProgress,
433                         tmpState)
434             }
435         } else if (startHostState != null && !startHostState.visible) {
436             // We have a start state and it is gone.
437             // Let's get presentation from the endState
438             result = layoutController.getGoneState(endViewState, endHostState.disappearParameters,
439                     1.0f - transitionProgress,
440                     tmpState)
441         } else if (transitionProgress == 1.0f || startViewState == null) {
442             // We're at the end. Let's use that state
443             result = endViewState
444         } else if (transitionProgress == 0.0f) {
445             // We're at the start. Let's use that state
446             result = startViewState
447         } else {
448             result = layoutController.getInterpolatedState(startViewState, endViewState,
449                     transitionProgress, tmpState)
450         }
451         layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration,
452                 animationDelay)
453     }
454 
455     private fun updateViewStateToCarouselSize(
456         viewState: TransitionViewState?,
457         location: Int,
458         outState: TransitionViewState
459     ): TransitionViewState? {
460         val result = viewState?.copy(outState) ?: return null
461         val overrideSize = mediaHostStatesManager.carouselSizes[location]
462         overrideSize?.let {
463             // To be safe we're using a maximum here. The override size should always be set
464             // properly though.
465             result.height = Math.max(it.measuredHeight, result.height)
466             result.width = Math.max(it.measuredWidth, result.width)
467         }
468         return result
469     }
470 
471     private fun updateMediaViewControllerType(type: TYPE) {
472         this.type = type
473         if (type == TYPE.PLAYER) {
474             collapsedLayout.load(context, R.xml.media_collapsed)
475             expandedLayout.load(context, R.xml.media_expanded)
476         } else {
477             collapsedLayout.load(context, R.xml.media_recommendation_collapsed)
478             expandedLayout.load(context, R.xml.media_recommendation_expanded)
479         }
480         refreshState()
481     }
482 
483     /**
484      * Retrieves the [TransitionViewState] and [MediaHostState] of a [@MediaLocation].
485      * In the event of [location] not being visible, [locationWhenHidden] will be used instead.
486      *
487      * @param location Target
488      * @param locationWhenHidden Location that will be used when the target is not
489      * [MediaHost.visible]
490      * @return State require for executing a transition, and also the respective [MediaHost].
491      */
492     private fun obtainViewStateForLocation(@MediaLocation location: Int): TransitionViewState? {
493         val mediaHostState = mediaHostStatesManager.mediaHostStates[location] ?: return null
494         return obtainViewState(mediaHostState)
495     }
496 
497     /**
498      * Notify that the location is changing right now and a [setCurrentState] change is imminent.
499      * This updates the width the view will me measured with.
500      */
501     fun onLocationPreChange(@MediaLocation newLocation: Int) {
502         obtainViewStateForLocation(newLocation)?.let {
503             layoutController.setMeasureState(it)
504         }
505     }
506 
507     /**
508      * Request that the next state change should be animated with the given parameters.
509      */
510     fun animatePendingStateChange(duration: Long, delay: Long) {
511         animateNextStateChange = true
512         animationDuration = duration
513         animationDelay = delay
514     }
515 
516     /**
517      * Clear all existing measurements and refresh the state to match the view.
518      */
519     fun refreshState() {
520         // Let's clear all of our measurements and recreate them!
521         viewStates.clear()
522         if (firstRefresh) {
523             // This is the first bind, let's ensure we pre-cache all measurements. Otherwise
524             // We'll just load these on demand.
525             ensureAllMeasurements()
526             firstRefresh = false
527         }
528         setCurrentState(currentStartLocation, currentEndLocation, currentTransitionProgress,
529                 applyImmediately = true)
530     }
531 }
532 
533 /**
534  * An internal key for the cache of mediaViewStates. This is a subset of the full host state.
535  */
536 private data class CacheKey(
537     var widthMeasureSpec: Int = -1,
538     var heightMeasureSpec: Int = -1,
539     var expansion: Float = 0.0f,
540     var gutsVisible: Boolean = false
541 )
542