Blog Infos
Author
Published
Topics
, , , ,
Published
Listeners — Buffering and Analytics, Timeline UI, and Cache Mechanism

Photo by Kelly Sikkema on Unsplash

Overview

This is the second part of the series that delves into the EXO player in Android. In the first part, we explored the origins of the player, its basic functionalities, integration with the Jetpack Compose, custom video controllers, playlist implementation, and some real-time use-case handling.

I recommend reading the first part before proceeding further to gain a comprehensive understanding.

In this part of the series, we’ll deep dive into player listeners to implement loading views during buffering and analytics. Then, we’ll implement a timeline view similar to the YouTube player, featuring a seekbar and a timer at the bottom. To conclude the article, we’ll introduce a caching mechanism to enhance playback speed.

Settle into your comfortable office space with a cup of coffee; this will be an interesting article.

Implementing Buffering Indicators

Let’s begin with a simple real-time use case for the player listener, which is nothing but showing the circular loading while the player is yet to be ready to start the playback or while buffering the video.

To achieve this functionality, we’ll subscribe to Player.Listener and override onPlaybackStateChanged, which provides us with access to playbackState. This allows us to determine the current playback state. As this is UI related, we’ll subscribe to this listener in our VideoControls compose function. Have a look at the listener subscription:

DisposableEffect(player) {
        val listener = object : Player.Listener {
            override fun onPlaybackStateChanged(playbackState: Int) {
                super.onPlaybackStateChanged(playbackState)
                duration = player.duration.coerceAtLeast(0L)
                if(playbackState == Player.STATE_BUFFERING) {
                    // show loading
                }
            }
        }
        player.addListener(listener)

        onDispose {
            player.removeListener(listener)
        }
    }

Now, we need to create a state within the VideoControls Compose that will be updated whenever a callback occurs in the listener. Based on this state, we’ll display either the play/pause icons or a circular loading view. The state we’re going to add in the VideoControls looks as follows:

var isBuffering by remember { mutableStateOf(player.isLoading) }

Before proceeding with the implementation, we’ll separate the concerns of the VideoControls function since it’s becoming too complex. We’ll create a separate compose function called ShowButtonControllers, which will contain all buttons related to the UI. Additionally, we’ll pass all the necessary states as parameters to this function.

After segregation, this is how VideoControls and ShowButtonControllers looks

 

