summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Treehugger Robot <android-test-infra-autosubmit@system.gserviceaccount.com> 2025-03-05 16:50:26 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-03-05 16:50:26 -0800
commit6ab4600217ed592217377dbf95619616a84facd7 (patch)
treeb8cfac7641419094d475d499f78b8cf6626a6bee
parentf44287b77e3f11d3bb7dfa1886be6d8dfe6a0409 (diff)
parent626bcf1d28be476bb5a21a1574a73d7ba6ebf86d (diff)
Merge changes I940a95b5,Ieacc6e2f,I6c474693 into main
* changes: [Media] Color extraction. [Media] Background loading improvements. [Media] Card carousel, top-level view-model, and interactor interface.
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt38
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaActionModel.kt27
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaOutputDeviceModel.kt21
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaSessionModel.kt77
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaActionState.kt23
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaCardActionButtonLayout.kt24
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaColorScheme.kt21
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/DismissibleHorizontalPager.kt159
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/Media.kt412
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCardViewModel.kt19
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCarouselVisibility.kt31
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaNavigationViewModel.kt (renamed from packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSeekBarViewModel.kt)25
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaPlayPauseActionViewModel.kt5
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSecondaryActionViewModel.kt12
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaViewModel.kt253
15 files changed, 1013 insertions, 134 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt
new file mode 100644
index 000000000000..afed141644ff
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/interactor/MediaInteractor.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.domain.interactor
+
+import com.android.systemui.media.remedia.domain.model.MediaSessionModel
+
+/**
+ * Defines interface for classes that can provide business logic in the domain of the media controls
+ * element.
+ */
+interface MediaInteractor {
+
+ /** The list of sessions. Needs to be backed by a compose snapshot state. */
+ val sessions: List<MediaSessionModel>
+
+ /** Seek to [to], in milliseconds on the media session with the given [sessionKey]. */
+ fun seek(sessionKey: Any, to: Long)
+
+ /** Hide the representation of the media session with the given [sessionKey]. */
+ fun hide(sessionKey: Any)
+
+ /** Open media settings. */
+ fun openMediaSettings()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaActionModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaActionModel.kt
new file mode 100644
index 000000000000..02e4d7a966c2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaActionModel.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.model
+
+import com.android.systemui.common.shared.model.Icon
+
+sealed interface MediaActionModel {
+ data class Action(val icon: Icon, val onClick: (() -> Unit)?) : MediaActionModel
+
+ data object ReserveSpace : MediaActionModel
+
+ data object None : MediaActionModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaOutputDeviceModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaOutputDeviceModel.kt
new file mode 100644
index 000000000000..d581ae3e4e40
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaOutputDeviceModel.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.domain.model
+
+import com.android.systemui.common.shared.model.Icon
+
+data class MediaOutputDeviceModel(val name: String, val icon: Icon, val isInProgress: Boolean)
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaSessionModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaSessionModel.kt
new file mode 100644
index 000000000000..e64ce73226f2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/domain/model/MediaSessionModel.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.domain.model
+
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.graphics.ImageBitmap
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.media.remedia.shared.model.MediaCardActionButtonLayout
+import com.android.systemui.media.remedia.shared.model.MediaColorScheme
+import com.android.systemui.media.remedia.shared.model.MediaSessionState
+
+/** Data model representing a media session. */
+@Stable
+interface MediaSessionModel {
+ /** Unique identifier. */
+ val key: Any
+
+ val appName: String
+
+ val appIcon: Icon
+
+ val background: ImageBitmap?
+
+ val colorScheme: MediaColorScheme
+
+ val title: String
+
+ val subtitle: String
+
+ val onClick: () -> Unit
+
+ /**
+ * Whether the session is currently active. Under some UIs, only currently active session should
+ * be shown.
+ */
+ val isActive: Boolean
+
+ /** Whether the session can be hidden/dismissed by the user. */
+ val canBeHidden: Boolean
+
+ /**
+ * Whether the session currently supports scrubbing (e.g. moving to a different position iin the
+ * playback.
+ */
+ val canBeScrubbed: Boolean
+
+ val state: MediaSessionState
+
+ /** The position of the playback within the current track. */
+ val positionMs: Long
+
+ /** The total duration of the current track. */
+ val durationMs: Long
+
+ val outputDevice: MediaOutputDeviceModel
+
+ /** How to lay out the action buttons. */
+ val actionButtonLayout: MediaCardActionButtonLayout
+ val playPauseAction: MediaActionModel
+ val leftAction: MediaActionModel
+ val rightAction: MediaActionModel
+ val additionalActions: List<MediaActionModel.Action>
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaActionState.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaActionState.kt
new file mode 100644
index 000000000000..c3ce5035accc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaActionState.kt
@@ -0,0 +1,23 @@
+/*
+ * 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
+
+enum class MediaActionState {
+ Enabled,
+ Disabled,
+ Hidden,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaCardActionButtonLayout.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaCardActionButtonLayout.kt
new file mode 100644
index 000000000000..554fb1f2fc43
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaCardActionButtonLayout.kt
@@ -0,0 +1,24 @@
+/*
+ * 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
+
+enum class MediaCardActionButtonLayout {
+ /** Shows the play/pause button and left/right buttons in privileged positions on the card */
+ WithPlayPause,
+ /** Shows all action buttons along the bottom row. */
+ SecondaryActionsOnly,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaColorScheme.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaColorScheme.kt
new file mode 100644
index 000000000000..8dba170f0928
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/shared/model/MediaColorScheme.kt
@@ -0,0 +1,21 @@
+/*
+ * 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
+
+import androidx.compose.ui.graphics.Color
+
+data class MediaColorScheme(val primary: Color, val onPrimary: Color)
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/DismissibleHorizontalPager.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/DismissibleHorizontalPager.kt
new file mode 100644
index 000000000000..fea5b3267562
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/compose/DismissibleHorizontalPager.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerScope
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import com.android.compose.modifiers.thenIf
+import kotlinx.coroutines.launch
+
+/** State for a [DismissibleHorizontalPager] */
+class DismissibleHorizontalPagerState(
+ val isDismissible: Boolean,
+ val isScrollingEnabled: Boolean,
+ val pagerState: PagerState,
+ val offset: Animatable<Float, AnimationVector1D>,
+)
+
+/**
+ * Returns a remembered [DismissibleHorizontalPagerState] that starts at [initialPage] and has
+ * [pageCount] total pages.
+ */
+@Composable
+fun rememberDismissibleHorizontalPagerState(
+ isDismissible: Boolean = true,
+ isScrollingEnabled: Boolean = true,
+ initialPage: Int = 0,
+ pageCount: () -> Int,
+): DismissibleHorizontalPagerState {
+ val pagerState = rememberPagerState(initialPage = initialPage, pageCount = pageCount)
+ val offset = remember { Animatable(0f) }
+
+ return remember(isDismissible, isScrollingEnabled, pagerState, offset) {
+ DismissibleHorizontalPagerState(
+ isDismissible = isDismissible,
+ isScrollingEnabled = isScrollingEnabled,
+ pagerState = pagerState,
+ offset = offset,
+ )
+ }
+}
+
+/**
+ * A [HorizontalPager] that can be swiped-away to dismiss by the user when swiped farther left or
+ * right once fully scrolled to the left-most or right-most page, respectively.
+ */
+@Composable
+fun DismissibleHorizontalPager(
+ state: DismissibleHorizontalPagerState,
+ onDismissed: () -> Unit,
+ modifier: Modifier = Modifier,
+ key: ((Int) -> Any)? = null,
+ pageSpacing: Dp = 0.dp,
+ indicator: @Composable BoxScope.() -> Unit,
+ pageContent: @Composable PagerScope.(page: Int) -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+
+ val nestedScrollConnection = remember {
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ return if (state.offset.value > 0f && available.x < 0f) {
+ scope.launch { state.offset.snapTo(state.offset.value + available.x) }
+ Offset(available.x, 0f)
+ } else if (state.offset.value < 0f && available.x > 0f) {
+ scope.launch { state.offset.snapTo(state.offset.value + available.x) }
+ Offset(available.x, 0f)
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset {
+ return if (available.x > 0f) {
+ scope.launch { state.offset.snapTo(state.offset.value + available.x) }
+ Offset(available.x, 0f)
+ } else if (available.x < 0f) {
+ scope.launch { state.offset.snapTo(state.offset.value + available.x) }
+ Offset(available.x, 0f)
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ scope.launch {
+ state.offset.animateTo(
+ if (state.offset.value >= state.pagerState.layoutInfo.pageSize / 2f) {
+ state.pagerState.layoutInfo.pageSize * 2f
+ } else if (
+ state.offset.value <= -state.pagerState.layoutInfo.pageSize / 2f
+ ) {
+ -state.pagerState.layoutInfo.pageSize * 2f
+ } else {
+ 0f
+ }
+ )
+ if (state.offset.value != 0f) {
+ onDismissed()
+ }
+ }
+ return super.onPostFling(consumed, available)
+ }
+ }
+ }
+
+ Box(modifier = modifier) {
+ HorizontalPager(
+ state = state.pagerState,
+ userScrollEnabled = state.isScrollingEnabled,
+ key = key,
+ pageSpacing = pageSpacing,
+ pageContent = pageContent,
+ modifier =
+ Modifier.thenIf(state.isDismissible) {
+ Modifier.nestedScroll(nestedScrollConnection).graphicsLayer {
+ translationX = state.offset.value
+ }
+ },
+ )
+
+ indicator()
+ }
+}
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 c9fb8e877009..9c6568057d6f 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
@@ -20,6 +20,7 @@ package com.android.systemui.media.remedia.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -46,6 +47,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -65,12 +67,11 @@ import androidx.compose.material3.SliderState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
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
@@ -90,6 +91,7 @@ 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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
@@ -105,20 +107,137 @@ 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.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
+import com.android.systemui.common.ui.compose.PagerDots
import com.android.systemui.common.ui.compose.load
import com.android.systemui.communal.ui.compose.extensions.detectLongPressGesture
+import com.android.systemui.lifecycle.rememberViewModel
+import com.android.systemui.media.remedia.shared.model.MediaCardActionButtonLayout
+import com.android.systemui.media.remedia.shared.model.MediaColorScheme
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.MediaCarouselVisibility
+import com.android.systemui.media.remedia.ui.viewmodel.MediaNavigationViewModel
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 com.android.systemui.media.remedia.ui.viewmodel.MediaViewModel
import kotlin.math.max
+/**
+ * Renders a media controls UI element.
+ *
+ * This composable supports a multitude of presentation styles/layouts controlled by the
+ * [presentationStyle] parameter. If the card carousel can be swiped away to dismiss by the user,
+ * the [onDismissed] callback will be invoked when/if that happens.
+ */
+@Composable
+fun Media(
+ viewModelFactory: MediaViewModel.Factory,
+ presentationStyle: MediaPresentationStyle,
+ behavior: MediaUiBehavior,
+ onDismissed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val viewModel: MediaViewModel =
+ rememberViewModel("Media.viewModel") {
+ viewModelFactory.create(
+ context = context,
+ carouselVisibility = behavior.carouselVisibility,
+ )
+ }
+
+ CardCarousel(
+ viewModel = viewModel,
+ presentationStyle = presentationStyle,
+ behavior = behavior,
+ onDismissed = onDismissed,
+ modifier = modifier,
+ )
+}
+
+/**
+ * Renders a media controls carousel of cards.
+ *
+ * This composable supports a multitude of presentation styles/layouts controlled by the
+ * [presentationStyle] parameter. The behavior is controlled by [behavior]. If
+ * [MediaUiBehavior.isCarouselDismissible] is `true`, the [onDismissed] callback will be invoked
+ * when/if that happens.
+ */
+@Composable
+private fun CardCarousel(
+ viewModel: MediaViewModel,
+ presentationStyle: MediaPresentationStyle,
+ behavior: MediaUiBehavior,
+ onDismissed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedVisibility(visible = viewModel.isCarouselVisible, modifier = modifier) {
+ CardCarouselContent(
+ viewModel = viewModel,
+ presentationStyle = presentationStyle,
+ behavior = behavior,
+ onDismissed = onDismissed,
+ )
+ }
+}
+
+@Composable
+private fun CardCarouselContent(
+ viewModel: MediaViewModel,
+ presentationStyle: MediaPresentationStyle,
+ behavior: MediaUiBehavior,
+ onDismissed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val pagerState =
+ rememberDismissibleHorizontalPagerState(
+ isDismissible = behavior.isCarouselDismissible,
+ isScrollingEnabled = behavior.isCarouselScrollingEnabled,
+ ) {
+ viewModel.cards.size
+ }
+
+ val roundedCornerShape = RoundedCornerShape(32.dp)
+
+ LaunchedEffect(pagerState.pagerState.currentPage) {
+ viewModel.onCardSelected(pagerState.pagerState.currentPage)
+ }
+
+ DismissibleHorizontalPager(
+ state = pagerState,
+ onDismissed = onDismissed,
+ pageSpacing = 8.dp,
+ key = { index -> viewModel.cards[index].key },
+ indicator = {
+ if (pagerState.pagerState.pageCount > 1) {
+ PagerDots(
+ pagerState = pagerState.pagerState,
+ activeColor = Color(0xffdee0ff),
+ nonActiveColor = Color(0xffa7a9ca),
+ dotSize = 6.dp,
+ spaceSize = 6.dp,
+ modifier =
+ Modifier.align(Alignment.BottomCenter).padding(8.dp).graphicsLayer {
+ translationX = pagerState.offset.value
+ },
+ )
+ }
+ },
+ modifier = modifier.padding(8.dp).clip(roundedCornerShape),
+ ) { index ->
+ Card(
+ viewModel = viewModel.cards[index],
+ presentationStyle = presentationStyle,
+ modifier = Modifier.clip(roundedCornerShape),
+ )
+ }
+}
+
/** Renders the UI of a single media card. */
@Composable
private fun Card(
@@ -139,7 +258,7 @@ private fun Card(
Box(modifier) {
if (stlState.currentScene != Media.Scenes.Compact) {
- CardBackground(imageLoader = viewModel.artLoader, modifier = Modifier.matchParentSize())
+ CardBackground(image = viewModel.background, modifier = Modifier.matchParentSize())
}
key(stlState) {
@@ -158,6 +277,22 @@ private fun Card(
}
}
+@Composable
+private fun rememberAnimatedColorScheme(colorScheme: MediaColorScheme): AnimatedColorScheme {
+ val animatedPrimary by animateColorAsState(targetValue = colorScheme.primary)
+ val animatedOnPrimary by animateColorAsState(targetValue = colorScheme.onPrimary)
+
+ return remember {
+ object : AnimatedColorScheme {
+ override val primary: Color
+ get() = animatedPrimary
+
+ override val onPrimary: Color
+ get() = animatedOnPrimary
+ }
+ }
+}
+
/**
* Renders the foreground of a card, including all UI content and the internal "guts".
*
@@ -180,6 +315,8 @@ private fun ContentScope.CardForeground(
val isGutsVisible = viewModel.guts.isVisible
LaunchedEffect(isGutsVisible) { gutsAlphaAnimatable.animateTo(if (isGutsVisible) 1f else 0f) }
+ val colorScheme = rememberAnimatedColorScheme(viewModel.colorScheme)
+
// 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.
@@ -189,6 +326,7 @@ private fun ContentScope.CardForeground(
viewModel = viewModel,
threeRows = threeRows,
fillHeight = fillHeight,
+ colorScheme = colorScheme,
modifier =
Modifier.graphicsLayer {
compositingStrategy = CompositingStrategy.ModulateAlpha
@@ -198,6 +336,7 @@ private fun ContentScope.CardForeground(
CardGuts(
viewModel = viewModel.guts,
+ colorScheme = colorScheme,
modifier =
Modifier.graphicsLayer {
compositingStrategy = CompositingStrategy.ModulateAlpha
@@ -232,6 +371,7 @@ private fun ContentScope.CardForegroundContent(
viewModel: MediaCardViewModel,
threeRows: Boolean,
fillHeight: Boolean,
+ colorScheme: AnimatedColorScheme,
modifier: Modifier = Modifier,
) {
Column(
@@ -249,12 +389,16 @@ private fun ContentScope.CardForegroundContent(
// Icon.
Icon(
icon = viewModel.icon,
- tint = LocalAndroidColorScheme.current.primaryFixed,
+ tint = colorScheme.primary,
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))
+ OutputSwitcherChip(
+ viewModel = chip,
+ colorScheme = colorScheme,
+ modifier = Modifier.padding(start = 8.dp),
+ )
}
}
@@ -280,14 +424,16 @@ private fun ContentScope.CardForegroundContent(
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 },
- )
+ if (viewModel.actionButtonLayout == MediaCardActionButtonLayout.WithPlayPause) {
+ AnimatedVisibility(visible = viewModel.playPauseAction != null) {
+ PlayPauseAction(
+ viewModel = checkNotNull(viewModel.playPauseAction),
+ buttonWidth = 48.dp,
+ buttonColor = colorScheme.primary,
+ iconColor = colorScheme.onPrimary,
+ buttonCornerRadius = { isPlaying -> if (isPlaying) 16.dp else 48.dp },
+ )
+ }
}
}
@@ -297,7 +443,14 @@ private fun ContentScope.CardForegroundContent(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 24.dp),
) {
- Navigation(viewModel = viewModel.seekBar, isSeekBarVisible = true)
+ Navigation(
+ viewModel = viewModel.navigation,
+ isSeekBarVisible = true,
+ areActionsVisible =
+ viewModel.actionButtonLayout == MediaCardActionButtonLayout.WithPlayPause,
+ modifier = Modifier.weight(1f),
+ )
+
viewModel.additionalActions.fastForEachIndexed { index, action ->
SecondaryAction(
viewModel = action,
@@ -321,18 +474,34 @@ private fun ContentScope.CardForegroundContent(
)
Navigation(
- viewModel = viewModel.seekBar,
+ viewModel = viewModel.navigation,
isSeekBarVisible = false,
+ areActionsVisible =
+ viewModel.actionButtonLayout == MediaCardActionButtonLayout.WithPlayPause,
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 },
- )
+ if (
+ viewModel.actionButtonLayout == MediaCardActionButtonLayout.SecondaryActionsOnly
+ ) {
+ viewModel.additionalActions.fastForEachIndexed { index, action ->
+ SecondaryAction(
+ viewModel = action,
+ element = Media.Elements.additionalActionButton(index),
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = viewModel.playPauseAction != null) {
+ PlayPauseAction(
+ viewModel = checkNotNull(viewModel.playPauseAction),
+ buttonWidth = 48.dp,
+ buttonColor = colorScheme.primary,
+ iconColor = colorScheme.onPrimary,
+ buttonCornerRadius = { isPlaying -> if (isPlaying) 16.dp else 48.dp },
+ )
+ }
}
}
}
@@ -375,18 +544,18 @@ private fun ContentScope.CompactCardForeground(
iconColor = MaterialTheme.colorScheme.onSurface,
)
- val nextAction = (viewModel.seekBar as? MediaSeekBarViewModel.Showing)?.next
- if (nextAction != null) {
+ val rightAction = (viewModel.navigation as? MediaNavigationViewModel.Showing)?.right
+ if (rightAction != null) {
SecondaryAction(
- viewModel = nextAction,
+ viewModel = rightAction,
element = Media.Elements.NextButton,
iconColor = MaterialTheme.colorScheme.onSurface,
)
}
- AnimatedVisibility(visible = viewModel.playPauseAction.isVisible) {
+ AnimatedVisibility(visible = viewModel.playPauseAction != null) {
PlayPauseAction(
- viewModel = viewModel.playPauseAction,
+ viewModel = checkNotNull(viewModel.playPauseAction),
buttonWidth = 72.dp,
buttonColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer,
@@ -398,47 +567,38 @@ private fun ContentScope.CompactCardForeground(
/** 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) {
- var image: ImageBitmap? by remember { mutableStateOf(null) }
- LaunchedEffect(imageLoader) {
- image = null
- image = imageLoader()
- }
-
- val gradientBaseColor = MaterialTheme.colorScheme.onSurface
- Box(
- modifier =
- modifier.drawWithContent {
- // Draw the content of the box (loaded art or placeholder).
- drawContent()
-
- if (image != null) {
- // Then draw the overlay.
- drawRect(
- brush =
- Brush.radialGradient(
- 0f to gradientBaseColor.copy(alpha = 0.65f),
- 1f to gradientBaseColor.copy(alpha = 0.75f),
- center = size.center,
- radius = max(size.width, size.height) / 2,
- )
- )
- }
- }
- ) {
- image?.let { loadedImage ->
+private fun CardBackground(image: ImageBitmap?, modifier: Modifier = Modifier) {
+ Crossfade(targetState = image, modifier = modifier) { imageOrNull ->
+ if (imageOrNull != null) {
// Loaded art.
+ val gradientBaseColor = MaterialTheme.colorScheme.onSurface
Image(
- bitmap = loadedImage,
+ bitmap = imageOrNull,
contentDescription = null,
contentScale = ContentScale.Crop,
- modifier = Modifier.matchParentSize(),
+ modifier =
+ Modifier.fillMaxSize().drawWithContent {
+ // Draw the content (loaded art).
+ drawContent()
+
+ if (image != null) {
+ // Then draw the overlay.
+ drawRect(
+ brush =
+ Brush.radialGradient(
+ 0f to gradientBaseColor.copy(alpha = 0.65f),
+ 1f to gradientBaseColor.copy(alpha = 0.75f),
+ center = size.center,
+ radius = max(size.width, size.height) / 2,
+ )
+ )
+ }
+ },
)
+ } else {
+ // Placeholder.
+ Box(Modifier.background(MaterialTheme.colorScheme.onSurface).fillMaxSize())
}
- ?: run {
- // Placeholder.
- Box(Modifier.background(MaterialTheme.colorScheme.onSurface).matchParentSize())
- }
}
}
@@ -449,22 +609,26 @@ private fun CardBackground(imageLoader: suspend () -> ImageBitmap, modifier: Mod
* would otherwise be showing based on the view-model alone. This is meant for callers to decide
* whether they'd like to show the seek bar in addition to the prev/next buttons or just show the
* buttons.
+ *
+ * If [areActionsVisible] is `false`, the left/right buttons to the left and right of the seek bar
+ * will not be included in the layout.
*/
@Composable
private fun ContentScope.Navigation(
- viewModel: MediaSeekBarViewModel,
+ viewModel: MediaNavigationViewModel,
isSeekBarVisible: Boolean,
+ areActionsVisible: Boolean,
modifier: Modifier = Modifier,
) {
when (viewModel) {
- is MediaSeekBarViewModel.Showing -> {
+ is MediaNavigationViewModel.Showing -> {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
- viewModel.previous?.let {
- SecondaryAction(viewModel = it, element = Media.Elements.PrevButton)
+ if (areActionsVisible) {
+ SecondaryAction(viewModel = viewModel.left, element = Media.Elements.PrevButton)
}
val interactionSource = remember { MutableInteractionSource() }
@@ -499,13 +663,16 @@ private fun ContentScope.Navigation(
}
}
- viewModel.next?.let {
- SecondaryAction(viewModel = it, element = Media.Elements.NextButton)
+ if (areActionsVisible) {
+ SecondaryAction(
+ viewModel = viewModel.right,
+ element = Media.Elements.NextButton,
+ )
}
}
}
- is MediaSeekBarViewModel.Hidden -> Unit
+ is MediaNavigationViewModel.Hidden -> Unit
}
}
@@ -647,7 +814,11 @@ private fun SeekBarTrack(
/** Renders the internal "guts" of a card. */
@Composable
-private fun CardGuts(viewModel: MediaCardGutsViewModel, modifier: Modifier = Modifier) {
+private fun CardGuts(
+ viewModel: MediaCardGutsViewModel,
+ colorScheme: AnimatedColorScheme,
+ modifier: Modifier = Modifier,
+) {
Box(
modifier =
modifier.pointerInput(Unit) { detectLongPressGesture { viewModel.onLongClick() } }
@@ -682,7 +853,7 @@ private fun CardGuts(viewModel: MediaCardGutsViewModel, modifier: Modifier = Mod
) {
Text(
text = checkNotNull(viewModel.primaryAction.text),
- color = LocalAndroidColorScheme.current.onPrimaryFixed,
+ color = colorScheme.onPrimary,
)
}
@@ -740,28 +911,22 @@ private fun ContentScope.Metadata(
@Composable
private fun OutputSwitcherChip(
viewModel: MediaOutputSwitcherChipViewModel,
+ colorScheme: AnimatedColorScheme,
modifier: Modifier = Modifier,
) {
PlatformButton(
onClick = viewModel.onClick,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = LocalAndroidColorScheme.current.primaryFixed
- ),
+ colors = ButtonDefaults.buttonColors(containerColor = colorScheme.primary),
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),
- )
+ Icon(icon = viewModel.icon, tint = colorScheme.onPrimary, 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,
+ color = colorScheme.onPrimary,
)
}
}
@@ -785,7 +950,8 @@ private fun ContentScope.PlayPauseAction(
// This element can be animated when switching between scenes inside a media card.
Element(key = Media.Elements.PlayPauseButton, modifier = modifier) {
PlatformButton(
- onClick = viewModel.onClick,
+ onClick = viewModel.onClick ?: {},
+ enabled = viewModel.onClick != null,
colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
shape = RoundedCornerShape(cornerRadius),
modifier = Modifier.size(width = buttonWidth, height = 48.dp),
@@ -793,22 +959,29 @@ private fun ContentScope.PlayPauseAction(
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),
- )
+ val painterOrNull =
+ when (viewModel.icon) {
+ // TODO(b/399860531): load this expensive-to-load animated vector
+ // drawable off the main thread.
+ is Icon.Resource ->
+ rememberAnimatedVectorPainter(
+ animatedImageVector =
+ AnimatedImageVector.animatedVectorResource(
+ id = viewModel.icon.res
+ ),
+ atEnd = viewModel.state == MediaSessionState.Playing,
+ )
+ is Icon.Loaded -> rememberDrawablePainter(viewModel.icon.drawable)
+ null -> null
+ }
+ painterOrNull?.let { painter ->
+ Icon(
+ painter = painter,
+ contentDescription = viewModel.icon?.contentDescription?.load(),
+ tint = iconColor,
+ modifier = Modifier.size(24.dp),
+ )
+ }
}
is MediaSessionState.Buffering -> {
CircularProgressIndicator(color = iconColor, modifier = Modifier.size(24.dp))
@@ -831,7 +1004,7 @@ private fun ContentScope.SecondaryAction(
element: ElementKey? = null,
iconColor: Color = Color.White,
) {
- if (element != null) {
+ if (viewModel !is MediaSecondaryActionViewModel.None && element != null) {
Element(key = element, modifier = modifier) {
SecondaryActionContent(viewModel = viewModel, iconColor = iconColor)
}
@@ -847,14 +1020,22 @@ private fun SecondaryActionContent(
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),
- enabled = viewModel.isEnabled,
- modifier = modifier.size(48.dp).padding(13.dp),
- )
+ val sharedModifier = modifier.size(48.dp).padding(13.dp)
+ when (viewModel) {
+ is MediaSecondaryActionViewModel.Action ->
+ PlatformIconButton(
+ onClick = viewModel.onClick ?: {},
+ iconResource = (viewModel.icon as Icon.Resource).res,
+ contentDescription = viewModel.icon.contentDescription?.load(),
+ colors = IconButtonDefaults.iconButtonColors(contentColor = iconColor),
+ enabled = viewModel.onClick != null,
+ modifier = sharedModifier,
+ )
+
+ is MediaSecondaryActionViewModel.ReserveSpace -> Spacer(modifier = sharedModifier)
+
+ is MediaSecondaryActionViewModel.None -> Unit
+ }
}
/** Enumerates all supported media presentation styles. */
@@ -867,6 +1048,19 @@ enum class MediaPresentationStyle {
Compact,
}
+data class MediaUiBehavior(
+ val isCarouselDismissible: Boolean = true,
+ val isCarouselScrollingEnabled: Boolean = true,
+ val carouselVisibility: MediaCarouselVisibility = MediaCarouselVisibility.WhenNotEmpty,
+ val isFalsingProtectionNeeded: Boolean = false,
+)
+
+@Stable
+private interface AnimatedColorScheme {
+ val primary: Color
+ val onPrimary: Color
+}
+
private object Media {
/**
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
index ecd6e6d094d9..833a04ddcb55 100644
--- 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
@@ -19,6 +19,8 @@ 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
+import com.android.systemui.media.remedia.shared.model.MediaCardActionButtonLayout
+import com.android.systemui.media.remedia.shared.model.MediaColorScheme
/** Models UI state for a media card. */
@Stable
@@ -31,20 +33,19 @@ interface MediaCardViewModel {
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 background: ImageBitmap?
+
+ val colorScheme: MediaColorScheme
val title: String
val subtitle: String
- val playPauseAction: MediaPlayPauseActionViewModel
+ val actionButtonLayout: MediaCardActionButtonLayout
+
+ val playPauseAction: MediaPlayPauseActionViewModel?
- val seekBar: MediaSeekBarViewModel
+ val navigation: MediaNavigationViewModel
val additionalActions: List<MediaSecondaryActionViewModel>
@@ -53,7 +54,7 @@ interface MediaCardViewModel {
val outputSwitcherChips: List<MediaOutputSwitcherChipViewModel>
/** Simple icon-only version of the output switcher for use in compact UIs. */
- val outputSwitcherChipButton: MediaSecondaryActionViewModel
+ val outputSwitcherChipButton: MediaSecondaryActionViewModel.Action
val onClick: () -> Unit
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCarouselVisibility.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCarouselVisibility.kt
new file mode 100644
index 000000000000..53aa87ce0843
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaCarouselVisibility.kt
@@ -0,0 +1,31 @@
+/*
+ * 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
+
+/** Enumerates the known rules for media carousel visibility. */
+enum class MediaCarouselVisibility {
+
+ /** The carousel should be shown as long as it has at least one card. */
+ WhenNotEmpty,
+
+ /**
+ * The carousel should be shown as long as it has at least one card that represents an active
+ * media session. In other words: if all cards in the carousel represent _inactive_ sessions,
+ * the carousel should _not_ be visible.
+ */
+ WhenAnyCardIsActive,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaNavigationViewModel.kt
index f1ced6bf908d..c34c7337290f 100644
--- a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaSeekBarViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaNavigationViewModel.kt
@@ -18,17 +18,26 @@ package com.android.systemui.media.remedia.ui.viewmodel
import androidx.annotation.FloatRange
-/** Models UI state for the seek bar. */
-sealed interface MediaSeekBarViewModel {
+/**
+ * Models UI state for the navigation component of the UI (potentially containing the seek bar and
+ * the buttons to its left and right).
+ */
+sealed interface MediaNavigationViewModel {
/** The seek bar should be showing. */
data class Showing(
/** The progress to show on the seek bar, between `0` and `1`. */
@FloatRange(from = 0.0, to = 1.0) val progress: Float,
- /** The previous button; or `null` if it should be absent in the UI. */
- val previous: MediaSecondaryActionViewModel?,
- /** The next button; or `null` if it should be absent in the UI. */
- val next: MediaSecondaryActionViewModel?,
+ /**
+ * The action button to the left of the seek bar; or `null` if it should be absent in the
+ * UI.
+ */
+ val left: MediaSecondaryActionViewModel,
+ /**
+ * The action button to the right of the seek bar; or `null` if it should be absent in the
+ * UI.
+ */
+ val right: MediaSecondaryActionViewModel,
/**
* Whether the portion of the seek bar track before the thumb should show the squiggle
* animation.
@@ -50,8 +59,8 @@ sealed interface MediaSeekBarViewModel {
* the seek bar). The position/progress should be committed.
*/
val onScrubFinished: () -> Unit,
- ) : MediaSeekBarViewModel
+ ) : MediaNavigationViewModel
/** The seek bar should be hidden. */
- data object Hidden : MediaSeekBarViewModel
+ data object Hidden : MediaNavigationViewModel
}
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
index 4cb11bc0b8d0..ecc92d778f8e 100644
--- 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
@@ -21,8 +21,7 @@ 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,
+ val icon: Icon?,
+ 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
index a4806800a7b1..d28ca7ab7121 100644
--- 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
@@ -19,8 +19,10 @@ 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 isEnabled: Boolean,
- val onClick: () -> Unit,
-)
+sealed interface MediaSecondaryActionViewModel {
+ data class Action(val icon: Icon, val onClick: (() -> Unit)?) : MediaSecondaryActionViewModel
+
+ data object ReserveSpace : MediaSecondaryActionViewModel
+
+ data object None : MediaSecondaryActionViewModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaViewModel.kt
new file mode 100644
index 000000000000..b4f3d2724e75
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/remedia/ui/viewmodel/MediaViewModel.kt
@@ -0,0 +1,253 @@
+/*
+ * 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 android.content.Context
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.ImageBitmap
+import com.android.systemui.common.shared.model.ContentDescription
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.lifecycle.ExclusiveActivatable
+import com.android.systemui.media.remedia.domain.interactor.MediaInteractor
+import com.android.systemui.media.remedia.domain.model.MediaActionModel
+import com.android.systemui.media.remedia.shared.model.MediaColorScheme
+import com.android.systemui.media.remedia.shared.model.MediaSessionState
+import com.android.systemui.res.R
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlin.math.roundToLong
+import kotlinx.coroutines.awaitCancellation
+
+/** Models UI state for a media element. */
+class MediaViewModel
+@AssistedInject
+constructor(
+ private val interactor: MediaInteractor,
+ @Assisted private val context: Context,
+ @Assisted private val carouselVisibility: MediaCarouselVisibility,
+) : ExclusiveActivatable() {
+
+ /** Whether the user is actively moving the thumb of the seek bar. */
+ private var isScrubbing: Boolean by mutableStateOf(false)
+ /** The position of the thumb of the seek bar as the user is scrubbing it. */
+ private var seekProgress: Float by mutableFloatStateOf(0f)
+ /** Whether the internal "guts" are visible. */
+ private var isGutsVisible: Boolean by mutableStateOf(false)
+ /** The index of the currently-selected card. */
+ private var selectedCardIndex: Int by mutableIntStateOf(0)
+ private set
+
+ /** The current list of cards to show in the UI. */
+ val cards: List<MediaCardViewModel> by derivedStateOf {
+ interactor.sessions.mapIndexed { sessionIndex, session ->
+ val isCurrentSessionAndScrubbing = isScrubbing && sessionIndex == selectedCardIndex
+ object : MediaCardViewModel {
+ override val key = session.key
+ override val icon = session.appIcon
+ override val background: ImageBitmap?
+ get() = session.background
+
+ override val colorScheme: MediaColorScheme
+ get() = session.colorScheme
+
+ override val title = session.title
+ override val subtitle = session.subtitle
+ override val actionButtonLayout = session.actionButtonLayout
+ override val playPauseAction =
+ session.playPauseAction.toPlayPauseActionViewModel(session.state)
+ override val additionalActions: List<MediaSecondaryActionViewModel>
+ get() {
+ return session.additionalActions.map { action ->
+ action.toSecondaryActionViewModel()
+ }
+ }
+
+ override val navigation: MediaNavigationViewModel
+ get() {
+ return if (session.canBeScrubbed) {
+ MediaNavigationViewModel.Showing(
+ progress =
+ if (!isCurrentSessionAndScrubbing) {
+ session.positionMs.toFloat() / session.durationMs
+ } else {
+ seekProgress
+ },
+ left = session.leftAction.toSecondaryActionViewModel(),
+ right = session.rightAction.toSecondaryActionViewModel(),
+ isSquiggly =
+ session.state != MediaSessionState.Paused &&
+ !isCurrentSessionAndScrubbing,
+ isScrubbing = isCurrentSessionAndScrubbing,
+ onScrubChange = { progress ->
+ check(selectedCardIndex == sessionIndex) {
+ "Can't seek on a card that's not the selected card!"
+ }
+ isScrubbing = true
+ seekProgress = progress
+ },
+ onScrubFinished = {
+ interactor.seek(
+ sessionKey = session.key,
+ to = (seekProgress * session.durationMs).roundToLong(),
+ )
+ isScrubbing = false
+ },
+ )
+ } else {
+ MediaNavigationViewModel.Hidden
+ }
+ }
+
+ override val guts: MediaCardGutsViewModel
+ get() {
+ return MediaCardGutsViewModel(
+ isVisible = isGutsVisible,
+ text =
+ if (session.canBeHidden) {
+ context.getString(
+ R.string.controls_media_close_session,
+ session.appName,
+ )
+ } else {
+ context.getString(R.string.controls_media_active_session)
+ },
+ primaryAction =
+ if (session.canBeHidden) {
+ MediaGutsButtonViewModel(
+ text =
+ context.getString(
+ R.string.controls_media_dismiss_button
+ ),
+ onClick = {
+ interactor.hide(session.key)
+ isGutsVisible = false
+ },
+ )
+ } else {
+ MediaGutsButtonViewModel(
+ text = context.getString(R.string.cancel),
+ onClick = { isGutsVisible = false },
+ )
+ },
+ secondaryAction =
+ MediaGutsButtonViewModel(
+ text = context.getString(R.string.cancel),
+ onClick = { isGutsVisible = false },
+ )
+ .takeIf { session.canBeHidden },
+ settingsButton =
+ MediaGutsSettingsButtonViewModel(
+ icon =
+ Icon.Resource(
+ res = R.drawable.ic_settings,
+ contentDescription =
+ ContentDescription.Resource(
+ res = R.string.controls_media_settings_button
+ ),
+ ),
+ onClick = { interactor.openMediaSettings() },
+ ),
+ onLongClick = { isGutsVisible = false },
+ )
+ }
+
+ override val outputSwitcherChips: List<MediaOutputSwitcherChipViewModel>
+ get() {
+ return listOf(
+ MediaOutputSwitcherChipViewModel(
+ icon = session.outputDevice.icon,
+ text = session.outputDevice.name,
+ onClick = {
+ // TODO(b/397989775): tell the UI to show the output switcher.
+ },
+ )
+ )
+ }
+
+ override val outputSwitcherChipButton: MediaSecondaryActionViewModel.Action
+ get() {
+ return MediaSecondaryActionViewModel.Action(
+ icon = session.outputDevice.icon,
+ onClick = {
+ // TODO(b/397989775): tell the UI to show the output switcher.
+ },
+ )
+ }
+
+ override val onClick = session.onClick
+ override val onClickLabel =
+ context.getString(R.string.controls_media_playing_item_description)
+ override val onLongClick = { isGutsVisible = true }
+ }
+ }
+ }
+
+ /** Whether the carousel should be visible. */
+ val isCarouselVisible: Boolean
+ get() =
+ when (carouselVisibility) {
+ MediaCarouselVisibility.WhenNotEmpty -> interactor.sessions.isNotEmpty()
+
+ MediaCarouselVisibility.WhenAnyCardIsActive ->
+ interactor.sessions.any { session -> session.isActive }
+ }
+
+ /** Notifies that the card at [cardIndex] has been selected in the UI. */
+ fun onCardSelected(cardIndex: Int) {
+ check(cardIndex >= 0 && cardIndex < cards.size)
+ selectedCardIndex = cardIndex
+ }
+
+ override suspend fun onActivated(): Nothing {
+ awaitCancellation()
+ }
+
+ private fun MediaActionModel.toPlayPauseActionViewModel(
+ mediaSessionState: MediaSessionState
+ ): MediaPlayPauseActionViewModel? {
+ return when (this) {
+ is MediaActionModel.Action ->
+ MediaPlayPauseActionViewModel(
+ state = mediaSessionState,
+ icon = icon,
+ onClick = onClick ?: {},
+ )
+ is MediaActionModel.None,
+ is MediaActionModel.ReserveSpace -> null
+ }
+ }
+
+ private fun MediaActionModel.toSecondaryActionViewModel(): MediaSecondaryActionViewModel {
+ return when (this) {
+ is MediaActionModel.Action ->
+ MediaSecondaryActionViewModel.Action(icon = icon, onClick = onClick)
+ is MediaActionModel.ReserveSpace -> MediaSecondaryActionViewModel.ReserveSpace
+ is MediaActionModel.None -> MediaSecondaryActionViewModel.None
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(context: Context, carouselVisibility: MediaCarouselVisibility): MediaViewModel
+ }
+}