Creating a Custom Layout Video Player with Media3/ExoPlayer and Jetpack Compose

Learn how to create a basic video player that is lifecycle-aware and maintains its state across configuration changes and system-initiated process death.

Jesus Ericks
15 min readMay 20, 2024
Simple Player with Jetpack Compose and Media3/ExoPlayer

ExoPlayer, now integrated into Jetpack Media3, is one of the most popular libraries for creating media players on Android. Unfortunately, it still doesn’t have a native version for Jetpack Compose, which leads us to use AndroidView().

In this article, we will learn how to create a small video player with basic functions, such as skipping to the next video, changing playback speed, screen rotation, being lifecycle-aware, and with a state that survives configuration changes and system-initiated process death. The player will be fullscreen by default, and will hide the main buttons after a defined time (5 seconds, in our case) or a touch on the screen. Remember to use enableEdgeToEdge() in the onCreate() of MainActivity.

The main focus is on creating a basic structure that maintains the player’s primary state. This structure is as follows:

  • PlayerState/PlayerAction: All the states and actions of the player that we are interested in.
  • PlayerStateHolder: Class responsible for maintaining the state (PlayerState) that survives configuration changes and process death.
  • MediaPlayerManager: Class responsible for managing the player’s actions, such as pausing, skipping to the next media, etc. It also manages the Player.Listener callbacks, updating the PlayerState when there are important changes.

Dependencies

First, we need to add the necessary dependencies in build.gradle (app). Besides Media3, we will also need lifecycle-viewmodel-compose:

val media3Version = "1.3.1"
implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")

Don’t forget to enable Kotlin Parcelize in the plugins section of the same file: id(“kotlin-parcelize”). And also add internet permission in AndroidManifest.xml:

<uses-permission android:name=”android.permission.INTERNET” />

Starting the main structure with PlayerState and PlayerAction

Before creating the Composables, let’s create the PlayerState, PlayerAction, and some related classes/enums. You can decide where and how to organize this, but for the sake of simplicity, I will include everything in the same file PlayerState.kt:

@Parcelize
data class PlayerState(
val showMainUi: Boolean = true,
val playWhenReady: Boolean = true,
val currentMediaItemIndex: Int = 0,
val currentPlaybackPosition: Long = 0L,
val bufferedPercentage: Int = 0,
val videoDuration: Long = 0L,
val playbackState: PlaybackState = PlaybackState.Idle,
val seekIncrementMs: Long = 5000L,
val resizeMode: ResizeMode = ResizeMode.Fit,
val repeatMode: RepeatMode = RepeatMode.None,
val isPlaying: Boolean = true,
val isNextButtonAvailable: Boolean = true,
val isSeekForwardButtonAvailable: Boolean = true,
val isSeekBackButtonAvailable: Boolean = true,
val playbackSpeed: Float = playbackSpeedNormal.speedValue,
val isLandscapeMode: Boolean = false,
) : Parcelable {
val isStateBuffering: Boolean
get() = playbackState == PlaybackState.Buffering
}

sealed class PlayerAction {
data object PlayOrPause : PlayerAction()
data object Next : PlayerAction()
data object Previous : PlayerAction()
data object SeekForward : PlayerAction()
data object SeekBack : PlayerAction()
data class SeekTo(val positionMs: Long) : PlayerAction()
data class ChangeShowMainUi(val showMainUi: Boolean) : PlayerAction()
data class ChangeCurrentPlaybackPosition(val currentPlaybackPosition: Long) : PlayerAction()
data class ChangeBufferedPercentage(val bufferedPercentage: Int) : PlayerAction()
data class ChangeResizeMode(val resizeMode: ResizeMode) : PlayerAction()
data class ChangeRepeatMode(val repeatMode: RepeatMode) : PlayerAction()
data class ChangePlaybackSpeed(val playbackSpeed: Float) : PlayerAction()
data class ChangeIsLandscapeMode(val isLandscapeMode: Boolean) : PlayerAction()
}

enum class PlaybackState(val value: Int) {
Idle(Player.STATE_IDLE),
Buffering(Player.STATE_BUFFERING),
Ready(Player.STATE_READY),
Ended(Player.STATE_ENDED);

companion object {
fun Int.toPlaybackState(): PlaybackState {
return when (this) {
Player.STATE_IDLE -> Idle
Player.STATE_BUFFERING -> Buffering
Player.STATE_READY -> Ready
Player.STATE_ENDED -> Ended
else -> Idle
}
}
}
}

