diff options
| -rw-r--r-- | packages/SystemUI/src/com/android/systemui/media/controls/ui/binder/MediaControlViewBinder.kt | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/binder/MediaControlViewBinder.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/binder/MediaControlViewBinder.kt index 14a917999bb7..8dd3379a2978 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/binder/MediaControlViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/binder/MediaControlViewBinder.kt @@ -16,12 +16,585 @@ package com.android.systemui.media.controls.ui.binder +import android.content.Context +import android.graphics.BlendMode +import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.drawable.Animatable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.TransitionDrawable +import android.os.Trace +import android.util.Pair +import android.view.Gravity +import android.view.View import android.widget.ImageButton import androidx.constraintlayout.widget.ConstraintSet +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.android.settingslib.widget.AdaptiveIcon +import com.android.systemui.animation.Expandable +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.media.controls.ui.animation.AnimationBindHandler +import com.android.systemui.media.controls.ui.animation.ColorSchemeTransition +import com.android.systemui.media.controls.ui.controller.MediaViewController +import com.android.systemui.media.controls.ui.util.MediaArtworkHelper +import com.android.systemui.media.controls.ui.view.MediaViewHolder +import com.android.systemui.media.controls.ui.viewmodel.MediaActionViewModel +import com.android.systemui.media.controls.ui.viewmodel.MediaControlViewModel +import com.android.systemui.media.controls.ui.viewmodel.MediaControlViewModel.Companion.MEDIA_PLAYER_SCRIM_END_ALPHA +import com.android.systemui.media.controls.ui.viewmodel.MediaControlViewModel.Companion.MEDIA_PLAYER_SCRIM_START_ALPHA +import com.android.systemui.media.controls.ui.viewmodel.MediaControlViewModel.Companion.SEMANTIC_ACTIONS_ALL +import com.android.systemui.media.controls.ui.viewmodel.MediaControlViewModel.Companion.SEMANTIC_ACTIONS_COMPACT +import com.android.systemui.media.controls.ui.viewmodel.MediaOutputSwitcherViewModel +import com.android.systemui.media.controls.ui.viewmodel.MediaPlayerViewModel +import com.android.systemui.media.controls.util.MediaDataUtils +import com.android.systemui.media.controls.util.MediaFlags +import com.android.systemui.monet.ColorScheme +import com.android.systemui.plugins.FalsingManager import com.android.systemui.res.R +import com.android.systemui.surfaceeffects.ripple.MultiRippleView +import com.android.systemui.surfaceeffects.ripple.RippleAnimation +import com.android.systemui.surfaceeffects.ripple.RippleAnimationConfig +import com.android.systemui.surfaceeffects.ripple.RippleShader +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext object MediaControlViewBinder { + fun bind( + viewHolder: MediaViewHolder, + viewModel: MediaControlViewModel, + viewController: MediaViewController, + falsingManager: FalsingManager, + @Background backgroundDispatcher: CoroutineDispatcher, + @Main mainDispatcher: CoroutineDispatcher, + mediaFlags: MediaFlags, + ) { + val mediaCard = viewHolder.player + mediaCard.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.player.collectLatest { playerViewModel -> + playerViewModel?.let { + bindMediaCard( + viewHolder, + viewController, + it, + falsingManager, + backgroundDispatcher, + mainDispatcher, + mediaFlags + ) + } + } + } + } + } + } + + private suspend fun bindMediaCard( + viewHolder: MediaViewHolder, + viewController: MediaViewController, + viewModel: MediaPlayerViewModel, + falsingManager: FalsingManager, + backgroundDispatcher: CoroutineDispatcher, + mainDispatcher: CoroutineDispatcher, + mediaFlags: MediaFlags, + ) { + with(viewHolder) { + // AlbumView uses a hardware layer so that clipping of the foreground is handled with + // clipping the album art. Otherwise album art shows through at the edges. + albumView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + turbulenceNoiseView.setBlendMode(BlendMode.SCREEN) + loadingEffectView.setBlendMode(BlendMode.SCREEN) + loadingEffectView.visibility = View.INVISIBLE + + player.contentDescription = + viewModel.contentDescription.invoke(viewController.isGutsVisible) + player.setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + if (!viewController.isGutsVisible) { + viewModel.onClicked(Expandable.fromView(player)) + } + } + } + player.setOnLongClickListener { + if (!falsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) { + if (!viewController.isGutsVisible) { + openGuts(viewHolder, viewController, viewModel) + } else { + closeGuts(viewHolder, viewController, viewModel) + } + } + return@setOnLongClickListener true + } + } + + viewController.bindSeekBar(viewModel.onSeek, viewModel.onBindSeekbar) + bindOutputSwitcherModel( + viewHolder, + viewModel.outputSwitcher, + viewController, + falsingManager + ) + bindGutsViewModel(viewHolder, viewModel, viewController, falsingManager) + bindActionButtons(viewHolder, viewModel, viewController, falsingManager) + bindScrubbingTime(viewHolder, viewModel, viewController) + + val isSongUpdated = bindSongMetadata(viewHolder, viewModel, viewController) + + bindArtworkAndColor( + viewHolder, + viewModel, + viewController, + backgroundDispatcher, + mainDispatcher, + mediaFlags, + isSongUpdated + ) + + // TODO: We don't need to refresh this state constantly, only if the + // state actually changed to something which might impact the + // measurement. State refresh interferes with the translation + // animation, only run it if it's not running. + if (!viewController.metadataAnimationHandler.isRunning) { + // Don't refresh in scene framework, because it will calculate + // with invalid layout sizes + if (!mediaFlags.isSceneContainerEnabled()) { + viewController.refreshState() + } + } + + if (viewModel.playTurbulenceNoise) { + viewController.setUpTurbulenceNoise() + } + } + + private fun bindOutputSwitcherModel( + viewHolder: MediaViewHolder, + viewModel: MediaOutputSwitcherViewModel, + viewController: MediaViewController, + falsingManager: FalsingManager, + ) { + with(viewHolder.seamless) { + visibility = View.VISIBLE + isEnabled = viewModel.isTapEnabled + contentDescription = viewModel.deviceString + setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.MODERATE_PENALTY)) { + viewModel.onClicked.invoke(Expandable.fromView(viewHolder.seamlessButton)) + } + } + } + when (viewModel.deviceIcon) { + is Icon.Loaded -> { + val icon = viewModel.deviceIcon.drawable + if (icon is AdaptiveIcon) { + icon.setBackgroundColor(viewController.colorSchemeTransition.bgColor) + } + viewHolder.seamlessIcon.setImageDrawable(icon) + } + is Icon.Resource -> viewHolder.seamlessIcon.setImageResource(viewModel.deviceIcon.res) + } + viewHolder.seamlessButton.alpha = viewModel.alpha + viewHolder.seamlessText.text = viewModel.deviceString + } + + private fun bindGutsViewModel( + viewHolder: MediaViewHolder, + viewModel: MediaPlayerViewModel, + viewController: MediaViewController, + falsingManager: FalsingManager, + ) { + val gutsViewHolder = viewHolder.gutsViewHolder + val model = viewModel.gutsMenu + with(gutsViewHolder) { + gutsText.text = model.gutsText + dismissText.visibility = if (model.isDismissEnabled) View.VISIBLE else View.GONE + dismiss.isEnabled = model.isDismissEnabled + dismiss.setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + model.onDismissClicked.invoke() + } + } + cancelText.background = model.cancelTextBackground + cancel.setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + closeGuts(viewHolder, viewController, viewModel) + } + } + settings.setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + model.onSettingsClicked.invoke() + } + } + setDismissible(model.isDismissEnabled) + setTextPrimaryColor(model.textPrimaryColor) + setAccentPrimaryColor(model.accentPrimaryColor) + setSurfaceColor(model.surfaceColor) + } + } + + private fun bindActionButtons( + viewHolder: MediaViewHolder, + viewModel: MediaPlayerViewModel, + viewController: MediaViewController, + falsingManager: FalsingManager, + ) { + val genericButtons = MediaViewHolder.genericButtonIds.map { viewHolder.getAction(it) } + val expandedSet = viewController.expandedLayout + val collapsedSet = viewController.collapsedLayout + if (viewModel.useSemanticActions) { + // Hide all generic buttons + genericButtons.forEach { + setVisibleAndAlpha(expandedSet, it.id, false) + setVisibleAndAlpha(collapsedSet, it.id, false) + } + + SEMANTIC_ACTIONS_ALL.forEachIndexed { index, id -> + val button = viewHolder.getAction(id) + val actionViewModel = viewModel.actionButtons[index] + if (button.id == R.id.actionPrev) { + actionViewModel?.let { + viewController.setUpPrevButtonInfo(true, it.notVisibleValue) + } + } else if (button.id == R.id.actionNext) { + actionViewModel?.let { + viewController.setUpNextButtonInfo(true, it.notVisibleValue) + } + } + actionViewModel?.let { action -> + val animHandler = (button.tag ?: AnimationBindHandler()) as AnimationBindHandler + animHandler.tryExecute { + if (animHandler.updateRebindId(action.rebindId)) { + animHandler.unregisterAll() + animHandler.tryRegister(action.icon) + animHandler.tryRegister(action.background) + bindButtonCommon( + button, + viewHolder.multiRippleView, + action, + viewController, + falsingManager, + ) + } + val visible = action.isVisibleWhenScrubbing || !viewController.isScrubbing + setSemanticButtonVisibleAndAlpha( + viewHolder.getAction(id), + viewController.expandedLayout, + viewController.collapsedLayout, + visible, + action.notVisibleValue, + action.showInCollapsed + ) + } + } + ?: clearButton(button) + } + } else { + // Hide buttons that only appear for semantic actions + SEMANTIC_ACTIONS_COMPACT.forEach { buttonId -> + setVisibleAndAlpha(expandedSet, buttonId, visible = false) + setVisibleAndAlpha(expandedSet, buttonId, visible = false) + } + + // Set all generic buttons + genericButtons.forEachIndexed { index, button -> + if (index < viewModel.actionButtons.size) { + viewModel.actionButtons[index]?.let { action -> + bindButtonCommon( + button, + viewHolder.multiRippleView, + action, + viewController, + falsingManager, + ) + setVisibleAndAlpha(expandedSet, button.id, visible = true) + setVisibleAndAlpha( + collapsedSet, + button.id, + visible = action.showInCollapsed + ) + } + ?: clearButton(button) + } else { + // Hide any unused buttons + clearButton(button) + setVisibleAndAlpha(expandedSet, button.id, visible = false) + setVisibleAndAlpha(collapsedSet, button.id, visible = false) + } + } + } + updateSeekBarVisibility(viewController.expandedLayout, isSeekBarEnabled = false) + } + + private fun bindButtonCommon( + button: ImageButton, + multiRippleView: MultiRippleView, + actionViewModel: MediaActionViewModel, + viewController: MediaViewController, + falsingManager: FalsingManager, + ) { + button.setImageDrawable(actionViewModel.icon) + button.background = actionViewModel.background + button.contentDescription = actionViewModel.contentDescription + button.isEnabled = actionViewModel.isEnabled + if (actionViewModel.isEnabled) { + button.setOnClickListener { + if (!falsingManager.isFalseTap(FalsingManager.MODERATE_PENALTY)) { + actionViewModel.onClicked.invoke(it.id) + + viewController.multiRippleController.play( + createTouchRippleAnimation( + button, + viewController.colorSchemeTransition, + multiRippleView + ) + ) + + if (actionViewModel.icon is Animatable) { + actionViewModel.icon.start() + } + + if (actionViewModel.background is Animatable) { + actionViewModel.background.start() + } + } + } + } + } + + private fun bindSongMetadata( + viewHolder: MediaViewHolder, + viewModel: MediaPlayerViewModel, + viewController: MediaViewController, + ): Boolean { + val expandedSet = viewController.expandedLayout + val collapsedSet = viewController.collapsedLayout + + return viewController.metadataAnimationHandler.setNext( + Triple(viewModel.titleName, viewModel.artistName, viewModel.isExplicitVisible), + { + viewHolder.titleText.text = viewModel.titleName + viewHolder.artistText.text = viewModel.artistName + setVisibleAndAlpha( + expandedSet, + R.id.media_explicit_indicator, + viewModel.isExplicitVisible + ) + setVisibleAndAlpha( + collapsedSet, + R.id.media_explicit_indicator, + viewModel.isExplicitVisible + ) + + // refreshState is required here to resize the text views (and prevent ellipsis) + viewController.refreshState() + }, + { + // After finishing the enter animation, we refresh state. This could pop if + // something is incorrectly bound, but needs to be run if other elements were + // updated while the enter animation was running + viewController.refreshState() + } + ) + } + + private suspend fun bindArtworkAndColor( + viewHolder: MediaViewHolder, + viewModel: MediaPlayerViewModel, + viewController: MediaViewController, + backgroundDispatcher: CoroutineDispatcher, + mainDispatcher: CoroutineDispatcher, + mediaFlags: MediaFlags, + updateBackground: Boolean, + ) { + val traceCookie = viewHolder.hashCode() + val traceName = "MediaControlViewBinder#bindArtworkAndColor" + Trace.beginAsyncSection(traceName, traceCookie) + if (updateBackground) { + viewController.isArtworkBound = false + } + // Capture width & height from views in foreground for artwork scaling in background + var width = viewHolder.albumView.measuredWidth + var height = viewHolder.albumView.measuredHeight + if (mediaFlags.isSceneContainerEnabled() && (width <= 0 || height <= 0)) { + // TODO(b/312714128): ensure we have a valid size before setting background + width = viewController.widthInSceneContainerPx + height = viewController.heightInSceneContainerPx + } + withContext(backgroundDispatcher) { + val artwork = + if (viewModel.shouldAddGradient) { + addGradientToPlayerAlbum( + viewHolder.albumView.context, + viewModel.backgroundCover!!, + viewModel.colorScheme, + width, + height + ) + } else { + ColorDrawable(Color.TRANSPARENT) + } + withContext(mainDispatcher) { + // Transition Colors to current color scheme + val colorSchemeChanged = + viewController.colorSchemeTransition.updateColorScheme(viewModel.colorScheme) + val albumView = viewHolder.albumView + albumView.setPadding(0, 0, 0, 0) + if ( + updateBackground || + colorSchemeChanged || + (!viewController.isArtworkBound && viewModel.shouldAddGradient) + ) { + viewController.prevArtwork?.let { + // Since we throw away the last transition, this will pop if your + // backgrounds are cycled too fast (or the correct background arrives very + // soon after the metadata changes). + val transitionDrawable = TransitionDrawable(arrayOf(it, artwork)) + + scaleTransitionDrawableLayer(transitionDrawable, 0, width, height) + scaleTransitionDrawableLayer(transitionDrawable, 1, width, height) + transitionDrawable.setLayerGravity(0, Gravity.CENTER) + transitionDrawable.setLayerGravity(1, Gravity.CENTER) + transitionDrawable.isCrossFadeEnabled = true + + albumView.setImageDrawable(transitionDrawable) + transitionDrawable.startTransition( + if (viewModel.shouldAddGradient) 333 else 80 + ) + } + } + viewController.isArtworkBound = viewModel.shouldAddGradient + viewController.prevArtwork = artwork + + if (viewModel.useGrayColorFilter) { + // Used for resume players to use launcher icon + viewHolder.appIcon.colorFilter = getGrayscaleFilter() + when (viewModel.launcherIcon) { + is Icon.Loaded -> + viewHolder.appIcon.setImageDrawable(viewModel.launcherIcon.drawable) + is Icon.Resource -> + viewHolder.appIcon.setImageResource(viewModel.launcherIcon.res) + } + } else { + viewHolder.appIcon.setColorFilter( + viewController.colorSchemeTransition.accentPrimary.targetColor + ) + viewHolder.appIcon.setImageIcon(viewModel.appIcon) + } + Trace.endAsyncSection(traceName, traceCookie) + } + } + } + + private fun scaleTransitionDrawableLayer( + transitionDrawable: TransitionDrawable, + layer: Int, + targetWidth: Int, + targetHeight: Int + ) { + val drawable = transitionDrawable.getDrawable(layer) ?: return + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + val scale = + MediaDataUtils.getScaleFactor(Pair(width, height), Pair(targetWidth, targetHeight)) + if (scale == 0f) return + transitionDrawable.setLayerSize(layer, (scale * width).toInt(), (scale * height).toInt()) + } + + private fun addGradientToPlayerAlbum( + context: Context, + artworkIcon: android.graphics.drawable.Icon, + mutableColorScheme: ColorScheme, + width: Int, + height: Int + ): LayerDrawable { + val albumArt = MediaArtworkHelper.getScaledBackground(context, artworkIcon, width, height) + return MediaArtworkHelper.setUpGradientColorOnDrawable( + albumArt, + context.getDrawable(R.drawable.qs_media_scrim)?.mutate() as GradientDrawable, + mutableColorScheme, + MEDIA_PLAYER_SCRIM_START_ALPHA, + MEDIA_PLAYER_SCRIM_END_ALPHA + ) + } + + private fun clearButton(button: ImageButton) { + button.setImageDrawable(null) + button.contentDescription = null + button.isEnabled = false + button.background = null + } + + private fun bindScrubbingTime( + viewHolder: MediaViewHolder, + viewModel: MediaPlayerViewModel, + viewController: MediaViewController, + ) { + val expandedSet = viewController.expandedLayout + val visible = viewModel.canShowTime && viewController.isScrubbing + viewController.canShowScrubbingTime = viewModel.canShowTime + setVisibleAndAlpha(expandedSet, viewHolder.scrubbingElapsedTimeView.id, visible) + setVisibleAndAlpha(expandedSet, viewHolder.scrubbingTotalTimeView.id, visible) + // Collapsed view is always GONE as set in XML, so doesn't need to be updated dynamically. + } + + private fun createTouchRippleAnimation( + button: ImageButton, + colorSchemeTransition: ColorSchemeTransition, + multiRippleView: MultiRippleView + ): RippleAnimation { + val maxSize = (multiRippleView.width * 2).toFloat() + return RippleAnimation( + RippleAnimationConfig( + RippleShader.RippleShape.CIRCLE, + duration = 1500L, + centerX = button.x + button.width * 0.5f, + centerY = button.y + button.height * 0.5f, + maxSize, + maxSize, + button.context.resources.displayMetrics.density, + colorSchemeTransition.accentPrimary.currentColor, + opacity = 100, + sparkleStrength = 0f, + baseRingFadeParams = null, + sparkleRingFadeParams = null, + centerFillFadeParams = null, + shouldDistort = false + ) + ) + } + + private fun openGuts( + viewHolder: MediaViewHolder, + viewController: MediaViewController, + viewModel: MediaPlayerViewModel, + ) { + viewHolder.marquee(true, MediaViewController.GUTS_ANIMATION_DURATION) + viewController.openGuts() + viewHolder.player.contentDescription = viewModel.contentDescription.invoke(true) + viewModel.onLongClicked.invoke() + } + + private fun closeGuts( + viewHolder: MediaViewHolder, + viewController: MediaViewController, + viewModel: MediaPlayerViewModel, + ) { + viewHolder.marquee(false, MediaViewController.GUTS_ANIMATION_DURATION) + viewController.closeGuts(false) + viewHolder.player.contentDescription = viewModel.contentDescription.invoke(false) + } + fun setVisibleAndAlpha(set: ConstraintSet, resId: Int, visible: Boolean) { setVisibleAndAlpha(set, resId, visible, ConstraintSet.GONE) } @@ -62,4 +635,10 @@ object MediaControlViewBinder { setVisibleAndAlpha(expandedSet, button.id, visible, notVisibleValue) setVisibleAndAlpha(collapsedSet, button.id, visible = visible && showInCollapsed) } + + private fun getGrayscaleFilter(): ColorMatrixColorFilter { + val matrix = ColorMatrix() + matrix.setSaturation(0f) + return ColorMatrixColorFilter(matrix) + } } |