diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index d88933feef..f37881128b 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -57,8 +57,10 @@
* IMA extension:
* UI:
* Add `PlayerSurface` Composable to `media3-ui-compose` module.
- * Add `PlayPauseButtonState` class and `rememberPlayPauseButtonState`
- Composable to `media3-ui-compose` module.
+ * Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`
+ classes and the corresponding `rememberPlayPauseButtonState`,
+ `rememberNextButtonState`, `rememberPreviousButtonState` Composables to
+ `media3-ui-compose` module.
* Downloads:
* OkHttp Extension:
* Cronet Extension:
@@ -76,8 +78,9 @@
* Cast Extension:
* Test Utilities:
* Demo app:
- * Add `PlayPauseButton` Composable UI element to `demo-compose` utilizing
- `PlayPauseButtonState`.
+ * Add `PlayPauseButton`, `NextButton`, `PreviousButton` and
+ `MinimalControls` Composable UI elements to `demo-compose` utilizing
+ `PlayPauseButtonState`, `NextButtonState`, and `PreviousButtonState`.
* Remove deprecated symbols:
* Remove deprecated `AudioMixer.create()` method. Use
`DefaultAudioMixer.Factory().create()` instead.
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 72d14b043f..688911fbbc 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,18 +19,22 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.navigationBarsPadding
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.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.buttons.MinimalControls
import androidx.media3.demo.compose.data.videos
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.compose.PlayerSurface
@@ -45,21 +49,35 @@ class MainActivity : ComponentActivity() {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
- setMediaItem(MediaItem.fromUri(videos[0]))
+ setMediaItems(videos.map { MediaItem.fromUri(it) })
prepare()
- playWhenReady = true
- repeatMode = Player.REPEAT_MODE_ONE
}
}
- MediaPlayerScreen(player = exoPlayer, modifier = Modifier.fillMaxSize())
+ MediaPlayerScreen(
+ player = exoPlayer,
+ modifier = Modifier.fillMaxSize().navigationBarsPadding(),
+ )
}
}
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
+ var showControls by remember { mutableStateOf(true) }
Box(modifier) {
- PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)
- PlayPauseButton(player, Modifier.align(Alignment.Center).size(100.dp))
+ PlayerSurface(
+ player = player,
+ surfaceType = SURFACE_TYPE_SURFACE_VIEW,
+ modifier =
+ modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null, // to prevent the ripple from the tap
+ ) {
+ showControls = !showControls
+ },
+ )
+ if (showControls) {
+ MinimalControls(player, Modifier.align(Alignment.Center))
+ }
}
}
}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt
new file mode 100644
index 0000000000..5588d59f72
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/MinimalControls.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.media3.common.Player
+
+/**
+ * Minimal playback controls for a [Player].
+ *
+ * Includes buttons for seeking to a previous/next items or playing/pausing the playback.
+ */
+@Composable
+internal fun MinimalControls(player: Player, modifier: Modifier = Modifier) {
+ val graySemiTransparentBackground = Color.Gray.copy(alpha = 0.4f)
+ val modifierForIconButton =
+ modifier.size(80.dp).background(graySemiTransparentBackground, CircleShape)
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PreviousButton(player, modifierForIconButton)
+ PlayPauseButton(player, modifierForIconButton)
+ NextButton(player, modifierForIconButton)
+ }
+}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/NextButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/NextButton.kt
new file mode 100644
index 0000000000..4bd34ecb45
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/NextButton.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.SkipNext
+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.rememberNextButtonState
+
+@Composable
+internal fun NextButton(player: Player, modifier: Modifier = Modifier) {
+ val state = rememberNextButtonState(player)
+ IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
+ Icon(
+ Icons.Default.SkipNext,
+ contentDescription = stringResource(R.string.next_button),
+ modifier = modifier,
+ )
+ }
+}
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
index 9b1fd97d8a..cfa0a408e0 100644
--- 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
@@ -17,8 +17,8 @@
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.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
@@ -31,7 +31,7 @@ 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 icon = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause
val contentDescription =
if (state.showPlay) stringResource(R.string.playpause_button_play)
else stringResource(R.string.playpause_button_pause)
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PreviousButton.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PreviousButton.kt
new file mode 100644
index 0000000000..254d94f49f
--- /dev/null
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/buttons/PreviousButton.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.SkipPrevious
+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.rememberPreviousButtonState
+
+@Composable
+internal fun PreviousButton(player: Player, modifier: Modifier = Modifier) {
+ val state = rememberPreviousButtonState(player)
+ IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
+ Icon(
+ Icons.Default.SkipPrevious,
+ contentDescription = stringResource(R.string.previous_button),
+ modifier = modifier,
+ )
+ }
+}
diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt
index 0da7be9aba..42b8aef868 100644
--- a/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt
+++ b/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt
@@ -18,6 +18,5 @@ package androidx.media3.demo.compose.data
val videos =
listOf(
"https://html5demos.com/assets/dizzy.mp4",
- "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/gear0/fileSequence0.aac",
"https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm",
)
diff --git a/demos/compose/src/main/res/values/strings.xml b/demos/compose/src/main/res/values/strings.xml
index d06b2c97c5..eec5b50cd0 100644
--- a/demos/compose/src/main/res/values/strings.xml
+++ b/demos/compose/src/main/res/values/strings.xml
@@ -17,4 +17,6 @@
Media3 Compose Demo
Play
Pause
+ Next
+ Previous
diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/NextButtonState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/NextButtonState.kt
new file mode 100644
index 0000000000..0e489f44d8
--- /dev/null
+++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/NextButtonState.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.listen
+import androidx.media3.common.util.UnstableApi
+
+/**
+ * Remembers the value of [NextButtonState] 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 rememberNextButtonState(player: Player): NextButtonState {
+ val nextButtonState = remember(player) { NextButtonState(player) }
+ LaunchedEffect(player) { nextButtonState.observe() }
+ return nextButtonState
+}
+
+/**
+ * State that holds all interactions to correctly deal with a UI component representing a
+ * seek-to-next button.
+ *
+ * This button has no internal state to maintain, it can only be enabled or disabled.
+ *
+ * @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)`
+ */
+@UnstableApi
+class NextButtonState(private val player: Player) {
+ var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT))
+ private set
+
+ fun onClick() {
+ player.seekToNext()
+ }
+
+ suspend fun observe(): Nothing =
+ player.listen { events ->
+ if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
+ isEnabled = isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)
+ }
+ }
+}
diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PreviousButtonState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PreviousButtonState.kt
new file mode 100644
index 0000000000..2037def75f
--- /dev/null
+++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PreviousButtonState.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.listen
+import androidx.media3.common.util.UnstableApi
+
+/**
+ * Remembers the value of [PreviousButtonState] 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 rememberPreviousButtonState(player: Player): PreviousButtonState {
+ val previousButtonState = remember(player) { PreviousButtonState(player) }
+ LaunchedEffect(player) { previousButtonState.observe() }
+ return previousButtonState
+}
+
+/**
+ * State that holds all interactions to correctly deal with a UI component representing a
+ * seek-to-previous button.
+ *
+ * This button has no internal state to maintain, it can only be enabled or disabled.
+ *
+ * @property[isEnabled] determined by `isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)`
+ */
+@UnstableApi
+class PreviousButtonState(private val player: Player) {
+ var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS))
+ private set
+
+ fun onClick() {
+ player.seekToPrevious()
+ }
+
+ suspend fun observe(): Nothing =
+ player.listen { events ->
+ if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
+ isEnabled = isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)
+ }
+ }
+}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/NextButtonStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/NextButtonStateTest.kt
new file mode 100644
index 0000000000..d313c8e0c8
--- /dev/null
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/NextButtonStateTest.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.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 [NextButtonState]. */
+@RunWith(AndroidJUnit4::class)
+class NextButtonStateTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun addSeekNextCommandToPlayer_buttonStateTogglesFromDisabledToEnabled() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+ player.removeCommands(Player.COMMAND_SEEK_TO_NEXT)
+
+ lateinit var state: NextButtonState
+ composeTestRule.setContent { state = rememberNextButtonState(player = player) }
+
+ assertThat(state.isEnabled).isFalse()
+
+ player.addCommands(Player.COMMAND_SEEK_TO_NEXT)
+ composeTestRule.waitForIdle()
+
+ assertThat(state.isEnabled).isTrue()
+ }
+
+ @Test
+ fun removeSeekNextCommandToPlayer_buttonStateTogglesFromEnabledToDisabled() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+
+ lateinit var state: NextButtonState
+ composeTestRule.setContent { state = rememberNextButtonState(player = player) }
+
+ assertThat(state.isEnabled).isTrue()
+
+ player.removeCommands(Player.COMMAND_SEEK_TO_NEXT)
+ composeTestRule.waitForIdle()
+
+ assertThat(state.isEnabled).isFalse()
+ }
+
+ @Test
+ fun clickNextOnPenultimateMediaItem_buttonStateTogglesFromEnabledToDisabled() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+
+ lateinit var state: NextButtonState
+ composeTestRule.setContent { state = rememberNextButtonState(player = player) }
+
+ assertThat(state.isEnabled).isTrue()
+
+ player.seekToNext()
+ composeTestRule.waitForIdle()
+
+ assertThat(state.isEnabled).isFalse()
+ }
+
+ @Test
+ fun playerInReadyState_buttonClicked_nextItemPlaying() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+ val state = NextButtonState(player)
+
+ assertThat(player.currentMediaItemIndex).isEqualTo(0)
+
+ state.onClick()
+
+ assertThat(player.currentMediaItemIndex).isEqualTo(1)
+ }
+}
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
index 30d13610ee..cdeb0de4d3 100644
--- 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
@@ -16,15 +16,11 @@
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.media3.ui.compose.utils.TestPlayer
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
@@ -133,74 +129,3 @@ class PlayPauseButtonStateTest {
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()
- }
-}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PreviousButtonStateTest.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PreviousButtonStateTest.kt
new file mode 100644
index 0000000000..b84bc042ef
--- /dev/null
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/state/PreviousButtonStateTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.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 [PreviousButtonState]. */
+@RunWith(AndroidJUnit4::class)
+class PreviousButtonStateTest {
+
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun addSeekPrevCommandToPlayer_buttonStateTogglesFromDisabledToEnabled() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+ player.removeCommands(Player.COMMAND_SEEK_TO_PREVIOUS)
+
+ lateinit var state: PreviousButtonState
+ composeTestRule.setContent { state = rememberPreviousButtonState(player = player) }
+
+ assertThat(state.isEnabled).isFalse()
+
+ composeTestRule.runOnUiThread { player.addCommands(Player.COMMAND_SEEK_TO_PREVIOUS) }
+ composeTestRule.waitForIdle()
+
+ assertThat(state.isEnabled).isTrue()
+ }
+
+ @Test
+ fun removeSeekPrevCommandToPlayer_buttonStateTogglesFromEnabledToDisabled() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+
+ lateinit var state: PreviousButtonState
+ composeTestRule.setContent { state = rememberPreviousButtonState(player = player) }
+
+ assertThat(state.isEnabled).isTrue()
+
+ composeTestRule.runOnUiThread { player.removeCommands(Player.COMMAND_SEEK_TO_PREVIOUS) }
+ composeTestRule.waitForIdle()
+
+ assertThat(state.isEnabled).isFalse()
+ }
+
+ @Test
+ fun playerInReadyState_prevButtonClicked_sameItemPlayingFromBeginning() {
+ val player = TestPlayer()
+ player.playbackState = Player.STATE_READY
+ player.playWhenReady = true
+ val state = PreviousButtonState(player)
+
+ assertThat(player.currentMediaItemIndex).isEqualTo(0)
+
+ state.onClick()
+
+ assertThat(player.currentMediaItemIndex).isEqualTo(0)
+ }
+}
diff --git a/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt
new file mode 100644
index 0000000000..ffc9d6b57f
--- /dev/null
+++ b/libraries/ui_compose/src/test/java/androidx/media3/ui/compose/utils/TestPlayer.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.utils
+
+import android.os.Looper
+import androidx.media3.common.Player
+import androidx.media3.common.SimpleBasePlayer
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+
+/**
+ * A fake [Player] that uses [SimpleBasePlayer]'s minimal number of default methods implementations
+ * to build upon to simulate realistic playback scenarios for testing.
+ */
+internal class TestPlayer : SimpleBasePlayer(Looper.myLooper()!!) {
+ private var state =
+ State.Builder()
+ .setAvailableCommands(Player.Commands.Builder().addAllCommands().build())
+ .setPlaylist(
+ ImmutableList.of(
+ MediaItemData.Builder(/* uid= */ "First").build(),
+ MediaItemData.Builder(/* uid= */ "Second").build(),
+ )
+ )
+ .setPlayWhenReady(true, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
+ .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)
+ .setCurrentMediaItemIndex(mediaItemIndex)
+ .setContentPositionMs(positionMs)
+ .build()
+ if (mediaItemIndex == state.playlist.size - 1) {
+ removeCommands(Player.COMMAND_SEEK_TO_NEXT)
+ }
+ 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()
+ }
+}