Add PlayPauseButtonState 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 PlayPauseButtonState inside a PlayPauseButton Composable.

The smart State object has been deemed a preferred solution over collecting Flows due to queuing/timing/buffering limitations. Instead, it uses the new `Player.listen` suspending extension function to catch the relevant events.

PiperOrigin-RevId: 691879975
This commit is contained in:
jbibik 2024-10-31 11:39:35 -07:00 committed by Copybara-Service
parent f991e1f023
commit 676a3872a5
10 changed files with 422 additions and 39 deletions

View File

@ -57,6 +57,8 @@
* IMA extension:
* UI:
* Add `PlayerSurface` Composable to `media3-ui-compose` module.
* Add `PlayPauseButtonState` class and `rememberPlayPauseButtonState`
Composable to `media3-ui-compose` module.
* Downloads:
* OkHttp Extension:
* Cronet Extension:
@ -74,6 +76,8 @@
* Cast Extension:
* Test Utilities:
* Demo app:
* Add `PlayPauseButton` Composable UI element to `demo-compose` utilizing
`PlayPauseButtonState`.
* Remove deprecated symbols:
* Remove deprecated `AudioMixer.create()` method. Use
`DefaultAudioMixer.Factory().create()` instead.

View File

@ -52,7 +52,6 @@ android {
disable 'GoogleAppIndexingWarning','MissingTranslation'
}
buildFeatures {
viewBinding true
compose true
}
composeOptions {
@ -71,18 +70,16 @@ dependencies {
implementation composeBom
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.compose.foundation:foundation-android:1.6.7'
implementation 'androidx.compose.material3:material3-android:1.2.1'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'com.google.android.material:material:' + androidxMaterialVersion
implementation project(modulePrefix + 'lib-exoplayer')
implementation project(modulePrefix + 'lib-ui-compose')
debugImplementation 'androidx.compose.ui:ui-tooling'
// For detecting and debugging leaks only. LeakCanary is not needed for demo app to work.
debugImplementation 'com.squareup.leakcanary:leakcanary-android:' + leakCanaryVersion
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:' + kotlinxCoroutinesVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
testImplementation project(modulePrefix + 'test-utils')
}

20
demos/compose/lint.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<lint>
<issue id="UnsafeOptInUsageError">
<option name="opt-in" value="androidx.media3.common.util.UnstableApi" />
</issue>
</lint>

View File

@ -19,15 +19,18 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.demo.compose.buttons.PlayPauseButton
import androidx.media3.demo.compose.data.videos
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.compose.PlayerSurface
@ -39,27 +42,24 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Surface {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videos[0]))
prepare()
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ONE
}
}
PlayerSurface(
player = exoPlayer,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videos[0]))
prepare()
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ONE
}
}
MediaPlayerScreen(player = exoPlayer, modifier = Modifier.fillMaxSize())
}
}
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
Box(modifier) {
PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)
PlayPauseButton(player, Modifier.align(Alignment.Center).size(100.dp))
}
}
}

View File

