diff options
| author | 2025-03-04 00:43:09 -0800 | |
|---|---|---|
| committer | 2025-03-04 00:43:09 -0800 | |
| commit | 12c363ee39c5e1d92f15e32fc4472c216247c89c (patch) | |
| tree | 93801c3d4639faf611fe0be83f75260d841f9244 | |
| parent | 09c8b0da20e8a94f662c752f244b4efa533a9f3e (diff) | |
| parent | c60d737b148e8c61ba4dd6d3e12a8055576b7cb5 (diff) | |
Merge "[Media] Card." into main
| -rw-r--r-- | packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt | 340 | ||||
| -rw-r--r-- | packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCardViewModel.kt | 64 |
2 files changed, 404 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt index de01566993ea..c9fb8e877009 100644 --- a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt @@ -18,6 +18,7 @@ package com.android.systemui.media.remedia.ui.compose +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -33,6 +34,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.interaction.Interaction @@ -48,6 +50,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -63,33 +66,45 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed import com.android.compose.PlatformButton import com.android.compose.PlatformIconButton import com.android.compose.PlatformOutlinedButton import com.android.compose.animation.scene.ContentScope import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.SceneKey +import com.android.compose.animation.scene.SceneTransitionLayout +import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState +import com.android.compose.animation.scene.transitions import com.android.compose.theme.LocalAndroidColorScheme import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon @@ -97,12 +112,290 @@ import com.android.systemui.common.ui.compose.load import com.android.systemui.communal.ui.compose.extensions.detectLongPressGesture import com.android.systemui.media.remedia.shared.model.MediaSessionState import com.android.systemui.media.remedia.ui.viewmodel.MediaCardGutsViewModel +import com.android.systemui.media.remedia.ui.viewmodel.MediaCardViewModel import com.android.systemui.media.remedia.ui.viewmodel.MediaOutputSwitcherChipViewModel import com.android.systemui.media.remedia.ui.viewmodel.MediaPlayPauseActionViewModel import com.android.systemui.media.remedia.ui.viewmodel.MediaSecondaryActionViewModel import com.android.systemui.media.remedia.ui.viewmodel.MediaSeekBarViewModel import kotlin.math.max +/** Renders the UI of a single media card. */ +@Composable +private fun Card( + viewModel: MediaCardViewModel, + presentationStyle: MediaPresentationStyle, + modifier: Modifier = Modifier, +) { + val stlState = + rememberMutableSceneTransitionLayoutState( + initialScene = presentationStyle.toScene(), + transitions = Media.Transitions, + ) + + // Each time the presentation style changes, animate to the corresponding scene. + LaunchedEffect(presentationStyle) { + stlState.setTargetScene(targetScene = presentationStyle.toScene(), animationScope = this) + } + + Box(modifier) { + if (stlState.currentScene != Media.Scenes.Compact) { + CardBackground(imageLoader = viewModel.artLoader, modifier = Modifier.matchParentSize()) + } + + key(stlState) { + SceneTransitionLayout(state = stlState) { + scene(Media.Scenes.Default) { + CardForeground(viewModel = viewModel, threeRows = true, fillHeight = false) + } + + scene(Media.Scenes.Compressed) { + CardForeground(viewModel = viewModel, threeRows = false, fillHeight = false) + } + + scene(Media.Scenes.Compact) { CompactCardForeground(viewModel = viewModel) } + } + } + } +} + +/** + * Renders the foreground of a card, including all UI content and the internal "guts". + * + * If [threeRows] is `true`, the layout will be organized as three horizontal rows; if `false`, two + * rows will be used, resulting in a more compact layout. + * + * If [fillHeight] is `true`, the card will grow vertically to fill all available space in its + * parent. If not, it'll only be as tall as needed to show its UI. + */ +@Composable +private fun ContentScope.CardForeground( + viewModel: MediaCardViewModel, + threeRows: Boolean, + fillHeight: Boolean, + modifier: Modifier = Modifier, +) { + // Can't use a Crossfade composable because of the custom layout logic below. Animate the alpha + // of the guts (and, indirectly, of the content) from here. + val gutsAlphaAnimatable = remember { Animatable(0f) } + val isGutsVisible = viewModel.guts.isVisible + LaunchedEffect(isGutsVisible) { gutsAlphaAnimatable.animateTo(if (isGutsVisible) 1f else 0f) } + + // Use a custom layout to measure the content even if the content is being hidden because the + // internal guts are showing. This is needed because only the content knows the size the of the + // card and the guts are set to be the same size of the content. + Layout( + content = { + CardForegroundContent( + viewModel = viewModel, + threeRows = threeRows, + fillHeight = fillHeight, + modifier = + Modifier.graphicsLayer { + compositingStrategy = CompositingStrategy.ModulateAlpha + alpha = 1f - gutsAlphaAnimatable.value + }, + ) + + CardGuts( + viewModel = viewModel.guts, + modifier = + Modifier.graphicsLayer { + compositingStrategy = CompositingStrategy.ModulateAlpha + alpha = gutsAlphaAnimatable.value + }, + ) + }, + modifier = modifier, + ) { measurables, constraints -> + check(measurables.size == 2) + val contentPlaceable = measurables[0].measure(constraints) + // Guts should always have the exact dimensions as the content, even if we don't show the + // content. + val gutsPlaceable = + measurables[1].measure( + Constraints.fixed(contentPlaceable.width, contentPlaceable.height) + ) + + layout(contentPlaceable.measuredWidth, contentPlaceable.measuredHeight) { + if (!viewModel.guts.isVisible || gutsAlphaAnimatable.isRunning) { + contentPlaceable.place(0, 0) + } + if (viewModel.guts.isVisible || gutsAlphaAnimatable.isRunning) { + gutsPlaceable.place(0, 0) + } + } + } +} + +@Composable +private fun ContentScope.CardForegroundContent( + viewModel: MediaCardViewModel, + threeRows: Boolean, + fillHeight: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .combinedClickable( + onClick = viewModel.onClick, + onLongClick = viewModel.onLongClick, + onClickLabel = viewModel.onClickLabel, + ) + .padding(16.dp) + ) { + // Always add the first/top row, regardless of presentation style. + Row(verticalAlignment = Alignment.CenterVertically) { + // Icon. + Icon( + icon = viewModel.icon, + tint = LocalAndroidColorScheme.current.primaryFixed, + modifier = Modifier.size(24.dp).clip(CircleShape), + ) + Spacer(modifier = Modifier.weight(1f)) + viewModel.outputSwitcherChips.fastForEach { chip -> + OutputSwitcherChip(viewModel = chip, modifier = Modifier.padding(start = 8.dp)) + } + } + + // If the card is taller than necessary to show all the rows, this adds spacing + // between the top row and the rows below, anchoring the next rows to the bottom + // of the card. + if (fillHeight) { + Spacer(Modifier.weight(1f)) + } + + if (threeRows) { + // Three row presentation style. + // + // Second row. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 16.dp), + ) { + Metadata( + title = viewModel.title, + subtitle = viewModel.subtitle, + color = Color.White, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + + AnimatedVisibility(visible = viewModel.playPauseAction.isVisible) { + PlayPauseAction( + viewModel = viewModel.playPauseAction, + buttonWidth = 48.dp, + buttonColor = LocalAndroidColorScheme.current.primaryFixed, + iconColor = LocalAndroidColorScheme.current.onPrimaryFixed, + buttonCornerRadius = { isPlaying -> if (isPlaying) 16.dp else 48.dp }, + ) + } + } + + // Third row. + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 24.dp), + ) { + Navigation(viewModel = viewModel.seekBar, isSeekBarVisible = true) + viewModel.additionalActions.fastForEachIndexed { index, action -> + SecondaryAction( + viewModel = action, + element = Media.Elements.additionalActionButton(index), + ) + } + } + } else { + // Two row presentation style. + // + // Bottom row. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 36.dp), + ) { + Metadata( + title = viewModel.title, + subtitle = viewModel.subtitle, + color = Color.White, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) + + Navigation( + viewModel = viewModel.seekBar, + isSeekBarVisible = false, + modifier = Modifier.padding(end = 8.dp), + ) + + PlayPauseAction( + viewModel = viewModel.playPauseAction, + buttonWidth = 48.dp, + buttonColor = LocalAndroidColorScheme.current.primaryFixed, + iconColor = LocalAndroidColorScheme.current.onPrimaryFixed, + buttonCornerRadius = { isPlaying -> if (isPlaying) 16.dp else 48.dp }, + ) + } + } + } +} + +/** + * Renders a simplified version of [CardForeground] that puts everything on a single row and doesn't + * support the guts. + */ +@Composable +private fun ContentScope.CompactCardForeground( + viewModel: MediaCardViewModel, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .clickable(onClick = viewModel.onClick, onClickLabel = viewModel.onClickLabel) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp), + ) { + Icon( + icon = viewModel.icon, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + + Metadata( + title = viewModel.title, + subtitle = viewModel.subtitle, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + + SecondaryAction( + viewModel = viewModel.outputSwitcherChipButton, + element = Media.Elements.OutputSwitcherButton, + iconColor = MaterialTheme.colorScheme.onSurface, + ) + + val nextAction = (viewModel.seekBar as? MediaSeekBarViewModel.Showing)?.next + if (nextAction != null) { + SecondaryAction( + viewModel = nextAction, + element = Media.Elements.NextButton, + iconColor = MaterialTheme.colorScheme.onSurface, + ) + } + + AnimatedVisibility(visible = viewModel.playPauseAction.isVisible) { + PlayPauseAction( + viewModel = viewModel.playPauseAction, + buttonWidth = 72.dp, + buttonColor = MaterialTheme.colorScheme.primaryContainer, + iconColor = MaterialTheme.colorScheme.onPrimaryContainer, + buttonCornerRadius = { isPlaying -> if (isPlaying) 16.dp else 24.dp }, + ) + } + } +} + /** Renders the background of a card, loading the artwork and showing an overlay on top of it. */ @Composable private fun CardBackground(imageLoader: suspend () -> ImageBitmap, modifier: Modifier = Modifier) { @@ -564,9 +857,43 @@ private fun SecondaryActionContent( ) } +/** Enumerates all supported media presentation styles. */ +enum class MediaPresentationStyle { + /** The "normal" 3-row carousel look. */ + Default, + /** Similar to [Default] but not as tall (2-row carousel look). */ + Compressed, + /** A special single-row treatment that fits nicely in quick settings. */ + Compact, +} + private object Media { /** + * Scenes. + * + * The implementation uses a [SceneTransitionLayout] to smoothly animate transitions between + * different card layouts. Each card layout is identified as its own "scene" and the STL + * framework takes care of animating the layouts and their elements as the card morphs between + * scenes. + */ + object Scenes { + /** The "normal" 3-row carousel look. */ + val Default = SceneKey("default") + /** Similar to [Default] but not as tall (2-row carousel look). */ + val Compressed = SceneKey("compressed") + /** A special single-row treatment that fits nicely in quick settings. */ + val Compact = SceneKey("compact") + } + + /** Definitions of how scene changes are transition-animated. */ + val Transitions = transitions { + from(Scenes.Default, to = Scenes.Compact) {} + from(Scenes.Default, to = Scenes.Compressed) { fade(Elements.SeekBarSlider) } + from(Scenes.Compact, to = Scenes.Compressed) { fade(Elements.SeekBarSlider) } + } + + /** * Element keys. * * Composables that are wrapped in [ContentScope.Element] with one of these as their `key` @@ -582,5 +909,18 @@ private object Media { val PrevButton = ElementKey("prev") val NextButton = ElementKey("next") val SeekBarSlider = ElementKey("seek_bar_slider") + val OutputSwitcherButton = ElementKey("output_switcher") + + fun additionalActionButton(index: Int): ElementKey { + return ElementKey("additional_action_$index") + } + } +} + +private fun MediaPresentationStyle.toScene(): SceneKey { + return when (this) { + MediaPresentationStyle.Default -> Media.Scenes.Default + MediaPresentationStyle.Compressed -> Media.Scenes.Compressed + MediaPresentationStyle.Compact -> Media.Scenes.Compact } } diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCardViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCardViewModel.kt new file mode 100644 index 000000000000..ecd6e6d094d9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCardViewModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2025 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.remedia.ui.viewmodel + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.ImageBitmap +import com.android.systemui.common.shared.model.Icon + +/** Models UI state for a media card. */ +@Stable +interface MediaCardViewModel { + /** + * Identifier. Must be unique across all media cards currently shown, to help the horizontal + * pager in the UI. + */ + val key: Any + + val icon: Icon + + /** + * A callback to load the artwork for the media shown on this card. This callback will be + * invoked on the main thread, it's up to the implementation to move the loading off the main + * thread. + */ + val artLoader: suspend () -> ImageBitmap + + val title: String + + val subtitle: String + + val playPauseAction: MediaPlayPauseActionViewModel + + val seekBar: MediaSeekBarViewModel + + val additionalActions: List<MediaSecondaryActionViewModel> + + val guts: MediaCardGutsViewModel + + val outputSwitcherChips: List<MediaOutputSwitcherChipViewModel> + + /** Simple icon-only version of the output switcher for use in compact UIs. */ + val outputSwitcherChipButton: MediaSecondaryActionViewModel + + val onClick: () -> Unit + + /** Accessibility string for the click action of the card. */ + val onClickLabel: String? + + val onLongClick: () -> Unit +} |