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: * IMA extension:
* UI: * UI:
* Add `PlayerSurface` Composable to `media3-ui-compose` module. * Add `PlayerSurface` Composable to `media3-ui-compose` module.
* Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState` * Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`,
classes and the corresponding `rememberPlayPauseButtonState`, `RepeatButtonState`, `ShuffleButtonState` classes and the corresponding
`rememberNextButtonState`, `rememberPreviousButtonState` Composables to `rememberPlayPauseButtonState`, `rememberNextButtonState`,
`media3-ui-compose` module. `rememberPreviousButtonState`, `rememberRepeatButtonState`,
`rememberShuffleButtonState` Composables to `media3-ui-compose` module.
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:
* Cronet Extension: * Cronet Extension:
@ -70,9 +71,11 @@
* Cast Extension: * Cast Extension:
* Test Utilities: * Test Utilities:
* Demo app: * Demo app:
* Add `PlayPauseButton`, `NextButton`, `PreviousButton` and * Add `MinimalControls` (`PlayPauseButton`, `NextButton`,
`MinimalControls` Composable UI elements to `demo-compose` utilizing `PreviousButton`) and `ExtraControls` (`RepeatButton`, `ShuffleButton`)
`PlayPauseButtonState`, `NextButtonState`, and `PreviousButtonState`. Composable UI elements to `demo-compose` utilizing
`PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`,
`RepeatButtonState`, `ShuffleButtonState`.
* Remove deprecated symbols: * Remove deprecated symbols:
* Remove deprecated `AudioMixer.create()` method. Use * Remove deprecated `AudioMixer.create()` method. Use
`DefaultAudioMixer.Factory().create()` instead. `DefaultAudioMixer.Factory().create()` instead.

View File

@ -19,10 +19,12 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -31,9 +33,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.platform.LocalContext
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.demo.compose.buttons.ExtraControls
import androidx.media3.demo.compose.buttons.MinimalControls import androidx.media3.demo.compose.buttons.MinimalControls
import androidx.media3.demo.compose.data.videos import androidx.media3.demo.compose.data.videos
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
@ -59,25 +64,31 @@ class MainActivity : ComponentActivity() {
) )
} }
} }
}
@Composable @Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) { private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
Box(modifier) { Box(modifier) {
PlayerSurface( PlayerSurface(
player = player, player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW, surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = modifier =
modifier.clickable( modifier.clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, // to prevent the ripple from the tap indication = null, // to prevent the ripple from the tap
) { ) {
showControls = !showControls 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))
}
} }
} }
} }

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 @Composable
internal fun MinimalControls(player: Player, modifier: Modifier = Modifier) { 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 = val modifierForIconButton =
modifier.size(80.dp).background(graySemiTransparentBackground, CircleShape) modifier.size(80.dp).background(graySemiTransparentBackground, CircleShape)
Row( 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="playpause_button_pause">Pause</string>
<string name="next_button">Next</string> <string name="next_button">Next</string>
<string name="previous_button">Previous</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> </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() 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) { fun setPlaybackState(playbackState: @Player.State Int) {
state = state.buildUpon().setPlaybackState(playbackState).build() state = state.buildUpon().setPlaybackState(playbackState).build()
invalidateState() invalidateState()