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
This commit is contained in:
jbibik 2024-11-04 06:10:05 -08:00 committed by Copybara-Service
parent 261ca326c5
commit 0270267e08
12 changed files with 568 additions and 25 deletions

View File

@ -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.

View File

@ -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,6 +64,7 @@ class MainActivity : ComponentActivity() {
)
}
}
}
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
@ -77,7 +83,12 @@ class MainActivity : ComponentActivity() {
)
if (showControls) {
MinimalControls(player, Modifier.align(Alignment.Center))
}
ExtraControls(
player,
Modifier.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Gray.copy(alpha = 0.4f)),
)
}
}
}

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -19,4 +19,9 @@
<string name="playpause_button_pause">Pause</string>
<string name="next_button">Next</string>
<string name="previous_button">Previous</string>
<string name="repeat_button_repeat_off_description">Current mode: Repeat none. Toggle repeat mode.</string>
<string name="repeat_button_repeat_one_description">Current mode: Repeat one. Toggle repeat mode.</string>
<string name="repeat_button_repeat_all_description">Current mode: Repeat all. Toggle repeat mode.</string>
<string name="shuffle_button_shuffle_on_description">Disable shuffle mode.</string>
<string name="shuffle_button_shuffle_off_description">Enable shuffle mode</string>
</resources>

View File

@ -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]
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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()