From 0270267e085f47d2204da3e4d530cda65f2b9741 Mon Sep 17 00:00:00 2001 From: jbibik Date: Mon, 4 Nov 2024 06:10:05 -0800 Subject: [PATCH] Add `ShuffleButtonState` and `RepeatButtonState` to ui-compose * Provide a helper Composable for remembering the state instance and launching the listening coroutine that observes the changes in the Player * Add an example to demo-compose of using Shuffle- and Repeat- ButtonStates inside a Shuffle- and RepeatButton Composable. * Reformat the MainActivity usage of `Shuffle` and `Repeat` buttons to form extra Player Controls and combine Prev/Play-Pause/Next with Shuffle/Repeat (Minimal controls + Extra controls = `PlayerControls`) PiperOrigin-RevId: 692939825 --- RELEASENOTES.md | 17 +-- .../media3/demo/compose/MainActivity.kt | 45 +++++--- .../demo/compose/buttons/ExtraControls.kt | 36 ++++++ .../demo/compose/buttons/MinimalControls.kt | 2 +- .../demo/compose/buttons/RepeatButton.kt | 58 ++++++++++ .../demo/compose/buttons/ShuffleButton.kt | 44 ++++++++ demos/compose/src/main/res/values/strings.xml | 5 + .../ui/compose/state/RepeatButtonState.kt | 101 +++++++++++++++++ .../ui/compose/state/ShuffleButtonState.kt | 73 ++++++++++++ .../ui/compose/state/RepeatButtonStateTest.kt | 106 ++++++++++++++++++ .../compose/state/ShuffleButtonStateTest.kt | 96 ++++++++++++++++ .../media3/ui/compose/utils/TestPlayer.kt | 10 ++ 12 files changed, 568 insertions(+), 25 deletions(-) create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/buttons/RepeatButton.kt create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ShuffleButton.kt create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RepeatButtonState.kt create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/ShuffleButtonState.kt create mode 100644 libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/RepeatButtonStateTest.kt create mode 100644 libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/ShuffleButtonStateTest.kt 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()