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