Learn to integrate ExoPlayer with Jetpack Compose
This is part of a multi-part series about learning to use Jetpack Compose through code. This part of the series will be focusing on building the game videos screen and also covering the test cases for this screen.
Other articles in this series:
Note: All the videos and game data in the article are taken from the awesome RAWG API.
Dissecting the videos screen
Game videos screen
The first half of the screen has the video player which in our case is ExoPlayer and the second half of the screen has the video playlist. The video title is overlapped on the video player and we are also indicating the current playing video in our playlist. Let’s code!
Column and weight
We can use the weight modifier available to the children of
Column
to segregate our screen into two equal halves.
Size the element’s height proportional to its
weight
relative to other weighted sibling elements in theColumn
.Whenfill
is true, the element will be forced to occupy the whole height allocated to it.
@Composable | |
fun ShowGameVideos(gameVideos: GameVideosEntity) { | |
Column(modifier = Modifier.fillMaxSize()) { | |
// video player | |
VideoPlayer( | |
modifier = | |
Modifier.fillMaxWidth() | |
.weight(1f, fill = true) | |
.background(Color.Black) | |
) | |
// video playlist | |
VideoPlayList( | |
Modifier.fillMaxWidth() | |
.weight(1f, fill = true) | |
.background(Color.Gray) | |
) | |
} | |
} |
Initial game videos screen
I have given the background colours for top and bottom half as black and grey to illustrate the screen segregation.
Previewing the composable now gives the following screen:
Initial videos screen
Now that we have our segregation ready, let’s move on to the interesting part and start designing our first half which is the video player!
Video Player — ExoPlayer
At the time of writing, ExoPlayers’ PlayerView is a traditional android view. To inflate a traditional android view in our composable we can make use of
AndroidView.
For our video player, we can simply create an instance of SimpleExoPlayer and pass it to our
PlayerView
.
We also need to release the SimpleExoPlayer
when it is no longer needed. To handle this we can wrap our AndroidView
inside a DisposableEffect.
Now that we know what we want, let’s code!
@Composable | |
fun VideoPlayer(modifier: Modifier = Modifier) { | |
val context = LocalContext.current | |
// create our player | |
val exoPlayer = remember { | |
SimpleExoPlayer.Builder(context).build().apply { | |
this.prepare() | |
} | |
} | |
ConstraintLayout(modifier = modifier) { | |
val (title, videoPlayer) = createRefs() | |
// video title | |
Text( | |
text = "Current Title", | |
color = Color.White, | |
modifier = | |
Modifier.padding(16.dp) | |
.fillMaxWidth() | |
.wrapContentHeight() | |
.constrainAs(title) { | |
top.linkTo(parent.top) | |
start.linkTo(parent.start) | |
end.linkTo(parent.end) | |
} | |
) | |
// player view | |
DisposableEffect( | |
AndroidView( | |
modifier = | |
Modifier.testTag("VideoPlayer") | |
.constrainAs(videoPlayer) { | |
top.linkTo(parent.top) | |
start.linkTo(parent.start) | |
end.linkTo(parent.end) | |
bottom.linkTo(parent.bottom) | |
}, | |
factory = { | |
// exo player view for our video player | |
PlayerView(context).apply { | |
player = exoPlayer | |
layoutParams = | |
FrameLayout.LayoutParams( | |
ViewGroup.LayoutParams | |
.MATCH_PARENT, | |
ViewGroup.LayoutParams | |
.MATCH_PARENT | |
) | |
} | |
} | |
) | |
) { | |
onDispose { | |
// relase player when no longer needed | |
exoPlayer.release() | |
} | |
} | |
} | |
} |
Initial video player
Now that we have created the UI for our video player, let’s start playing the game videos!
First we need to create a playlist. To do this, we need a list of MediaItem. Once we have that, we simply need to provide these
MediaItem
s to our SimpleExoPlayer
.
@Composable | |
fun VideoPlayer( | |
modifier: Modifier = Modifier, | |
gameVideos: List<VideoResultEntity> | |
) { | |
val context = LocalContext.current | |
val mediaItems = arrayListOf<MediaItem>() | |
// create MediaItem | |
gameVideos.forEach { | |
mediaItems.add( | |
MediaItem.Builder() | |
.setUri(it.video) | |
.setMediaId(it.id.toString()) | |
.setTag(it) | |
.setMediaMetadata( | |
MediaMetadata.Builder() | |
.setDisplayTitle(it.name) | |
.build() | |
) | |
.build() | |
) | |
} | |
val exoPlayer = remember { | |
SimpleExoPlayer.Builder(context).build().apply { | |
this.setMediaItems(mediaItems) | |
this.prepare() | |
this.playWhenReady = true | |
} | |
} | |
// views same as above code snippet | |
} |
Play videos
I will explain why we are setting MediaMetadata
for our MediaItem
shortly. Let’s go ahead and preview and see what we got so far.
Play video playlist
We are able to play our videos but we are not displaying the current video title as of now.
Video title
We need to display the current video title and also hide the title once it is shown. To do this we can add a listener to our SimpleExoPlayer
and listen to the callbacks.
Whenever a new MediaItem
starts playing in the playlist, we can get this callback in onMediaItemTransition.
If we want to hide the video title after the video plays for sometime we can do that by listening to the onEvents callback as this callback will be triggered every time the player state changes.
From the compose side of things, to animate the appearance and disappearance we can make use of AnimatedVisibility.
Note: At the time of writing, AnimatedVisibility is experimental and may change in the future and be different as described in this article.
@ExperimentalAnimationApi | |
@Composable | |
fun VideoPlayer( | |
modifier: Modifier = Modifier, | |
gameVideos: List<VideoResultEntity> | |
) { | |
val context = LocalContext.current | |
val mediaItems = arrayListOf<MediaItem>() | |
val videoTitle = remember { | |
mutableStateOf(gameVideos[0].name) | |
} | |
val visibleState = remember { mutableStateOf(true) } | |
gameVideos.forEach { | |
mediaItems.add( | |
MediaItem.Builder() | |
.setUri(it.video) | |
.setMediaId(it.id.toString()) | |
.setTag(it) | |
.setMediaMetadata( | |
MediaMetadata.Builder() | |
.setDisplayTitle(it.name) | |
.build() | |
) | |
.build() | |
) | |
} | |
val exoPlayer = remember { | |
SimpleExoPlayer.Builder(context).build().apply { | |
this.setMediaItems(mediaItems) | |
this.prepare() | |
this.playWhenReady = true | |
addListener( | |
object : Player.Listener { | |
override fun onEvents( | |
player: Player, | |
events: Player.Events | |
) { | |
super.onEvents(player, events) | |
// hide title only when player duration is at least 200ms | |
if (player.currentPosition >= 200) | |
visibleState.value = false | |
} | |
override fun onMediaItemTransition( | |
mediaItem: MediaItem?, | |
reason: Int | |
) { | |
super.onMediaItemTransition( | |
mediaItem, | |
reason | |
) | |
// everytime the media item changes show the title | |
visibleState.value = true | |
videoTitle.value = | |
mediaItem?.mediaMetadata | |
?.displayTitle.toString() | |
} | |
} | |
) | |
} | |
} | |
ConstraintLayout(modifier = modifier) { | |
val (title, videoPlayer) = createRefs() | |
AnimatedVisibility( | |
visible = visibleState.value, | |
modifier = | |
Modifier.constrainAs(title) { | |
top.linkTo(parent.top) | |
start.linkTo(parent.start) | |
end.linkTo(parent.end) | |
} | |
) { | |
Text( | |
text = videoTitle.value, | |
color = Color.White, | |
fontWeight = FontWeight.Bold, | |
modifier = | |
Modifier.padding(16.dp) | |
.fillMaxWidth() | |
.wrapContentHeight() | |
) | |
} | |
// player view same as before | |
} | |
} |
Video title of current playing video
As you can see, initially setting our MediaItem
with MediaMetadata
helped us to get the video title of the current playing video.
Running the app now will give the following result:
Video title with playlist
Now that we have our video player setup, let’s move onto displaying the game videos playlist.
Videos playlist
As you would have already guessed, we can make use of LazyColumn for the displaying the playlist.
LazyColum is a vertically scrolling list that only composes and lays out the currently visible items.
@Composable | |
fun VideoPlayList( | |
modifier: Modifier = Modifier, | |
gameVideos: List<VideoResultEntity> | |
) { | |
LazyColumn(modifier = modifier) { | |
itemsIndexed( | |
items = gameVideos, | |
key = { _, item -> item.id } | |
) { index, item -> | |
VideoItem(index = index, video = item) | |
} | |
} | |
} |
Video playlist
Let’s design our VideoItem
now.
Video item
Here, the Now Playing text and the Play Image should only be displayed for the current playing item.
@Composable | |
fun VideoItem(index: Int, video: VideoResultEntity) { | |
val currentlyPlaying = remember { mutableStateOf(true) } | |
ConstraintLayout( | |
modifier = | |
Modifier.testTag("VideoParent") | |
.padding(8.dp) | |
.wrapContentSize() | |
) { | |
val (thumbnail, play, title, nowPlaying) = | |
createRefs() | |
// thumbnail | |
Image( | |
contentScale = ContentScale.Crop, | |
painter = | |
rememberImagePainter( | |
data = video.preview, | |
builder = { | |
placeholder(R.drawable.app_logo) | |
crossfade(true) | |
} | |
), | |
contentDescription = "Thumbnail", | |
modifier = | |
Modifier.height(120.dp) | |
.width(120.dp) | |
.clip(RoundedCornerShape(20.dp)) | |
.shadow(elevation = 20.dp) | |
.constrainAs(thumbnail) { | |
top.linkTo( | |
parent.top, | |
margin = 8.dp | |
) | |
start.linkTo( | |
parent.start, | |
margin = 8.dp | |
) | |
bottom.linkTo(parent.bottom) | |
} | |
) | |
// title | |
Text( | |
text = video.name, | |
modifier = | |
Modifier.constrainAs(title) { | |
top.linkTo(thumbnail.top, margin = 8.dp) | |
start.linkTo( | |
thumbnail.end, | |
margin = 8.dp | |
) | |
end.linkTo(parent.end, margin = 8.dp) | |
width = Dimension.preferredWrapContent | |
height = Dimension.wrapContent | |
}, | |
color = Color.Black, | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
softWrap = true, | |
) | |
// divider | |
Divider( | |
modifier = | |
Modifier.padding(horizontal = 8.dp) | |
.testTag("Divider"), | |
color = Color(0xFFE0E0E0) | |
) | |
// show only if video is currently playing | |
if (currentlyPlaying.value) { | |
// play button image | |
Image( | |
contentScale = ContentScale.Crop, | |
colorFilter = | |
if (video.preview.isEmpty()) | |
ColorFilter.tint(Color.White) | |
else | |
ColorFilter.tint(Color(0xFFF50057)), | |
painter = | |
painterResource( | |
id = R.drawable.ic_play | |
), | |
contentDescription = "Playing", | |
modifier = | |
Modifier.height(50.dp) | |
.width(50.dp) | |
.graphicsLayer { | |
clip = true | |
shadowElevation = 20.dp.toPx() | |
} | |
.constrainAs(play) { | |
top.linkTo(thumbnail.top) | |
start.linkTo(thumbnail.start) | |
end.linkTo(thumbnail.end) | |
bottom.linkTo(thumbnail.bottom) | |
} | |
) | |
// Now playing text | |
Text( | |
text = "Now Playing", | |
color = Color(0xFFF50057), | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
modifier = | |
Modifier.constrainAs(nowPlaying) { | |
top.linkTo( | |
title.bottom, | |
margin = 8.dp | |
) | |
start.linkTo( | |
thumbnail.end, | |
margin = 8.dp | |
) | |
bottom.linkTo( | |
thumbnail.bottom, | |
margin = 8.dp | |
) | |
end.linkTo( | |
parent.end, | |
margin = 8.dp | |
) | |
width = | |
Dimension.preferredWrapContent | |
height = | |
Dimension.preferredWrapContent | |
} | |
) | |
} | |
} | |
} |
Video item
Previewing the screen now gives the following result:
Video playlist
Job Offers
We have almost reached our desired state. First, we need to know which item is currently playing. Also, once we click on an item we need to start playing that particular video.
We can get the info about current playing item from our video player i.e. SimpleExoPlayer
and whenever the current playing item changes we can notify our PlayList
and update it accordingly.
Similarly, on click of any item in our PlayList
we need to again notify our SimpleExoPlayer
to start playing that particular MediaItem
.
@ExperimentalAnimationApi | |
@Composable | |
fun ShowGameVideos(gameVideos: GameVideosEntity) { | |
val playingIndex = remember { mutableStateOf(0) } | |
// keep track of current playing video | |
fun onVideoChange(index: Int) { | |
playingIndex.value = index | |
} | |
Column(modifier = Modifier.fillMaxSize()) { | |
VideoPlayer( | |
modifier = | |
Modifier.fillMaxWidth() | |
.weight(1f, fill = true) | |
.background(Color.Black), | |
gameVideos = gameVideos.results, | |
currentPlaying = playingIndex, | |
onVideoChange = { newIndex -> | |
onVideoChange(newIndex) | |
} | |
) | |
VideoPlayList( | |
Modifier.fillMaxWidth().weight(1f, fill = true), | |
gameVideos = gameVideos.results, | |
currentPlaying = playingIndex, | |
onVideoChange = { newIndex -> | |
onVideoChange(newIndex) | |
} | |
) | |
} | |
} |
Game video and playlist
This is our parent composable and our single source of truth to both VideoPlayer
and VideoPlayList
about current playing item.
@ExperimentalAnimationApi | |
@Composable | |
fun VideoPlayer( | |
modifier: Modifier = Modifier, | |
gameVideos: List<VideoResultEntity>, | |
currentPlaying: State<Int>, | |
onVideoChange: (Int) -> Unit | |
) { | |
val videoTitle = remember { | |
mutableStateOf( | |
gameVideos[currentPlaying.value].name | |
) | |
} | |
val exoPlayer = remember { | |
SimpleExoPlayer.Builder(context).build().apply { | |
this.setMediaItems(mediaItems) | |
this.prepare() | |
addListener( | |
object : Player.Listener { | |
override fun onEvents( | |
player: Player, | |
events: Player.Events | |
) { | |
super.onEvents(player, events) | |
// hide title only when player duration is at least 200ms | |
if (player.currentPosition >= 200) | |
visibleState.value = false | |
} | |
override fun onMediaItemTransition( | |
mediaItem: MediaItem?, | |
reason: Int | |
) { | |
super.onMediaItemTransition( | |
mediaItem, | |
reason | |
) | |
// everytime media item changes notify playlist about current playing | |
onVideoChange( | |
this@apply.currentPeriodIndex | |
) | |
// everytime the media item changes show the title | |
visibleState.value = true | |
videoTitle.value = | |
mediaItem?.mediaMetadata | |
?.displayTitle.toString() | |
} | |
} | |
) | |
} | |
} | |
// everytime an item in playlist is clicked play that video | |
exoPlayer.seekTo(currentPlaying.value, C.TIME_UNSET) | |
exoPlayer.playWhenReady = true | |
// rest of things remain same | |
} |
Video player to play videos
Since we get a callback whenever a new MediaItem
starts playing in onMediaItemTransition we can update current playing item here. Also, we can use the seekTo method to tell SimpleExoPlayer
to play an item at a particular index in our MediaItem
s.
@Composable | |
fun VideoPlayList( | |
modifier: Modifier = Modifier, | |
gameVideos: List<VideoResultEntity>, | |
currentPlaying: State<Int>, | |
onVideoChange: (Int) -> Unit | |
) { | |
LazyColumn(modifier = modifier) { | |
itemsIndexed( | |
items = gameVideos, | |
key = { _, item -> item.id } | |
) { index, item -> | |
VideoItem( | |
index = index, | |
video = item, | |
currentPlaying = currentPlaying, | |
onVideoChange = onVideoChange | |
) | |
} | |
} | |
} |
Final video playlist
@Composable | |
fun VideoItem( | |
index: Int, | |
video: VideoResultEntity, | |
currentPlaying: State<Int>, | |
onVideoChange: (Int) -> Unit | |
) { | |
val currentlyPlaying = remember { | |
mutableStateOf(false) | |
} | |
currentlyPlaying.value = index == currentPlaying.value | |
ConstraintLayout( | |
modifier = | |
Modifier.testTag("VideoParent") | |
.padding(8.dp) | |
.wrapContentSize() | |
.clickable { | |
// notify video player to play this video | |
onVideoChange(index) | |
} | |
) { | |
// rest of the things remain same | |
} | |
} |
Final video item
Finally, on clicking of any item in our PlayList
we need to tell the SimpleExoPlayer
to play that particular video by updating the current playing item.
Running the app now gives the following result:
Game video screen
Looks pretty good! One thing we have not added here is that, what should happen when the app is put to background and the video is playing? Ideally we would want to pause the video and resume it once the app is back to foreground.
I will leave this to you. A hint — you need to observe the lifecycle state.
Testing the composables
Now that we have designed our game video screen, let’s go ahead and write a test for it. Here we will not be testing the ExoPlayer and will be focusing on our compose UI.
class GameVideoTest() { | |
@get:Rule val composeTestRule = createComposeRule() | |
@Test | |
fun vide_player_and_playlist_show_be_shown() { | |
composeTestRule.setContent { | |
ShowGameVideos( | |
gameVideos = | |
FakeGamesData.getFakeGameVideos() | |
) | |
} | |
// Video player should be shown | |
composeTestRule | |
.onNodeWithTag("VideoPlayer") | |
.assertIsDisplayed() | |
// Thubnail of video should be shown in playlist | |
composeTestRule | |
.onAllNodesWithContentDescription("Thumbnail") | |
.assertCountEquals(3) | |
// Play button should be shown for currently playing video in playlist | |
composeTestRule | |
.onNodeWithContentDescription("Playing") | |
.assertIsDisplayed() | |
// Now playing should be shown for currently playing video in playlist | |
composeTestRule | |
.onNodeWithText("Now Playing") | |
.assertIsDisplayed() | |
} | |
} | |
object FakeGamesData { | |
fun getFakeGameVideos(): GameVideosEntity { | |
// provide fake data | |
} | |
} |
Game video test
The test is pretty self-explanatory. We are passing in fake GameVideosEntity
to our composable and then asserting that all the views are displayed.
I will leave the rest of the test cases to you. Use all your creativity and make sure you cover as many test cases as possible for this screen!
You can find the complete source code with all the tests for the video screen in this repository.
What’s next?
In this post we have designed our game videos screen and also tested the same. This is the end of this series but not the app. Pull requests are welcome for minor changes. If you want to propose major changes or add features please feel free to start a discussion or open an issue!
Thanks for reading! If you liked the article please do leave a clap 👏 and don’t forget to subscribe and follow to get regular updates! 🙂 You can also connect with me on LinkedIn.
Additional Resources
- Official ExoPlayer docs to dive deeper into the library
- Android Developers website to learn more about lazy lists in Jetpack Compose
- Android Developers website to learn more about animation in Jetpack Compose
- Android Developers website to dive deeper into testing in Jetpack Compose