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:

  1. Rendering the timeline

  2. Scrolling automatically based on the video playback

  3. 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!👋

Related articles: