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.
Step 1: VideosScreen
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.
Step 2: VideoCard, VideoPlayer & VideoThumbnail
@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() }) | |
} | |
} | |
} |
- 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 }) | |
} |
- 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.
Step 3: Playing & pausing the video on tap
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.
Step 4: Playing & pausing on app lifecycle changes
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 — DisposableEffect. It 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.
Step 5: Pausing if item becomes invisible to user
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
- 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.
Step 6: Auto-playback
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.
- Manual play/pause approach described in this post:
2. Auto-playback version described in Step 6:
3. Dynamic thumbnail extraction using Coil: