Blog Infos
Author
Published
Topics
,
Published

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.

Note: All the videos and game data in the article are taken from the awesome RAWG API.

 

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!

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 the Column.When fill 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!

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 MediaItems 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.

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.

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
}
)
}
}
}
view raw VideoItem.kt hosted with ❤ by GitHub

Video item

 

Previewing the screen now gives the following result:

 

Video playlist

Job Offers

Job Offers


    Senior Android Engineer – Big Release Team

    Zalando SE
    Berlin
    • Full Time
    apply now

    Android Developer

    ClearScore
    London (Hybrid role)
    • Full Time
    apply now

    Android Entwickler (m/w/d)

    CHECK24
    Augsburg, Berlin, Düsseldorf, Essen, Frankfurt, Leipzig, München und Münster
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

, ,

What does Recomposition mean to your app?

You’ve heard a lot that Jetpack Compose is a declarative UI toolkit and it recomposes only the components that changed. But what does it exactly mean? How does it apply not only in the scale…
Watch Video

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engi ...
Android

What does Recomposition mean to your app?

Aida Issayeva
Senior Software Engineer
Android

Jobs

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
}
view raw VideoPlayer.kt hosted with ❤ by GitHub

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 MediaItems.

@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.

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.

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.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
Yes! You heard it right. We’ll try to understand the complete OTP (one time…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu