From 676a3872a5743b3888771731e35bfecbe338f391 Mon Sep 17 00:00:00 2001 From: jbibik Date: Thu, 31 Oct 2024 11:39:35 -0700 Subject: [PATCH] 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 --- RELEASENOTES.md | 4 + demos/compose/build.gradle | 13 +- demos/compose/lint.xml | 20 ++ .../media3/demo/compose/MainActivity.kt | 44 ++-- .../demo/compose/buttons/PlayPauseButton.kt | 41 ++++ demos/compose/src/main/res/values/strings.xml | 10 +- libraries/ui_compose/build.gradle | 5 + .../ui/compose/state/PlayPauseButtonState.kt | 100 +++++++++ .../ui_compose/src/test/AndroidManifest.xml | 18 +- .../compose/state/PlayPauseButtonStateTest.kt | 206 ++++++++++++++++++ 10 files changed, 422 insertions(+), 39 deletions(-) create mode 100644 demos/compose/lint.xml create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlayPauseButton.kt create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlayPauseButtonState.kt create mode 100644 libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlayPauseButtonStateTest.kt diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 39845a2260..d88933feef 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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. diff --git a/demos/compose/build.gradle b/demos/compose/build.gradle index 67e4f56a6f..023ba912b9 100644 --- a/demos/compose/build.gradle +++ b/demos/compose/build.gradle @@ -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') - } diff --git a/demos/compose/lint.xml b/demos/compose/lint.xml new file mode 100644 index 0000000000..46a2afc3a1 --- /dev/null +++ b/demos/compose/lint.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt index dc0a4e6fce..72d14b043f 100644 --- a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt @@ -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)) } } } diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlayPauseButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlayPauseButton.kt new file mode 100644 index 0000000000..9b1fd97d8a --- /dev/null +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PlayPauseButton.kt @@ -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) + } +} diff --git a/demos/compose/src/main/res/values/strings.xml b/demos/compose/src/main/res/values/strings.xml index a11306db98..d06b2c97c5 100644 --- a/demos/compose/src/main/res/values/strings.xml +++ b/demos/compose/src/main/res/values/strings.xml @@ -15,12 +15,6 @@ --> Media3 Compose Demo - Current playlist - Click to view your play list - Added %1$s to playlist - Shuffle - Play - Waiting for playlist to load… - - "Without notification access the app can't warn about failed background operations" + Play + Pause diff --git a/libraries/ui_compose/build.gradle b/libraries/ui_compose/build.gradle index 91a6454af3..a551722825 100644 --- a/libraries/ui_compose/build.gradle +++ b/libraries/ui_compose/build.gradle @@ -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 { diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlayPauseButtonState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlayPauseButtonState.kt new file mode 100644 index 0000000000..5e7fedad45 --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PlayPauseButtonState.kt @@ -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) + } + } +} diff --git a/libraries/ui_compose/src/test/AndroidManifest.xml b/libraries/ui_compose/src/test/AndroidManifest.xml index cbe925f2c1..efba2d57b3 100644 --- a/libraries/ui_compose/src/test/AndroidManifest.xml +++ b/libraries/ui_compose/src/test/AndroidManifest.xml @@ -14,6 +14,22 @@ limitations under the License. --> - + + + + + + + + + + + diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlayPauseButtonStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlayPauseButtonStateTest.kt new file mode 100644 index 0000000000..30d13610ee --- /dev/null +++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PlayPauseButtonStateTest.kt @@ -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() + } +}