@SuppressLint("UnsafeOptInUsageError")
enum class ResizeMode(val value: Int) {
Fit(AspectRatioFrameLayout.RESIZE_MODE_FIT),
Fill(AspectRatioFrameLayout.RESIZE_MODE_FILL),
Zoom(AspectRatioFrameLayout.RESIZE_MODE_ZOOM);

companion object {
fun getNewResizeMode(currentResizeMode: ResizeMode): ResizeMode {
return when (currentResizeMode) {
Fit -> Fill
Fill -> Zoom
Zoom -> Fit
}
}
}
}

@SuppressLint("UnsafeOptInUsageError")
enum class RepeatMode(val value: Int) {
None(RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE),
One(RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE),
ModeAll(RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL);

companion object {
fun getNewRepeatMode(currentRepeatMode: RepeatMode): RepeatMode {
return when (currentRepeatMode) {
None -> One
One -> ModeAll
ModeAll -> None
}
}
}
}

data class PlaybackSpeed(
val speedValue: Float,
val title: String
)

val playbackSpeedNormal = PlaybackSpeed(1.0f, "Normal")
val playbackSpeedOptions = listOf(
PlaybackSpeed(0.25f, "0.25x"),
PlaybackSpeed(0.5f, "0.5x"),
PlaybackSpeed(0.75f, "0.75x"),
playbackSpeedNormal,
PlaybackSpeed(1.25f, "1.25x"),
PlaybackSpeed(1.5f, "1.5x"),
PlaybackSpeed(1.75f, "1.75x"),
PlaybackSpeed(2.0f, "2x")
)

In PlayerState, we have some of the useful properties for our basic player. PlayerAction groups some of the actions that can change the state, making it easier to use in Composables to avoid multiple lambdas. Not all properties will be modified by a direct user action; some will be modified solely by the ExoPlayer listeners (e.g., videoDuration), contained in the MediaPlayerManager that we will create later.

PlayerStateHolder

Let’s create PlayerStateHolder, which is simply a ViewModel containing our PlayerState. To ensure the state survives both configuration changes (such as screen rotation) and process death, we will use SavedStateHandle with the saveable() function, which is similar to rememberSaveable() in Compose. It is important to remember that saveable() is still experimental.

class PlayerStateHolder(savedStateHandle: SavedStateHandle) : ViewModel() {
@OptIn(SavedStateHandleSaveableApi::class)
var playerState: PlayerState by savedStateHandle.saveable {
mutableStateOf(PlayerState())
}

fun onPlayerStateChange(newPlayerState: PlayerState) {
playerState = newPlayerState
}
}

MediaPlayerManager

Now we will create MediaPlayerManager. It will be responsible for controlling the ExoPlayer listeners and also for handling PlayerAction events.

class MediaPlayerManager(
private val player: ExoPlayer,
private val playerStateHolder: PlayerStateHolder
) {
val playerState: PlayerState
get() = playerStateHolder.playerState

private val listener = object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
playerStateHolder.onPlayerStateChange(
playerState.copy(
playbackState = player.playbackState.toPlaybackState(),
isNextButtonAvailable = isNextButtonAvailable(),
isSeekForwardButtonAvailable = isSeekButtonAvailable(isSeekForward = true),
isSeekBackButtonAvailable = isSeekButtonAvailable(isSeekForward = false),
)
)
val videoDuration = player.duration.coerceAtLeast(0L)
if (videoDuration != 0L) {
playerStateHolder.onPlayerStateChange(playerState.copy(videoDuration = videoDuration))
}
}

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (playerState.currentMediaItemIndex != player.currentMediaItemIndex) {
playerStateHolder.onPlayerStateChange(
playerState.copy(
currentMediaItemIndex = player.currentMediaItemIndex,
videoDuration = player.duration.coerceAtLeast(0L)
)
)
}
}

override fun onPlayWhenReadyChanged(
playWhenReady: Boolean,
@Player.PlayWhenReadyChangeReason reason: Int
) {
playerStateHolder.onPlayerStateChange(playerState.copy(playWhenReady = playWhenReady))
}
}

init {
initializePlayer()
}

private fun initializePlayer() {
player.addListener(listener)
player.playWhenReady = playerState.playWhenReady
player.repeatMode = playerState.repeatMode.value
player.seekTo(playerState.currentMediaItemIndex, playerState.currentPlaybackPosition)
player.setPlaybackSpeed(playerState.playbackSpeed)
}

