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