blob: 38817d7b579e284d367229698eb37f3af0af89c9 [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.systemui.media
import android.content.Context
import android.content.res.Configuration
import androidx.constraintlayout.widget.ConstraintSet
import com.android.systemui.R
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.animation.MeasurementOutput
import com.android.systemui.util.animation.TransitionLayout
import com.android.systemui.util.animation.TransitionLayoutController
import com.android.systemui.util.animation.TransitionViewState
import javax.inject.Inject
/**
* A class responsible for controlling a single instance of a media player handling interactions
* with the view instance and keeping the media view states up to date.
*/
class MediaViewController @Inject constructor(
context: Context,
private val configurationController: ConfigurationController,
private val mediaHostStatesManager: MediaHostStatesManager
) {
/**
* A listener when the current dimensions of the player change
*/
lateinit var sizeChangedListener: () -> Unit
private var firstRefresh: Boolean = true
private var transitionLayout: TransitionLayout? = null
private val layoutController = TransitionLayoutController()
private var animationDelay: Long = 0
private var animationDuration: Long = 0
private var animateNextStateChange: Boolean = false
private val measurement = MeasurementOutput(0, 0)
/**
* A map containing all viewStates for all locations of this mediaState
*/
private val viewStates: MutableMap<CacheKey, TransitionViewState?> = mutableMapOf()
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
@MediaLocation
private var currentEndLocation: Int = -1
/**
* The ending location of the view where it ends when all animations and transitions have
* finished
*/
@MediaLocation
private var currentStartLocation: Int = -1
/**
* The progress of the transition or 1.0 if there is no transition happening
*/
private var currentTransitionProgress: Float = 1.0f
/**
* A temporary state used to store intermediate measurements.
*/
private val tmpState = TransitionViewState()
/**
* A temporary state used to store intermediate measurements.
*/
private val tmpState2 = TransitionViewState()
/**
* A temporary state used to store intermediate measurements.
*/
private val tmpState3 = TransitionViewState()
/**
* A temporary cache key to be used to look up cache entries
*/
private val tmpKey = CacheKey()
/**
* The current width of the player. This might not factor in case the player is animating
* to the current state, but represents the end state
*/
var currentWidth: Int = 0
/**
* The current height of the player. This might not factor in case the player is animating
* to the current state, but represents the end state
*/
var currentHeight: Int = 0
/**
* Get the translationX of the layout
*/
var translationX: Float = 0.0f
private set
get() {
return transitionLayout?.translationX ?: 0.0f
}
/**
* Get the translationY of the layout
*/
var translationY: Float = 0.0f
private set
get() {
return transitionLayout?.translationY ?: 0.0f
}
/**
* A callback for RTL config changes
*/
private val configurationListener = object : ConfigurationController.ConfigurationListener {
override fun onConfigChanged(newConfig: Configuration?) {
// Because the TransitionLayout is not always attached (and calculates/caches layout
// results regardless of attach state), we have to force the layoutDirection of the view
// to the correct value for the user's current locale to ensure correct recalculation
// when/after calling refreshState()
newConfig?.apply {
if (transitionLayout?.rawLayoutDirection != layoutDirection) {
transitionLayout?.layoutDirection = layoutDirection
refreshState()
}
}
}
}
/**
* A callback for media state changes
*/
val stateCallback = object : MediaHostStatesManager.Callback {
override fun onHostStateChanged(
@MediaLocation location: Int,
mediaHostState: MediaHostState
) {
if (location == currentEndLocation || location == currentStartLocation) {
setCurrentState(currentStartLocation,
currentEndLocation,
currentTransitionProgress,
applyImmediately = false)
}
}
}
/**
* The expanded constraint set used to render a expanded player. If it is modified, make sure
* to call [refreshState]
*/
val collapsedLayout = ConstraintSet()
/**
* The expanded constraint set used to render a collapsed player. If it is modified, make sure
* to call [refreshState]
*/
val expandedLayout = ConstraintSet()
init {
collapsedLayout.load(context, R.xml.media_collapsed)
expandedLayout.load(context, R.xml.media_expanded)
mediaHostStatesManager.addController(this)
layoutController.sizeChangedListener = { width: Int, height: Int ->
currentWidth = width
currentHeight = height
sizeChangedListener.invoke()
}
configurationController.addCallback(configurationListener)
}
/**
* Notify this controller that the view has been removed and all listeners should be destroyed
*/
fun onDestroy() {
mediaHostStatesManager.removeController(this)
configurationController.removeCallback(configurationListener)
}
private fun ensureAllMeasurements() {
val mediaStates = mediaHostStatesManager.mediaHostStates
for (entry in mediaStates) {
obtainViewState(entry.value)
}
}
/**
* Get the constraintSet for a given expansion
*/
private fun constraintSetForExpansion(expansion: Float): ConstraintSet =
if (expansion > 0) expandedLayout else collapsedLayout
/**
* Obtain a new viewState for a given media state. This usually returns a cached state, but if
* it's not available, it will recreate one by measuring, which may be expensive.
*/
private fun obtainViewState(state: MediaHostState?): TransitionViewState? {
if (state == null || state.measurementInput == null) {
return null
}
// Only a subset of the state is relevant to get a valid viewState. Let's get the cachekey
var cacheKey = getKey(state, tmpKey)
val viewState = viewStates[cacheKey]
if (viewState != null) {
// we already have cached this measurement, let's continue
return viewState
}
// Copy the key since this might call recursively into it and we're using tmpKey
cacheKey = cacheKey.copy()
val result: TransitionViewState?
if (transitionLayout != null) {
// Let's create a new measurement
if (state.expansion == 0.0f || state.expansion == 1.0f) {
result = transitionLayout!!.calculateViewState(
state.measurementInput!!,
constraintSetForExpansion(state.expansion),
TransitionViewState())
// We don't want to cache interpolated or null states as this could quickly fill up
// our cache. We only cache the start and the end states since the interpolation
// is cheap
viewStates[cacheKey] = result
} else {
// This is an interpolated state
val startState = state.copy().also { it.expansion = 0.0f }
// Given that we have a measurement and a view, let's get (guaranteed) viewstates
// from the start and end state and interpolate them
val startViewState = obtainViewState(startState) as TransitionViewState
val endState = state.copy().also { it.expansion = 1.0f }
val endViewState = obtainViewState(endState) as TransitionViewState
result = layoutController.getInterpolatedState(
startViewState,
endViewState,
state.expansion)
}
} else {
result = null
}
return result
}
private fun getKey(state: MediaHostState, result: CacheKey): CacheKey {
result.apply {
heightMeasureSpec = state.measurementInput?.heightMeasureSpec ?: 0
widthMeasureSpec = state.measurementInput?.widthMeasureSpec ?: 0
expansion = state.expansion
}
return result
}
/**
* Attach a view to this controller. This may perform measurements if it's not available yet
* and should therefore be done carefully.
*/
fun attach(transitionLayout: TransitionLayout) {
this.transitionLayout = transitionLayout
layoutController.attach(transitionLayout)
if (currentEndLocation == -1) {
return
}
// Set the previously set state immediately to the view, now that it's finally attached
setCurrentState(
startLocation = currentStartLocation,
endLocation = currentEndLocation,
transitionProgress = currentTransitionProgress,
applyImmediately = true)
}
/**
* Obtain a measurement for a given location. This makes sure that the state is up to date
* and all widgets know their location. Calling this method may create a measurement if we
* don't have a cached value available already.
*/
fun getMeasurementsForState(hostState: MediaHostState): MeasurementOutput? {
val viewState = obtainViewState(hostState) ?: return null
measurement.measuredWidth = viewState.width
measurement.measuredHeight = viewState.height
return measurement
}
/**
* Set a new state for the controlled view which can be an interpolation between multiple
* locations.
*/
fun setCurrentState(
@MediaLocation startLocation: Int,
@MediaLocation endLocation: Int,
transitionProgress: Float,
applyImmediately: Boolean
) {
currentEndLocation = endLocation
currentStartLocation = startLocation
currentTransitionProgress = transitionProgress
val shouldAnimate = animateNextStateChange && !applyImmediately
val endHostState = mediaHostStatesManager.mediaHostStates[endLocation] ?: return
val startHostState = mediaHostStatesManager.mediaHostStates[startLocation]
// Obtain the view state that we'd want to be at the end
// The view might not be bound yet or has never been measured and in that case will be
// reset once the state is fully available
var endViewState = obtainViewState(endHostState) ?: return
endViewState = updateViewStateToCarouselSize(endViewState, endLocation, tmpState2)!!
layoutController.setMeasureState(endViewState)
// If the view isn't bound, we can drop the animation, otherwise we'll execute it
animateNextStateChange = false
if (transitionLayout == null) {
return
}
val result: TransitionViewState
var startViewState = obtainViewState(startHostState)
startViewState = updateViewStateToCarouselSize(startViewState, startLocation, tmpState3)
if (!endHostState.visible) {
// Let's handle the case where the end is gone first. In this case we take the
// start viewState and will make it gone
if (startViewState == null || startHostState == null || !startHostState.visible) {
// the start isn't a valid state, let's use the endstate directly
result = endViewState
} else {
// Let's get the gone presentation from the start state
result = layoutController.getGoneState(startViewState,
startHostState.disappearParameters,
transitionProgress,
tmpState)
}
} else if (startHostState != null && !startHostState.visible) {
// We have a start state and it is gone.
// Let's get presentation from the endState
result = layoutController.getGoneState(endViewState, endHostState.disappearParameters,
1.0f - transitionProgress,
tmpState)
} else if (transitionProgress == 1.0f || startViewState == null) {
// We're at the end. Let's use that state
result = endViewState
} else if (transitionProgress == 0.0f) {
// We're at the start. Let's use that state
result = startViewState
} else {
result = layoutController.getInterpolatedState(startViewState, endViewState,
transitionProgress, tmpState)
}
layoutController.setState(result, applyImmediately, shouldAnimate, animationDuration,
animationDelay)
}
private fun updateViewStateToCarouselSize(
viewState: TransitionViewState?,
location: Int,
outState: TransitionViewState
) : TransitionViewState? {
val result = viewState?.copy(outState) ?: return null
val overrideSize = mediaHostStatesManager.carouselSizes[location]
overrideSize?.let {
// To be safe we're using a maximum here. The override size should always be set
// properly though.
result.height = Math.max(it.measuredHeight, result.height)
result.width = Math.max(it.measuredWidth, result.width)
}
return result
}
/**
* Retrieves the [TransitionViewState] and [MediaHostState] of a [@MediaLocation].
* In the event of [location] not being visible, [locationWhenHidden] will be used instead.
*
* @param location Target
* @param locationWhenHidden Location that will be used when the target is not
* [MediaHost.visible]
* @return State require for executing a transition, and also the respective [MediaHost].
*/
private fun obtainViewStateForLocation(@MediaLocation location: Int): TransitionViewState? {
val mediaHostState = mediaHostStatesManager.mediaHostStates[location] ?: return null
return obtainViewState(mediaHostState)
}
/**
* Notify that the location is changing right now and a [setCurrentState] change is imminent.
* This updates the width the view will me measured with.
*/
fun onLocationPreChange(@MediaLocation newLocation: Int) {
obtainViewStateForLocation(newLocation)?.let {
layoutController.setMeasureState(it)
}
}
/**
* Request that the next state change should be animated with the given parameters.
*/
fun animatePendingStateChange(duration: Long, delay: Long) {
animateNextStateChange = true
animationDuration = duration
animationDelay = delay
}
/**
* Clear all existing measurements and refresh the state to match the view.
*/
fun refreshState() {
// Let's clear all of our measurements and recreate them!
viewStates.clear()
if (firstRefresh) {
// This is the first bind, let's ensure we pre-cache all measurements. Otherwise
// We'll just load these on demand.
ensureAllMeasurements()
firstRefresh = false
}
setCurrentState(currentStartLocation, currentEndLocation, currentTransitionProgress,
applyImmediately = true)
}
}
/**
* An internal key for the cache of mediaViewStates. This is a subset of the full host state.
*/
private data class CacheKey(
var widthMeasureSpec: Int = -1,
var heightMeasureSpec: Int = -1,
var expansion: Float = 0.0f
)