blob: fc22c026974ac68701ffa5ba765328d7621de0d8 [file] [log] [blame]
Selim Cinek2de5ebb2020-05-20 15:39:03 -07001/*
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
17package com.android.systemui.media
18
19import android.content.Context
Jeff DeCewafec78f2020-06-12 13:57:23 -040020import android.content.res.Configuration
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070021import android.graphics.PointF
Selim Cinek2de5ebb2020-05-20 15:39:03 -070022import androidx.constraintlayout.widget.ConstraintSet
23import com.android.systemui.R
Jeff DeCewafec78f2020-06-12 13:57:23 -040024import com.android.systemui.statusbar.policy.ConfigurationController
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070025import com.android.systemui.util.animation.MeasurementOutput
Selim Cinek2de5ebb2020-05-20 15:39:03 -070026import com.android.systemui.util.animation.TransitionLayout
27import com.android.systemui.util.animation.TransitionLayoutController
28import com.android.systemui.util.animation.TransitionViewState
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070029import javax.inject.Inject
Selim Cinek2de5ebb2020-05-20 15:39:03 -070030
31/**
32 * A class responsible for controlling a single instance of a media player handling interactions
33 * with the view instance and keeping the media view states up to date.
34 */
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070035class MediaViewController @Inject constructor(
Selim Cinek2de5ebb2020-05-20 15:39:03 -070036 context: Context,
Jeff DeCewafec78f2020-06-12 13:57:23 -040037 private val configurationController: ConfigurationController,
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070038 private val mediaHostStatesManager: MediaHostStatesManager
Selim Cinek2de5ebb2020-05-20 15:39:03 -070039) {
40
Selim Cinekafae4e72020-06-16 18:21:41 -070041 /**
42 * A listener when the current dimensions of the player change
43 */
44 lateinit var sizeChangedListener: () -> Unit
Selim Cinek2de5ebb2020-05-20 15:39:03 -070045 private var firstRefresh: Boolean = true
46 private var transitionLayout: TransitionLayout? = null
47 private val layoutController = TransitionLayoutController()
48 private var animationDelay: Long = 0
49 private var animationDuration: Long = 0
50 private var animateNextStateChange: Boolean = false
51 private val measurement = MeasurementOutput(0, 0)
52
53 /**
54 * A map containing all viewStates for all locations of this mediaState
55 */
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070056 private val viewStates: MutableMap<MediaHostState, TransitionViewState?> = mutableMapOf()
Selim Cinek2de5ebb2020-05-20 15:39:03 -070057
58 /**
59 * The ending location of the view where it ends when all animations and transitions have
60 * finished
61 */
Jeff DeCewafec78f2020-06-12 13:57:23 -040062 @MediaLocation
Selim Cinek2de5ebb2020-05-20 15:39:03 -070063 private var currentEndLocation: Int = -1
64
65 /**
66 * The ending location of the view where it ends when all animations and transitions have
67 * finished
68 */
Jeff DeCewafec78f2020-06-12 13:57:23 -040069 @MediaLocation
Selim Cinek2de5ebb2020-05-20 15:39:03 -070070 private var currentStartLocation: Int = -1
71
72 /**
73 * The progress of the transition or 1.0 if there is no transition happening
74 */
75 private var currentTransitionProgress: Float = 1.0f
76
77 /**
78 * A temporary state used to store intermediate measurements.
79 */
80 private val tmpState = TransitionViewState()
81
82 /**
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070083 * Temporary variable to avoid unnecessary allocations.
84 */
85 private val tmpPoint = PointF()
86
87 /**
Selim Cinekafae4e72020-06-16 18:21:41 -070088 * The current width of the player. This might not factor in case the player is animating
89 * to the current state, but represents the end state
90 */
91 var currentWidth: Int = 0
92 /**
93 * The current height of the player. This might not factor in case the player is animating
94 * to the current state, but represents the end state
95 */
96 var currentHeight: Int = 0
97
98 /**
Jeff DeCewafec78f2020-06-12 13:57:23 -040099 * A callback for RTL config changes
100 */
101 private val configurationListener = object : ConfigurationController.ConfigurationListener {
102 override fun onConfigChanged(newConfig: Configuration?) {
103 // Because the TransitionLayout is not always attached (and calculates/caches layout
104 // results regardless of attach state), we have to force the layoutDirection of the view
105 // to the correct value for the user's current locale to ensure correct recalculation
106 // when/after calling refreshState()
107 newConfig?.apply {
108 if (transitionLayout?.rawLayoutDirection != layoutDirection) {
109 transitionLayout?.layoutDirection = layoutDirection
110 refreshState()
111 }
112 }
113 }
114 }
115
116 /**
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700117 * A callback for media state changes
118 */
119 val stateCallback = object : MediaHostStatesManager.Callback {
Jeff DeCewafec78f2020-06-12 13:57:23 -0400120 override fun onHostStateChanged(
121 @MediaLocation location: Int,
122 mediaHostState: MediaHostState
123 ) {
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700124 if (location == currentEndLocation || location == currentStartLocation) {
125 setCurrentState(currentStartLocation,
126 currentEndLocation,
127 currentTransitionProgress,
128 applyImmediately = false)
129 }
130 }
131 }
132
133 /**
134 * The expanded constraint set used to render a expanded player. If it is modified, make sure
135 * to call [refreshState]
136 */
137 val collapsedLayout = ConstraintSet()
138
139 /**
140 * The expanded constraint set used to render a collapsed player. If it is modified, make sure
141 * to call [refreshState]
142 */
143 val expandedLayout = ConstraintSet()
144
145 init {
146 collapsedLayout.load(context, R.xml.media_collapsed)
147 expandedLayout.load(context, R.xml.media_expanded)
148 mediaHostStatesManager.addController(this)
Selim Cinekafae4e72020-06-16 18:21:41 -0700149 layoutController.sizeChangedListener = { width: Int, height: Int ->
150 currentWidth = width
151 currentHeight = height
152 sizeChangedListener.invoke()
153 }
Jeff DeCewafec78f2020-06-12 13:57:23 -0400154 configurationController.addCallback(configurationListener)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700155 }
156
157 /**
158 * Notify this controller that the view has been removed and all listeners should be destroyed
159 */
160 fun onDestroy() {
161 mediaHostStatesManager.removeController(this)
Jeff DeCewafec78f2020-06-12 13:57:23 -0400162 configurationController.removeCallback(configurationListener)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700163 }
164
165 private fun ensureAllMeasurements() {
166 val mediaStates = mediaHostStatesManager.mediaHostStates
167 for (entry in mediaStates) {
168 obtainViewState(entry.value)
169 }
170 }
171
172 /**
173 * Get the constraintSet for a given expansion
174 */
175 private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
176 if (expansion > 0) expandedLayout else collapsedLayout
177
178 /**
179 * Obtain a new viewState for a given media state. This usually returns a cached state, but if
180 * it's not available, it will recreate one by measuring, which may be expensive.
181 */
182 private fun obtainViewState(state: MediaHostState): TransitionViewState? {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700183 val viewState = viewStates[state]
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700184 if (viewState != null) {
185 // we already have cached this measurement, let's continue
186 return viewState
187 }
188
189 val result: TransitionViewState?
190 if (transitionLayout != null && state.measurementInput != null) {
191 // Let's create a new measurement
192 if (state.expansion == 0.0f || state.expansion == 1.0f) {
193 result = transitionLayout!!.calculateViewState(
194 state.measurementInput!!,
195 constraintSetForExpansion(state.expansion),
196 TransitionViewState())
197
198 // We don't want to cache interpolated or null states as this could quickly fill up
199 // our cache. We only cache the start and the end states since the interpolation
200 // is cheap
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700201 viewStates[state.copy()] = result
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700202 } else {
203 // This is an interpolated state
204 val startState = state.copy().also { it.expansion = 0.0f }
205
206 // Given that we have a measurement and a view, let's get (guaranteed) viewstates
207 // from the start and end state and interpolate them
208 val startViewState = obtainViewState(startState) as TransitionViewState
209 val endState = state.copy().also { it.expansion = 1.0f }
210 val endViewState = obtainViewState(endState) as TransitionViewState
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700211 tmpPoint.set(startState.getPivotX(), startState.getPivotY())
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700212 result = TransitionViewState()
213 layoutController.getInterpolatedState(
214 startViewState,
215 endViewState,
216 state.expansion,
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700217 tmpPoint,
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700218 result)
219 }
220 } else {
221 result = null
222 }
223 return result
224 }
225
226 /**
227 * Attach a view to this controller. This may perform measurements if it's not available yet
228 * and should therefore be done carefully.
229 */
230 fun attach(transitionLayout: TransitionLayout) {
231 this.transitionLayout = transitionLayout
232 layoutController.attach(transitionLayout)
233 ensureAllMeasurements()
234 if (currentEndLocation == -1) {
235 return
236 }
237 // Set the previously set state immediately to the view, now that it's finally attached
238 setCurrentState(
239 startLocation = currentStartLocation,
240 endLocation = currentEndLocation,
241 transitionProgress = currentTransitionProgress,
242 applyImmediately = true)
243 }
244
245 /**
246 * Obtain a measurement for a given location. This makes sure that the state is up to date
247 * and all widgets know their location. Calling this method may create a measurement if we
248 * don't have a cached value available already.
249 */
250 fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? {
251 val viewState = obtainViewState(hostState) ?: return null
252 measurement.measuredWidth = viewState.width
253 measurement.measuredHeight = viewState.height
254 return measurement
255 }
256
257 /**
258 * Set a new state for the controlled view which can be an interpolation between multiple
259 * locations.
260 */
261 fun setCurrentState(
262 @MediaLocation startLocation: Int,
263 @MediaLocation endLocation: Int,
264 transitionProgress: Float,
265 applyImmediately: Boolean
266 ) {
267 currentEndLocation = endLocation
268 currentStartLocation = startLocation
269 currentTransitionProgress = transitionProgress
270
271 val shouldAnimate = animateNextStateChange && !applyImmediately
272
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700273 var startHostState = mediaHostStatesManager.mediaHostStates[startLocation]
274 var endHostState = mediaHostStatesManager.mediaHostStates[endLocation]
275 var swappedStartState = false
276 var swappedEndState = false
277
278 // if we're going from or to a non visible state, let's grab the visible one and animate
279 // the view being clipped instead.
280 if (endHostState?.visible != true) {
281 endHostState = startHostState
282 swappedEndState = true
283 }
284 if (startHostState?.visible != true) {
285 startHostState = endHostState
286 swappedStartState = true
287 }
288 if (startHostState == null || endHostState == null) {
289 return
290 }
291
292 var endViewState = obtainViewState(endHostState) ?: return
293 if (swappedEndState) {
294 endViewState = endViewState.copy()
295 endViewState.height = 0
296 }
297
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700298 // Obtain the view state that we'd want to be at the end
299 // The view might not be bound yet or has never been measured and in that case will be
300 // reset once the state is fully available
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700301 layoutController.setMeasureState(endViewState)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700302
303 // If the view isn't bound, we can drop the animation, otherwise we'll executute it
304 animateNextStateChange = false
305 if (transitionLayout == null) {
306 return
307 }
308
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700309 var startViewState = obtainViewState(startHostState)
310 if (swappedStartState) {
311 startViewState = startViewState?.copy()
312 startViewState?.height = 0
313 }
314
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700315 val result: TransitionViewState?
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700316 result = if (transitionProgress == 1.0f || startViewState == null) {
317 endViewState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700318 } else if (transitionProgress == 0.0f) {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700319 startViewState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700320 } else {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700321 if (swappedEndState || swappedStartState) {
322 tmpPoint.set(startHostState.getPivotX(), startHostState.getPivotY())
323 } else {
324 tmpPoint.set(0.0f, 0.0f)
325 }
326 layoutController.getInterpolatedState(startViewState, endViewState, transitionProgress,
327 tmpPoint, tmpState)
328 tmpState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700329 }
Selim Cinekafae4e72020-06-16 18:21:41 -0700330 currentWidth = result.width
331 currentHeight = result.height
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700332 layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration,
333 animationDelay)
334 }
335
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700336 /**
337 * Retrieves the [TransitionViewState] and [MediaHostState] of a [@MediaLocation].
338 * In the event of [location] not being visible, [locationWhenHidden] will be used instead.
339 *
340 * @param location Target
341 * @param locationWhenHidden Location that will be used when the target is not
342 * [MediaHost.visible]
343 * @return State require for executing a transition, and also the respective [MediaHost].
344 */
345 private fun obtainViewStateForLocation(@MediaLocation location: Int): TransitionViewState? {
346 val mediaHostState = mediaHostStatesManager.mediaHostStates[location] ?: return null
347 return obtainViewState(mediaHostState)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700348 }
349
350 /**
351 * Notify that the location is changing right now and a [setCurrentState] change is imminent.
352 * This updates the width the view will me measured with.
353 */
354 fun onLocationPreChange(@MediaLocation newLocation: Int) {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700355 obtainViewStateForLocation(newLocation)?.let {
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700356 layoutController.setMeasureState(it)
357 }
358 }
359
360 /**
361 * Request that the next state change should be animated with the given parameters.
362 */
363 fun animatePendingStateChange(duration: Long, delay: Long) {
364 animateNextStateChange = true
365 animationDuration = duration
366 animationDelay = delay
367 }
368
369 /**
370 * Clear all existing measurements and refresh the state to match the view.
371 */
372 fun refreshState() {
373 if (!firstRefresh) {
374 // Let's clear all of our measurements and recreate them!
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700375 viewStates.clear()
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700376 setCurrentState(currentStartLocation, currentEndLocation, currentTransitionProgress,
Jeff DeCewafec78f2020-06-12 13:57:23 -0400377 applyImmediately = true)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700378 }
379 firstRefresh = false
380 }
381}