diff options
| author | 2025-03-05 16:50:26 -0800 | |
|---|---|---|
| committer | 2025-03-05 16:50:26 -0800 | |
| commit | 6ab4600217ed592217377dbf95619616a84facd7 (patch) | |
| tree | b8cfac7641419094d475d499f78b8cf6626a6bee | |
| parent | f44287b77e3f11d3bb7dfa1886be6d8dfe6a0409 (diff) | |
| parent | 626bcf1d28be476bb5a21a1574a73d7ba6ebf86d (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.
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 + } +} |