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