Basics, Custom Video Controls, and Playlist Support
Photo by Kelly Sikkema on Unsplash
Overview of the Series
This is the first part of our series exploring the EXO Player in Android. We’ll start with the basic concepts, then set up the project and add the required dependencies. Next, we’ll develop the player with a single item and eventually create a playlist with play, pause, previous, and next controls.
Introduction to EXO Player
What is ExoPlayer? Why use it over Android’s MediaPlayer?
A team at Google developed EXO Player as a standalone library with core, ui, etc., modules. Later on, it became part of the Jetpack suite to improve the lifecycle-aware playback, ease of integration on Android Auto, and other form factors.
Media3 EXO Player is actively maintained and recommended to use in new projects. Media3 EXO Player has close integration with MediaSession, MediaController, and Media2.
Key features and advantages of modern media playback
- EXO Player being part of the Media3 Jetpack library provides advantages like lifecycle-aware playback, meaning that when the activity/fragment goes to the background, playback will be handled accordingly without any manual work(though it has limitations like with only particular player surfaces).
- Consistent updates alongside the Jetpack suite of libraries and easier integration with Jetpack Compose.
- Easier integration in Android Auto, Wear OS, TV, etc.
- It also supports persistent playback out of the box with the media3-database module. This adds significant value to the product in real-time by keeping the user engaged with minimal buffering.
Setting Up Your Project
Fire up Android Studio and create a simple project with an empty Jetpack Compose template. Then we’ll start adding the libraries required as the project progresses.
Overview of project structure
We’ll create a simple EXO Player compose screen. This screen will be hosted by an Activity that can handle composable views. We’ll also create a view-model to manage the EXO Player and necessary data classes and state flows for communication. At this point, we’ll keep the project simple, so no modular architecture. The vital point of this series is to learn about the EXO player.
We’ll use Jetpack Compose for the UI, meaning compatible libraries to host the EXO player within Compose without relying on the AndroidView bridge. Additionally, we’ll use view models to address real-time challenges, such as persisting the player state across configuration changes. As we’ve Compose and view models, it’s always better to have a dependency injection framework. For that purpose, we’ll use Hilt.
Integration of required EXO player and Jetpack Compose dependencies
To integrate dependencies, I’m using the latest recommended approach, which involves using the version catalog with toml files. Additionally, I have switched from the old Groovy scripts to using Kotlin Gradle scripts.
Let’s start by adding Hilt, material icons, and view-models library configuration to the toml file. Note that we integrate material icons as part of the compose BOM integration.
[versions]
lifecycleRuntimeKtx = "2.8.7"
hiltNavigationCompose = "1.2.0"
[libraries]
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
Let’s complete the integration of these libraries by adding them to the dependencies node of the app-level Gradle file. Have a look:
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.material.icons.extended)kotlin
Now let’s add Media3 EXO Player libraries, starting with the toml file:
[versions]
media3ExoplayerDash = "1.6.1"
[libraries]
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3ExoplayerDash" }
androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3ExoplayerDash" }
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3ExoplayerDash" }
Then add them in the app-level gradle file:
// For building media playback UIs using Jetpack Compose implementation(libs.androidx.media3.ui.compose) // For media playback using ExoPlayer implementation(libs.androidx.media3.exoplayer)
Build the UI with Jetpack Compose and View Model to hold EXO Player
Let’s begin by creating the PlayerScreen composable. This composable will use the PlayerSurface composable function from the Media3 compose UI library. It takes a Media3 player as a parameter and manages it within the compose layers. Since the EXO player is part of the media3-exoplayer module extends the Media3 player, we can directly pass it to the PlayerSurface.
Have a look at the implementation:
@Composable
fun PlayerScreen(
modifier: Modifier = Modifier,
exoPlayer: ExoPlayer,
) {
Box(
modifier = modifier
) {
PlayerSurface(
player = exoPlayer,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.align(Alignment.Center)
)
}
}
Player ViewModel
Now, let’s create PlayerViewModel kotlin class and extend it with ViewModel() and annotate it with @HiltViewModel for DI purpose. This class will contain two components for now: a stateflow with an EXO player and a companion object that holds sample video URLs.. Have a look:
@HiltViewModel
class PlayerViewModel: ViewModel() {
companion object {
// Source for videos: https://gist.github.com/jsturgis/3b19447b304616f18657
const val Video_1 = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
const val Video_2 = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
const val Video_3 = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
const val Video_4 = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4"
}
private val _playerState = MutableStateFlow<ExoPlayer?>(null)
val playerState: StateFlow<ExoPlayer?> = _playerState
}
Player compose route
Now, let’s create a top-level composable called PlayerRoute to host the player screen and manage state-level operations. Have a look:
@Composable
fun PlayerRoute(
modifier: Modifier = Modifier,
playerViewModel: PlayerViewModel = viewModel(),
) {
val exoPlayer = playerViewModel.playerState.collectAsStateWithLifecycle()
Box(modifier.fillMaxSize()) {
exoPlayer.value?.let {
PlayerScreen(exoPlayer = it)
}
}
}
For now, this function appears to be overkill, but as the project grows, more and more aspects like side effects and states will be part of it. This function will be effective in isolating these elements from the PlayerScreen.
Activity level integration
Finally, create PlayerActivity and integrate PlayerRoute inside the onCreate via the setContent function. Have a look:
class PlayerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ExoPlayerLearningTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PlayerRoute(
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
Managing Playback with ViewModel
Now that we’ve completed building the compose UI and basic view model configuration, it’s time to build the EXO player and update UI via stateflow.
Step 1: Create a Media3 MediaItem with a sample URL as shown below:
val mediaItem = MediaItem.Builder().setUri(Video_1).setMediaId("Video_1").build(),
Step 2: Create the EXO player instance with this media item as shown below:
ExoPlayer.Builder(context).build().apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
play()
}
Step 3: Update the stateflow with the EXO Player created in step 2.
Finally, have a look at the function after putting things together:
fun createPlayerWithMediaItems(context: Context,) {
if (_playerState.value == null) {
// Create Media item
val mediaItem = MediaItem.Builder().setUri(Video_1).setMediaId("Video_1").build(),
// Create the player instance and update it to UI via stateFlow
_playerState.update {
ExoPlayer.Builder(context).build().apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
}
}
}
}
setMediaItemSet the media source to the player.prepare()will tell the player to prepare the media item to be played.playWhenReadyWill tell the player to start playing once the media item is ready.
Now, let’s call this function from PlayerRoute via LaunchedEffect as shown below:
@Composable
fun PlayerRoute(
modifier: Modifier = Modifier,
playerViewModel: PlayerViewModel = viewModel(),
) {
val exoPlayer = playerViewModel.playerState.collectAsStateWithLifecycle()
val context = LocalContext.current
Box(modifier.fillMaxSize()) {
exoPlayer.value?.let {
PlayerScreen(exoPlayer = it)
}
}
LaunchedEffect(Unit) {
playerViewModel.createPlayerWithMediaItems(context)
}
}
If you run the application, you should be able to see a video playback, and it can survive configuration changes.
Implementing a Playlist in ExoPlayer
Configure Playlist
Now let’s get into the playlist, first step is to create the list of media items that we intend to play. Have a look:
val mediaItems = listOf(
MediaItem.Builder().setUri(Video_1).setMediaId("Video_1").build(),
MediaItem.Builder().setUri(Video_2).setMediaId("Video_2").build(),
MediaItem.Builder().setUri(Video_3).setMediaId("Video_3").build(),
MediaItem.Builder().setUri(Video_4).setMediaId("Video_4").build(),
)
Then, instead of using setMediaItemWe’ll use setMediaItems and pass the list we created above.
ExoPlayer.Builder(context).build().apply {
setMediaItems(mediaItems)
prepare()
playWhenReady = true
}
Now, if you run the application upon finishing the first video, the player moves to the next media item in the list.
Design Player Controls to Manage Playback and Playlist
Now that we’ve created the playlist, it’s time to develop controls that allow users to navigate between media items using the previous and next buttons. To enhance the user experience, let’s also include play/pause, rewind, and forward buttons.
Let’s begin by creating a simple enum class that represents the player action type and maps it to the viewmodel.
enum class ActionType {
PLAY, PAUSE, REWIND, FORWARD, PREVIOUS, NEXT
}
Let’s wrap this enum inside a data, along with Any type variable, so that we can associate data along with action type on click actions in the UI to communicate to the view model.
Let’s wrap this enum inside a data class, along with a variable of typeAny, so that we can associate data with the ActionType when clicked on UI elements to communicate to the view model.
data class PlayerAction(
val actionType: ActionType,
val data: Any? = null,
)
Now, let’s create a composable function VideoControls with two parameters: Exo Player and a lambda function for the click action. Here we’ll have two states representing the playback status and controls visibility.
Now, let’s create a composable function called VideoControls that takes two parameters: an Exo Player and a lambda function representing the click action. This function will have two states: one for the playback status and another for the visibility of the controls. Have a look:
@Composable
fun VideoControls(
player: ExoPlayer,
playerActions: (PlayerAction) -> Unit,
) {
var isPlaying by remember { mutableStateOf(player.isPlaying) }
var controlsVisible by remember { mutableStateOf(true) }
|
Before going to the design part, let’s put these states to use:
Update states on necessary callbacks
isPlaying has to be updated whenever there is a change in the playback. For this, we’ll attach a local player listener to the player to get the playback updates. We’ll use DisposableEffect so that we can remove the listener when not required. Have a look:
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
}
}
Then, we need to make the controllers visible. We’ll toggle their visibility for every touch interaction and ensure that the controllers are removed from the player after three seconds of interaction. In this case, we’ll use LaunchedEffect because we don’t need to dispose any resources. Have a look:
LaunchedEffect(isPlaying, controlsVisible) {
if (isPlaying && controlsVisible) {
delay(3000)
controlsVisible = false
}
}
Let’s design the Controls
It’s quite a straightforward implementation. At the core, we use the Row component to align the buttons. To represent each action, we use the IconButton component from compose. At the top level, we use the Box component to represent the Row of actions or an empty Box based on the controlsVisible state. Have a look:
| @Composable | |
| fun VideoControls( | |
| player: ExoPlayer, | |
| playerActions: (PlayerAction) -> Unit, | |
| ) { | |
| var isPlaying by remember { mutableStateOf(player.isPlaying) } | |
| var controlsVisible by remember { mutableStateOf(true) } | |
| if (controlsVisible) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black.copy(alpha = 0.4f)) | |
| .clickable { controlsVisible = !controlsVisible }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.SpaceEvenly, | |
| modifier = Modifier.fillMaxWidth() | |
| ) { | |
| IconButton(onClick = { | |
| playerActions(PlayerAction(ActionType.PREVIOUS)) | |
| }) { | |
| Icon( | |
| imageVector = Icons.Default.SkipPrevious, | |
| contentDescription = "Previous", | |
| tint = Color.White, | |
| modifier = Modifier.size(48.dp) | |
| ) | |
| } | |
| IconButton(onClick = { | |
| playerActions(PlayerAction(ActionType.REWIND)) | |
| }) { | |
| Icon( | |
| imageVector = Icons.Default.Replay10, | |
| contentDescription = "Rewind 10 seconds", | |
| tint = Color.White, | |
| modifier = Modifier.size(48.dp) | |
| ) | |
| } | |
| IconButton(onClick = { | |
| playerActions(PlayerAction(if (player.isPlaying) ActionType.PAUSE else ActionType.PLAY)) | |
| }) { | |
| Icon( | |
| imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | |
| contentDescription = "Play/Pause", | |
| tint = Color.White, | |
| modifier = Modifier.size(64.dp) | |
| ) | |
| } | |
| IconButton(onClick = { | |
| playerActions(PlayerAction(ActionType.FORWARD)) | |
| }) { | |
| Icon( | |
| imageVector = Icons.Default.Forward10, | |
| contentDescription = "Forward 10 seconds", | |
| tint = Color.White, | |
| modifier = Modifier.size(48.dp) | |
| ) | |
| } | |
| IconButton(onClick = { | |
| playerActions(PlayerAction(ActionType.NEXT)) | |
| }) { | |
| Icon( | |
| imageVector = Icons.Default.SkipNext, | |
| contentDescription = "Next", | |
| tint = Color.White, | |
| modifier = Modifier.size(48.dp) | |
| ) | |
| } | |
| } | |
| } | |
| } else { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .clickable { controlsVisible = true } | |
| ) | |
| } | |
| DisposableEffect(player) { | |
| val listener = object : Player.Listener { | |
| override fun onIsPlayingChanged(isPlayingNow: Boolean) { | |
| isPlaying = isPlayingNow | |
| } | |
| } | |
| player.addListener(listener) | |
| onDispose { | |
| player.removeListener(listener) | |
| } | |
| } | |
| // Auto-hide controls after 3s | |
| LaunchedEffect(isPlaying, controlsVisible) { | |
| if (isPlaying && controlsVisible) { | |
| delay(3000) | |
| controlsVisible = false | |
| } | |
| } | |
| } |
Player actions in the View model
Let’s create a function in the view model with a PlayerAction as a parameter. Then handle the action type as shown below.
| fun executeAction(playerAction: PlayerAction) { | |
| when(playerAction.actionType) { | |
| ActionType.PLAY -> _playerState.value?.play() | |
| ActionType.PAUSE -> _playerState.value?.pause() | |
| ActionType.REWIND -> _playerState.value?.rewind() | |
| ActionType.FORWARD -> _playerState.value?.forward() | |
| ActionType.NEXT -> _playerState.value?.playNext() | |
| ActionType.PREVIOUS -> _playerState.value?.playPrevious() | |
| } | |
| } | |
| private fun ExoPlayer.rewind() { | |
| val newPosition = (currentPosition - 10_000).coerceAtLeast(0) | |
| seekTo(newPosition) | |
| } | |
| private fun ExoPlayer.forward() { | |
| val newPosition = (currentPosition + 10_000) | |
| .coerceAtMost(duration) | |
| seekTo(newPosition) | |
| } | |
| private fun ExoPlayer.playNext() { | |
| if (hasNextMediaItem()) { | |
| val nextIndex = currentMediaItemIndex + 1 | |
| val mediaItemId = getMediaItemAt(nextIndex) | |
| seekTo(nextIndex, 0) | |
| } | |
| } | |
| private fun ExoPlayer.playPrevious() { | |
| if ( | |
| isCommandAvailable(Player.COMMAND_SEEK_TO_MEDIA_ITEM) && | |
| hasPreviousMediaItem() | |
| ) { | |
| val previousIndex = currentMediaItemIndex - 1 | |
| val mediaItemId = getMediaItemAt(previousIndex) | |
| seekTo(previousIndex, 0) | |
| } | |
| } |
That’s all, now we just have to trigger the executeAction from the compose layer. The flow will execute based on the action type.
Playback Improvements
If you run the application, everything works fine. However, there are a couple of improvements we can make.
Lifecycle-aware playback
When the user clicks on the device’s home button and the app goes into the background, the video continues to play. This is because the PlayerSurface is not lifecycle-aware by default. We can fix this by executing play/pause actions on the player based on the lifecycle changes on the composable. We’ll use DisposableEffect for this purpose. Have a look:
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> playerViewModel.executeAction(PlayerAction(ActionType.PAUSE))
Lifecycle.Event.ON_RESUME -> playerViewModel.executeAction(PlayerAction(ActionType.PLAY))
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
The above code inside PlayerRoute will make sure to play and pause based on the lifecycle owner events.
Remember the current playback position
When users switch between playlist items, the video starts from the beginning instead of where the user left off. Developers must manually handle this issue. To resolve this problem, developers can manually track the current position and save it in the view model. When the user returns to the video, they can use the saved position to seek to that time.
Let’s start by creating a VideoItem data class with currentPosition as a Long variable.
data class VideoItem(
val currentPosition: Long = 0
)
We’ll have a hashmap in the view model to keep track of the positions. The key of the map is the video ID, and the value is VideoItem. Have a look:
private val hashMapVideoStates = mutableMapOf<String,VideoItem>()
The following is the function to update the hashmap with the current position while playback is ongoing.
fun updateCurrentPosition(id: String, position: Long) {
hashMapVideoStates[id] = hashMapVideoStates[id]?.copy(currentPosition = position)
?: VideoItem(currentPosition = position)
}
Finally, we need to trigger the above function from the PlayerRoute function every second. We can do this as shown below via LaunchedEffect. Have a look:
LaunchedEffect(Unit) {
while (true) {
exoPlayer.value?.currentMediaItem?.mediaId?.let {
playerViewModel.updateCurrentPosition(
it, exoPlayer.value?.currentPosition ?: 0
)
}
delay(1000)
}
}
Now, whenever the user triggers previous or next actions, we can fetch the current time from the hashMapVideoStates and apply the return value via seek function as shown below:
private fun ExoPlayer.playNext() {
if (hasNextMediaItem()) {
val nextIndex = currentMediaItemIndex + 1
val mediaItemId = getMediaItemAt(nextIndex)
val seekPosition = hashMapVideoStates[mediaItemId.mediaId]?.currentPosition ?: 0L
seekTo(nextIndex, seekPosition)
}
}
private fun ExoPlayer.playPrevious() {
if (
isCommandAvailable(Player.COMMAND_SEEK_TO_MEDIA_ITEM) &&
hasPreviousMediaItem()
) {
val previousIndex = currentMediaItemIndex - 1
val mediaItemId = getMediaItemAt(previousIndex)
val seekPosition = hashMapVideoStates[mediaItemId.mediaId]?.currentPosition ?: 0L
seekTo(previousIndex, seekPosition)
}
}
Job Offers
End Note:
Following is a link to the sample project, if you need it. Make sure to check out feature/bascis_play_list For this article related code:
In this part of the series, we explored the origins of the Media3 EXO player, its creation and management within the view model, and integration into Jetpack Compose. Additionally, we learned how to create playlists and manage video playback controls. In the next part, I’ll focus on Player listeners, timeline view implementation, analytics, the cache mechanism, and more.
That is all for now. I hope you learned something useful. Thanks for reading!
This article was previously published on proandroiddev.com.


