From d9deda7b6e8756e77500e3b3879afd71c1b91521 Mon Sep 17 00:00:00 2001 From: jbibik Date: Wed, 26 Feb 2025 14:07:24 -0800 Subject: [PATCH] [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) --- RELEASENOTES.md | 6 +- .../demo/compose/buttons/ExtraControls.kt | 1 + .../buttons/PlaybackSpeedPopUpButton.kt | 108 ++++++++++++++++++ .../ui/compose/state/PlaybackSpeedState.kt | 85 ++++++++++++++ .../compose/state/PlaybackSpeedStateTest.kt | 94 +++++++++++++++ .../media3/ui/compose/utils/TestPlayer.kt | 8 ++ 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlaybackSpeedPopUpButton.kt create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlaybackSpeedState.kt create mode 100644 libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlaybackSpeedStateTest.kt diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ed4f8d3cbd..8bd9160259 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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. diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt index 662846a22f..1dceba17d2 100644 --- a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/ExtraControls.kt @@ -30,6 +30,7 @@ internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { + PlaybackSpeedPopUpButton(player) ShuffleButton(player) RepeatButton(player) } diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlaybackSpeedPopUpButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlaybackSpeedPopUpButton.kt new file mode 100644 index 0000000000..c878b9dc26 --- /dev/null +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlaybackSpeedPopUpButton.kt @@ -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 = 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, + 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) + } + } + } + } + } +} diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlaybackSpeedState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlaybackSpeedState.kt new file mode 100644 index 0000000000..b6bdaaa40e --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlaybackSpeedState.kt @@ -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) + } + } +} diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlaybackSpeedStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlaybackSpeedStateTest.kt new file mode 100644 index 0000000000..1ff409adf2 --- /dev/null +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlaybackSpeedStateTest.kt @@ -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) + } +} diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt index 65e80a0dff..208421ae3d 100644 --- a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt @@ -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()