mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +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
|
# Release notes
|
||||||
|
|
||||||
|
* UI:
|
||||||
|
* Add `PlaybackSpeedState` class and the corresponding
|
||||||
|
`rememberPlaybackSpeedState` Composable to `media3-ui-compose` module.
|
||||||
|
|
||||||
## 1.6
|
## 1.6
|
||||||
|
|
||||||
### 1.6.0-rc01 (2025-03-12)
|
### 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
|
to stop the foreground service before `stopSelf()` when overriding
|
||||||
`onTaskRemoved`, use `MediaSessionService.pauseAllPlayersAndStopSelf()`
|
`onTaskRemoved`, use `MediaSessionService.pauseAllPlayersAndStopSelf()`
|
||||||
instead.
|
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
|
* Keep notification visible when playback enters an error or stopped
|
||||||
state. The notification is only removed if the playlist is cleared or
|
state. The notification is only removed if the playlist is cleared or
|
||||||
the player is released.
|
the player is released.
|
||||||
|
@ -30,6 +30,7 @@ internal fun ExtraControls(player: Player, modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
PlaybackSpeedPopUpButton(player)
|
||||||
ShuffleButton(player)
|
ShuffleButton(player)
|
||||||
RepeatButton(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
|
package androidx.media3.ui.compose.utils
|
||||||
|
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import androidx.media3.common.PlaybackParameters
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.SimpleBasePlayer
|
import androidx.media3.common.SimpleBasePlayer
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
@ -91,6 +92,13 @@ internal class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
|
|||||||
return Futures.immediateVoidFuture()
|
return Futures.immediateVoidFuture()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun handleSetPlaybackParameters(
|
||||||
|
playbackParameters: PlaybackParameters
|
||||||
|
): ListenableFuture<*> {
|
||||||
|
state = state.buildUpon().setPlaybackParameters(playbackParameters).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()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user