@Composable
fun VideoControls(
player: ExoPlayer,
playerActions: (PlayerAction) -> Unit,
) {
var isPlaying by remember { mutableStateOf(player.isPlaying) }
var isBuffering by remember { mutableStateOf(player.isLoading) }
var controlsVisible by remember { mutableStateOf(true) }
if (controlsVisible) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { controlsVisible = !controlsVisible },
contentAlignment = Alignment.Center
) {
ShowButtonControllers(
isPlaying = isPlaying,
isBuffering = isBuffering,
playerActions = playerActions,
isPlayerPlaying = {
player.isPlaying
}
)
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { controlsVisible = true }
)
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
isBuffering = playbackState == Player.STATE_BUFFERING
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
}
}
// Auto-hide controls after 3s
LaunchedEffect(isPlaying, controlsVisible) {
if (isPlaying && controlsVisible) {
delay(3000)
controlsVisible = false
}
}
}
@Composable
fun ShowButtonControllers(
modifier: Modifier = Modifier,
isPlaying: Boolean,
isBuffering: Boolean,
isPlayerPlaying: () -> Boolean,
playerActions: (PlayerAction) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier.fillMaxWidth()
) {
IconButton(onClick = {
playerActions(PlayerAction(ActionType.PREVIOUS))
}) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = "Previous",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
IconButton(onClick = {
playerActions(PlayerAction(ActionType.REWIND))
}) {
Icon(
imageVector = Icons.Default.Replay10,
contentDescription = "Rewind 10 seconds",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
Row (
modifier = Modifier.size(48.dp)
) {
AnimatedVisibility(visible = isBuffering) {
CircularProgressIndicator(
Modifier.size(48.dp)
)
}
AnimatedVisibility(isBuffering.not()) {
IconButton(onClick = {
playerActions(PlayerAction(if (isPlayerPlaying()) ActionType.PAUSE else ActionType.PLAY))
}) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(64.dp)
)
}
}
}
IconButton(onClick = {
playerActions(PlayerAction(ActionType.FORWARD))
}) {
Icon(
imageVector = Icons.Default.Forward10,
contentDescription = "Forward 10 seconds",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
IconButton(onClick = {
playerActions(PlayerAction(ActionType.NEXT))
}) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "Next",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
}

That’s all we need to do. Whenever there’s buffering or the player isn’t ready to start playback, we are displaying a nice loading view to inform the user about the situation.

Playback Analytics

Analytics is one of the most common real-time use cases where we heavily depend on EXO Player listeners. Fortunately, we already have an AnalyticsListener out of the box from the Media3 EXO player library for this purpose.

To achieve scalable implementation, we can create a custom analytics class and extend it with the AnalyticsListener from EXO Player. Then we can override the functions as per our specific requirements and log them across various analytics platforms.

class LearningsPlayerAnalytics(): AnalyticsListener {

}

A few of the commonly used functions are onPlaybackStateChanged, onPlayerError, onMediaItemTransition, etc. I’ve also decided to pass the EXO player instance to this via the constructor, to get the video ID from the player and log the events along with that ID. To keep things simple, just print the events in the logcat, have a look at the implementation:

A few commonly used functions include onPlaybackStateChanged, onPlayerError, and onMediaItemTransition. I’ve also decided to pass the EXO player instance via the constructor to retrieve the video ID from the player media item and log the events along with that ID. To keep things simple, I’ll simply print the events in the logcat. For a more detailed look at the implementation, refer to the code below:

class LearningsPlayerAnalytics(
private val exoPlayer: ExoPlayer?
): AnalyticsListener {
@OptIn(UnstableApi::class)
override fun onPlayerReleased(eventTime: EventTime) {
super.onPlayerReleased(eventTime)
logEvent("Player Released")
}
override fun onPlaybackStateChanged(
eventTime: EventTime,
state: Int,
) {
val itemId = currentMediaItemTag()
when (state) {
Player.STATE_READY -> logEvent("Playback Ready time = ${eventTime.realtimeMs} and itemId = $itemId")
Player.STATE_ENDED -> logEvent("Playback Ended time = ${eventTime.realtimeMs} and itemId = $itemId")
Player.STATE_BUFFERING -> logEvent("Buffering time = ${eventTime.realtimeMs} and itemId = $itemId")
Player.STATE_IDLE -> logEvent("Player Idle time = ${eventTime.realtimeMs} and itemId = $itemId")
}
}
override fun onPlayerError(
eventTime: EventTime,
error: PlaybackException,
) {
currentMediaItemTag().let { itemId -> logEvent("Playback Error: ${error.message} time = ${eventTime.realtimeMs} and itemId = $itemId") }
}
override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) {
currentMediaItemTag().let { itemId ->
logEvent("Is Playing: $isPlaying time = ${eventTime.realtimeMs} and itemId = $itemId")
}
}
override fun onMediaItemTransition(eventTime: EventTime, mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(eventTime, mediaItem, reason)
logEvent("Media Item transitioned, reason = $reason and itemId = ${mediaItem?.mediaId}")
}
private fun currentMediaItemTag(): String? = exoPlayer?.currentMediaItem?.localConfiguration?.tag as? String
}
fun logEvent(value: String) {
Log.d("SivaGanesh_Debug", "PlayerViewModel logEvent 153: value = $value");
}

Here is the code to set the video ID as a tag to the media item of the EXO player:

MediaItem.Builder().setUri(Video_1).setMediaId("Video_1").setTag("Video_1").build(),

There are many more things that can be logged, such as the duration of playback, buffer, and audio-related information, among others. However, you’ll get the general idea of how to do it from the above simple implementation, so I’ll leave it for now.

Building a Custom Timeline View

The custom timeline view is nothing but a seekbar that allows you to jump between the video timeline and a view that displays the current and total duration, similar to how it works in the YouTube player.

Let’s begin by creating the Composable function to design the view. We’ll include the current player position, the total video duration, and the formatted time as parameters. Additionally, we’ll have a couple of lambda functions, one that returns a boolean variable to represent the seeking status and another that communicates the seekbar position after a seek action. Have a look:

@Composable
fun TimelineControllers(
    modifier: Modifier = Modifier,
    playerPosition: Long,
    duration: Long,
    seeking: (Boolean) -> Unit,
    formatedTime: String,
    seekPlayerToPosition:(Long) -> Unit,
)

Now, let’s begin with the Compose code. We’ll use Slider and Text Composable wrapped inside a Row. To customize the appearance of the Slider, we’ll utilize the track. Then, we need to calculate the fraction of the video that is watched to highlight it and dim down the rest of the slider. Take a look at the implementation:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimelineControllers(
modifier: Modifier = Modifier,
playerPosition: Long,
duration: Long,
seeking: (Boolean) -> Unit,
formatedTime: String,
seekPlayerToPosition:(Long) -> Unit,
) {
var position by remember { mutableLongStateOf(0L) }
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Slider(
value = position.toFloat().coerceAtMost(duration.toFloat()),
onValueChange = {
seeking(true)
position = it.toLong()
},
onValueChangeFinished = {
seekPlayerToPosition(position)
seeking(false)
},
valueRange = 0f..duration.toFloat(),
modifier = Modifier
.weight(1f) // Takes all the space except what Text needs
.padding(end = 8.dp), // small space between slider and text
colors = SliderDefaults.colors(
thumbColor = Color.Red,
activeTrackColor = Color.Red
),
thumb = {
Box(
modifier = Modifier
.size(12.dp)
.background(Color.Red, shape = CircleShape)
)
},
track = {
val fraction = if (duration == 0L) 0f else (position.toFloat().coerceAtMost(duration.toFloat()) / duration).coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.background(
Color.Gray.copy(alpha = 0.3f),
shape = RoundedCornerShape(1.5.dp)
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.height(3.dp)
.background(Color.Red, shape = RoundedCornerShape(1.5.dp))
)
}
}
)
androidx.compose.animation.AnimatedVisibility(visible = formatedTime.isNotEmpty()) {
Text(
text = formatedTime,
color = Color.White,
fontSize = 15.sp
)
}
}
LaunchedEffect(playerPosition) {
position = playerPosition
}
}

