I have been playing around with Compose
and recently implemented video playback in a list. While there are tons of great examples on
how to do it with a RecyclerView
, I couldn’t find any for Compose
.
So in this article, I will describe how I went about doing it in Compose
. This article assumes you have some familiarity with Compose
and Exoplayer
.
Let’s get started then - this is what we will be building.
Thinking it through
Before jumping into the code, let’s think about what we actually want to do. There are two scenarios:
- A single video item is visible
- Multiple video items are visible
In case of a single item, it’s pretty straightforward - we start the video playback as soon as the item is visible on the screen. In case of multiple videos however, we want to play the video which is closest to the center of the screen.
We also want to use a single Exoplayer
instance instead of creating a new one for each video.
With that in mind, let’s dive into the code 🤿
Detecting the currently playing item
val listState = rememberLazyListState()
val currentlyPlayingItem = determineCurrentlyPlayingItem(listState, state.items)
LazyColumn(state = listState) {
...
}
...
private fun determineCurrentlyPlayingItem(listState: LazyListState, items: TweetItems): TweetItem? {
val layoutInfo = listState.layoutInfo
val visibleTweets = layoutInfo.visibleItemsInfo.map { items[it.index] }
val tweetsWithVideo = visibleTweets.filter { it.hasAnimatedMedia }
return if (tweetsWithVideo.size == 1) {
tweetsWithVideo.first()
} else {
val midPoint = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2
val itemsFromCenter =
layoutInfo.visibleItemsInfo.sortedBy { abs((it.offset + it.size / 2) - midPoint) }
itemsFromCenter.map { items[it.index] }.firstOrNull { it.hasAnimatedMedia }
}
}
There’s a lot going on in the code so let’s go through it bit by bit. We will be providing our own liststate
to the LazyColumn
so we can use it to access the currently visible tweets. Moving onto the method determineCurrentlyPlayingItem
, we first check if any of the visible tweets have a video or gif in them. If we only find one such tweet, then we simply return that tweet. Else, we sort the column items by their distance from the center of the column. Once that is done, we return the first item from that list.
Managing Exoplayer instance
Next up, we need to maintain an instance of Exoplayer
and update it to play the currentlyPlayingItem
determined in the previous step. We will use remember
to maintain an instance of SimpleExoPlayer
for the lifetime of the screen.
val context = LocalContext.current
val exoPlayer = remember {
SimpleExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
}
}
For video playback, we will pass the currentlyPlayingItem
to the following method which will update the media source of the player.
@Composable
private fun updateCurrentlyPlayingItem(exoPlayer: SimpleExoPlayer, tweet: TweetItem?) {
val context = LocalContext.current
LaunchedEffect(tweet) {
exoPlayer.apply {
if (tweet != null)) {
val dataSourceFactory = DefaultDataSourceFactory(
context,
Util.getUserAgent(context, context.packageName)
)
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(tweet.media.url)))
setMediaSource(source)
prepare()
playWhenReady = true
} else {
stop()
}
}
}
}
However, if the tweet
is null
, we stop the playback so that any offscreen item isn’t playing video anymore.
While it might seem like we are done here, there is one little thing we have forgotten about.
Just because we are in Compose-land
doesn’t mean we get to ignore the Android lifecycle. We will need to pause/resume the playback based on the lifecycle state as well as dispose of the Exoplayer
instance when the lifecycle is destroyed.
Thankfully, Compose
provides a handy DisposableEffect
which allows you to perform a cleanup whenever the key changes. We will use a lifecycleOwner
as the key.
val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner) {
val lifecycle = lifecycleOwner.lifecycle
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.playWhenReady = false
}
Lifecycle.Event.ON_RESUME -> {
exoPlayer.playWhenReady = true
}
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.run {
stop()
release()
}
}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
}
}
This code is pretty self-explanatory - we just update the player
for various lifecycle events.
Playing the video
Last but not the least, we need to use the Exoplayer
instance we created to actually play the video. Since there is no PlayerView
Composable yet, we will leverage Compose
interoperability and use AndroidView
instead.
Box {
AndroidView(
factory = { context ->
context.inflate<PlayerView>(R.layout.player_view).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
player = exoPlayer
}
}
)
}
Fin
Well, that’s about it. I was pleasantly surprised by how simple the Compose
implementation is as compared to it’s View based counterpart as we don’t have to deal with the recycling of views.
Feel free to hit me up @SHKM9 if you have any further questions or thoughts!