Skip to navigation

Build video feeds

Copy-ready setups for reels, carousels, and multi-video screens, built on FastPixPlayer, FastPixPlayerPool, ViewPager2, Compose pagers, and RecyclerView.

Build reels, carousels, and multi-video screens in the FastPix Android Player.

These are complete, copy-ready setups for the common ways to show more than one video. They share one rule: whoever shows a video decides nothing about playback. A page or screen binds a player to a PlayerView; the player (or the pool of players) decides what plays and what is prepared next.

Pick the recipe that matches your UI:

Your UIUseRecipe
One screen, Next / Previous (episodes, a course, a playlist)FastPixPlayer + setPlaylistA
Vertical or horizontal feed with ViewPager2, pages are ViewsFastPixPlayerPoolB
ViewPager2 whose pages are ComposeViewsFastPixPlayerPoolC
Compose VerticalPager / HorizontalPagerFastPixPlayerPoolD
RecyclerView with PagerSnapHelperFastPixPlayerPoolE
A single video on its own screenFastPixPlayerF

Every recipe builds its items the same way:

val items = videos.map { video ->
PlaylistItem.fastPix(video.playbackId, playbackToken = video.token) // or PlaylistItem.fromUrl(url)
}

A. One player with next and previous

One PlayerView, one player, a playlist. PrerenderConfig makes Next / Previous show the entry’s first frame at once instead of a black frame.

class EpisodesActivity : AppCompatActivity() {
private lateinit var player: FastPixPlayer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_episodes) // contains <io.fastpix.media3.PlayerView android:id="@+id/playerView" .../>
player = FastPixPlayer.Builder(this)
.setAutoplay(true)
.setPreloadConfig(PreloadConfig(count = 2, behind = 1))
.setCacheConfig(CacheConfig.enabled())
.setPrerenderConfig(PrerenderConfig(count = 1, behind = 1))
.build()
findViewById<PlayerView>(R.id.playerView).player = player
player.setPlaylist(items, startIndex = 0)
findViewById<View>(R.id.next).setOnClickListener { player.next() }
findViewById<View>(R.id.previous).setOnClickListener { player.previous() }
player.addPlaylistListener(object : PlaylistListener {
override fun onPlaylistItemChanged(index: Int, item: PlaylistItem, reason: PlaylistItemChangeReason) {
title = "Episode ${index + 1}"
}
})
}
override fun onPause() { super.onPause(); player.pause() }
override fun onResume() { super.onResume(); player.play() }
override fun onDestroy() { player.release(); super.onDestroy() }
}

The player moves to the next entry by itself when one ends (unless loop is on). Add entries as they load with player.addToPlaylist(moreItems).


B. ViewPager2 with View pages

A reel feed: one page per video, each page its own PlayerView, players from the pool.

class ReelsActivity : AppCompatActivity() {
private lateinit var pool: FastPixPlayerPool
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pool = FastPixPlayerPool.Builder(this)
.setPlayerConfig { it.setLoop(true).setBufferConfig(BufferConfig.FEED) }
.setPreloadConfig(PreloadConfig(count = 3, behind = 1))
.setCacheConfig(CacheConfig.enabled())
.setPrerenderConfig(PrerenderConfig(count = 1, behind = 1))
.build()
pool.setPlaylist(items)
val pager = ViewPager2(this).apply {
orientation = ViewPager2.ORIENTATION_VERTICAL
offscreenPageLimit = 1 // >= pre-render counts: neighbors need views
adapter = ReelAdapter(pool)
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) = pool.setCurrentIndex(position)
})
}
setContentView(pager)
}
override fun onPause() {
super.onPause()
pool.getCurrentIndex().takeIf { it >= 0 }?.let { pool.playerAt(it).pause() }
}
override fun onResume() {
super.onResume()
pool.getCurrentIndex().takeIf { it >= 0 }?.let { pool.playerAt(it).play() }
}
override fun onDestroy() { pool.release(); super.onDestroy() }
}
class ReelAdapter(private val pool: FastPixPlayerPool) : RecyclerView.Adapter<ReelAdapter.Holder>() {
class Holder(val playerView: PlayerView) : RecyclerView.ViewHolder(playerView)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val view = PlayerView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(MATCH_PARENT, MATCH_PARENT)
resizeMode = ResizeMode.ZOOM
// Pages share one layout; the pool, not the view, keeps players across changes.
retainPlayerOnConfigChange = false
}
return Holder(view)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.playerView.player = pool.playerAt(position)
}
// ViewPager2 re-attaches cached pages without rebinding them; the pool may have reused their
// player meanwhile, so ask again.
override fun onViewAttachedToWindow(holder: Holder) {
super.onViewAttachedToWindow(holder)
val position = holder.bindingAdapterPosition
if (position == RecyclerView.NO_POSITION) return
val player = pool.playerAt(position)
if (holder.playerView.player !== player) holder.playerView.player = player
}
override fun getItemCount() = pool.getPlaylist().size
}