Now we need to integrate this function inside VideoControls, and supply all the states needed by updating them from the player parameter in VideoControls. Let’s start with the states to be added in the function:

Now, we need to integrate this function into VideoControls and provide all the necessary states that need to be updated from the player parameter in VideoControls. Let’s begin by listing the states that need to be added to the function:

var duration by remember { mutableStateOf(0L) }
var position by remember { mutableStateOf(0L) }
var isSeeking by remember { mutableStateOf(false) }
var formatedTime by remember { mutableStateOf("") }

Let’s begin with duration. We’ll utilize the onPlaybackStateChanged event, which we’ve already observed in the function to retrieve the duration state from the player. Take a look:

DisposableEffect(player) {
    val listener = object : Player.Listener {
        override fun onIsPlayingChanged(isPlayingNow: Boolean) {
        }

        override fun onPlaybackStateChanged(playbackState: Int) {
            super.onPlaybackStateChanged(playbackState)
            // Updating the duration, if not fallback to 0
            duration = player.duration.coerceAtLeast(0L)
        }
    }
    player.addListener(listener)

    onDispose {
        player.removeListener(listener)
    }
}

To update the position and formattedTime, we’ll use the LaunchedEffect with the keys as player and isSeeking. Whenever any of them changes, we’ll recalculate both position and formattedTime. To ensure the UI is in sync with the playback, we need to update them every 500 milliseconds. However, we need to be cautious and avoid recalculating them while the player is seeking. Here’s the implementation:

LaunchedEffect(player, isSeeking) {
    while (isActive) {
        if (player.isPlaying && isSeeking.not()) {
            position = player.currentPosition
            if (player.duration != duration) {
                duration = player.duration
            }
        }
        formatedTime = "${formatTime(position)} : ${formatTime(duration)}"
        delay(500)
    }
}

Finally, isSeeking is to be updated on the callback of the lambda function we’re sending to TimelineControllers. Have a look at the complete implementation:

@Composable
fun VideoControls(
player: ExoPlayer,
playerActions: (PlayerAction) -> Unit,
) {
var isPlaying by remember { mutableStateOf(player.isPlaying) }
var formatedTime by remember { mutableStateOf("") }
var isBuffering by remember { mutableStateOf(player.isLoading) }
var controlsVisible by remember { mutableStateOf(true) }
var position by remember { mutableStateOf(0L) }
var duration by remember { mutableStateOf(0L) }
var isSeeking by remember { mutableStateOf(false) }
if (controlsVisible) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { controlsVisible = !controlsVisible },
contentAlignment = Alignment.Center
) {
ShowButtonControllers(
isPlaying = isPlaying,
isBuffering = isBuffering,
playerActions = playerActions,
isPlayerPlaying = {
player.isPlaying
}
)
TimelineControllers(
modifier = Modifier.align(Alignment.BottomStart),
duration = duration,
playerPosition = position,
formatedTime = formatedTime,
seeking = { isSeeking = it }
) {
playerActions(PlayerAction(ActionType.SEEK, it))
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { controlsVisible = true }
)
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
duration = player.duration.coerceAtLeast(0L)
isBuffering = playbackState == Player.STATE_BUFFERING
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
}
}
// Auto-hide controls after 3s
LaunchedEffect(isPlaying, controlsVisible, isSeeking) {
if (isPlaying && controlsVisible) {
delay(3000)
if (isSeeking.not()) {
controlsVisible = false
}
}
}
LaunchedEffect(player, isSeeking) {
while (isActive) {
if (player.isPlaying && isSeeking.not()) {
position = player.currentPosition
if (player.duration != duration) {
duration = player.duration
}
}
formatedTime = "${formatTime(position)} : ${formatTime(duration)}"
delay(500)
}
}
}

