Recently, I had to work on displaying a custom timeline for a video playlist. Since a picture speaks a thousand words let me just show you what it looked like:
Not a picture but still…
As you can see, there are a few things which need to be accomplished here:
-
Rendering the timeline
-
Scrolling automatically based on the video playback
-
Seeking the video player if we manually scroll the timeline
I used ExoPlayer for video playback and a RecyclerView for implementing the timeline. Additionally, I am using Reclaim to simplify dealing with the RecyclerView adapter.
You might be wondering why didn’t I look into implementing a custom seekbar
for ExoPlayer instead of using RecyclerView
? Well, we also needed to support
selecting and rearranging video segments down the line. So keeping that in
mind, going with RecyclerView
made the most sense.
1. Rendering the timeline
First of all, we will need to find the total duration of the media we are playing. We will listen to onTimelineChanged from Player.EventListener
which is called whenever the videos in the timeline are added, updated or removed. We can then query all of the videos to determine their duration.
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
durationList.clear()
val timeline = player.currentTimeline
var totalTime = 0F
val tempWindow = Timeline.Window()
for (i in 0 until timeline.windowCount) {
val windowDuration = timeline.getWindow(i, tempWindow).durationMs
totalTime += windowDuration
durationList.add(Duration(windowDuration))
}
if (totalTime > 0) {
renderTimeline()
}
}
We need to check for totalTime > 0
since ExoPlayer returns a Long.MIN_VALUE + 1
for an unknown or unset duration. You will get this value for videos if ExoPlayer is still buffering them (yes, buffering takes place with local videos as well).
Now that we have the total duration, it’s time to render our timeline. Each second of the timeline occupies 50px of space (denoted by SEGMENT_MULTIPLIER
). So the width of each adapter item will be videoDuration * SEGMENT_MULTIPLIER
:
private fun renderTimeline() {
val segmentAdapterItems = videos.mapIndexed { i, video ->
VideoSegmentAdapterItem(
video,
durationList[i].seconds * SEGMENT_MULTIPLIER,
segmentBackgrounds[i]
)
}
timelineAdapter.replaceItems(segmentAdapterItems, true)
}
We also need to make sure that the timeline starts off at the center of the
screen but can scroll to the beginning. For this, I calculated the screen’s
width and set it as the start & end padding of RecyclerView
. Next the most
important part: setting android:clipToPadding=false
. For those of you who
are unfamiliar with this attribute, here’s a short explanation:
A
ViewGroup
does not draw it’s children under it’s padding area. But you can prevent that by setting clipToPadding to false.
So even though the timeline starts off with some padding, scrolling it allows the children to be shown in the padded area. This is what gives us the effect that we see in the GIF (pronounced /dʒɪf/ JIF 👻) above.
2. Auto-scrolling the timeline
Moving on to more complicated stuff, we now want to auto-scroll the timeline when the media is playing. First up, we’ll need to monitor the playback position of ExoPlayer. Interestingly, it doesn’t provide a callback to monitor the current progress. Instead, (and this makes much more sense) it’s up to you to decide how and when you want to access the current playback position.
I decided to use our old friend RxJava to poll the current playback information from ExoPlayer:
private fun observePlayback() {
disposable = timerObservable
.subscribe(::scrollTimeline)
}
private val timerObservable: Observable<PlaybackInfo>
get() =
Observable.interval(PLAYBACK_POLL_DURATION, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.map {
val currentPosition = player.currentPosition
val currentWindowIndex = player.currentWindowIndex
PlaybackInfo(currentPosition, currentWindowIndex)
}
In this example, currentPosition
represents the playback position (in ms)
for the video represented by currentWindowIndex
in the playlist. We’ll also
keep a reference to the subscription as we’ll be needing it later.
Now onto the scrollTimeline
method:
private fun scrollTimeline(info: PlaybackInfo) {
val window = info.window
val position = info.position
val windowOffset = durationList.subList(0, window).map { it.seconds }.sum()
val positionInSeconds = position * MILLIS_TO_SECONDS_MULTIPLIER
val scrollPosition = (windowOffset + positionInSeconds) * SEGMENT_MULTIPLIER
layoutManager.scrollToPositionWithOffset(
0,
-scrollPosition.toInt()
)
}
The window offset represents the duration of videos before the currently
playing video. We then simply add it to the current position (after converting
it to seconds) and then calculate how many pixels we should scroll by
multiplying it with the SEGMENT_MULTIPLIER
. Then it’s just a matter of
scrolling the timeline by that many pixels using its layout manager.
3. Seeking the player when the user scrolls the timeline
We’ll use a scroll listener on the RecyclerView
to accomplish the seeking
behavior:
private val seekListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && player.isPlaying) {
player.playWhenReady = false
disposable?.dispose()
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (!player.isPlaying) seekPlayer()
}
}
We’ll then pause the playback and also dispose of our previously saved
disposable as soon as the user starts dragging the timeline. Finally, we’ll
call the seekPlayer
method, which is responsible for calculating the
playback duration:
private fun seekPlayer() {
val (index, offset) = positionAndOffsetFromCenter()
val segmentDuration = durationList[index].millis
val isAtTheEnd = index == durationList.size - 1 && offset == 1F
val millis = if (isAtTheEnd) {
offset * segmentDuration - END_OFFSET_BUFFER
} else {
offset * segmentDuration
}
player.seekTo(index, millis.roundToLong())
}
private fun positionAndOffsetFromCenter(): Pair<Int, Float> {
val (index, offset) = timeline.findChildViewUnder(
halfScreenWidth.toFloat(),
timeline.pivotY
)?.run {
val position = timeline.getChildAdapterPosition(this)
position to (halfScreenWidth - left).toFloat() / width
} ?: 0 to 0F
return index to offset
}
The index of the adapter item in the center of the screen tells us the video we are supposed to be playing, and the offset from the center tells us how much that particular video has been played. Using these two values allows us to seek the player to the correct position based on the user’s scrolling.
You may notice that we need to adjust the milliseconds by a small amount when the playback has reached the end. This is because ExoPlayer doesn’t show the correct frame preview without it.
Fin
That’s it! You should now have the same behavior as shown in the GIF above.
You can check out the complete code here. While working on this feature, I gained a newfound appreciation for RecyclerView
and its
straightforward APIs. There were definitely some hiccups at the beginning, but
this was more due to gaps in my knowledge rather than RecyclerView’s
APIs. 😄
Anyway, thanks for sticking around till the end! I hope you learned something!👋