blob: 0788e6172a78f8e723049cad96f8667fb8626e02 [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.controls.ui
import android.content.Context
import android.content.res.Configuration
import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.ConstraintSet
import com.android.systemui.R
import com.android.systemui.media.controls.models.GutsViewHolder
import com.android.systemui.media.controls.models.player.MediaViewHolder
import com.android.systemui.media.controls.models.recommendation.RecommendationViewHolder
import com.android.systemui.media.controls.ui.MediaCarouselController.Companion.calculateAlpha
import com.android.systemui.media.controls.util.MediaFlags
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 com.android.systemui.util.traceSection
import java.lang.Float.max
import java.lang.Float.min
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(
private val context: Context,
private val configurationController: ConfigurationController,
private val mediaHostStatesManager: MediaHostStatesManager,
private val logger: MediaViewLogger,
private val mediaFlags: MediaFlags,
) {
/**
* Indicating that the media view controller is for a notification-based player, session-based
* player, or recommendation
*/
enum class TYPE {
PLAYER,
RECOMMENDATION
}
companion object {
@JvmField val GUTS_ANIMATION_DURATION = 500L
val controlIds =
setOf(
R.id.media_progress_bar,
R.id.actionNext,
R.id.actionPrev,
R.id.action0,
R.id.action1,
R.id.action2,
R.id.action3,
R.id.action4,
R.id.media_scrubbing_elapsed_time,
R.id.media_scrubbing_total_time
)
val detailIds =
setOf(
R.id.header_title,
R.id.header_artist,
R.id.media_explicit_indicator,
R.id.actionPlayPause,
)
val backgroundIds =
setOf(
R.id.album_art,
R.id.turbulence_noise_view,
R.id.touch_ripple_view,
)
}
/** A listener when the current dimensions of the player change */
lateinit var sizeChangedListener: () -> Unit
private var firstRefresh: Boolean = true
@VisibleForTesting 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)
private var type: TYPE = TYPE.PLAYER
/** 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 var currentEndLocation: Int = -1
/** The starting location of the view where it starts for all animations and transitions */
@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()
/** Whether the guts are visible for the associated player. */
var isGutsVisible = false
private set
init {
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)
}
/** Show guts with an animated transition. */
fun openGuts() {
if (isGutsVisible) return
isGutsVisible = true
animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
setCurrentState(
currentStartLocation,
currentEndLocation,
currentTransitionProgress,
applyImmediately = false
)
}
/**
* Close the guts for the associated player.
*
* @param immediate if `false`, it will animate the transition.
*/
@JvmOverloads
fun closeGuts(immediate: Boolean = false) {
if (!isGutsVisible) return
isGutsVisible = false
if (!immediate) {
animatePendingStateChange(GUTS_ANIMATION_DURATION, 0L)
}
setCurrentState(
currentStartLocation,
currentEndLocation,
currentTransitionProgress,
applyImmediately = immediate
)
}
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
/**
* Set the views to be showing/hidden based on the [isGutsVisible] for a given
* [TransitionViewState].
*/
private fun setGutsViewState(viewState: TransitionViewState) {
val controlsIds =
when (type) {
TYPE.PLAYER -> MediaViewHolder.controlsIds
TYPE.RECOMMENDATION -> RecommendationViewHolder.controlsIds
}
val gutsIds = GutsViewHolder.ids
controlsIds.forEach { id ->
viewState.widgetStates.get(id)?.let { state ->
// Make sure to use the unmodified state if guts are not visible.
state.alpha = if (isGutsVisible) 0f else state.alpha
state.gone = if (isGutsVisible) true else state.gone
}
}
gutsIds.forEach { id ->
viewState.widgetStates.get(id)?.let { state ->
// Make sure to use the unmodified state if guts are visible
state.alpha = if (isGutsVisible) state.alpha else 0f
state.gone = if (isGutsVisible) state.gone else true
}
}
}
/** Apply squishFraction to a copy of viewState such that the cached version is untouched. */
internal fun squishViewState(
viewState: TransitionViewState,
squishFraction: Float
): TransitionViewState {
val squishedViewState = viewState.copy()
val squishedHeight = (squishedViewState.measureHeight * squishFraction).toInt()
squishedViewState.height = squishedHeight
// We are not overriding the squishedViewStates height but only the children to avoid
// them remeasuring the whole view. Instead it just remains as the original size
backgroundIds.forEach { id ->
squishedViewState.widgetStates.get(id)?.let { state -> state.height = squishedHeight }
}
// media player
calculateWidgetGroupAlphaForSquishiness(
controlIds,
squishedViewState.measureHeight.toFloat(),
squishedViewState,
squishFraction
)
calculateWidgetGroupAlphaForSquishiness(
detailIds,
squishedViewState.measureHeight.toFloat(),
squishedViewState,
squishFraction
)
// recommendation card
val titlesTop =
calculateWidgetGroupAlphaForSquishiness(
RecommendationViewHolder.mediaTitlesAndSubtitlesIds,
squishedViewState.measureHeight.toFloat(),
squishedViewState,
squishFraction
)
calculateWidgetGroupAlphaForSquishiness(
RecommendationViewHolder.mediaContainersIds,
titlesTop,
squishedViewState,
squishFraction
)
return squishedViewState
}
/**
* This function is to make each widget in UMO disappear before being clipped by squished UMO
*
* The general rule is that widgets in UMO has been divided into several groups, and widgets in
* one group have the same alpha during squishing It will change from alpha 0.0 when the visible
* bottom of UMO reach the bottom of this group It will change to alpha 1.0 when the visible
* bottom of UMO reach the top of the group below e.g.Album title, artist title and play-pause
* button will change alpha together.
*
* ```
* And their alpha becomes 1.0 when the visible bottom of UMO reach the top of controls,
* including progress bar, next button, previous button
* ```
*
* widgetGroupIds: a group of widgets have same state during UMO is squished,
* ```
* e.g. Album title, artist title and play-pause button
* ```
*
* groupEndPosition: the height of UMO, when the height reaches this value,
* ```
* widgets in this group should have 1.0 as alpha
* e.g., the group of album title, artist title and play-pause button will become fully
* visible when the height of UMO reaches the top of controls group
* (progress bar, previous button and next button)
* ```
*
* squishedViewState: hold the widgetState of each widget, which will be modified
* squishFraction: the squishFraction of UMO
*/
private fun calculateWidgetGroupAlphaForSquishiness(
widgetGroupIds: Set<Int>,
groupEndPosition: Float,
squishedViewState: TransitionViewState,
squishFraction: Float
): Float {
val nonsquishedHeight = squishedViewState.measureHeight
var groupTop = squishedViewState.measureHeight.toFloat()
var groupBottom = 0F
widgetGroupIds.forEach { id ->
squishedViewState.widgetStates.get(id)?.let { state ->
groupTop = min(groupTop, state.y)
groupBottom = max(groupBottom, state.y + state.height)
}
}
// startPosition means to the height of squished UMO where the widget alpha should start
// changing from 0.0
// generally, it equals to the bottom of widgets, so that we can meet the requirement that
// widget should not go beyond the bounds of background
// endPosition means to the height of squished UMO where the widget alpha should finish
// changing alpha to 1.0
var startPosition = groupBottom
val endPosition = groupEndPosition
if (startPosition == endPosition) {
startPosition = (endPosition - 0.2 * (groupBottom - groupTop)).toFloat()
}
widgetGroupIds.forEach { id ->
squishedViewState.widgetStates.get(id)?.let { state ->
state.alpha =
calculateAlpha(
squishFraction,
startPosition / nonsquishedHeight,
endPosition / nonsquishedHeight
)
}
}
return groupTop // used for the widget group above this group
}
/**
* 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.
*/
@VisibleForTesting
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, isGutsVisible, tmpKey)
val viewState = viewStates[cacheKey]
if (viewState != null) {
// we already have cached this measurement, let's continue
if (state.squishFraction <= 1f) {
return squishViewState(viewState, state.squishFraction)
}
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) {
return 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()
)
setGutsViewState(result)
// 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)
}
if (state.squishFraction <= 1f) {
return squishViewState(result, state.squishFraction)
}
return result
}
private fun getKey(state: MediaHostState, guts: Boolean, result: CacheKey): CacheKey {
result.apply {
heightMeasureSpec = state.measurementInput?.heightMeasureSpec ?: 0
widthMeasureSpec = state.measurementInput?.widthMeasureSpec ?: 0
expansion = state.expansion
gutsVisible = guts
}
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, type: TYPE) =
traceSection("MediaViewController#attach") {
updateMediaViewControllerType(type)
logger.logMediaLocation("attach $type", currentStartLocation, currentEndLocation)
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? =
traceSection("MediaViewController#getMeasurementsForState") {
// measurements should never factor in the squish fraction
val viewState = obtainViewState(hostState) ?: return null
measurement.measuredWidth = viewState.measureWidth
measurement.measuredHeight = viewState.measureHeight
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
) =
traceSection("MediaViewController#setCurrentState") {
currentEndLocation = endLocation
currentStartLocation = startLocation
currentTransitionProgress = transitionProgress
logger.logMediaLocation("setCurrentState", startLocation, endLocation)
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 = updateViewStateSize(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 = updateViewStateSize(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
)
}
logger.logMediaSize("setCurrentState", result.width, result.height)
layoutController.setState(
result,
applyImmediately,
shouldAnimate,
animationDuration,
animationDelay
)
}
private fun updateViewStateSize(
viewState: TransitionViewState?,
location: Int,
outState: TransitionViewState
): TransitionViewState? {
var result = viewState?.copy(outState) ?: return null
val state = mediaHostStatesManager.mediaHostStates[location]
val overrideSize = mediaHostStatesManager.carouselSizes[location]
var overridden = false
overrideSize?.let {
// To be safe we're using a maximum here. The override size should always be set
// properly though.
if (
result.measureHeight != it.measuredHeight || result.measureWidth != it.measuredWidth
) {
result.measureHeight = Math.max(it.measuredHeight, result.measureHeight)
result.measureWidth = Math.max(it.measuredWidth, result.measureWidth)
// The measureHeight and the shown height should both be set to the overridden
// height
result.height = result.measureHeight
result.width = result.measureWidth
// Make sure all background views are also resized such that their size is correct
backgroundIds.forEach { id ->
result.widgetStates.get(id)?.let { state ->
state.height = result.height
state.width = result.width
}
}
overridden = true
}
}
if (overridden && state != null && state.squishFraction <= 1f) {
// Let's squish the media player if our size was overridden
result = squishViewState(result, state.squishFraction)
}
logger.logMediaSize("update to carousel", result.width, result.height)
return result
}
private fun updateMediaViewControllerType(type: TYPE) {
this.type = type
// These XML resources contain ConstraintSets that will apply to this player type's layout
when (type) {
TYPE.PLAYER -> {
collapsedLayout.load(context, R.xml.media_session_collapsed)
expandedLayout.load(context, R.xml.media_session_expanded)
}
TYPE.RECOMMENDATION -> {
if (mediaFlags.isRecommendationCardUpdateEnabled()) {
collapsedLayout.load(context, R.xml.media_recommendations_view_collapsed)
expandedLayout.load(context, R.xml.media_recommendations_view_expanded)
} else {
collapsedLayout.load(context, R.xml.media_recommendation_collapsed)
expandedLayout.load(context, R.xml.media_recommendation_expanded)
}
}
}
refreshState()
}
/**
* 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
val viewState = obtainViewState(mediaHostState)
if (viewState != null) {
// update the size of the viewstate for the location with the override
updateViewStateSize(viewState, location, tmpState)
return tmpState
}
return viewState
}
/**
* 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() =
traceSection("MediaViewController#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,
var gutsVisible: Boolean = false
)