Add PreviousButtonState and NextButtonState 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 Previous- and Next- ButtonStates inside a Previous- and NextButton Composable.
* Reformat the MainActivity usage of `Previous`, `PlayPause`, `Next` buttons to form Minimal Player Controls

PiperOrigin-RevId: 691943147
This commit is contained in:
jbibik 2024-10-31 14:49:43 -07:00 committed by Copybara-Service
parent 676a3872a5
commit 1b302e879a
14 changed files with 595 additions and 93 deletions

View File

@ -57,8 +57,10 @@
* IMA extension: * IMA extension:
* UI: * UI:
* Add `PlayerSurface` Composable to `media3-ui-compose` module. * Add `PlayerSurface` Composable to `media3-ui-compose` module.
* Add `PlayPauseButtonState` class and `rememberPlayPauseButtonState` * Add `PlayPauseButtonState`, `NextButtonState`, `PreviousButtonState`
Composable to `media3-ui-compose` module. classes and the corresponding `rememberPlayPauseButtonState`,
`rememberNextButtonState`, `rememberPreviousButtonState` Composables to
`media3-ui-compose` module.
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:
* Cronet Extension: * Cronet Extension:
@ -76,8 +78,9 @@
* Cast Extension: * Cast Extension:
* Test Utilities: * Test Utilities:
* Demo app: * Demo app:
* Add `PlayPauseButton` Composable UI element to `demo-compose` utilizing * Add `PlayPauseButton`, `NextButton`, `PreviousButton` and
`PlayPauseButtonState`. `MinimalControls` Composable UI elements to `demo-compose` utilizing
`PlayPauseButtonState`, `NextButtonState`, and `PreviousButtonState`.
* Remove deprecated symbols: * Remove deprecated symbols:
* Remove deprecated `AudioMixer.create()` method. Use * Remove deprecated `AudioMixer.create()` method. Use
`DefaultAudioMixer.Factory().create()` instead. `DefaultAudioMixer.Factory().create()` instead.

View File

@ -19,18 +19,22 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.Box
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player 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.demo.compose.data.videos
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
@ -45,21 +49,35 @@ class MainActivity : ComponentActivity() {
val context = LocalContext.current val context = LocalContext.current
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videos[0])) setMediaItems(videos.map { MediaItem.fromUri(it) })
prepare() prepare()
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ONE
} }
} }
MediaPlayerScreen(player = exoPlayer, modifier = Modifier.fillMaxSize()) MediaPlayerScreen(
player = exoPlayer,
modifier = Modifier.fillMaxSize().navigationBarsPadding(),
)
} }
} }
@Composable @Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) { private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
var showControls by remember { mutableStateOf(true) }
Box(modifier) { Box(modifier) {
PlayerSurface(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW) PlayerSurface(
PlayPauseButton(player, Modifier.align(Alignment.Center).size(100.dp)) 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))
}
} }
} }
} }

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -17,8 +17,8 @@
package androidx.media3.demo.compose.buttons package androidx.media3.demo.compose.buttons
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PauseCircle import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -31,7 +31,7 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
@Composable @Composable
internal fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) { internal fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
val state = rememberPlayPauseButtonState(player) 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 = val contentDescription =
if (state.showPlay) stringResource(R.string.playpause_button_play) if (state.showPlay) stringResource(R.string.playpause_button_play)
else stringResource(R.string.playpause_button_pause) else stringResource(R.string.playpause_button_pause)

View File

@ -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,
)
}
}

View File

@ -18,6 +18,5 @@ package androidx.media3.demo.compose.data
val videos = val videos =
listOf( listOf(
"https://html5demos.com/assets/dizzy.mp4", "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", "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm",
) )

View File

@ -17,4 +17,6 @@
<string name="app_name">Media3 Compose Demo</string> <string name="app_name">Media3 Compose Demo</string>
<string name="playpause_button_play">Play</string> <string name="playpause_button_play">Play</string>
<string name="playpause_button_pause">Pause</string> <string name="playpause_button_pause">Pause</string>
<string name="next_button">Next</string>
<string name="previous_button">Previous</string>
</resources> </resources>

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -16,15 +16,11 @@
package androidx.media3.ui.compose.state package androidx.media3.ui.compose.state
import android.os.Looper
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.media3.common.Player 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 androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.collect.ImmutableList
import com.google.common.truth.Truth.assertThat 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.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -133,74 +129,3 @@ class PlayPauseButtonStateTest {
assertThat(player.playbackState).isEqualTo(Player.STATE_BUFFERING) 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()
}
}

View File

@ -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)
}
}

View File

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