Inflating pages from XML instead? Give the PlayerView no android:id, or set retainPlayerOnConfigChange = false on it: every page inflates the same id, and ids are what configuration-change retention keys on.

For a horizontal carousel, use ViewPager2.ORIENTATION_HORIZONTAL, and nothing else changes.


C. ViewPager2 with Compose pages

The same feed where each page is a ComposeView calling your app’s shared player composable. The composable only shows the player it is given.

@Composable
fun AppVideoPlayer(player: FastPixPlayer, modifier: Modifier = Modifier) {
AndroidView(
factory = { context ->
PlayerView(context).apply {
resizeMode = ResizeMode.ZOOM
this.player = player
}
},
update = { view -> view.player = player }, // a recycled page gets another player
modifier = modifier,
)
}
class ComposeReelAdapter(private val pool: FastPixPlayerPool) :
RecyclerView.Adapter<ComposeReelAdapter.Holder>() {
class Holder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
var player: FastPixPlayer? = null
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(
ComposeView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
)
override fun onBindViewHolder(holder: Holder, position: Int) = bind(holder, position)
override fun onViewAttachedToWindow(holder: Holder) {
super.onViewAttachedToWindow(holder)
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION && holder.player !== pool.playerAt(position)) {
bind(holder, position)
}
}
private fun bind(holder: Holder, position: Int) {
val player = pool.playerAt(position)
holder.player = player
holder.composeView.setContent {
AppVideoPlayer(player, Modifier.fillMaxSize())
}
}
override fun getItemCount() = pool.getPlaylist().size
}

The pager and activity are exactly as in recipe B.


D. Compose VerticalPager and HorizontalPager

All Compose, no ViewPager2:

@Composable
fun ReelFeed(items: List<PlaylistItem>) {
val context = LocalContext.current
val pool = remember {
FastPixPlayerPool.Builder(context)
.setPlayerConfig { it.setLoop(true).setBufferConfig(BufferConfig.FEED) }
.setPreloadConfig(PreloadConfig(count = 3, behind = 1))
.setCacheConfig(CacheConfig.enabled())
.setPrerenderConfig(PrerenderConfig(count = 1, behind = 1))
.build()
.apply { setPlaylist(items) }
}
DisposableEffect(pool) { onDispose { pool.release() } }
// Pause in the background, resume in the foreground.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, pool) {
val observer = LifecycleEventObserver { _, event ->
val current = pool.getCurrentIndex().takeIf { it >= 0 } ?: return@LifecycleEventObserver
when (event) {
Lifecycle.Event.ON_PAUSE -> pool.playerAt(current).pause()
Lifecycle.Event.ON_RESUME -> pool.playerAt(current).play()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val pagerState = rememberPagerState { items.size }
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { pool.setCurrentIndex(it) }
}
VerticalPager( // or HorizontalPager
state = pagerState,
beyondViewportPageCount = 1, // >= pre-render counts: neighbors need views
modifier = Modifier.fillMaxSize(),
) { page ->
val player = remember(page) { pool.playerAt(page) }
AppVideoPlayer(player, Modifier.fillMaxSize()) // from recipe C
}
}

Compose disposes pages that leave the pager, so there is no re-attach case to handle here.


E. RecyclerView with PagerSnapHelper

The adapter from recipe B works unchanged. What ViewPager2 did for you (telling the pool which page is current) you do from the snap:

// Lay out one screen beyond each edge, so the neighboring pages are bound (and pre-rendered)
// before they scroll into view. This is what offscreenPageLimit does for ViewPager2.
recyclerView.layoutManager = object : LinearLayoutManager(this, RecyclerView.VERTICAL, false) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace[0] = recyclerView.height
extraLayoutSpace[1] = recyclerView.height
}
}
val snapHelper = PagerSnapHelper().apply { attachToRecyclerView(recyclerView) }
recyclerView.adapter = ReelAdapter(pool)
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState != RecyclerView.SCROLL_STATE_IDLE) return
val snapped = snapHelper.findSnapView(recyclerView.layoutManager) ?: return
val position = recyclerView.getChildAdapterPosition(snapped)
if (position != RecyclerView.NO_POSITION) pool.setCurrentIndex(position)
}
})
recyclerView.post { pool.setCurrentIndex(0) }

