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.

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) { SimpleExoPlayer.Builder(context).build() }
val videos by viewModel.videos.observeAsState()
val playingItemIndex by viewModel.currentlyPlayingIndex.observeAsState()
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 = MutableLiveData<List<VideoItem>>()
val currentlyPlayingIndex = MutableLiveData<Int?>()
init {
populateVideosWithTestData()
}
fun onPlayVideoClick(playbackPosition: Long, videoIndex: Int) {
when (currentlyPlayingIndex.value) {
null -> currentlyPlayingIndex.postValue(videoIndex)
videoIndex -> {
currentlyPlayingIndex.postValue(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.postValue(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: SimpleExoPlayer,
onClick: OnClick
) {
val isPlayerUiVisible = remember { mutableStateOf(false) }
val isPlayButtonVisible = if (isPlayerUiVisible.value) true else !isPlaying
Box {
if (isPlaying) {
VideoPlayer(exoPlayer) { uiVisible ->
if (isPlayerUiVisible.value) {
isPlayerUiVisible.value = uiVisible
} else {
isPlayerUiVisible.value = 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: SimpleExoPlayer,
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()
val isCurrentItemVisible = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow {
listState.visibleAreaContainsItem(playingItemIndex, videos)
}.collect { isItemVisible ->
isCurrentItemVisible.value = 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


    Lead Android Engineer

    ASOS
    London
    • Full Time
    apply now

    Developer (m/w/d) Backend/ Mobile

    Payback GmbH
    Cologne, Germany
    • Full Time
    apply now

    Senior Android Engineer – Big Release Team

    Zalando SE
    Berlin
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

Behind the Curtains

All smartphones have cameras, and we know we can use specific APIs to get amazing shots. But are they the best cameras? Probably not! What if we wanted to drive an external camera, much more powerful than a smartphone? How would we connect to it, and how would we trigger a shot? This and much more…
READ MORE

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
Nowadays authentication has become common in almost all apps. And many of us know…
READ MORE
blog
Collections are a set of interfaces and classes that implement highly optimised data structures.…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
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

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