mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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:
parent
261ca326c5
commit
0270267e08
@ -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.
|
||||
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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]
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user