When I was building Samachar — a real-time news app — the product team wanted a TikTok-style vertical video feed that autoplays news clips as users scroll. Simple enough as a product requirement. Non-trivial to build well.
The naive implementation would have been a RecyclerView where each item creates and releases its own ExoPlayer instance. It "works" until you scroll fast, then you get blank frames, memory spikes, and dropped frames on mid-range devices. Here's how I built it the right way.
Architecture Decision: ViewPager2 vs RecyclerView
First question: should you use ViewPager2 or a plain RecyclerView with snap behavior?
I went with ViewPager2 for one reason: it enforces one-item-at-a-time scrolling at the framework level. You get the page-snapping behavior for free, it handles accessibility correctly, and the lifecycle callbacks (onPageSelected) make it trivial to know which video is active at any moment.
The downside: ViewPager2's default offscreen page limit is 0, meaning pages outside the viewport get destroyed. For video, this means we need to handle preloading ourselves — which we would have to do anyway.
The Core Insight: One Player Pool, Many Surfaces
The key architectural decision: don't create one ExoPlayer per item. ExoPlayer instances are expensive — each one allocates codec resources, a decoder thread, and a buffer pool. Creating and destroying them on scroll is both slow and memory-intensive.
Instead, use a pool of 3 players — current, next, and previous — and swap them across items as the user scrolls:
class VideoPlayerPool {
private val players = mutableListOf<ExoPlayer>()
private val playerMap = mutableMapOf<Int, ExoPlayer>() // page -> player
fun getPlayer(context: Context, page: Int): ExoPlayer {
return playerMap.getOrPut(page) {
players.removeFirstOrNull() ?: buildPlayer(context)
}
}
fun releaseFor(page: Int) {
playerMap.remove(page)?.let { player ->
player.stop()
player.clearMediaItems()
players.add(player) // return to pool
}
}
private fun buildPlayer(context: Context) = ExoPlayer.Builder(context)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
500, // playback start buffer: 500ms is enough to start
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
)
.build()
fun releaseAll() {
players.forEach { it.release() }
playerMap.values.forEach { it.release() }
players.clear()
playerMap.clear()
}
}
Preloading: Start Buffering Before the User Gets There
The smoothest feeds preload the next video before the user swipes to it. With ViewPager2, we can hook into the page change callback to preload adjacent items:
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
// Play current
val currentPlayer = playerPool.getPlayer(context, position)
currentPlayer.setMediaItem(MediaItem.fromUri(videoUrls[position]))
currentPlayer.prepare()
currentPlayer.play()
// Preload next
if (position + 1 < videoUrls.size) {
val nextPlayer = playerPool.getPlayer(context, position + 1)
nextPlayer.setMediaItem(MediaItem.fromUri(videoUrls[position + 1]))
nextPlayer.prepare()
// Don't play — just buffer
}
// Release previous (not the one before current — keep it for back-swipe)
if (position > 1) {
playerPool.releaseFor(position - 2)
}
}
})
Keep 3 players alive at any time: position - 1, position, and position + 1. Release position - 2 and beyond. This gives instant playback in either scroll direction without keeping the entire list buffered.
Attaching the Player to the ViewHolder
Each ViewPager2 page holds a PlayerView. The player needs to be attached to whichever surface is currently visible:
class VideoViewHolder(private val binding: ItemVideoBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(videoUrl: String, player: ExoPlayer) {
// Detach from any previous surface
player.clearVideoSurface()
// Attach to this holder's surface
player.setVideoSurfaceView(binding.playerView.videoSurfaceView)
// PlayerView's own player reference
binding.playerView.player = player
}
fun unbind() {
binding.playerView.player = null
}
}
Always call player.clearVideoSurface() before attaching a player to a new SurfaceView. Skipping this causes a black frame flash as the old surface tears away.
Memory Management: The OOM Trap
Video buffers are large. Without discipline, a 20-item feed will OOM on anything below 3GB RAM. Three rules I follow:
- Cap the player pool at 3. Never let more than 3 ExoPlayer instances exist simultaneously.
- Clear media items, don't release. Releasing a player destroys the codec. Clearing media items frees the buffer while keeping the codec warm for reuse.
- Release everything in
onDestroyView(). CallplayerPool.releaseAll()when the fragment dies. If you leak even one player, the codec stays allocated and your next fragment will OOM on decoder creation.
override fun onDestroyView() {
super.onDestroyView()
viewPager.unregisterOnPageChangeCallback(pageChangeCallback)
playerPool.releaseAll()
_binding = null
}
Smooth Transitions: The Gap Frame Problem
When the user swipes quickly, there's a brief moment where the incoming page's player hasn't rendered its first frame yet, so the user sees a black flash. Fix it with a thumbnail:
// In your PlayerView, set a static thumbnail that shows until the first frame renders
binding.playerView.apply {
setShutterBackgroundColor(Color.TRANSPARENT)
// Coil or Glide loads thumbnail into this ImageView
Glide.with(context)
.load(thumbnailUrl)
.into(binding.thumbnailImage)
}
// Hide the thumbnail once ExoPlayer renders its first frame
player.addListener(object : Player.Listener {
override fun onRenderedFirstFrame() {
binding.thumbnailImage.animate().alpha(0f).setDuration(150).start()
}
})
What Samachar's Feed Looks Like
With this approach, Samachar's vertical news feed achieves:
- First-frame playback in under 300ms on a stable connection
- Zero OOM crashes across 10K+ daily sessions
- Smooth 60fps scrolling on mid-range devices (Redmi Note 10 tier)
- Instant back-swipe playback (because the previous player stays buffered)
The pool approach is more complex than the naive one-player-per-item solution, but it's the only approach that holds up at scale. Once you understand the three-player pool model, it becomes intuitive — and you can apply the same pattern to any horizontally or vertically scrolling video feed.
No comments yet. Be the first to leave one!