[ui-compose] Add PlaybackSpeedState to control playbackParameters.speed

A state holder that handles interaction with a UI component that toggles through a range of playback speeds.

[demo-compose] Use PlaybackSpeedState to create PlaybackSpeedTextButton

Add the button to the bottom extra controls.

PiperOrigin-RevId: 731449526
(cherry picked from commit addf01b9a84bcea945107b3b2993540ec59fbb54)
This commit is contained in:
jbibik 2025-02-26 14:07:24 -08:00 committed by tonihei
parent 5d1e8d1279
commit d9deda7b6e
6 changed files with 300 additions and 2 deletions

View File

@ -1,5 +1,9 @@
# Release notes
* UI:
* Add `PlaybackSpeedState` class and the corresponding
`rememberPlaybackSpeedState` Composable to `media3-ui-compose` module.
## 1.6
### 1.6.0-rc01 (2025-03-12)
@ -77,8 +81,6 @@ This release includes the following changes since the
to stop the foreground service before `stopSelf()` when overriding
`onTaskRemoved`, use `MediaSessionService.pauseAllPlayersAndStopSelf()`
instead.
* Make `MediaSession.setSessionActivity(PendingIntent)` accept null
([#2109](https://github.com/androidx/media/issues/2109)).
* Keep notification visible when playback enters an error or stopped
state. The notification is only removed if the playlist is cleared or
the player is released.

View File

@ -30,6 +30,7 @@ internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlaybackSpeedPopUpButton(player)
ShuffleButton(player)
RepeatButton(player)
}

View File

@ -0,0 +1,108 @@
/*
* Copyright 2025 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 android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.media3.common.Player
import androidx.media3.ui.compose.state.rememberPlaybackSpeedState
@Composable
internal fun PlaybackSpeedPopUpButton(
player: Player,
modifier: Modifier = Modifier,
speedSelection: List<Float> = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f),
) {
val state = rememberPlaybackSpeedState(player)
var openDialog by remember { mutableStateOf(false) }
TextButton(onClick = { openDialog = true }, modifier = modifier, enabled = state.isEnabled) {
// TODO: look into TextMeasurer to ensure 1.1 and 2.2 occupy the same space
BasicText("%.1fx".format(state.playbackSpeed))
}
if (openDialog) {
BottomDialogOfChoices(
currentSpeed = state.playbackSpeed,
choices = speedSelection,
onDismissRequest = { openDialog = false },
onSelectChoice = state::updatePlaybackSpeed,
)
}
}
@Composable
private fun BottomDialogOfChoices(
currentSpeed: Float,
choices: List<Float>,
onDismissRequest: () -> Unit,
onSelectChoice: (Float) -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.BOTTOM) // Move down, by default dialogs are in the centre
window.setDimAmount(0f) // Remove dimmed background of ongoing playback
}
Box(modifier = Modifier.wrapContentSize().background(Color.LightGray)) {
Column(
modifier = Modifier.fillMaxWidth().wrapContentWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
choices.forEach { speed ->
TextButton(
onClick = {
onSelectChoice(speed)
onDismissRequest()
}
) {
var fontWeight = FontWeight(400)
if (speed == currentSpeed) {
fontWeight = FontWeight(1000)
}
Text("%.1fx".format(speed), fontWeight = fontWeight)
}
}
}
}
}
}

View File

@ -0,0 +1,85 @@
/*
* 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.mutableFloatStateOf
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 [PlaybackSpeedState] created based on the passed [Player] and launch a
* coroutine to listen to [Player's][Player] changes. If the [Player] instance changes between
* compositions, produce and remember a new value.
*/
@UnstableApi
@Composable
fun rememberPlaybackSpeedState(player: Player): PlaybackSpeedState {
val playbackSpeedState = remember(player) { PlaybackSpeedState(player) }
LaunchedEffect(player) { playbackSpeedState.observe() }
return playbackSpeedState
}
/**
* State that holds all interactions to correctly deal with a UI component representing a playback
* speed controller.
*
* In most cases, this will be created via [rememberPlaybackSpeedState].
*
* @param[player] [Player] object that operates as a state provider.
* @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH)`
* @property[playbackSpeed] determined by
* [Player.playbackParameters.speed][androidx.media3.common.PlaybackParameters.speed].
*/
@UnstableApi
class PlaybackSpeedState(private val player: Player) {
var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH))
private set
var playbackSpeed by mutableFloatStateOf(player.playbackParameters.speed)
private set
/** Updates the playback speed of the [Player] backing this state. */
fun updatePlaybackSpeed(speed: Float) {
player.playbackParameters = player.playbackParameters.withSpeed(speed)
}
/**
* Subscribes to updates from [Player.Events] and listens to
* * [Player.EVENT_PLAYBACK_PARAMETERS_CHANGED] in order to determine the latest playback speed.
* * [Player.EVENT_AVAILABLE_COMMANDS_CHANGED] in order to determine whether the UI element
* responsible for setting the playback speed should be enabled, i.e. respond to user input.
*/
suspend fun observe(): Nothing =
player.listen { events ->
if (
events.containsAny(
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED,
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
)
) {
playbackSpeed = playbackParameters.speed
isEnabled = isCommandAvailable(Player.COMMAND_SET_SPEED_AND_PITCH)
}
}
}

