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!

Discuss on Twitter

Related articles: