Blog Infos
Author
Published
Topics
, ,
Published

Article will cover both manual & auto-playback of videos in an efficient way, storing/restoring last played video position, pausing playback if video card is not visible to the user and handling application lifecycle. ExoPlayer will be used for video playback, and these videos as a test data set. Coil will be used for displaying the video thumbnails.

Video shows manual play/pause & auto-playback versions side to side.

Article will cover both manual & auto-playback of videos in an efficient way, storing/restoring last played video position, pausing playback if video card is not visible to the user and handling application lifecycle. ExoPlayer will be used for video playback, and these videos as a test data set. Coil will be used for displaying the video thumbnails.

Let’s start implementing all of those features step by step. First we create a composable which will contain the exoPlayer instance, list of videos and playingItemIndex:

@Composable
fun VideosScreen(viewModel: VideosViewModel = hiltViewModel()) {
val context = LocalContext.current
val exoPlayer = remember(context) { ExoPlayer.Builder(context).build() }
val videos by viewModel.videos.collectAsStateWithLifecycle()
val playingItemIndex by viewModel.currentlyPlayingIndex.collectAsStateWithLifecycle()
LazyColumn {
itemsIndexed(items = videos, key = { _, video -> video.id }) { index, video ->
VideoCard(
videoItem = video,
exoPlayer = exoPlayer,
isPlaying = index == playingItemIndex,
onClick = {
viewModel.onPlayVideoClick(exoPlayer.currentPosition, index)
}
)
}
}
}

Very important part here is using keys for the lazyColumn. In short: we provide a key which enables item state to be consistent across data-set changes

  • isPlaying — we can always know if video is playing or not. If it’s playing — playingItemIndex will represent the position of the item in the list, if nothing is playing — null. We need this field to know if we should show play/pause icon, and thumbnail image or playerView.
  • VideoCard — composable which exposes click event on play/pause icon. Click will be handled inside the viewModel. Notice that we’re passing current playback position from exoPlayer as well as the index of clicked item. We’ll use it to save & restore the playback position for each video.
@HiltViewModel
class VideosViewModel @Inject constructor() : ViewModel() {
val videos = MutableStateFlow<List<VideoItem>>(listOf())
val currentlyPlayingIndex = MutableStateFlow<Int?>(null)
init {
populateVideosWithTestData()
}
fun onPlayVideoClick(playbackPosition: Long, videoIndex: Int) {
when (currentlyPlayingIndex.value) {
null -> currentlyPlayingIndex.value = videoIndex
videoIndex -> {
currentlyPlayingIndex.value = null
videos.value = videos.value!!.toMutableList().also { list ->
list[videoIndex] = list[videoIndex].copy(lastPlayedPosition = playbackPosition)
}
}
else -> {
videos.value = videos.value!!.toMutableList().also { list ->
list[currentlyPlayingIndex.value!!] = list[currentlyPlayingIndex.value!!].copy(lastPlayedPosition = playbackPosition)
}
currentlyPlayingIndex.value = videoIndex
}
}
}
}

Whenever the play/pause icon is clicked, we’re checking for three scenarios:

  • currentlyPlayingIndex is null — video is not playing at the moment, so we are assigning the videoIndex to currentlyPlayingIndex.
  • currentlyPlayingIndex is the same as videoIndex clicked — this means that the same video is already playing, and we want to pause the playback.
    That’s why we assign null to currentlyPlayingIndex. To store the last played video position we’re mutating list, and saving the position which we’ve got from exoPlayer.
@Composable
fun VideoCard(
videoItem: VideoItem,
isPlaying: Boolean,
exoPlayer: ExoPlayer,
onClick: OnClick
) {
var isPlayerUiVisible by remember { mutableStateOf(false) }
val isPlayButtonVisible = if (isPlayerUiVisible) true else !isPlaying
Box {
if (isPlaying) {
VideoPlayer(exoPlayer) { uiVisible ->
isPlayerUiVisible = when {
isPlayerUiVisible -> uiVisible
else -> true
}
}
} else {
VideoThumbnail(videoItem.thumbnail)
}
if (isPlayButtonVisible) {
Icon(
painter = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
modifier = Modifier.clickable { onClick() })
}
}
}
view raw VideoCard.kt hosted with ❤ by GitHub
  • If current item is played, we’re building VideoPlayer and providing it the exoPlayer instance, so the latter can be attached to the playerView.
  • VideoPlayer composable will expose a lambda which tells us whether the player UI is visible or not ( the current playback time, video duration & seekBar). This allows us to synchronise behaviour between play/pause icon & the old view system UI provided from the playerView.
  • isPlayingButtonVisible — controls whether we show or hide the play/pause icon.
  • VideoThumbnail shows image from a url.
