mirror of
https://github.com/androidx/media.git
synced 2025-04-29 22:36:54 +08:00
[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:
parent
5d1e8d1279
commit
d9deda7b6e
@ -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.
|
||||
|
@ -30,6 +30,7 @@ internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlaybackSpeedPopUpButton(player)
|
||||
ShuffleButton(player)
|
||||
RepeatButton(player)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user