diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 7b4f4756f4..863ad19687 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -47,10 +47,11 @@
* IMA extension:
* UI:
* Add `PlayerSurface` Composable to `media3-ui-compose` module.
- * Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`
- classes and the corresponding `rememberPlayPauseButtonState`,
- `rememberNextButtonState`, `rememberPreviousButtonState` Composables to
- `media3-ui-compose` module.
+ * Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`,
+ `RepeatButtonState`, `ShuffleButtonState` classes and the corresponding
+ `rememberPlayPauseButtonState`, `rememberNextButtonState`,
+ `rememberPreviousButtonState`, `rememberRepeatButtonState`,
+ `rememberShuffleButtonState` Composables to `media3-ui-compose` module.
* Downloads:
* OkHttp Extension:
* Cronet Extension:
@@ -70,9 +71,11 @@
* Cast Extension:
* Test Utilities:
* Demo app:
- * Add `PlayPauseButton`, `NextButton`, `PreviousButton` and
- `MinimalControls` Composable UI elements to `demo-compose` utilizing
- `PlayPauseButtonState`, `NextButtonState`, and `PreviousButtonState`.
+ * Add `MinimalControls` (`PlayPauseButton`, `NextButton`,
+ `PreviousButton`) and `ExtraControls` (`RepeatButton`, `ShuffleButton`)
+ Composable UI elements to `demo-compose` utilizing
+ `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`,
+ `RepeatButtonState`, `ShuffleButtonState`.
* Remove deprecated symbols:
* Remove deprecated `AudioMixer.create()` method. Use
`DefaultAudioMixer.Factory().create()` instead.
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt
index 688911fbbc..2e17133535 100644
--- a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt
@@ -19,10 +19,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -31,9 +33,12 @@ 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.alpha
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
+import androidx.media3.demo.compose.buttons.ExtraControls
import androidx.media3.demo.compose.buttons.MinimalControls
import androidx.media3.demo.compose.data.videos
import androidx.media3.exoplayer.ExoPlayer
@@ -59,25 +64,31 @@ class MainActivity : ComponentActivity() {
)
}
}
+}
- @Composable
- private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
- var showControls by remember { mutableStateOf(true) }
- Box(modifier) {
- PlayerSurface(
- player = player,
- surfaceType = SURFACE_TYPE_SURFACE_VIEW,
- modifier =
- modifier.clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null, // to prevent the ripple from the tap
- ) {
- showControls = !showControls
- },
+@Composable
+private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
+ var showControls by remember { mutableStateOf(true) }
+ Box(modifier) {
+ PlayerSurface(
+ player = player,
+ surfaceType = SURFACE_TYPE_SURFACE_VIEW,
+ modifier =
+ modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null, // to prevent the ripple from the tap
+ ) {
+ showControls = !showControls
+ },
+ )
+ if (showControls) {
+ MinimalControls(player, Modifier.align(Alignment.Center))
+ ExtraControls(
+ player,
+ Modifier.fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .background(Color.Gray.copy(alpha = 0.4f)),
)
- if (showControls) {
- MinimalControls(player, Modifier.align(Alignment.Center))
- }
}
}
}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt
new file mode 100644
index 0000000000..662846a22f
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.demo.compose.buttons
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.media3.common.Player
+
+@Composable
+internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ShuffleButton(player)
+ RepeatButton(player)
+ }
+}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt
index 5588d59f72..119c61f51b 100644
--- a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt
@@ -36,7 +36,7 @@ import androidx.media3.common.Player
*/
@Composable
internal fun MinimalControls(player: Player, modifier: Modifier = Modifier) {
- val graySemiTransparentBackground = Color.Gray.copy(alpha = 0.4f)
+ val graySemiTransparentBackground = Color.Gray.copy(alpha = 0.1f)
val modifierForIconButton =
modifier.size(80.dp).background(graySemiTransparentBackground, CircleShape)
Row(
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/RepeatButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/RepeatButton.kt
new file mode 100644
index 0000000000..888722205d
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/RepeatButton.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.demo.compose.buttons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Repeat
+import androidx.compose.material.icons.filled.RepeatOn
+import androidx.compose.material.icons.filled.RepeatOneOn
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.media3.common.Player
+import androidx.media3.demo.compose.R
+import androidx.media3.ui.compose.state.rememberRepeatButtonState
+
+@Composable
+internal fun RepeatButton(player: Player, modifier: Modifier = Modifier) {
+ val state = rememberRepeatButtonState(player)
+ val icon = repeatModeIcon(state.repeatModeState)
+ val contentDescription = repeatModeContentDescription(state.repeatModeState)
+ IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
+ Icon(icon, contentDescription = contentDescription, modifier = modifier)
+ }
+}
+
+private fun repeatModeIcon(repeatMode: @Player.RepeatMode Int): ImageVector {
+ return when (repeatMode) {
+ Player.REPEAT_MODE_OFF -> Icons.Default.Repeat
+ Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOneOn
+ else -> Icons.Default.RepeatOn
+ }
+}
+
+@Composable
+private fun repeatModeContentDescription(repeatMode: @Player.RepeatMode Int): String {
+ return when (repeatMode) {
+ Player.REPEAT_MODE_OFF -> stringResource(R.string.repeat_button_repeat_off_description)
+ Player.REPEAT_MODE_ONE -> stringResource(R.string.repeat_button_repeat_one_description)
+ else -> stringResource(R.string.repeat_button_repeat_all_description)
+ }
+}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ShuffleButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ShuffleButton.kt
new file mode 100644
index 0000000000..99164f065e
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ShuffleButton.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.demo.compose.buttons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Shuffle
+import androidx.compose.material.icons.filled.ShuffleOn
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.media3.common.Player
+import androidx.media3.demo.compose.R
+import androidx.media3.ui.compose.state.rememberShuffleButtonState
+
+@Composable
+internal fun ShuffleButton(player: Player, modifier: Modifier = Modifier) {
+ val state = rememberShuffleButtonState(player)
+ val icon = if (state.shuffleOn) Icons.Default.ShuffleOn else Icons.Default.Shuffle
+ val contentDescription =
+ if (state.shuffleOn) {
+ stringResource(R.string.shuffle_button_shuffle_on_description)
+ } else {
+ stringResource(R.string.shuffle_button_shuffle_off_description)
+ }
+ IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
+ Icon(icon, contentDescription = contentDescription, modifier = modifier)
+ }
+}
diff --git a/demos/compose/src/main/res/values/strings.xml b/demos/compose/src/main/res/values/strings.xml
index eec5b50cd0..d74d01827f 100644
--- a/demos/compose/src/main/res/values/strings.xml
+++ b/demos/compose/src/main/res/values/strings.xml
@@ -19,4 +19,9 @@
Pause
Next
Previous
+ Current mode: Repeat none. Toggle repeat mode.
+ Current mode: Repeat one. Toggle repeat mode.
+ Current mode: Repeat all. Toggle repeat mode.
+ Disable shuffle mode.
+ Enable shuffle mode
diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RepeatButtonState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RepeatButtonState.kt
new file mode 100644
index 0000000000..439d161d0d
--- /dev/null
+++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RepeatButtonState.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.ui.compose.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.media3.common.Player
+import androidx.media3.common.listen
+import androidx.media3.common.util.UnstableApi
+
+/**
+ * Remember the value of [RepeatButtonState] created based on the passed [Player] and launch a
+ * coroutine to listen to [Player]'s changes. If the [Player] instance changes between compositions,
+ * produce and remember a new value.
+ */
+@UnstableApi
+@Composable
+fun rememberRepeatButtonState(
+ player: Player,
+ toggleModeSequence: List<@Player.RepeatMode Int> =
+ listOf(Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL),
+): RepeatButtonState {
+ val repeatButtonState = remember(player) { RepeatButtonState(player, toggleModeSequence) }
+ LaunchedEffect(player) { repeatButtonState.observe() }
+ return repeatButtonState
+}
+
+/**
+ * State that holds all interactions to correctly deal with a UI component representing a Repeat
+ * On/All/Off button.
+ *
+ * @param[player] [Player] object that operates as a state provider and can be control via clicking
+ * @param[toggleModeSequence] An ordered list of [Player.RepeatMode]s to cycle through when the
+ * button is clicked. Defaults to [Player.REPEAT_MODE_OFF], [Player.REPEAT_MODE_ONE],
+ * [Player.REPEAT_MODE_ALL].
+ * @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE)`
+ * @property[repeatModeState] determined by [Player]'s `repeatMode`. Note that there is no guarantee
+ * for this state to be one from [toggleModeSequence]. A button click in such case will toggle the
+ * mode into the first one of [toggleModeSequence].
+ */
+@UnstableApi
+class RepeatButtonState(
+ private val player: Player,
+ private val toggleModeSequence: List<@Player.RepeatMode Int> =
+ listOf(Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL),
+) {
+ var isEnabled by
+ mutableStateOf(
+ player.isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE) && toggleModeSequence.isNotEmpty()
+ )
+ private set
+
+ var repeatModeState by mutableIntStateOf(player.repeatMode)
+ private set
+
+ /**
+ * Cycles to the next repeat mode in the [toggleModeSequence]. If the current repeat mode from the
+ * [Player] is not among the modes in the provided [toggleModeSequence], pick the first one.
+ */
+ fun onClick() {
+ player.repeatMode = getNextRepeatModeInSequence()
+ }
+
+ suspend fun observe(): Nothing =
+ player.listen { events ->
+ if (
+ events.containsAny(
+ Player.EVENT_REPEAT_MODE_CHANGED,
+ Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
+ )
+ ) {
+ repeatModeState = repeatMode
+ isEnabled = isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE)
+ }
+ }
+
+ private fun getNextRepeatModeInSequence(): @Player.RepeatMode Int {
+ val currRepeatModeIndex = toggleModeSequence.indexOf(player.repeatMode)
+ // -1 (i.e. not found) and the last element both loop back to 0
+ return toggleModeSequence[(currRepeatModeIndex + 1) % toggleModeSequence.size]
+ }
+}
diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/ShuffleButtonState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/ShuffleButtonState.kt
new file mode 100644
index 0000000000..6a5bab515a
--- /dev/null
+++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/ShuffleButtonState.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.ui.compose.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.media3.common.Player
+import androidx.media3.common.listen
+import androidx.media3.common.util.UnstableApi
+
+/**
+ * Remember the value of [ShuffleButtonState] created based on the passed [Player] and launch a
+ * coroutine to listen to [Player]'s changes. If the [Player] instance changes between compositions,
+ * produce and remember a new value.
+ */
+@UnstableApi
+@Composable
+fun rememberShuffleButtonState(player: Player): ShuffleButtonState {
+ val shuffleButtonState = remember(player) { ShuffleButtonState(player) }
+ LaunchedEffect(player) { shuffleButtonState.observe() }
+ return shuffleButtonState
+}
+
+/**
+ * State that holds all interactions to correctly deal with a UI component representing a Shuffle
+ * On/Off button.
+ *
+ * @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_SET_SHUFFLE_MODE)`
+ * @property[shuffleOn] determined by [Player]'s `shuffleModeEnabled`
+ */
+@UnstableApi
+class ShuffleButtonState(private val player: Player) {
+ var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SET_SHUFFLE_MODE))
+ private set
+
+ var shuffleOn by mutableStateOf(player.shuffleModeEnabled)
+ private set
+
+ fun onClick() {
+ player.shuffleModeEnabled = !player.shuffleModeEnabled
+ }
+
+ suspend fun observe(): Nothing =
+ player.listen { events ->
+ if (
+ events.containsAny(
+ Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED,
+ Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
+ )
+ ) {
+ shuffleOn = shuffleModeEnabled
+ isEnabled = isCommandAvailable(Player.COMMAND_SET_SHUFFLE_MODE)
+ }
+ }
+}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/RepeatButtonStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/RepeatButtonStateTest.kt
new file mode 100644
index 0000000000..1e861eb255
--- /dev/null
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/RepeatButtonStateTest.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.ui.compose.state
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.media3.common.Player
+import androidx.media3.ui.compose.utils.TestPlayer
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowLooper
+
+/** Unit test for [RepeatButtonState]. */
+@RunWith(AndroidJUnit4::class)
+class RepeatButtonStateTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun playerRepeatModeChanged_buttonRepeatModeChanged() {
+ val player = TestPlayer()
+
+ lateinit var state: RepeatButtonState
+ composeTestRule.setContent { state = rememberRepeatButtonState(player = player) }
+
+ assertThat(state.repeatModeState).isEqualTo(Player.REPEAT_MODE_OFF)
+
+ player.repeatMode = Player.REPEAT_MODE_ONE
+ composeTestRule.waitForIdle()
+
+ assertThat(state.repeatModeState).isEqualTo(Player.REPEAT_MODE_ONE)
+ }
+
+ @Test
+ fun buttonClicked_withLimitedNumberOfModes_playerShuffleModeChangedToNextInSequence() {
+ val player = TestPlayer()
+ val state = RepeatButtonState(player, listOf(Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE))
+ assertThat(state.repeatModeState).isEqualTo(Player.REPEAT_MODE_OFF)
+
+ state.onClick()
+
+ assertThat(player.repeatMode).isEqualTo(Player.REPEAT_MODE_ONE)
+ }
+
+ @Test
+ fun playerSetRepeatModeAndOnClick_inTheSameHandlerMessage_uiStateSynchronises() {
+ // The UDF model of Compose relies on holding the Player as the single source of truth with
+ // RepeatButtonState changing its state in sync with the relevant Player events. This means that
+ // we should never find ourselves in a situation where a button's icon (here: determined by
+ // RepeatButtonState.repeatModeState) is out of sync with the Player's repeat mode. It can cause
+ // confusion for a human user whose intent to toggle the mode will not be fulfilled. The
+ // following test tries to simulate this scenario by squeezing the 2 actions together (setter +
+ // onClick) into a single Looper iteration. This is a practically unlikely scenario for a human
+ // user's tapping to race with a programmatic change to the Player.
+
+ // However, it is possible to achieve by changing the Player and straight away programmatically
+ // invoking the tapping operation (via the ButtonState object) that internally sends an inverse
+ // setting command to the Player in its new configuration (the onEvents message here is
+ // irrelevant because we are operating on the live mutable Player object). The expectation then
+ // is that the State object and Player finally synchronise, even if it means the UI interaction
+ // would have been confusing.
+ val player = TestPlayer()
+ lateinit var state: RepeatButtonState
+ composeTestRule.setContent {
+ state =
+ rememberRepeatButtonState(
+ player = player,
+ toggleModeSequence =
+ listOf(Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ONE, Player.REPEAT_MODE_ALL),
+ )
+ }
+ assertThat(state.repeatModeState)
+ .isEqualTo(Player.REPEAT_MODE_OFF) // Correct UI state in sync with Player
+
+ player.repeatMode = Player.REPEAT_MODE_ONE
+ // pretend like State didn't catch the relevant event in observe() by omitting
+ // ShadowLooper.idleMainLooper()
+ assertThat(state.repeatModeState)
+ .isEqualTo(Player.REPEAT_MODE_OFF) // Temporarily out-of-sync incorrect UI
+ // A click operated on Player's true state at the time (= REPEAT_MODE_ONE)
+ // A potential human user would have the intention of toggling Off->One
+ // But this is a programmatic user who had just set the mode and hence expects One->All
+ state.onClick()
+ ShadowLooper.idleMainLooper()
+
+ assertThat(player.repeatMode).isEqualTo(Player.REPEAT_MODE_ALL)
+ assertThat(state.repeatModeState)
+ .isEqualTo(Player.REPEAT_MODE_ALL) // UI state synchronises with Player, icon jumps 2 steps
+ }
+}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/ShuffleButtonStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/ShuffleButtonStateTest.kt
new file mode 100644
index 0000000000..371b8d716f
--- /dev/null
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/ShuffleButtonStateTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright 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
+ *
+ * https://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 androidx.media3.ui.compose.state
+
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.media3.ui.compose.utils.TestPlayer
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowLooper
+
+/** Unit test for [ShuffleButtonState]. */
+@RunWith(AndroidJUnit4::class)
+class ShuffleButtonStateTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun playerShuffleModeChanged_buttonShuffleModeChanged() {
+ val player = TestPlayer()
+
+ lateinit var state: ShuffleButtonState
+ composeTestRule.setContent { state = rememberShuffleButtonState(player = player) }
+
+ assertThat(state.shuffleOn).isFalse()
+
+ player.shuffleModeEnabled = true
+ composeTestRule.waitForIdle()
+
+ assertThat(state.shuffleOn).isTrue()
+ }
+
+ @Test
+ fun buttonClicked_playerShuffleModeChanged() {
+ val player = TestPlayer()
+ val state = ShuffleButtonState(player)
+ assertThat(state.shuffleOn).isFalse()
+
+ state.onClick()
+
+ assertThat(player.shuffleModeEnabled).isTrue()
+ }
+
+ @Test
+ fun playerSetShuffleModeAndOnClick_inTheSameHandlerMessage_uiStateSynchronises() {
+ // The UDF model of Compose relies on holding the Player as the single source of truth with
+ // RepeatButtonState changing its state in sync with the relevant Player events. This means that
+ // we should never find ourselves in a situation where a button's icon (here: determined by
+ // RepeatButtonState.repeatModeState) is out of sync with the Player's repeat mode. It can cause
+ // confusion for a human user whose intent to toggle the mode will not be fulfilled. The
+ // following test tries to simulate this scenario by squeezing the 2 actions together (setter +
+ // onClick) into a single Looper iteration. This is a practically unlikely scenario for a human
+ // user's tapping to race with a programmatic change to the Player.
+
+ // However, it is possible to achieve by changing the Player and straight away programmatically
+ // invoking the tapping operation (via the ButtonState object) that internally sends an inverse
+ // setting command to the Player in its new configuration (the onEvents message here is
+ // irrelevant because we are operating on the live mutable Player object). The expectation then
+ // is that the State object and Player finally synchronise, even if it means the UI interaction
+ // would have been confusing.
+ val player = TestPlayer()
+ lateinit var state: ShuffleButtonState
+ composeTestRule.setContent { state = rememberShuffleButtonState(player = player) }
+ assertThat(state.shuffleOn).isFalse() // Correct UI state in sync with Player
+
+ player.shuffleModeEnabled = true
+ // pretend like State didn't catch the EVENT in observe() by omitting
+ // ShadowLooper.idleMainLooper()
+ assertThat(state.shuffleOn).isFalse() // Temporarily out-of-sync incorrect UI
+ // A click operated on Player's true state at the time (= Shuffle On)
+ // A potential human user would have the intention of toggling Off->On
+ // But this is a programmatic user who had just set the mode and hence expects the reverse
+ // On->Off
+ state.onClick()
+ ShadowLooper.idleMainLooper()
+
+ assertThat(player.shuffleModeEnabled).isFalse()
+ assertThat(state.shuffleOn).isFalse() // UI state synchronises with Player
+ }
+}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt
index ffc9d6b57f..b0a26963ad 100644
--- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt
@@ -81,6 +81,16 @@ internal class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
return Futures.immediateVoidFuture()
}
+ override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> {
+ state = state.buildUpon().setShuffleModeEnabled(shuffleModeEnabled).build()
+ return Futures.immediateVoidFuture()
+ }
+
+ override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> {
+ state = state.buildUpon().setRepeatMode(repeatMode).build()
+ return Futures.immediateVoidFuture()
+ }
+
fun setPlaybackState(playbackState: @Player.State Int) {
state = state.buildUpon().setPlaybackState(playbackState).build()
invalidateState()