@ -0,0 +1,41 @@
/*
* 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.PauseCircle
import androidx.compose.material.icons.filled.PlayCircle
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.rememberPlayPauseButtonState
@Composable
internal fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
val state = rememberPlayPauseButtonState(player)
val icon = if (state.showPlay) Icons.Default.PlayCircle else Icons.Default.PauseCircle
val contentDescription =
if (state.showPlay) stringResource(R.string.playpause_button_play)
else stringResource(R.string.playpause_button_pause)
IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
Icon(icon, contentDescription = contentDescription, modifier = modifier)
}
}

View File

@ -15,12 +15,6 @@
-->
<resources>
<string name="app_name">Media3 Compose Demo</string>
<string name="current_playlist_name">Current playlist</string>
<string name="open_player_content_description">Click to view your play list</string>
<string name="added_media_item_format">Added %1$s to playlist</string>
<string name="shuffle">Shuffle</string>
<string name="play_button">Play</string>
<string name="waiting_for_metadata">Waiting for playlist to load…</string>
<string name="notification_permission_denied">
"Without notification access the app can't warn about failed background operations"</string>
<string name="playpause_button_play">Play</string>
<string name="playpause_button_pause">Pause</string>
</resources>

View File

@ -55,6 +55,11 @@ dependencies {
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.core:core:' + androidxCoreVersion
testImplementation 'androidx.compose.ui:ui-test'
testImplementation 'androidx.compose.ui:ui-test-junit4'
testImplementation project(modulePrefix + 'test-utils')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
}
ext {

View File

@ -0,0 +1,100 @@
/*
* 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.Timeline
import androidx.media3.common.listen
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util.handlePauseButtonAction
import androidx.media3.common.util.Util.handlePlayButtonAction
import androidx.media3.common.util.Util.handlePlayPauseButtonAction
import androidx.media3.common.util.Util.shouldEnablePlayPauseButton
import androidx.media3.common.util.Util.shouldShowPlayButton
/**
* Remembers the value of [PlayPauseButtonState] 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 rememberPlayPauseButtonState(player: Player): PlayPauseButtonState {
val playPauseButtonState = remember(player) { PlayPauseButtonState(player) }
LaunchedEffect(player) { playPauseButtonState.observe() }
return playPauseButtonState
}
/**
* State that converts the necessary information from the [Player] to correctly deal with a UI
* component representing a PlayPause button.
*
* @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_PLAY_PAUSE)` and having
* something in the [Timeline] to play
* @property[showPlay] determined by [shouldShowPlayButton]
*/
@UnstableApi
class PlayPauseButtonState(private val player: Player) {
var isEnabled by mutableStateOf(shouldEnablePlayPauseButton(player))
private set
var showPlay by mutableStateOf(shouldShowPlayButton(player))
private set
/**
* Handles the interaction with the PlayPause button according to the current state of the
* [Player].
*
* The [Player] update that follows can take a form of [Player.play], [Player.pause],
* [Player.prepare] or [Player.seekToDefaultPosition].
*
* @see [handlePlayButtonAction]
* @see [handlePauseButtonAction]
* @see [shouldShowPlayButton]
*/
fun onClick() {
handlePlayPauseButtonAction(player)
}
/**
* Subscribes to updates from [Player.Events] and listens to
* * [Player.EVENT_PLAYBACK_STATE_CHANGED] and [Player.EVENT_PLAY_WHEN_READY_CHANGED] in order to
* determine whether a play or a pause button should be presented on a UI element for playback
* control.
* * [Player.EVENT_AVAILABLE_COMMANDS_CHANGED] in order to determine whether the button should be
* enabled, i.e. respond to user input.
*/
suspend fun observe(): Nothing =
player.listen { events ->
if (
events.containsAny(
Player.EVENT_PLAYBACK_STATE_CHANGED,
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
)
) {
showPlay = shouldShowPlayButton(this)
isEnabled = shouldEnablePlayPauseButton(this)
}
}
}

View File