View File

@ -0,0 +1,94 @@
/*
* Copyright 2025 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
/** Unit test for [PlaybackSpeedState]. */
@RunWith(AndroidJUnit4::class)
class PlaybackSpeedStateTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun addSetSpeedAndPitchCommandToPlayer_stateTogglesFromDisabledToEnabled() {
val player = TestPlayer()
player.playbackState = Player.STATE_READY
player.playWhenReady = true
player.removeCommands(Player.COMMAND_SET_SPEED_AND_PITCH)
lateinit var state: PlaybackSpeedState
composeTestRule.setContent { state = rememberPlaybackSpeedState(player = player) }
assertThat(state.isEnabled).isFalse()
player.addCommands(Player.COMMAND_SET_SPEED_AND_PITCH)
composeTestRule.waitForIdle()
assertThat(state.isEnabled).isTrue()
}
@Test
fun removeSetSpeedAndPitchCommandToPlayer_stateTogglesFromEnabledToDisabled() {
val player = TestPlayer()
player.playbackState = Player.STATE_READY
player.playWhenReady = true
lateinit var state: PlaybackSpeedState
composeTestRule.setContent { state = rememberPlaybackSpeedState(player = player) }
assertThat(state.isEnabled).isTrue()
player.removeCommands(Player.COMMAND_SET_SPEED_AND_PITCH)
composeTestRule.waitForIdle()
assertThat(state.isEnabled).isFalse()
}
@Test
fun playerPlaybackSpeedChanged_statePlaybackSpeedChanged() {
val player = TestPlayer()
lateinit var state: PlaybackSpeedState
composeTestRule.setContent { state = rememberPlaybackSpeedState(player = player) }
assertThat(state.playbackSpeed).isEqualTo(1f)
player.playbackParameters = player.playbackParameters.withSpeed(1.5f)
composeTestRule.waitForIdle()
assertThat(state.playbackSpeed).isEqualTo(1.5f)
}
@Test
fun stateUpdatePlaybackSpeed_playerPlaybackSpeedChanged() {
val player = TestPlayer()
val state = PlaybackSpeedState(player)
assertThat(state.playbackSpeed).isEqualTo(1f)
state.updatePlaybackSpeed(2.7f)
assertThat(player.playbackParameters.speed).isEqualTo(2.7f)
}
}

View File

@ -17,6 +17,7 @@
package androidx.media3.ui.compose.utils
import android.os.Looper
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import com.google.common.collect.ImmutableList
@ -91,6 +92,13 @@ internal class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
return Futures.immediateVoidFuture()
}
override fun handleSetPlaybackParameters(
playbackParameters: PlaybackParameters
): ListenableFuture<*> {
state = state.buildUpon().setPlaybackParameters(playbackParameters).build()
return Futures.immediateVoidFuture()
}
fun setPlaybackState(playbackState: @Player.State Int) {
state = state.buildUpon().setPlaybackState(playbackState).build()
invalidateState()