Without the extra layout space a RecyclerView binds the next item only as it scrolls into view, too late to pre-render it.


F. A single video anywhere

A detail screen, a preview, a banner: one player, owned by the screen:

// Views
val player = FastPixPlayer.Builder(context).setAutoplay(true).build()
playerView.player = player
player.setFastPixMediaItem { playbackId = "your-playback-id" }
// ...and player.release() when the screen goes away.
// Compose
@Composable
fun VideoDetail(item: PlaylistItem) {
val context = LocalContext.current
val player = remember(item) {
FastPixPlayer.Builder(context).setAutoplay(true).build()
.apply { setPlaylist(listOf(item)) }
}
DisposableEffect(player) { onDispose { player.release() } }
AppVideoPlayer(player, Modifier.fillMaxWidth().aspectRatio(16f / 9f)) // from recipe C
}

Things every recipe relies on

  • Showing a player never releases it. A PlayerView releases only a player it created itself; one you assign stays yours (or the pool’s), however often views detach and re-attach.
  • Your UI should read the player’s real state. Pool players are stopped outside the pre-render window and reused for other entries without PlaybackListener events. If you draw your own spinner or play button, re-read hasRenderedFirstFrame() and isPlaying() when the engine changes state rather than keeping flags from earlier callbacks. Hide a poster on PlaybackListener.onFirstFrameRendered().
  • Listeners may remove themselves inside a callback. A one-shot listener is safe.
  • Pre-render counts are a maximum. The device’s decoder limit decides how many entries actually pre-render, and DRM-protected entries are never pre-rendered.
  • Analytics counts watched videos only. Add setAnalyticsConfig(AnalyticsConfig.Builder(workSpaceId).build()) to a pool (or a config to a player) and each video the user lands on is one view. Preloaded and pre-rendered entries are not counted.

Frequently asked questions

How do I build a vertical reels feed on Android?

Use FastPixPlayerPool with a pager. For ViewPager2 with View pages, bind each page’s PlayerView to pool.playerAt(position) and call pool.setCurrentIndex(position) when the pager settles (recipe B). For Compose, use VerticalPager and drive pool.setCurrentIndex from the settled page (recipe D). Keep offscreenPageLimit or beyondViewportPageCount at least as large as your pre-render counts.

Do I need to release the players in a feed?

No. The pool owns its players, so don’t release them yourself. Call pool.release() when the screen goes away. A PlayerView releases only a player it created itself; a player you assign from the pool stays the pool’s.

Why is my page showing the wrong or a black video after scrolling?

ViewPager2 re-attaches cached pages without rebinding them, and the pool may have reused a page’s player meanwhile. In onViewAttachedToWindow, compare holder.player (or the view’s current player) with pool.playerAt(position) and rebind if they differ. Also drive your UI from the player’s real state with hasRenderedFirstFrame() and isPlaying().

Does pre-rendering inflate my analytics view counts?

No. Analytics counts a view for each video the user actually lands on. Preloaded and pre-rendered entries off screen are never counted, so pre-rendering does not inflate view counts.


What’s next