To complete the timeline view, we need to handle the manual seek action to play the content from the desired position. To do this, we need to add SEEK as part of ActionType and handle it in the view model.

enum class ActionType {
    PLAY, PAUSE, REWIND, FORWARD, PREVIOUS, NEXT, SEEK,
}

Now in the view model, we’ll create a separate EXO Player extension function and seek the player to the given position. Have a look:

private fun ExoPlayer.seekWithValidation(position: Long?) {
    logEvent("seeking")
    position?.let {
        seekTo(position)
    }
}

That’s all, we’re done with the timeline view implementation with YouTube style.

To implement a cache mechanism, we’ll use SimpleCache from the Media3 EXO player library. Additionally, we’ll use Okhttp as the data source (this is optional, but I prefer Okhttp for HTTP clients).

To add okhttp as a data source, for which we need to add media3-datasource-okhttp. Add the following to the toml file

media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3ExoplayerDash" }

Then integrate it in the application via app-level gradle file:

implementation(libs.media3.datasource.okhttp)

Now that we’ve completed the integration part, let’s begin by creating a class to encapsulate the logic related to cache location, maximum cache size, and so on. Finally, we’ll create an instance of the SimpleCache class from the EXO player. Take a look:

@UnstableApi
object ExoPlayerCache {
private var simpleCache: SimpleCache? = null
fun getSimpleCache(context: Context): SimpleCache {
if (simpleCache == null) {
val cacheDir = File(context.cacheDir, "media_cache")
val cacheEvictor = LeastRecentlyUsedCacheEvictor(200L * 1024L * 1024L) // 200 MB
val databaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(cacheDir, cacheEvictor, databaseProvider)
}
return simpleCache!!
}
}

Now, we need to construct the CacheDataSource by setting OkHttpDataSource as the upstream data source factory and using the SimpleCache instance we created earlier. Have a look:

fun buildOkHttoDataSourceFactory(context: Context): DataSource.Factory {
val simpleCache = ExoPlayerCache.getSimpleCache(context)
val okHttpClient = OkHttpClient.Builder().build()
val okHttpDataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(okHttpDataSourceFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
return cacheDataSourceFactory
}

As a final step, we need to add the above-created data source to the EXO player while creating the instance of the player. Have a look:

ExoPlayer.Builder(context)
    .setMediaSourceFactory(
        DefaultMediaSourceFactory(
              buildOkHttoDataSourceFactory(context)
        )
    )
    .build().apply {
        setMediaItems(mediaItems)
        prepare()
        playWhenReady = true
    }

That’s all. If you run the application now, you will notice less buffering while navigating through the playlist.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Media Transition Handling — Real Time Playlist Problem

In the previous part of the series, we introduced a playlist feature. This feature allows us to track the current positions of each video, enabling us to resume playback of each video based on its progress.

It worked well, but the issue arises when the video reaches its end, and the playback transitions to the next video. In such a case, the current saved position and the total duration remain the same. Consequently, when the user returns to the previous video, it will automatically move to the next one, as the playback is completed.

We can handle this scenario by setting the current position to the beginning of the media item when the player listener callback onMediaItemTransition is triggered. This will happen if the media item reaches the end while the player is transitioning to the previous or next item. Have a look:

private fun trackMediaItemTransitions() {
_playerState.value?.addListener(
object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
_playerState.value?.currentMediaItemIndex?.let {
checkAndResetPreviousMediaItemProgress(it)
}
}
}
)
}
private fun checkAndResetPreviousMediaItemProgress(currentMediaItemIndex: Int) {
val previousIndex = currentMediaItemIndex - 1
if (previousIndex >= 0) {
_playerState.value?.getMediaItemAt(previousIndex)?.let { previousMediaItem ->
hashMapVideoStates[previousMediaItem.mediaId]?.let { previousVideoItem ->
if (previousVideoItem.duration - previousVideoItem.currentPosition <= 3000) {
hashMapVideoStates[previousMediaItem.mediaId] = previousVideoItem.copy(currentPosition = 0)
}
}
}
}
}
End Note:

Following is a link to the sample project, if you need it. Make sure to check out feature/timeline-listeners For this article related code:

That is all for now. I hope you learned something useful. Thanks for reading!

This article was previously published on proandroiddev.com.

Menu