fun releasePlayer() {
player.release()
player.removeListener(listener)
}

fun onPlayerAction(action: PlayerAction) {
when (action) {
is PlayerAction.PlayOrPause -> {
playOrPause()
}
is PlayerAction.Next -> {
restartCurrentPlaybackPosition()
player.seekToNext()
}
is PlayerAction.Previous -> {
restartCurrentPlaybackPosition()
player.seekToPrevious()
}
is PlayerAction.SeekForward -> {
val updatedPlaybackPosition = (player.currentPosition + playerState.seekIncrementMs)
.coerceAtMost(playerState.videoDuration)
playerStateHolder.onPlayerStateChange(
playerState.copy(currentPlaybackPosition = updatedPlaybackPosition)
)
player.seekTo(updatedPlaybackPosition)
}
is PlayerAction.SeekBack -> {
val updatedPlaybackPosition = (player.currentPosition - playerState.seekIncrementMs)
.coerceAtLeast(0L)
playerStateHolder.onPlayerStateChange(
playerState.copy(currentPlaybackPosition = updatedPlaybackPosition)
)
player.seekTo(updatedPlaybackPosition)
}
is PlayerAction.SeekTo -> {
val positionMs = action.positionMs
player.seekTo(positionMs)
playerStateHolder.onPlayerStateChange(playerState.copy(currentPlaybackPosition = positionMs))
}
is PlayerAction.ChangeShowMainUi -> {
playerStateHolder.onPlayerStateChange(playerState.copy(showMainUi = action.showMainUi))
}
is PlayerAction.ChangeCurrentPlaybackPosition -> {
playerStateHolder.onPlayerStateChange(
playerState.copy(currentPlaybackPosition = action.currentPlaybackPosition)
)
}
is PlayerAction.ChangeBufferedPercentage -> {
playerStateHolder.onPlayerStateChange(
playerState.copy(bufferedPercentage = action.bufferedPercentage)
)
}
is PlayerAction.ChangeResizeMode -> {
playerStateHolder.onPlayerStateChange(playerState.copy(resizeMode = action.resizeMode))
}
is PlayerAction.ChangeRepeatMode -> {
if (isRepeatModeAvailable()) {
val repeatMode = action.repeatMode
player.repeatMode = repeatMode.value
playerStateHolder.onPlayerStateChange(playerState.copy(repeatMode = repeatMode))
}
}
is PlayerAction.ChangePlaybackSpeed -> {
player.setPlaybackSpeed(action.playbackSpeed)
playerStateHolder.onPlayerStateChange(
playerState.copy(playbackSpeed = action.playbackSpeed)
)
}
is PlayerAction.ChangeIsLandscapeMode -> {
playerStateHolder.onPlayerStateChange(
playerState.copy(isLandscapeMode = action.isLandscapeMode)
)
}
}
}

private fun playOrPause() {
if (playerState.isPlaying && playerState.playbackState != PlaybackState.Ended) {
player.pause()
playerStateHolder.onPlayerStateChange(playerState.copy(isPlaying = false))
return
}
if (playerState.playbackState == PlaybackState.Ended) {
restartCurrentPlaybackPosition()
player.seekTo(0L)
}
player.play()
playerStateHolder.onPlayerStateChange(playerState.copy(isPlaying = true))
}

private fun restartCurrentPlaybackPosition() {
playerStateHolder.onPlayerStateChange(playerState.copy(currentPlaybackPosition = 0L))
}

private fun isRepeatModeAvailable(): Boolean {
return player.isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE)
}

private fun isNextButtonAvailable(): Boolean {
return player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)
}

private fun isSeekButtonAvailable(isSeekForward: Boolean): Boolean {
val command = if (isSeekForward) {
Player.COMMAND_SEEK_FORWARD
} else {
Player.COMMAND_SEEK_BACK
}
return player.isCommandAvailable(command)
}
}

Some important points:

  • We maintain a reference to the PlayerState so that our Composables can easily access it through the MediaPlayerManager without direct access to the PlayerStateHolder.
  • We created the Player.Listener and obtained the events we are interested in. There are several other events you might be interested in, but for this example, we have enough. There are individual callbacks, such as onMediaItemTransition(), and also onEvents(), which is called whenever one or more events occur together.
  • As you may have noticed, videoDuration is being obtained twice: in onEvents() and onMediaItemTransition(). In onEvents(), it will be modified whenever an event occurs, but only if the duration is not 0 (to not replace the current duration). Usually, this is where the actual duration will be obtained. In onMediaItemTransition(), it will be obtained whenever there is a change in currentMediaItemIndex (when the user changes the video), which for videos loaded by URL will probably return 0 at first, serving only to reset the current duration. This is done so that the duration is always obtained correctly and not replaced by 0 in cases of screen rotation, for example.
  • initializePlayer() initializes the important properties of ExoPlayer with the values of the current PlayerState, ensuring that the player state is always maintained.
  • releasePlayer() releases the player (later called in onDispose of a DisposableEffect) and removes the listeners.
  • onPlayerAction() handles the possible actions and triggers the change in PlayerState. There isn’t much to comment on about it, except Player.SeekForward and Player.SeekBack. We are using the player.seekTo() method in both cases, but there are two standard ExoPlayer methods for this: seekForward() and seekBack(). We are not using them because we want to more easily control the increment time, as these methods have a predefined default time, and to change this, we would have to manually provide the time in the ExoPlayer.Builder with the methods setSeekForwardIncrementMs() and setSeekBackIncrementMs().

With the comments finished, let’s move on to another important function, which you can create in the same file as the MediaPlayerManager, rememberMediaPlayerManager().

@Composable
fun rememberMediaPlayerManager(player: ExoPlayer): MediaPlayerManager {
val playerStateHolder: PlayerStateHolder = viewModel {
val savedStateHandle = createSavedStateHandle()
PlayerStateHolder(savedStateHandle)
}

val mediaPlayerManager = remember {
MediaPlayerManager(
player = player,
playerStateHolder = playerStateHolder
)
}
val playerState = mediaPlayerManager.playerState

// Change the current playback position only while the video is playing
LaunchedEffect(playerState.isPlaying) {
while (playerState.isPlaying) {
mediaPlayerManager.onPlayerAction(
PlayerAction.ChangeCurrentPlaybackPosition(player.currentPosition)
)
delay(300L)
}
}

// Change the buffered percentage only while displaying the main UI
LaunchedEffect(playerState.showMainUi) {
while (playerState.showMainUi) {
mediaPlayerManager.onPlayerAction(
PlayerAction.ChangeBufferedPercentage(player.bufferedPercentage)
)
delay(300L)
}
}

return mediaPlayerManager
}

This is the function we will use in our Composables. It’s also where we get the current playback position and buffered percentage with the help of LaunchedEffect.

And it’s done! Now we have a basic structure to maintain the player state. Let’s move on to the Composables!

ExoPlayerLifecycleController

This will just be a function that handles the lifecycle to help us later on. I won’t comment much on it, we simply observe the LocalLifecycleOwner to act accordingly.

private val AndroidSdkVersion = Build.VERSION.SDK_INT

@Composable
fun ExoPlayerLifecycleController(
playerView: PlayerView,
onStartOrResume: () -> Unit,
onPauseOrStop: () -> Unit,
onDispose: () -> Unit
) {
LifecycleObserver(
onStart = {
if (AndroidSdkVersion > 23) {
playerView.onResume()
onStartOrResume.invoke()
}
},
onResume = {
if (AndroidSdkVersion <= 23) {
playerView.onResume()
onStartOrResume.invoke()
}
},
onPause = {
if (AndroidSdkVersion <= 23) {
playerView.onPause()
onPauseOrStop.invoke()
}
},
onStop = {
if (AndroidSdkVersion > 23) {
playerView.onPause()
onPauseOrStop.invoke()
}
},
onDispose = { onDispose.invoke() }
)
}

@Composable
private fun LifecycleObserver(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit = {},
onResume: () -> Unit = {},
onPause: () -> Unit = {},
onStop: () -> Unit = {},
onDispose: () -> Unit = {}
) {
val currentOnStart by rememberUpdatedState(onStart)
val currentOnResume by rememberUpdatedState(onResume)
val currentOnPause by rememberUpdatedState(onPause)
val currentOnStop by rememberUpdatedState(onStop)
val currentOnDispose by rememberUpdatedState(onDispose)

DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> currentOnStart()
Lifecycle.Event.ON_RESUME -> currentOnResume()
Lifecycle.Event.ON_PAUSE -> currentOnPause()
Lifecycle.Event.ON_STOP -> currentOnStop()
else -> Log.d("LifecycleObserver", "Called any")
}
}

lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
currentOnDispose()
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}

PlayerView

This function will be responsible for using AndroidView() with the Media3’s PlayerView. This is where we will use ExoPlayerLifecycleController() to properly control the player’s lifecycle.

@OptIn(UnstableApi::class)
@Composable
fun PlayerView(
mediaPlayerState: MediaPlayerManager,
exoPlayer: ExoPlayer,
onPlayerViewClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerView = remember {
PlayerView(context).apply {
this.useController = false
this.player = exoPlayer
}
}

AndroidView(
factory = {
playerView
},
update = {
it.resizeMode = mediaPlayerState.playerState.resizeMode.value
},
modifier = modifier
.fillMaxSize()
.background(Color(0xFF111318))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onPlayerViewClick.invoke() }
)

ExoPlayerLifecycleController(
playerView = playerView,
onStartOrResume = {
if (mediaPlayerState.playerState.isPlaying) {
exoPlayer.play()
}
},
onPauseOrStop = {
exoPlayer.pause()
},
onDispose = {
mediaPlayerState.releasePlayer()
}
)
}

Note: The current behavior is to always maintain the last player state. For example, if the video is playing and the user switches to another app or goes to the home screen, the video will pause when exiting, but when returning to the player, the video will continue playing where it left off. The same applies if it was paused before leaving the app; when you return, it will continue to be paused. You may want to change this behavior and keep the video paused whenever the user leaves and returns to the screen, even if it was playing before. You can modify onStartOrResume to achieve this.

Utility functions and classes

Let’s first create some utilities and extensions that will be useful from here on. I’ll skip commenting on some of them.

fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}

fun Context.getWindow(): Window? {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context.window
context = context.baseContext
}
return null
}

object Utils {

fun formatVideoDuration(durationInMs: Long): String {
val seconds = durationInMs / 1000
val second = seconds % 60
val minute = seconds / 60 % 60
val hour = seconds / (60 * 60) % 24
return if (hour > 0) {
String.format(Locale.getDefault(), "%02d:%02d:%02d", hour, minute, second)
} else {
String.format(Locale.getDefault(), "%02d:%02d", minute, second)
}
}

fun showSystemBars(context: Context) {
context.getWindow()?.let { window ->
val insetsControllerCompat = WindowInsetsControllerCompat(window, window.decorView)
insetsControllerCompat.show(WindowInsetsCompat.Type.systemBars())
}
}

fun hideSystemBars(context: Context) {
context.getWindow()?.let { window ->
val insetsControllerCompat = WindowInsetsControllerCompat(window, window.decorView)
insetsControllerCompat.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
insetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
}

fun changeScreenOrientation(
context: Context,
isLandscapeMode: Boolean
) {
context.findActivity()?.let { activity ->
val orientation = if (isLandscapeMode) {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity.requestedOrientation = orientation
}
}
}

Media

Just a model class containing some sample data for convenience.

data class Media(
val uri: String,
val title: String,
)

val mediaList = listOf(
Media(
uri = "https://storage.googleapis.com/downloads.webmproject.org/av1/exoplayer/bbb-av1-480p.mp4",
title = "MP4: Big Buck Bunny"
),
Media(
uri = "https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv",
title = "MKV (1280x720): Android Screens"
),
Media(
uri = "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8",
title = "HLS (adaptive): Apple 16x9 basic stream"
)
)

private fun getMediaItemList(): List<MediaItem> {
val mediaItemList = mutableListOf<MediaItem>()
mediaList.forEachIndexed { index, media ->
val mediaMetadata = MediaMetadata.Builder()
.setTitle(media.title)
.build()
val mediaItem = MediaItem.Builder()
.setUri(media.uri)
.setMediaId(index.toString())
.setMediaMetadata(mediaMetadata)
.build()
mediaItemList.add(mediaItem)
}
return mediaItemList
}

LockScreenOrientation

Just another utility function to lock the screen orientation according to isLandscapeMode.

@Composable
fun LockScreenOrientation(isLandscapeMode: Boolean) {
val context = LocalContext.current
LaunchedEffect(isLandscapeMode) {
Utils.changeScreenOrientation(
context = context,
isLandscapeMode = isLandscapeMode
)
}
}

Player layout components

Now let’s create the basic components of the layout that you saw at the beginning of this article. You will probably create your own UI differently, so treat the components here only as a base and a more practical example.

CustomIconButton

@Composable
fun CustomIconButton(
@DrawableRes iconResId: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
contentDescription: String? = null,
reducedIconSize: Boolean = false
) {
FilledIconButton(
onClick = onClick,
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(0.40f),
disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.40f)
),
modifier = modifier
.padding(horizontal = 8.dp)
.size(54.dp)
) {
Icon(
painter = painterResource(id = iconResId),
contentDescription = contentDescription,
modifier = Modifier
.size(32.dp)
.padding(if (reducedIconSize) 2.dp else 0.dp)
)
}
}

