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()
+ }
+}