diff options
5 files changed, 330 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaSessionState.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaSessionState.kt new file mode 100644 index 000000000000..40d55af9ba3e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaSessionState.kt @@ -0,0 +1,25 @@ +/* + * 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.shared.model + +sealed interface MediaSessionState { +    data object Playing : MediaSessionState + +    data object Paused : MediaSessionState + +    data object Buffering : MediaSessionState +} 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 new file mode 100644 index 000000000000..ae276707c3db --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt @@ -0,0 +1,230 @@ +/* + * 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.compose + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.compose.PlatformButton +import com.android.compose.PlatformIconButton +import com.android.compose.animation.scene.ContentScope +import com.android.compose.animation.scene.ElementKey +import com.android.compose.theme.LocalAndroidColorScheme +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.common.ui.compose.Icon +import com.android.systemui.common.ui.compose.load +import com.android.systemui.media.remedia.shared.model.MediaSessionState +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 + +/** Renders the metadata labels of a track. */ +@Composable +private fun ContentScope.Metadata( +    title: String, +    subtitle: String, +    color: Color, +    modifier: Modifier = Modifier, +) { +    // This element can be animated when switching between scenes inside a media card. +    Element(key = Media.Elements.Metadata, modifier = modifier) { +        // When the title and/or subtitle change, crossfade between the old and the new. +        Crossfade(targetState = title to subtitle, label = "Labels.crossfade") { (title, subtitle) +            -> +            Column { +                Text( +                    text = title, +                    style = MaterialTheme.typography.bodyLarge, +                    color = color, +                    maxLines = 1, +                    overflow = TextOverflow.Ellipsis, +                ) + +                Text( +                    text = subtitle, +                    style = MaterialTheme.typography.bodyMedium, +                    color = color, +                    maxLines = 1, +                    overflow = TextOverflow.Ellipsis, +                ) +            } +        } +    } +} + +/** + * Renders a small chip showing the current output device and providing a way to switch to a + * different output device. + */ +@Composable +private fun OutputSwitcherChip( +    viewModel: MediaOutputSwitcherChipViewModel, +    modifier: Modifier = Modifier, +) { +    PlatformButton( +        onClick = viewModel.onClick, +        colors = +            ButtonDefaults.buttonColors( +                containerColor = LocalAndroidColorScheme.current.primaryFixed +            ), +        contentPadding = PaddingValues(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), +        modifier = modifier.height(24.dp), +    ) { +        Icon( +            icon = viewModel.icon, +            tint = LocalAndroidColorScheme.current.onPrimaryFixed, +            modifier = Modifier.size(16.dp), +        ) +        viewModel.text?.let { +            Spacer(Modifier.size(4.dp)) +            Text( +                text = viewModel.text, +                style = MaterialTheme.typography.bodySmall, +                color = LocalAndroidColorScheme.current.onPrimaryFixed, +            ) +        } +    } +} + +/** Renders the primary action of media controls: the play/pause button. */ +@Composable +private fun ContentScope.PlayPauseAction( +    viewModel: MediaPlayPauseActionViewModel, +    buttonWidth: Dp, +    buttonColor: Color, +    iconColor: Color, +    buttonCornerRadius: (isPlaying: Boolean) -> Dp, +    modifier: Modifier = Modifier, +) { +    val cornerRadius: Dp by +        animateDpAsState( +            targetValue = buttonCornerRadius(viewModel.state != MediaSessionState.Paused), +            label = "PlayPauseAction.cornerRadius", +        ) +    // This element can be animated when switching between scenes inside a media card. +    Element(key = Media.Elements.PlayPauseButton, modifier = modifier) { +        PlatformButton( +            onClick = viewModel.onClick, +            colors = ButtonDefaults.buttonColors(containerColor = buttonColor), +            shape = RoundedCornerShape(cornerRadius), +            modifier = Modifier.size(width = buttonWidth, height = 48.dp), +        ) { +            when (viewModel.state) { +                is MediaSessionState.Playing, +                is MediaSessionState.Paused -> { +                    // TODO(b/399860531): load this expensive-to-load animated vector drawable off +                    //  the main thread. +                    val iconResource = checkNotNull(viewModel.icon) +                    Icon( +                        painter = +                            rememberAnimatedVectorPainter( +                                animatedImageVector = +                                    AnimatedImageVector.animatedVectorResource( +                                        id = iconResource.res +                                    ), +                                atEnd = viewModel.state == MediaSessionState.Playing, +                            ), +                        contentDescription = iconResource.contentDescription?.load(), +                        tint = iconColor, +                        modifier = Modifier.size(24.dp), +                    ) +                } +                is MediaSessionState.Buffering -> { +                    CircularProgressIndicator(color = iconColor, modifier = Modifier.size(24.dp)) +                } +            } +        } +    } +} + +/** + * Renders an icon button for an action that's not the play/pause action. + * + * If [element] is provided, the secondary action element will be able to animate when switching + * between scenes inside a media card. + */ +@Composable +private fun ContentScope.SecondaryAction( +    viewModel: MediaSecondaryActionViewModel, +    modifier: Modifier = Modifier, +    element: ElementKey? = null, +    iconColor: Color = Color.White, +) { +    if (element != null) { +        Element(key = element, modifier = modifier) { +            SecondaryActionContent(viewModel = viewModel, iconColor = iconColor) +        } +    } else { +        SecondaryActionContent(viewModel = viewModel, iconColor = iconColor, modifier = modifier) +    } +} + +/** The content of a [SecondaryAction]. */ +@Composable +private fun SecondaryActionContent( +    viewModel: MediaSecondaryActionViewModel, +    iconColor: Color, +    modifier: Modifier = Modifier, +) { +    PlatformIconButton( +        onClick = viewModel.onClick, +        iconResource = (viewModel.icon as Icon.Resource).res, +        contentDescription = viewModel.icon.contentDescription?.load(), +        colors = IconButtonDefaults.iconButtonColors(contentColor = iconColor), +        modifier = modifier.size(48.dp).padding(13.dp), +    ) +} + +private object Media { + +    /** +     * Element keys. +     * +     * Composables that are wrapped in [ContentScope.Element] with one of these as their `key` +     * parameter will automatically be picked up by the STL transition animation framework and will +     * be animated from their bounds in the original scene to their bounds in the destination scene. +     * +     * In addition, tagging such elements with a key allows developers to customize the transition +     * animations even further. +     */ +    object Elements { +        val PlayPauseButton = ElementKey("play_pause") +        val Metadata = ElementKey("metadata") +    } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaOutputSwitcherChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaOutputSwitcherChipViewModel.kt new file mode 100644 index 000000000000..f2724da2c3af --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaOutputSwitcherChipViewModel.kt @@ -0,0 +1,25 @@ +/* + * 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 com.android.systemui.common.shared.model.Icon + +data class MediaOutputSwitcherChipViewModel( +    val icon: Icon, +    val text: String? = null, +    val onClick: () -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaPlayPauseActionViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaPlayPauseActionViewModel.kt new file mode 100644 index 000000000000..4cb11bc0b8d0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaPlayPauseActionViewModel.kt @@ -0,0 +1,28 @@ +/* + * 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 com.android.systemui.common.shared.model.Icon +import com.android.systemui.media.remedia.shared.model.MediaSessionState + +/** Models UI state for the play/pause action button within media controls. */ +data class MediaPlayPauseActionViewModel( +    val isVisible: Boolean, +    val state: MediaSessionState, +    val icon: Icon.Resource?, +    val onClick: () -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSecondaryActionViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSecondaryActionViewModel.kt new file mode 100644 index 000000000000..2d7765d861a7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSecondaryActionViewModel.kt @@ -0,0 +1,22 @@ +/* + * 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 com.android.systemui.common.shared.model.Icon + +/** Models UI state for a secondary action button within media controls. */ +data class MediaSecondaryActionViewModel(val icon: Icon, val onClick: () -> Unit)  |