@ -14,6 +14,22 @@
limitations under the License.
-->
<manifest package="androidx.media3.ui.compose.test">
<manifest package="androidx.media3.ui.compose.test"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-sdk/>
<application>
<!--
Setting a base ComponentActivity as the test app's main activity.
The androidx.compose.ui.test.junit4.createComposeRule() defaults to this
activity, so in order for the runner to resolve it, it must be defined here.
-->
<activity android:name="androidx.activity.ComponentActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,206 @@
/*
* 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 android.os.Looper
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.collect.ImmutableList
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Unit test for [PlayPauseButtonState]. */
@RunWith(AndroidJUnit4::class)
class PlayPauseButtonStateTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun playerIsBuffering_pausePlayer_playIconShowing() {
val player = TestPlayer()
player.playbackState = Player.STATE_BUFFERING
player.play()
lateinit var state: PlayPauseButtonState
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
assertThat(state.showPlay).isFalse()
player.pause()
composeTestRule.waitForIdle()
assertThat(state.showPlay).isTrue()
}
@Test
fun playerIsIdling_preparePlayer_pauseIconShowing() {
val player = TestPlayer()
player.playbackState = Player.STATE_IDLE
player.play()
lateinit var state: PlayPauseButtonState
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
assertThat(state.showPlay).isTrue()
player.prepare()
composeTestRule.waitForIdle()
assertThat(state.showPlay).isFalse()
}
@Test
fun addPlayPauseCommandToPlayer_buttonStateTogglesFromDisabledToEnabled() {
val player = TestPlayer()
player.playbackState = Player.STATE_READY
player.play()
player.removeCommands(Player.COMMAND_PLAY_PAUSE)
lateinit var state: PlayPauseButtonState
composeTestRule.setContent { state = rememberPlayPauseButtonState(player = player) }
assertThat(state.isEnabled).isFalse()
player.addCommands(Player.COMMAND_PLAY_PAUSE)
composeTestRule.waitForIdle()
assertThat(state.isEnabled).isTrue()
}
@Test
fun playerInReadyState_buttonClicked_playerPaused() {
val player = TestPlayer()
player.playbackState = Player.STATE_READY
player.play()
val state = PlayPauseButtonState(player)
assertThat(state.showPlay).isFalse()
assertThat(player.playWhenReady).isTrue()
state.onClick() // Player pauses
assertThat(player.playWhenReady).isFalse()
}
@Test
fun playerInEndedState_buttonClicked_playerBuffersAndPlays() {
val player = TestPlayer()
player.playbackState = Player.STATE_ENDED
player.setPosition(456)
val state = PlayPauseButtonState(player)
assertThat(state.showPlay).isTrue()
state.onClick() // Player seeks to default position and plays
assertThat(player.contentPosition).isEqualTo(0)
assertThat(player.playWhenReady).isTrue()
assertThat(player.playbackState).isEqualTo(Player.STATE_BUFFERING)
}
@Test
fun playerInIdleState_buttonClicked_playerBuffersAndPlays() {
val player = TestPlayer()
player.playbackState = Player.STATE_IDLE
val state = PlayPauseButtonState(player)
assertThat(state.showPlay).isTrue() // Player not prepared, Play icon
state.onClick() // Player prepares and goes into buffering
assertThat(player.playWhenReady).isTrue()
assertThat(player.playbackState).isEqualTo(Player.STATE_BUFFERING)
}
}
private class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
private var state =
State.Builder()
.setAvailableCommands(Player.Commands.Builder().addAllCommands().build())
.setPlaylist(ImmutableList.of(MediaItemData.Builder(/* uid= */ Any()).build()))
.build()
override fun getState(): State {
return state
}
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
state =
state
.buildUpon()
.setPlayWhenReady(playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
.build()
return Futures.immediateVoidFuture()
}
override fun handlePrepare(): ListenableFuture<*> {
state =
state
.buildUpon()
.setPlayerError(null)
.setPlaybackState(if (state.timeline.isEmpty) STATE_ENDED else STATE_BUFFERING)
.build()
return Futures.immediateVoidFuture()
}
override fun handleSeek(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: @Player.Command Int,
): ListenableFuture<*> {
state =
state.buildUpon().setPlaybackState(STATE_BUFFERING).setContentPositionMs(positionMs).build()
return Futures.immediateVoidFuture()
}
fun setPlaybackState(playbackState: @Player.State Int) {
state = state.buildUpon().setPlaybackState(playbackState).build()
invalidateState()
}
fun setPosition(positionMs: Long) {
state = state.buildUpon().setContentPositionMs(positionMs).build()
invalidateState()
}
fun removeCommands(vararg commands: @Player.Command Int) {
state =
state
.buildUpon()
.setAvailableCommands(
Player.Commands.Builder().addAllCommands().removeAll(*commands).build()
)
.build()
invalidateState()
}
fun addCommands(vararg commands: @Player.Command Int) {
state =
state
.buildUpon()
.setAvailableCommands(Player.Commands.Builder().addAll(*commands).build())
.build()
invalidateState()
}
}