blob: 90ccfc6ca725e3f540d3ddd9d41fc63ed7d16a8f [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
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070020import android.graphics.PointF
Selim Cinek2de5ebb2020-05-20 15:39:03 -070021import androidx.constraintlayout.widget.ConstraintSet
22import com.android.systemui.R
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070023import com.android.systemui.util.animation.MeasurementOutput
Selim Cinek2de5ebb2020-05-20 15:39:03 -070024import com.android.systemui.util.animation.TransitionLayout
25import com.android.systemui.util.animation.TransitionLayoutController
26import com.android.systemui.util.animation.TransitionViewState
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070027import javax.inject.Inject
Selim Cinek2de5ebb2020-05-20 15:39:03 -070028
29/**
30 * A class responsible for controlling a single instance of a media player handling interactions
31 * with the view instance and keeping the media view states up to date.
32 */
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070033class MediaViewController @Inject constructor(
Selim Cinek2de5ebb2020-05-20 15:39:03 -070034 context: Context,
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070035 private val mediaHostStatesManager: MediaHostStatesManager
Selim Cinek2de5ebb2020-05-20 15:39:03 -070036) {
37
38 private var firstRefresh: Boolean = true
39 private var transitionLayout: TransitionLayout? = null
40 private val layoutController = TransitionLayoutController()
41 private var animationDelay: Long = 0
42 private var animationDuration: Long = 0
43 private var animateNextStateChange: Boolean = false
44 private val measurement = MeasurementOutput(0, 0)
45
46 /**
47 * A map containing all viewStates for all locations of this mediaState
48 */
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070049 private val viewStates: MutableMap<MediaHostState, TransitionViewState?> = mutableMapOf()
Selim Cinek2de5ebb2020-05-20 15:39:03 -070050
51 /**
52 * The ending location of the view where it ends when all animations and transitions have
53 * finished
54 */
55 private var currentEndLocation: Int = -1
56
57 /**
58 * The ending location of the view where it ends when all animations and transitions have
59 * finished
60 */
61 private var currentStartLocation: Int = -1
62
63 /**
64 * The progress of the transition or 1.0 if there is no transition happening
65 */
66 private var currentTransitionProgress: Float = 1.0f
67
68 /**
69 * A temporary state used to store intermediate measurements.
70 */
71 private val tmpState = TransitionViewState()
72
73 /**
Lucas Dupin84f5a0e2020-06-08 19:55:33 -070074 * Temporary variable to avoid unnecessary allocations.
75 */
76 private val tmpPoint = PointF()
77
78 /**
Selim Cinek2de5ebb2020-05-20 15:39:03 -070079 * A callback for media state changes
80 */
81 val stateCallback = object : MediaHostStatesManager.Callback {
82 override fun onHostStateChanged(location: Int, mediaHostState: MediaHostState) {
83 if (location == currentEndLocation || location == currentStartLocation) {
84 setCurrentState(currentStartLocation,
85 currentEndLocation,
86 currentTransitionProgress,
87 applyImmediately = false)
88 }
89 }
90 }
91
92 /**
93 * The expanded constraint set used to render a expanded player. If it is modified, make sure
94 * to call [refreshState]
95 */
96 val collapsedLayout = ConstraintSet()
97
98 /**
99 * The expanded constraint set used to render a collapsed player. If it is modified, make sure
100 * to call [refreshState]
101 */
102 val expandedLayout = ConstraintSet()
103
104 init {
105 collapsedLayout.load(context, R.xml.media_collapsed)
106 expandedLayout.load(context, R.xml.media_expanded)
107 mediaHostStatesManager.addController(this)
108 }
109
110 /**
111 * Notify this controller that the view has been removed and all listeners should be destroyed
112 */
113 fun onDestroy() {
114 mediaHostStatesManager.removeController(this)
115 }
116
117 private fun ensureAllMeasurements() {
118 val mediaStates = mediaHostStatesManager.mediaHostStates
119 for (entry in mediaStates) {
120 obtainViewState(entry.value)
121 }
122 }
123
124 /**
125 * Get the constraintSet for a given expansion
126 */
127 private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
128 if (expansion > 0) expandedLayout else collapsedLayout
129
130 /**
131 * Obtain a new viewState for a given media state. This usually returns a cached state, but if
132 * it's not available, it will recreate one by measuring, which may be expensive.
133 */
134 private fun obtainViewState(state: MediaHostState): TransitionViewState? {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700135 val viewState = viewStates[state]
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700136 if (viewState != null) {
137 // we already have cached this measurement, let's continue
138 return viewState
139 }
140
141 val result: TransitionViewState?
142 if (transitionLayout != null && state.measurementInput != null) {
143 // Let's create a new measurement
144 if (state.expansion == 0.0f || state.expansion == 1.0f) {
145 result = transitionLayout!!.calculateViewState(
146 state.measurementInput!!,
147 constraintSetForExpansion(state.expansion),
148 TransitionViewState())
149
150 // We don't want to cache interpolated or null states as this could quickly fill up
151 // our cache. We only cache the start and the end states since the interpolation
152 // is cheap
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700153 viewStates[state.copy()] = result
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700154 } else {
155 // This is an interpolated state
156 val startState = state.copy().also { it.expansion = 0.0f }
157
158 // Given that we have a measurement and a view, let's get (guaranteed) viewstates
159 // from the start and end state and interpolate them
160 val startViewState = obtainViewState(startState) as TransitionViewState
161 val endState = state.copy().also { it.expansion = 1.0f }
162 val endViewState = obtainViewState(endState) as TransitionViewState
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700163 tmpPoint.set(startState.getPivotX(), startState.getPivotY())
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700164 result = TransitionViewState()
165 layoutController.getInterpolatedState(
166 startViewState,
167 endViewState,
168 state.expansion,
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700169 tmpPoint,
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700170 result)
171 }
172 } else {
173 result = null
174 }
175 return result
176 }
177
178 /**
179 * Attach a view to this controller. This may perform measurements if it's not available yet
180 * and should therefore be done carefully.
181 */
182 fun attach(transitionLayout: TransitionLayout) {
183 this.transitionLayout = transitionLayout
184 layoutController.attach(transitionLayout)
185 ensureAllMeasurements()
186 if (currentEndLocation == -1) {
187 return
188 }
189 // Set the previously set state immediately to the view, now that it's finally attached
190 setCurrentState(
191 startLocation = currentStartLocation,
192 endLocation = currentEndLocation,
193 transitionProgress = currentTransitionProgress,
194 applyImmediately = true)
195 }
196
197 /**
198 * Obtain a measurement for a given location. This makes sure that the state is up to date
199 * and all widgets know their location. Calling this method may create a measurement if we
200 * don't have a cached value available already.
201 */
202 fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? {
203 val viewState = obtainViewState(hostState) ?: return null
204 measurement.measuredWidth = viewState.width
205 measurement.measuredHeight = viewState.height
206 return measurement
207 }
208
209 /**
210 * Set a new state for the controlled view which can be an interpolation between multiple
211 * locations.
212 */
213 fun setCurrentState(
214 @MediaLocation startLocation: Int,
215 @MediaLocation endLocation: Int,
216 transitionProgress: Float,
217 applyImmediately: Boolean
218 ) {
219 currentEndLocation = endLocation
220 currentStartLocation = startLocation
221 currentTransitionProgress = transitionProgress
222
223 val shouldAnimate = animateNextStateChange && !applyImmediately
224
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700225 var startHostState = mediaHostStatesManager.mediaHostStates[startLocation]
226 var endHostState = mediaHostStatesManager.mediaHostStates[endLocation]
227 var swappedStartState = false
228 var swappedEndState = false
229
230 // if we're going from or to a non visible state, let's grab the visible one and animate
231 // the view being clipped instead.
232 if (endHostState?.visible != true) {
233 endHostState = startHostState
234 swappedEndState = true
235 }
236 if (startHostState?.visible != true) {
237 startHostState = endHostState
238 swappedStartState = true
239 }
240 if (startHostState == null || endHostState == null) {
241 return
242 }
243
244 var endViewState = obtainViewState(endHostState) ?: return
245 if (swappedEndState) {
246 endViewState = endViewState.copy()
247 endViewState.height = 0
248 }
249
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700250 // Obtain the view state that we'd want to be at the end
251 // The view might not be bound yet or has never been measured and in that case will be
252 // reset once the state is fully available
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700253 layoutController.setMeasureState(endViewState)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700254
255 // If the view isn't bound, we can drop the animation, otherwise we'll executute it
256 animateNextStateChange = false
257 if (transitionLayout == null) {
258 return
259 }
260
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700261 var startViewState = obtainViewState(startHostState)
262 if (swappedStartState) {
263 startViewState = startViewState?.copy()
264 startViewState?.height = 0
265 }
266
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700267 val result: TransitionViewState?
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700268 result = if (transitionProgress == 1.0f || startViewState == null) {
269 endViewState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700270 } else if (transitionProgress == 0.0f) {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700271 startViewState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700272 } else {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700273 if (swappedEndState || swappedStartState) {
274 tmpPoint.set(startHostState.getPivotX(), startHostState.getPivotY())
275 } else {
276 tmpPoint.set(0.0f, 0.0f)
277 }
278 layoutController.getInterpolatedState(startViewState, endViewState, transitionProgress,
279 tmpPoint, tmpState)
280 tmpState
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700281 }
282 layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration,
283 animationDelay)
284 }
285
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700286 /**
287 * Retrieves the [TransitionViewState] and [MediaHostState] of a [@MediaLocation].
288 * In the event of [location] not being visible, [locationWhenHidden] will be used instead.
289 *
290 * @param location Target
291 * @param locationWhenHidden Location that will be used when the target is not
292 * [MediaHost.visible]
293 * @return State require for executing a transition, and also the respective [MediaHost].
294 */
295 private fun obtainViewStateForLocation(@MediaLocation location: Int): TransitionViewState? {
296 val mediaHostState = mediaHostStatesManager.mediaHostStates[location] ?: return null
297 return obtainViewState(mediaHostState)
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700298 }
299
300 /**
301 * Notify that the location is changing right now and a [setCurrentState] change is imminent.
302 * This updates the width the view will me measured with.
303 */
304 fun onLocationPreChange(@MediaLocation newLocation: Int) {
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700305 obtainViewStateForLocation(newLocation)?.let {
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700306 layoutController.setMeasureState(it)
307 }
308 }
309
310 /**
311 * Request that the next state change should be animated with the given parameters.
312 */
313 fun animatePendingStateChange(duration: Long, delay: Long) {
314 animateNextStateChange = true
315 animationDuration = duration
316 animationDelay = delay
317 }
318
319 /**
320 * Clear all existing measurements and refresh the state to match the view.
321 */
322 fun refreshState() {
323 if (!firstRefresh) {
324 // Let's clear all of our measurements and recreate them!
Lucas Dupin84f5a0e2020-06-08 19:55:33 -0700325 viewStates.clear()
Selim Cinek2de5ebb2020-05-20 15:39:03 -0700326 setCurrentState(currentStartLocation, currentEndLocation, currentTransitionProgress,
327 applyImmediately = false)
328 }
329 firstRefresh = false
330 }
331}