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