Add the dependency
The Media3 library includes a Jetpack Compose-based UI module. To use it, add the following dependency:
Kotlin
implementation("androidx.media3:media3-ui-compose:1.6.0")
Groovy
implementation "androidx.media3:media3-ui-compose:1.6.0"
We highly encourage you to develop your app in a Compose-first fashion or migrate from using Views.
Fully Compose demo app
While the media3-ui-compose
library does not include out-of-the-box
Composables (such as buttons, indicators, images or dialogs), you can find a
demo app written fully in Compose that avoids any interoperability
solutions like wrapping PlayerView
in AndroidView
. The demo app
utilises the UI state holder classes from media3-ui-compose
module and makes
use of the Compose Material3 library.
UI state holders
To better understand how you can use the flexibility of UI state holders versus composables, read up on how Compose manages State.
Button state holders
For some UI states, we make the assumption that they will most likely be consumed by button-like Composables.
State | remember*State | Type |
---|---|---|
PlayPauseButtonState |
rememberPlayPauseButtonState |
2-Toggle |
PreviousButtonState |
rememberPreviousButtonState |
Constant |
NextButtonState |
rememberNextButtonState |
Constant |
RepeatButtonState |
rememberRepeatButtonState |
3-Toggle |
ShuffleButtonState |
rememberShuffleButtonState |
2-Toggle |
PlaybackSpeedState |
rememberPlaybackSpeedState |
Menu or N-Toggle |
Example usage of PlayPauseButtonState
:
@Composable
fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
val state = rememberPlayPauseButtonState(player)
IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
Icon(
imageVector = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription =
if (state.showPlay) stringResource(R.string.playpause_button_play)
else stringResource(R.string.playpause_button_pause),
)
}
}
Note how state
possesses no theming information, like icon to use for playing
or pausing. Its only responsibility is to transform the Player
into UI state.
You can then mix and match the buttons in the layout of your preference:
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
PreviousButton(player)
PlayPauseButton(player)
NextButton(player)
}
Visual output state holders
PresentationState
holds to information for when the video output in a
PlayerSurface
can be shown or should be covered by a placeholder UI element.
val presentationState = rememberPresentationState(player)
val scaledModifier = Modifier.resize(ContentScale.Fit, presentationState.videoSizeDp)
Box(modifier) {
// Always leave PlayerSurface to be part of the Compose tree because it will be initialised in
// the process. If this composable is guarded by some condition, it might never become visible
// because the Player won't emit the relevant event, e.g. the first frame being ready.
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
// Cover the surface that is being prepared with a shutter
Box(Modifier.background(Color.Black))
}
Here, we can use both presentationState.videoSizeDp
to scale the Surface to
the desired aspect ratio (see ContentScale docs for more types) and
presentationState.coverSurface
to know when the timing is not right to be
showing the Surface. In this case, you can position an opaque shutter on top of
the surface, which will disappear when the surface becomes ready.
Where are Flows?
Many Android developers are familiar with using Kotlin Flow
objects to collect
ever-changing UI data. For example, you might be on the lookout for
Player.isPlaying
flow that you can collect
in a lifecycle-aware manner. Or
something like Player.eventsFlow
to provide you with a Flow<Player.Events>
that you can filter
the way you want.
However, using flows for Player
UI state has some drawbacks. One of the main
concerns is the asynchronous nature of data transfer. We want to ensure as
little latency as possible between a Player.Event
and its consumption on the
UI side, avoiding showing UI elements that are out-of-sync with the Player
.
Other points include:
- A flow with all the
Player.Events
wouldn't adhere to a single responsibility principle, each consumer would have to filter out the relevant events. - Creating a flow for each
Player.Event
will require you to combine them (withcombine
) for each UI element. There is a many-to-many mapping between a Player.Event and a UI element change. Having to usecombine
could lead the UI to potentially illegal states.
Create custom UI states
You can add custom UI states if the existing ones don't fulfil your needs. Check out the source code of the existing state to copy the pattern. A typical UI state holder class does the following:
- Takes in a
Player
. - Subscribes to the
Player
using coroutines. SeePlayer.listen
for more details. - Responds to particular
Player.Events
by updating its internal state. - Accept business-logic commands that will be transformed into an appropriate
Player
update. - Can be created in multiple places across the UI tree and will always maintain a consistent view of Player's state.
- Exposes Compose
State
fields that can be consumed by a Composable to dynamically respond to changes. - Comes with a
remember*State
function for remembering the instance between compositions.
What happens behind the scenes:
class SomeButtonState(private val player: Player) {
var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_ACTION_A))
private set
var someField by mutableStateOf(someFieldDefault)
private set
fun onClick() {
player.actionA()
}
suspend fun observe() =
player.listen { events ->
if (
events.containsAny(
Player.EVENT_B_CHANGED,
Player.EVENT_C_CHANGED,
Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
)
) {
someField = this.someField
isEnabled = this.isCommandAvailable(Player.COMMAND_ACTION_A)
}
}
}
To react to your own Player.Events
, you can catch them using Player.listen
which is a suspend fun
that lets you enter the coroutine world and
indefinitely listen to Player.Events
. Media3 implementation of various UI
states helps the end developer not to concern themselves with learning about
Player.Events
.