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