Merge "Add volume sliders UI" into main
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/PlatformSlider.kt b/packages/SystemUI/compose/core/src/com/android/compose/PlatformSlider.kt
index b8c4fae..62dd4ac 100644
--- a/packages/SystemUI/compose/core/src/com/android/compose/PlatformSlider.kt
+++ b/packages/SystemUI/compose/core/src/com/android/compose/PlatformSlider.kt
@@ -57,9 +57,6 @@
import com.android.compose.modifiers.padding
import com.android.compose.theme.LocalAndroidColorScheme
-/** Indicator corner radius used when the user drags the [PlatformSlider]. */
-private val DefaultPlatformSliderDraggingCornerRadius = 8.dp
-
/**
* Platform slider implementation that displays a slider with an [icon] and a [label] at the start.
*
@@ -83,10 +80,8 @@
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
- colors: PlatformSliderColors =
- if (isSystemInDarkTheme()) darkThemePlatformSliderColors()
- else lightThemePlatformSliderColors(),
- draggingCornersRadius: Dp = DefaultPlatformSliderDraggingCornerRadius,
+ colors: PlatformSliderColors = PlatformSliderDefaults.defaultPlatformSliderColors(),
+ draggingCornersRadius: Dp = PlatformSliderDefaults.DefaultPlatformSliderDraggingCornerRadius,
icon: (@Composable (isDragging: Boolean) -> Unit)? = null,
label: (@Composable (isDragging: Boolean) -> Unit)? = null,
) {
@@ -109,7 +104,7 @@
val paddingStart by
animateDpAsState(
targetValue =
- if ((!isDragging && value == 0f) || icon == null) {
+ if ((!isDragging && value == valueRange.start) || icon == null) {
16.dp
} else {
0.dp
@@ -125,6 +120,7 @@
valueRange = valueRange,
onValueChangeFinished = onValueChangeFinished,
interactionSource = interactionSource,
+ enabled = enabled,
track = {
Track(
sliderState = it,
@@ -286,6 +282,17 @@
val disabledLabelColor: Color,
)
+object PlatformSliderDefaults {
+
+ /** Indicator corner radius used when the user drags the [PlatformSlider]. */
+ val DefaultPlatformSliderDraggingCornerRadius = 8.dp
+
+ @Composable
+ fun defaultPlatformSliderColors(): PlatformSliderColors =
+ if (isSystemInDarkTheme()) darkThemePlatformSliderColors()
+ else lightThemePlatformSliderColors()
+}
+
/** [PlatformSliderColors] for the light theme */
@Composable
private fun lightThemePlatformSliderColors() =
diff --git a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt
new file mode 100644
index 0000000..b4cb098
--- /dev/null
+++ b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume
+
+import dagger.Module
+
+@Module interface VolumeSlidersModule
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt
new file mode 100644
index 0000000..453ff02
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/VolumeSlidersModule.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume
+
+import com.android.systemui.volume.panel.component.shared.model.VolumePanelComponents
+import com.android.systemui.volume.panel.component.volume.ui.composable.VolumeSlidersComponent
+import com.android.systemui.volume.panel.domain.AlwaysAvailableCriteria
+import com.android.systemui.volume.panel.domain.ComponentAvailabilityCriteria
+import com.android.systemui.volume.panel.shared.model.VolumePanelUiComponent
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import dagger.multibindings.StringKey
+
+@Module
+interface VolumeSlidersModule {
+
+ @Binds
+ @IntoMap
+ @StringKey(VolumePanelComponents.VOLUME_SLIDERS)
+ fun bindVolumePanelUiComponent(component: VolumeSlidersComponent): VolumePanelUiComponent
+
+ @Binds
+ @IntoMap
+ @StringKey(VolumePanelComponents.VOLUME_SLIDERS)
+ fun bindComponentAvailabilityCriteria(
+ criteria: AlwaysAvailableCriteria
+ ): ComponentAvailabilityCriteria
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt
new file mode 100644
index 0000000..a197a4b
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/ColumnVolumeSliders.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.updateTransition
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+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.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.android.compose.PlatformSliderColors
+import com.android.systemui.res.R
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
+
+private const val EXPAND_DURATION_MILLIS = 500
+private const val COLLAPSE_DURATION_MILLIS = 300
+
+/** Volume sliders laid out in a collapsable column */
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+fun ColumnVolumeSliders(
+ viewModels: List<SliderViewModel>,
+ sliderColors: PlatformSliderColors,
+ isExpandable: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ require(viewModels.isNotEmpty())
+ var isExpanded: Boolean by remember { mutableStateOf(false) }
+ val transition = updateTransition(isExpanded, label = "CollapsableSliders")
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val sliderViewModel: SliderViewModel = viewModels.first()
+ val sliderState by viewModels.first().slider.collectAsState()
+ VolumeSlider(
+ modifier = Modifier.weight(1f),
+ state = sliderState,
+ onValueChangeFinished = { sliderViewModel.onValueChangeFinished(sliderState, it) },
+ sliderColors = sliderColors,
+ )
+
+ if (isExpandable) {
+ ExpandButton(
+ isExpanded = isExpanded,
+ onExpandedChanged = { isExpanded = it },
+ sliderColors,
+ Modifier,
+ )
+ }
+ }
+ transition.AnimatedVisibility(
+ visible = { it },
+ enter =
+ expandVertically(
+ animationSpec = tween(durationMillis = EXPAND_DURATION_MILLIS),
+ expandFrom = Alignment.CenterVertically,
+ ),
+ exit =
+ shrinkVertically(
+ animationSpec = tween(durationMillis = COLLAPSE_DURATION_MILLIS),
+ shrinkTowards = Alignment.CenterVertically,
+ ),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ for (index in 1..viewModels.lastIndex) {
+ val sliderViewModel: SliderViewModel = viewModels[index]
+ val sliderState by sliderViewModel.slider.collectAsState()
+ transition.AnimatedVisibility(
+ visible = { it },
+ enter = enterTransition(index = index, totalCount = viewModels.size),
+ exit = exitTransition(index = index, totalCount = viewModels.size)
+ ) {
+ VolumeSlider(
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ state = sliderState,
+ onValueChangeFinished = {
+ sliderViewModel.onValueChangeFinished(sliderState, it)
+ },
+ sliderColors = sliderColors,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ExpandButton(
+ isExpanded: Boolean,
+ onExpandedChanged: (Boolean) -> Unit,
+ sliderColors: PlatformSliderColors,
+ modifier: Modifier = Modifier,
+) {
+ IconButton(
+ modifier = modifier.size(64.dp),
+ onClick = { onExpandedChanged(!isExpanded) },
+ colors =
+ IconButtonDefaults.filledIconButtonColors(
+ containerColor = sliderColors.indicatorColor,
+ contentColor = sliderColors.iconColor
+ ),
+ ) {
+ Icon(
+ painter =
+ painterResource(
+ if (isExpanded) {
+ R.drawable.ic_filled_arrow_down
+ } else {
+ R.drawable.ic_filled_arrow_up
+ }
+ ),
+ contentDescription = null,
+ )
+ }
+}
+
+private fun enterTransition(index: Int, totalCount: Int): EnterTransition {
+ val enterDelay = ((totalCount - index + 1) * 10).coerceAtLeast(0)
+ val enterDuration = (EXPAND_DURATION_MILLIS - enterDelay).coerceAtLeast(100)
+ return slideInVertically(
+ initialOffsetY = { (it * 0.25).toInt() },
+ animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
+ ) +
+ scaleIn(
+ initialScale = 0.9f,
+ animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
+ ) +
+ expandVertically(
+ initialHeight = { (it * 0.65).toInt() },
+ animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
+ clip = false,
+ expandFrom = Alignment.CenterVertically,
+ ) +
+ fadeIn(
+ animationSpec = tween(durationMillis = enterDuration, delayMillis = enterDelay),
+ )
+}
+
+private fun exitTransition(index: Int, totalCount: Int): ExitTransition {
+ val exitDuration = (COLLAPSE_DURATION_MILLIS - (totalCount - index + 1) * 10).coerceAtLeast(100)
+ return slideOutVertically(
+ targetOffsetY = { (it * 0.25).toInt() },
+ animationSpec = tween(durationMillis = exitDuration),
+ ) +
+ scaleOut(
+ targetScale = 0.9f,
+ animationSpec = tween(durationMillis = exitDuration),
+ ) +
+ shrinkVertically(
+ targetHeight = { (it * 0.65).toInt() },
+ animationSpec = tween(durationMillis = exitDuration),
+ clip = false,
+ shrinkTowards = Alignment.CenterVertically,
+ ) +
+ fadeOut(animationSpec = tween(durationMillis = exitDuration))
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/GridVolumeSliders.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/GridVolumeSliders.kt
new file mode 100644
index 0000000..910ee72
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/GridVolumeSliders.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.android.compose.PlatformSliderColors
+import com.android.compose.grid.VerticalGrid
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
+
+@Composable
+fun GridVolumeSliders(
+ viewModels: List<SliderViewModel>,
+ sliderColors: PlatformSliderColors,
+ modifier: Modifier = Modifier,
+) {
+ require(viewModels.isNotEmpty())
+ VerticalGrid(
+ modifier = modifier,
+ columns = 2,
+ verticalSpacing = 16.dp,
+ horizontalSpacing = 24.dp,
+ ) {
+ for (sliderViewModel in viewModels) {
+ val sliderState = sliderViewModel.slider.collectAsState().value
+ VolumeSlider(
+ modifier = Modifier.fillMaxWidth(),
+ state = sliderState,
+ onValueChangeFinished = { sliderViewModel.onValueChangeFinished(sliderState, it) },
+ sliderColors = sliderColors,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
new file mode 100644
index 0000000..5925b14
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlider.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.android.compose.PlatformSlider
+import com.android.compose.PlatformSliderColors
+import com.android.systemui.common.ui.compose.Icon
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderState
+
+@Composable
+fun VolumeSlider(
+ state: SliderState,
+ onValueChangeFinished: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+ sliderColors: PlatformSliderColors,
+) {
+ var value by remember { mutableFloatStateOf(state.value) }
+ PlatformSlider(
+ modifier = modifier,
+ value = value,
+ valueRange = state.valueRange,
+ onValueChange = { value = it },
+ onValueChangeFinished = { onValueChangeFinished(value) },
+ enabled = state.isEnabled,
+ icon = { isDragging ->
+ if (isDragging) {
+ Text(text = value.toInt().toString())
+ } else {
+ state.icon?.let { Icon(icon = it) }
+ }
+ },
+ colors = sliderColors,
+ label = {
+ Column(modifier = Modifier.animateContentSize()) {
+ Text(state.label, style = MaterialTheme.typography.titleMedium)
+
+ state.disabledMessage?.let { message ->
+ AnimatedVisibility(
+ !state.isEnabled,
+ enter = expandVertically { it },
+ exit = shrinkVertically { it },
+ ) {
+ Text(text = message, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlidersComponent.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlidersComponent.kt
new file mode 100644
index 0000000..6213dc5
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/component/volume/ui/composable/VolumeSlidersComponent.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.ui.composable
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import com.android.compose.PlatformSliderDefaults
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
+import com.android.systemui.volume.panel.component.volume.ui.viewmodel.AudioVolumeComponentViewModel
+import com.android.systemui.volume.panel.ui.composable.ComposeVolumePanelUiComponent
+import com.android.systemui.volume.panel.ui.composable.VolumePanelComposeScope
+import javax.inject.Inject
+
+class VolumeSlidersComponent
+@Inject
+constructor(
+ private val viewModel: AudioVolumeComponentViewModel,
+) : ComposeVolumePanelUiComponent {
+
+ @Composable
+ override fun VolumePanelComposeScope.Content(modifier: Modifier) {
+ val sliderViewModels: List<SliderViewModel> by viewModel.sliderViewModels.collectAsState()
+ if (sliderViewModels.isEmpty()) {
+ return
+ }
+ if (isLargeScreen) {
+ GridVolumeSliders(
+ viewModels = sliderViewModels,
+ sliderColors = PlatformSliderDefaults.defaultPlatformSliderColors(),
+ modifier = modifier.fillMaxWidth(),
+ )
+ } else {
+ ColumnVolumeSliders(
+ viewModels = sliderViewModels,
+ sliderColors = PlatformSliderDefaults.defaultPlatformSliderColors(),
+ isExpandable = true,
+ modifier = modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/ui/composable/VerticalVolumePanelContent.kt b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/ui/composable/VerticalVolumePanelContent.kt
index 400072e..4d07379 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/ui/composable/VerticalVolumePanelContent.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/volume/panel/ui/composable/VerticalVolumePanelContent.kt
@@ -17,7 +17,6 @@
package com.android.systemui.volume.panel.ui.composable
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -34,7 +33,7 @@
modifier: Modifier = Modifier,
) {
Column(
- modifier = modifier.animateContentSize(),
+ modifier = modifier,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
for (component in layout.headerComponents) {
diff --git a/packages/SystemUI/res/drawable/ic_call.xml b/packages/SystemUI/res/drawable/ic_call.xml
new file mode 100644
index 0000000..859506a
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_call.xml
@@ -0,0 +1,26 @@
+<!--
+ ~ Copyright (C) 2024 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M798,840Q673,840 551,785.5Q429,731 329,631Q229,531 174.5,409Q120,287 120,162Q120,144 132,132Q144,120 162,120L324,120Q338,120 349,129.5Q360,139 362,152L388,292Q390,308 387,319Q384,330 376,338L279,436Q299,473 326.5,507.5Q354,542 387,574Q418,605 452,631.5Q486,658 524,680L618,586Q627,577 641.5,572.5Q656,568 670,570L808,598Q822,602 831,612.5Q840,623 840,636L840,798Q840,816 828,828Q816,840 798,840ZM241,360L307,294Q307,294 307,294Q307,294 307,294L290,200Q290,200 290,200Q290,200 290,200L201,200Q201,200 201,200Q201,200 201,200Q206,241 215,281Q224,321 241,360ZM599,718Q638,735 678.5,745Q719,755 760,758Q760,758 760,758Q760,758 760,758L760,670Q760,670 760,670Q760,670 760,670L666,651Q666,651 666,651Q666,651 666,651L599,718ZM241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360L241,360Q241,360 241,360Q241,360 241,360L241,360ZM599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718L599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Q599,718 599,718Z"/>
+</vector>
diff --git a/packages/SystemUI/res/drawable/ic_filled_arrow_down.xml b/packages/SystemUI/res/drawable/ic_filled_arrow_down.xml
new file mode 100644
index 0000000..c85965f
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_filled_arrow_down.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2024 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:width="24dp"
+ android:height="24dp">
+ <path
+ android:pathData="M7 10l5 5 5 -5z"
+ android:fillColor="#FF000000"/>
+</vector>
diff --git a/packages/SystemUI/res/drawable/ic_filled_arrow_up.xml b/packages/SystemUI/res/drawable/ic_filled_arrow_up.xml
new file mode 100644
index 0000000..8ee7e13
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_filled_arrow_up.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ Copyright (C) 2024 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:width="24dp"
+ android:height="24dp">
+ <path
+ android:pathData="M7 14l5-5 5 5z"
+ android:fillColor="#FF000000"/>
+</vector>
diff --git a/packages/SystemUI/res/drawable/ic_music_note_off.xml b/packages/SystemUI/res/drawable/ic_music_note_off.xml
new file mode 100644
index 0000000..d583576
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_music_note_off.xml
@@ -0,0 +1,25 @@
+<!--
+ ~ Copyright (C) 2024 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="960"
+ android:viewportWidth="960">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M792,904L56,168L112,112L848,848L792,904ZM560,446L480,366L480,120L720,120L720,280L560,280L560,446ZM400,840Q334,840 287,793Q240,746 240,680Q240,614 287,567Q334,520 400,520Q423,520 442.5,525.5Q462,531 480,542L480,480L560,560L560,680Q560,746 513,793Q466,840 400,840Z" />
+</vector>
diff --git a/packages/SystemUI/res/drawable/ic_volume_off.xml b/packages/SystemUI/res/drawable/ic_volume_off.xml
new file mode 100644
index 0000000..209f684
--- /dev/null
+++ b/packages/SystemUI/res/drawable/ic_volume_off.xml
@@ -0,0 +1,27 @@
+<!--
+ ~ Copyright (C) 2024 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal"
+ android:autoMirrored="true">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M792,904L671,783Q646,799 618,810.5Q590,822 560,829L560,747Q574,742 587.5,737Q601,732 613,725L480,592L480,800L280,600L120,600L120,360L248,360L56,168L112,112L848,848L792,904ZM784,672L726,614Q743,583 751.5,549Q760,515 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,532 825.5,581Q811,630 784,672ZM650,538L560,448L560,318Q607,340 633.5,384Q660,428 660,480Q660,495 657.5,509.5Q655,524 650,538ZM480,368L376,264L480,160L480,368ZM400,606L400,512L328,440L328,440L200,440L200,520L314,520L400,606ZM364,476L364,476L364,476L364,476L364,476L364,476L364,476L364,476Z"/>
+</vector>
\ No newline at end of file
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 53ad344..4263d94 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1513,6 +1513,11 @@
<string name="volume_ringer_status_vibrate">Vibrate</string>
<string name="volume_ringer_status_silent">Mute</string>
+ <!-- Media device casting volume slider label [CHAR_LIMIT=20] -->
+ <string name="media_device_cast">Cast</string>
+ <!-- A message shown when the notification volume changing is disabled because of the muted ring stream [CHAR_LIMIT=40]-->
+ <string name="stream_notification_unavailable">Unavailable because ring is muted</string>
+
<!-- Shown in the header of quick settings to indicate to the user that their phone ringer is on vibrate. [CHAR_LIMIT=NONE] -->
<!-- Shown in the header of quick settings to indicate to the user that their phone ringer is on silent (muted). [CHAR_LIMIT=NONE] -->
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/shared/model/VolumePanelComponents.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/shared/model/VolumePanelComponents.kt
index f11ac5e..9d801fc 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/shared/model/VolumePanelComponents.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/shared/model/VolumePanelComponents.kt
@@ -22,6 +22,7 @@
const val MEDIA_OUTPUT: VolumePanelComponentKey = "media_output"
const val BOTTOM_BAR: VolumePanelComponentKey = "bottom_bar"
+ const val VOLUME_SLIDERS: VolumePanelComponentKey = "volume_sliders"
const val CAPTIONING: VolumePanelComponentKey = "captioning"
const val ANC: VolumePanelComponentKey = "anc"
}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/VolumeSliderInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/VolumeSliderInteractor.kt
index 52736c6..0c91bbf 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/VolumeSliderInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/domain/interactor/VolumeSliderInteractor.kt
@@ -24,7 +24,7 @@
class VolumeSliderInteractor @Inject constructor() {
/** mimic percentage volume setting */
- private val displayValueRange: ClosedFloatingPointRange<Float> = 0f..100f
+ val displayValueRange: ClosedFloatingPointRange<Float> = 0f..100f
/**
* Translates [volume], that belongs to [volumeRange] to the value that belongs to
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt
new file mode 100644
index 0000000..faf7434
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/AudioStreamSliderViewModel.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.slider.ui.viewmodel
+
+import android.content.Context
+import android.media.AudioManager
+import com.android.settingslib.volume.domain.interactor.AudioVolumeInteractor
+import com.android.settingslib.volume.shared.model.AudioStream
+import com.android.settingslib.volume.shared.model.AudioStreamModel
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.res.R
+import com.android.systemui.volume.panel.component.volume.domain.interactor.VolumeSliderInteractor
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/** Models a particular slider state. */
+class AudioStreamSliderViewModel
+@AssistedInject
+constructor(
+ @Assisted private val audioStreamWrapper: FactoryAudioStreamWrapper,
+ @Assisted private val coroutineScope: CoroutineScope,
+ private val context: Context,
+ private val audioVolumeInteractor: AudioVolumeInteractor,
+ private val volumeSliderInteractor: VolumeSliderInteractor,
+) : SliderViewModel {
+
+ private val audioStream = audioStreamWrapper.audioStream
+ private val iconsByStream =
+ mapOf(
+ AudioStream(AudioManager.STREAM_MUSIC) to R.drawable.ic_music_note,
+ AudioStream(AudioManager.STREAM_VOICE_CALL) to R.drawable.ic_call,
+ AudioStream(AudioManager.STREAM_RING) to R.drawable.ic_ring_volume,
+ AudioStream(AudioManager.STREAM_NOTIFICATION) to R.drawable.ic_volume_ringer,
+ AudioStream(AudioManager.STREAM_ALARM) to R.drawable.ic_volume_alarm,
+ )
+ private val mutedIconsByStream =
+ mapOf(
+ AudioStream(AudioManager.STREAM_MUSIC) to R.drawable.ic_volume_off,
+ AudioStream(AudioManager.STREAM_VOICE_CALL) to R.drawable.ic_volume_off,
+ AudioStream(AudioManager.STREAM_RING) to R.drawable.ic_volume_off,
+ AudioStream(AudioManager.STREAM_NOTIFICATION) to R.drawable.ic_volume_off,
+ AudioStream(AudioManager.STREAM_ALARM) to R.drawable.ic_volume_off,
+ )
+ private val labelsByStream =
+ mapOf(
+ AudioStream(AudioManager.STREAM_MUSIC) to R.string.stream_music,
+ AudioStream(AudioManager.STREAM_VOICE_CALL) to R.string.stream_voice_call,
+ AudioStream(AudioManager.STREAM_RING) to R.string.stream_ring,
+ AudioStream(AudioManager.STREAM_NOTIFICATION) to R.string.stream_notification,
+ AudioStream(AudioManager.STREAM_ALARM) to R.string.stream_alarm,
+ )
+ private val disabledTextByStream =
+ mapOf(
+ AudioStream(AudioManager.STREAM_NOTIFICATION) to
+ R.string.stream_notification_unavailable,
+ )
+
+ private var value = 0f
+ override val slider: StateFlow<SliderState> =
+ combine(
+ audioVolumeInteractor.getAudioStream(audioStream),
+ audioVolumeInteractor.canChangeVolume(audioStream),
+ ) { model, isEnabled ->
+ model.toState(value, isEnabled)
+ }
+ .stateIn(coroutineScope, SharingStarted.Eagerly, EmptyState)
+
+ override fun onValueChangeFinished(state: SliderState, newValue: Float) {
+ val audioViewModel = state as? State
+ audioViewModel ?: return
+ coroutineScope.launch {
+ value = newValue
+ val volume =
+ volumeSliderInteractor.translateValueToVolume(
+ newValue,
+ audioViewModel.audioStreamModel.volumeRange
+ )
+ audioVolumeInteractor.setVolume(audioStream, volume)
+ }
+ }
+
+ private fun AudioStreamModel.toState(value: Float, isEnabled: Boolean): State {
+ return State(
+ value =
+ volumeSliderInteractor.processVolumeToValue(
+ volume,
+ volumeRange,
+ value,
+ isMuted,
+ ),
+ valueRange = volumeSliderInteractor.displayValueRange,
+ icon = getIcon(this),
+ label = labelsByStream[audioStream]?.let(context::getString)
+ ?: error("No label for the stream: $audioStream"),
+ disabledMessage = disabledTextByStream[audioStream]?.let(context::getString),
+ isEnabled = isEnabled,
+ audioStreamModel = this,
+ )
+ }
+
+ private fun getIcon(model: AudioStreamModel): Icon {
+ val isMutedOrNoVolume = model.isMuted || model.volume == model.minVolume
+ val iconRes =
+ if (isMutedOrNoVolume) {
+ mutedIconsByStream
+ } else {
+ iconsByStream
+ }[audioStream]
+ ?: error("No icon for the stream: $audioStream")
+ return Icon.Resource(iconRes, null)
+ }
+
+ private val AudioStreamModel.volumeRange: IntRange
+ get() = minVolume..maxVolume
+
+ private data class State(
+ override val value: Float,
+ override val valueRange: ClosedFloatingPointRange<Float>,
+ override val icon: Icon,
+ override val label: String,
+ override val disabledMessage: String?,
+ override val isEnabled: Boolean,
+ val audioStreamModel: AudioStreamModel,
+ ) : SliderState
+
+ private data object EmptyState : SliderState {
+ override val value: Float = 0f
+ override val valueRange: ClosedFloatingPointRange<Float> = 0f..1f
+ override val icon: Icon? = null
+ override val label: String = ""
+ override val disabledMessage: String? = null
+ override val isEnabled: Boolean = true
+ }
+
+ @AssistedFactory
+ interface Factory {
+
+ fun create(
+ audioStream: FactoryAudioStreamWrapper,
+ coroutineScope: CoroutineScope,
+ ): AudioStreamSliderViewModel
+ }
+
+ /**
+ * AudioStream is a value class that compiles into a primitive. This fail AssistedFactory build
+ * when using [AudioStream] directly because it expects another type.
+ */
+ class FactoryAudioStreamWrapper(val audioStream: AudioStream)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt
new file mode 100644
index 0000000..ae93826
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/CastVolumeSliderViewModel.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.slider.ui.viewmodel
+
+import android.content.Context
+import com.android.settingslib.volume.domain.model.RoutingSession
+import com.android.systemui.common.shared.model.Icon
+import com.android.systemui.res.R
+import com.android.systemui.volume.panel.component.mediaoutput.domain.interactor.MediaOutputInteractor
+import com.android.systemui.volume.panel.component.volume.domain.interactor.CastVolumeInteractor
+import com.android.systemui.volume.panel.component.volume.domain.interactor.VolumeSliderInteractor
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class CastVolumeSliderViewModel
+@AssistedInject
+constructor(
+ @Assisted private val routingSession: RoutingSession,
+ @Assisted private val coroutineScope: CoroutineScope,
+ private val context: Context,
+ mediaOutputInteractor: MediaOutputInteractor,
+ private val volumeSliderInteractor: VolumeSliderInteractor,
+ private val castVolumeInteractor: CastVolumeInteractor,
+) : SliderViewModel {
+
+ private val volumeRange = 0..routingSession.routingSessionInfo.volumeMax
+ private val value = MutableStateFlow(0f)
+
+ override val slider: StateFlow<SliderState> =
+ combine(value, mediaOutputInteractor.currentConnectedDevice) { value, _ ->
+ getCurrentState(value)
+ }
+ .stateIn(coroutineScope, SharingStarted.Eagerly, getCurrentState(value.value))
+
+ override fun onValueChangeFinished(state: SliderState, newValue: Float) {
+ coroutineScope.launch {
+ value.value = newValue
+ castVolumeInteractor.setVolume(
+ routingSession,
+ volumeSliderInteractor.translateValueToVolume(newValue, volumeRange),
+ )
+ }
+ }
+
+ private fun getCurrentState(value: Float): State {
+ return State(
+ value =
+ volumeSliderInteractor.processVolumeToValue(
+ volume = routingSession.routingSessionInfo.volume,
+ volumeRange = volumeRange,
+ currentValue = value,
+ isMuted = false,
+ ),
+ valueRange = volumeSliderInteractor.displayValueRange,
+ icon = Icon.Resource(R.drawable.ic_cast, null),
+ label = context.getString(R.string.media_device_cast),
+ isEnabled = true,
+ )
+ }
+
+ private data class State(
+ override val value: Float,
+ override val valueRange: ClosedFloatingPointRange<Float>,
+ override val icon: Icon,
+ override val label: String,
+ override val isEnabled: Boolean,
+ ) : SliderState {
+ override val disabledMessage: String?
+ get() = null
+ }
+
+ @AssistedFactory
+ interface Factory {
+
+ fun create(
+ routingSession: RoutingSession,
+ coroutineScope: CoroutineScope,
+ ): CastVolumeSliderViewModel
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt
new file mode 100644
index 0000000..6e9794b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderState.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.slider.ui.viewmodel
+
+import com.android.systemui.common.shared.model.Icon
+
+/**
+ * Models a state of a volume slider.
+ *
+ * @property disabledMessage is shown when [isEnabled] is false
+ */
+sealed interface SliderState {
+ val value: Float
+ val valueRange: ClosedFloatingPointRange<Float>
+ val icon: Icon?
+ val label: String
+ val disabledMessage: String?
+ val isEnabled: Boolean
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderViewModel.kt
new file mode 100644
index 0000000..0c4b322
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/slider/ui/viewmodel/SliderViewModel.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.slider.ui.viewmodel
+
+import kotlinx.coroutines.flow.StateFlow
+
+/** Controls the behaviour of a volume slider. */
+interface SliderViewModel {
+
+ val slider: StateFlow<SliderState>
+
+ fun onValueChangeFinished(state: SliderState, newValue: Float)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt
new file mode 100644
index 0000000..2824323
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/component/volume/ui/viewmodel/AudioVolumeComponentViewModel.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 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.volume.panel.component.volume.ui.viewmodel
+
+import android.media.AudioManager
+import com.android.settingslib.volume.shared.model.AudioStream
+import com.android.systemui.volume.panel.component.volume.domain.interactor.CastVolumeInteractor
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.AudioStreamSliderViewModel
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.CastVolumeSliderViewModel
+import com.android.systemui.volume.panel.component.volume.slider.ui.viewmodel.SliderViewModel
+import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transformLatest
+
+/**
+ * Controls the behaviour of the whole audio
+ * [com.android.systemui.volume.panel.shared.model.VolumePanelUiComponent].
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@VolumePanelScope
+class AudioVolumeComponentViewModel
+@Inject
+constructor(
+ @VolumePanelScope private val scope: CoroutineScope,
+ castVolumeInteractor: CastVolumeInteractor,
+ private val streamSliderViewModelFactory: AudioStreamSliderViewModel.Factory,
+ private val castVolumeSliderViewModelFactory: CastVolumeSliderViewModel.Factory,
+) {
+
+ private val remoteSessionsViewModels: Flow<List<SliderViewModel>> =
+ castVolumeInteractor.remoteRoutingSessions.transformLatest { routingSessions ->
+ coroutineScope {
+ emit(
+ routingSessions.map { routingSession ->
+ castVolumeSliderViewModelFactory.create(routingSession, this)
+ }
+ )
+ }
+ }
+ private val streamViewModels: Flow<List<SliderViewModel>> =
+ flowOf(
+ listOf(
+ AudioStream(AudioManager.STREAM_MUSIC),
+ AudioStream(AudioManager.STREAM_VOICE_CALL),
+ AudioStream(AudioManager.STREAM_RING),
+ AudioStream(AudioManager.STREAM_NOTIFICATION),
+ AudioStream(AudioManager.STREAM_ALARM),
+ )
+ )
+ .transformLatest { streams ->
+ coroutineScope {
+ emit(
+ streams.map { stream ->
+ streamSliderViewModelFactory.create(
+ AudioStreamSliderViewModel.FactoryAudioStreamWrapper(stream),
+ this,
+ )
+ }
+ )
+ }
+ }
+
+ val sliderViewModels: StateFlow<List<SliderViewModel>> =
+ combine(remoteSessionsViewModels, streamViewModels) {
+ remoteSessionsViewModels,
+ streamViewModels ->
+ remoteSessionsViewModels + streamViewModels
+ }
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/dagger/VolumePanelComponent.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/dagger/VolumePanelComponent.kt
index df4972a..f31ee86 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/dagger/VolumePanelComponent.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/dagger/VolumePanelComponent.kt
@@ -20,6 +20,7 @@
import com.android.systemui.volume.panel.component.bottombar.BottomBarModule
import com.android.systemui.volume.panel.component.captioning.CaptioningModule
import com.android.systemui.volume.panel.component.mediaoutput.MediaOutputModule
+import com.android.systemui.volume.panel.component.volume.VolumeSlidersModule
import com.android.systemui.volume.panel.dagger.factory.VolumePanelComponentFactory
import com.android.systemui.volume.panel.dagger.scope.VolumePanelScope
import com.android.systemui.volume.panel.domain.DomainModule
@@ -48,6 +49,7 @@
// Components modules
BottomBarModule::class,
AncModule::class,
+ VolumeSlidersModule::class,
CaptioningModule::class,
MediaOutputModule::class,
]
diff --git a/packages/SystemUI/src/com/android/systemui/volume/panel/domain/DomainModule.kt b/packages/SystemUI/src/com/android/systemui/volume/panel/domain/DomainModule.kt
index 0d65c42..57ea997 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/panel/domain/DomainModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/panel/domain/DomainModule.kt
@@ -52,6 +52,7 @@
return setOf(
VolumePanelComponents.ANC,
VolumePanelComponents.CAPTIONING,
+ VolumePanelComponents.VOLUME_SLIDERS,
VolumePanelComponents.MEDIA_OUTPUT,
VolumePanelComponents.BOTTOM_BAR,
)