MainControlButtons

@Composable
fun MainControlButtons(
isNextButtonAvailable: Boolean,
isSeekForwardButtonAvailable: Boolean,
isSeekBackButtonAvailable: Boolean,
playbackState: PlaybackState,
isPlaying: Boolean,
onPlayerAction: (PlayerAction) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier
.fillMaxWidth()
.padding(12.dp)
) {
CustomIconButton(
iconResId = R.drawable.round_replay_5_24,
enabled = isSeekBackButtonAvailable,
onClick = {
onPlayerAction.invoke(PlayerAction.SeekBack)
}
)
CustomIconButton(
iconResId = R.drawable.round_skip_previous_24,
onClick = {
onPlayerAction.invoke(PlayerAction.Previous)
}
)

val centerIcon = if (playbackState == PlaybackState.Ended) {
R.drawable.round_replay_24
} else if (isPlaying) {
R.drawable.round_pause_24
} else {
R.drawable.round_play_arrow_24
}
CustomIconButton(
iconResId = centerIcon,
onClick = {
onPlayerAction.invoke(PlayerAction.PlayOrPause)
}
)

CustomIconButton(
iconResId = R.drawable.round_skip_next_24,
enabled = isNextButtonAvailable,
onClick = {
onPlayerAction.invoke(PlayerAction.Next)
}
)

CustomIconButton(
iconResId = R.drawable.round_forward_5_24,
enabled = isSeekForwardButtonAvailable,
onClick = {
onPlayerAction.invoke(PlayerAction.SeekForward)
}
)
}
}

SecondaryControlButtons

@Composable
fun SecondaryControlButtons(
resizeMode: ResizeMode,
repeatMode: RepeatMode,
playbackSpeed: Float,
isLandscapeMode: Boolean,
onPlayerAction: (PlayerAction) -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
var showPlaybackSpeedDropdownMenu by remember { mutableStateOf(false) }
Box(modifier = modifier.padding(end = 12.dp)) {
PlaybackSpeedDropdownMenu(
showOptionsMenu = showPlaybackSpeedDropdownMenu,
onDismiss = { showPlaybackSpeedDropdownMenu = false },
currentPlaybackSpeed = playbackSpeed,
onSelected = { playbackSpeed ->
onPlayerAction.invoke(
PlayerAction.ChangePlaybackSpeed(playbackSpeed)
)
}
)
}

CustomIconButton(
iconResId = R.drawable.round_slow_motion_video_24,
onClick = { showPlaybackSpeedDropdownMenu = true },
modifier = Modifier
)

Spacer(Modifier.width(4.dp))

val repeatModeIconResId = when (repeatMode) {
RepeatMode.None -> R.drawable.round_repeat_24
RepeatMode.One -> R.drawable.round_repeat_one_24
RepeatMode.ModeAll -> R.drawable.round_repeat_on_24
}
CustomIconButton(
iconResId = repeatModeIconResId,
onClick = {
val newRepeatMode = RepeatMode.getNewRepeatMode(repeatMode)
onPlayerAction.invoke(PlayerAction.ChangeRepeatMode(newRepeatMode))
},
modifier = Modifier
)

Spacer(Modifier.width(4.dp))

CustomIconButton(
iconResId = R.drawable.round_screen_rotation_24,
onClick = {
onPlayerAction.invoke(PlayerAction.ChangeIsLandscapeMode(!isLandscapeMode))
},
modifier = Modifier
)

Spacer(Modifier.width(4.dp))

CustomIconButton(
iconResId = R.drawable.round_aspect_ratio_24,
onClick = {
val newResizeMode = ResizeMode.getNewResizeMode(resizeMode)
onPlayerAction.invoke(PlayerAction.ChangeResizeMode(newResizeMode))
}
)

Spacer(Modifier.width(8.dp))
}
}

ProgressContent