@Composable
fun VideoPlayer(
exoPlayer: ExoPlayer,
onControllerVisibilityChanged: (uiVisible: Boolean) -> Unit
) {
val context = LocalContext.current
val playerView = remember {
val layout = LayoutInflater.from(context).inflate(R.layout.video_player, null, false)
val playerView = layout.findViewById(R.id.playerView) as PlayerView
playerView.apply {
setControllerVisibilityListener { onControllerVisibilityChanged(it == View.VISIBLE) }
player = exoPlayer
}
}
AndroidView({ playerView })
}
view raw VideoPlayer.kt hosted with ❤ by GitHub
  • We’re creating playerView here, providing the exoPlayer instance to it & placing it in AndroidView , which is a wrapper that allows usage of Views in the compose world.
  • R.layout.video_player — xml file containing PlayerView.

We’ve got the UI, know the index of video which should be played.
It’s time to play.

LaunchedEffect(playingItemIndex) {
if (playingItemIndex == null) {
exoPlayer.pause()
} else {
val video = videos[playingItemIndex]
exoPlayer.setMediaItem(MediaItem.fromUri(video.mediaUrl), video.lastPlayedPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}

We’ll be using a LaunchedEffect to play and pause videos. This code will be invoked only when the playingItemIndex value is changed, so we’re not executing it upon each recomposition.

DisposableEffect(exoPlayer) {
val lifecycleObserver = LifecycleEventObserver { _, event ->
if (playingItemIndex == null) return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_START -> exoPlayer.play()
Lifecycle.Event.ON_STOP -> exoPlayer.pause()
}
}
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
exoPlayer.release()
}
}

We’re introducing another compose side effect — DisposableEffectIt will be invoked single time upon composable creation ( except the onDispose{} block) so we’ll create a lifecycleEventObserver here. Observer allows us to pause video playback if app goes to background/screen goes off, and resume playback when the composable is active again. onDispose will be invoked when the composable leaves the composition, so that’s the place to release the exoPlayer & unregister lifecycleObserver.

This scenario can happen when video is played, but card is already off the visible screen area. Let’s introduce changes to the VideosScreen composable.

@Composable
fun VideosScreen(viewModel: VideosViewModel = hiltViewModel()) {
val listState = rememberLazyListState()
val playingItemIndex by viewModel.currentlyPlayingIndex.observeAsState()
var isCurrentItemVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow {
listState.visibleAreaContainsItem(playingItemIndex, videos)
}.collect { isItemVisible ->
isCurrentItemVisible = isItemVisible
}
}
}
  • listState — state object that allows us to observe LazyColumn scroll related events.
  • isCurrentItemVisible — will be set true if playing item is visible, false otherwise.
  • We’re introducing another LaunchedEffect here, that will be invoked only during the initial VideosScreen composition. Inside we are using snapshotFlow which converts the listState into a cold Flow, and emits new values when one of the state objects read inside the snapshotFlow block mutates. Whenever this happens, we’re updating the isCurrentItemVisible.
private fun LazyListState.visibleAreaContainsItem(
currentlyPlayedIndex: Int?,
videos: List<VideoItem>
): Boolean {
return when {
currentlyPlayedIndex == null -> false
videos.isEmpty() -> false
else -> {
layoutInfo.visibleItemsInfo.map { videos[it.index] }.contains(videos[currentlyPlayedIndex])
}
}
}

This approach is index based, but you can always use object references or ids if it better suits your needs. LazyListState.layoutInfo.visibleItemsInfo tells us which indexes are visible, we convert them to video items and find if there is a match with currently played video.

Now that we have isCurrentItemVisible, we can to use it:

LaunchedEffect(isCurrentItemVisible.value) {
if (!isCurrentItemVisible.value && playingItemIndex != null) {
viewModel.onPlayVideoClick(exoPlayer.currentPosition, playingItemIndex!!)
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Improving LazyColumn performance

Do you ever look at a LazyColumn and wonder: what is going on inside? Under a simple API surface, you’ll see arguably the most complex component in the Compose UI.
Watch Video

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Improving LazyColumn performance

Andrei Shikov
Compose
Google

Jobs

  • As soon as item is not visible, we’re invoking onPlayVideoClick which will execute the previously described logic of storing the playback position, and pausing the player.

For this we need to do a few adjustments to the snapshotFlow. Before it was responsible for providing us with info with whether the playing item is visible or not. Now, it will tell us which item is “in the focus“. To be precise — if we’re on the top of the list — first item will play. If we’ve scrolled to the bottom of the list — last item will play. Everything in between will be played using a closest one to the centre strategy.

Since the article is already long enough, I’ll put the complete, working samples below.

  1. Manual play/pause approach described in this post:

2. Auto-playback version described in Step 6:

3. Dynamic thumbnail extraction using Coil:

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
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
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

1 Comment. Leave new

  • Tony
    16.04.2022 14:31

    Why is there a delay starting to play the video? Even if .m38u files used there is a delay before the video starts. Can you explain this?

Leave a Reply

Your email address will not be published. Required fields are marked *

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

Menu