@Composable
fun ProgressContent(
currentPlaybackPosition: Long,
currentBufferedPercentage: Int,
videoDuration: Long,
onPlayerAction: (PlayerAction) -> Unit,
modifier: Modifier = Modifier
) {
var isChangingPosition by remember { mutableStateOf(false) }
var temporaryPlaybackPosition by remember { mutableLongStateOf(0L) }
val sliderValue = remember(currentPlaybackPosition, isChangingPosition) {
if (isChangingPosition) temporaryPlaybackPosition else currentPlaybackPosition
}

val formattedCurrentVideoTime = remember(sliderValue) {
Utils.formatVideoDuration(sliderValue)
}
val formattedVideoDuration = remember(videoDuration) {
Utils.formatVideoDuration(videoDuration)
}

Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
Text(
text = formattedCurrentVideoTime,
color = Color.White,
fontSize = 14.sp,
)
Spacer(Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f)) {
Slider(
value = currentBufferedPercentage.toFloat(),
enabled = false,
onValueChange = {},
valueRange = 0f..100f,
colors = SliderDefaults.colors(
disabledThumbColor = Color.Transparent,
disabledActiveTrackColor = MaterialTheme.colorScheme.primaryContainer.copy(0.6f)
)
)
Slider(
modifier = Modifier.fillMaxWidth(),
value = sliderValue.toFloat(),
onValueChange = { value ->
temporaryPlaybackPosition = value.toLong()
isChangingPosition = true
onPlayerAction.invoke(
PlayerAction.ChangeCurrentPlaybackPosition(temporaryPlaybackPosition)
)
},
onValueChangeFinished = {
isChangingPosition = false
onPlayerAction.invoke(PlayerAction.SeekTo(temporaryPlaybackPosition))
temporaryPlaybackPosition = 0
},
valueRange = 0f..videoDuration.toFloat(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primaryContainer,
activeTickColor = MaterialTheme.colorScheme.primaryContainer,
inactiveTrackColor = Color.Transparent
)
)
}
Spacer(Modifier.width(8.dp))
Text(
text = formattedVideoDuration,
color = Color.White,
fontSize = 14.sp,
)
}
}

PlaybackSpeedDropdownMenu

@Composable
fun PlaybackSpeedDropdownMenu(
showOptionsMenu: Boolean,
onDismiss: () -> Unit,
currentPlaybackSpeed: Float,
onSelected: (playbackSpeed: Float) -> Unit,
modifier: Modifier = Modifier
) {
DropdownMenu(
expanded = showOptionsMenu,
onDismissRequest = onDismiss,
modifier = modifier
.clip(MaterialTheme.shapes.medium)
) {
playbackSpeedOptions.forEach { playbackSpeed ->
PlaybackSpeedDropdownMenuItem(
title = playbackSpeed.title,
isSelected = currentPlaybackSpeed == playbackSpeed.speedValue,
onClick = {
onDismiss.invoke()
onSelected.invoke(playbackSpeed.speedValue)
}
)
}
}
}

@Composable
private fun PlaybackSpeedDropdownMenuItem(
title: String,
isSelected: Boolean,
onClick: () -> Unit
) {
DropdownMenuItem(
onClick = onClick,
text = {
Text(
text = title,
fontSize = 14.sp
)
},
leadingIcon = {
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null
)
}
},
contentPadding = PaddingValues(12.dp)
)
}

Finally, PlayerScreen!

We have finally reached the final part, the Composable for the main screen.

@OptIn(UnstableApi::class)
@Composable
fun PlayerScreen() {
val mediaItems = remember { getMediaItemList() }
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItems(mediaItems)
prepare()
playWhenReady = true
}
}

val mediaPlayerManager = rememberMediaPlayerManager(player = exoPlayer)
val playerState = mediaPlayerManager.playerState
// Just to restart the delay to hide the main UI if the user performs some action (play/pause, for example)
var lastPlayerActionTimeMillis by remember { mutableLongStateOf(0L) }

// Hides the main UI after the time
LaunchedEffect(
key1 = playerState.showMainUi,
key2 = lastPlayerActionTimeMillis
) {
delay(5000L)
mediaPlayerManager.onPlayerAction(PlayerAction.ChangeShowMainUi(false))
}

Box(modifier = Modifier.fillMaxSize()) {
PlayerView(
mediaPlayerState = mediaPlayerManager,
exoPlayer = exoPlayer,
onPlayerViewClick = {
val showMainUi = !playerState.showMainUi
if (showMainUi) {
Utils.showSystemBars(context)
} else {
Utils.hideSystemBars(context)
}
mediaPlayerManager.onPlayerAction(PlayerAction.ChangeShowMainUi(showMainUi))
}
)

PlayerLayout(
playerState = playerState,
onPlayerAction = { action ->
mediaPlayerManager.onPlayerAction(action)
lastPlayerActionTimeMillis = System.currentTimeMillis()
},
title = exoPlayer.mediaMetadata.title.toString(),
showMainUi = playerState.showMainUi
)
}

LockScreenOrientation(isLandscapeMode = playerState.isLandscapeMode)
}

@Composable
private fun PlayerLayout(
playerState: PlayerState,
onPlayerAction: (PlayerAction) -> Unit,
title: String,
showMainUi: Boolean,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
if (playerState.isStateBuffering) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 6.dp,
modifier = Modifier
.align(Alignment.Center)
.size(80.dp),
)
}

AnimatedVisibility(
visible = showMainUi,
modifier = Modifier.align(Alignment.TopCenter)
) {
TopBarTitle(title = title)
}

AnimatedVisibility(
visible = showMainUi,
modifier = Modifier.align(Alignment.BottomCenter)
) {
BottomContent(
playerState = playerState,
onPlayerAction = onPlayerAction,
)
}
}
}

@kotlin.OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun TopBarTitle(
title: String,
modifier: Modifier = Modifier
) {
CenterAlignedTopAppBar(
title = {
Text(
text = title,
fontSize = 18.sp,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(0.6f)
),
modifier = modifier
)
}

@Composable
private fun BottomContent(
playerState: PlayerState,
onPlayerAction: (PlayerAction) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.safeDrawingPadding()) {
SecondaryControlButtons(
resizeMode = playerState.resizeMode,
repeatMode = playerState.repeatMode,
playbackSpeed = playerState.playbackSpeed,
isLandscapeMode = playerState.isLandscapeMode,
onPlayerAction = onPlayerAction,
modifier = Modifier.align(Alignment.End)
)
Spacer(Modifier.height(16.dp))
ProgressContent(
currentPlaybackPosition = playerState.currentPlaybackPosition,
currentBufferedPercentage = playerState.bufferedPercentage,
videoDuration = playerState.videoDuration,
onPlayerAction = onPlayerAction,
)
Spacer(Modifier.height(8.dp))
MainControlButtons(
isNextButtonAvailable = playerState.isNextButtonAvailable,
isSeekForwardButtonAvailable = playerState.isSeekForwardButtonAvailable,
isSeekBackButtonAvailable = playerState.isSeekBackButtonAvailable,
playbackState = playerState.playbackState,
isPlaying = playerState.isPlaying,
onPlayerAction = onPlayerAction,
)
Spacer(Modifier.height(8.dp))
}
}

In this function we create the instance of ExoPlayer and also the main layout. To get the video title, we’re using exoPlayer.mediaMetadata.title.toString(), but you could have this as a state in PlayerState and get it in some corresponding callback in MediaPlayerManager.

Note that only the PlayerState will be retained. When rotating the screen, the video buffer will be loaded again from the position it was at (PlayerState.currentPlaybackPosition), so for videos loaded from the internet, this may be slightly more noticeable.

It’s important to remember that in the BottomContent() Column, we’re using Modifier.safeDrawingPadding(). It’s necessary for the content to be safely drawn over the system bars. If you don’t use it, the UI will be inconsistent, as we’re using enableEdgeToEdge(). You could also use safeDrawingPadding() in PlayerLayout() instead of BottomContent(), but TopBarTitle() would also be affected. Below, you can see the differences.

Modifier.safeDrawingPadding() in BottomContent()
Modifier.safeDrawingPadding() in PlayerLayout()
Without using Modifier.safeDrawingPadding()

And here’s how our app looks:

Simple Player app demo

Conclusion

As you can see, creating a video player with custom buttons using the Media3/ExoPlayer library with Compose is not that difficult! Part of this project was inspired by this article and in this library, so thanks to those involved.

This is my first article in English on Medium, so I apologize if you find any errors in that regard. :)

You can get the full code for this project on GitHub: https://github.com/jsericksk/Simple-Player/tree/basic-version
There’s a version with some more layout options in the same repository, in the main branch: https://github.com/jsericksk/Simple-Player

Thank you for reading! 🚀!!!

--

--