> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://fastpix.com/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://fastpix.com/docs/_mcp/server.

# Build video feeds

> Build reels, carousels, and multi-video screens in the FastPix Android Player with 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 UI                                                        | Use                             | Recipe                                            |
| -------------------------------------------------------------- | ------------------------------- | ------------------------------------------------- |
| One screen, Next / Previous (episodes, a course, a playlist)   | `FastPixPlayer` + `setPlaylist` | [A](#a-one-player-with-next-and-previous)         |
| Vertical or horizontal feed with `ViewPager2`, pages are Views | `FastPixPlayerPool`             | [B](#b-viewpager2-with-view-pages)                |
| `ViewPager2` whose pages are `ComposeView`s                    | `FastPixPlayerPool`             | [C](#c-viewpager2-with-compose-pages)             |
| Compose `VerticalPager` / `HorizontalPager`                    | `FastPixPlayerPool`             | [D](#d-compose-verticalpager-and-horizontalpager) |
| `RecyclerView` with `PagerSnapHelper`                          | `FastPixPlayerPool`             | [E](#e-recyclerview-with-pagersnaphelper)         |
| A single video on its own screen                               | `FastPixPlayer`                 | [F](#f-a-single-video-anywhere)                   |

Every recipe builds its items the same way:

```kotlin
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.

```kotlin
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.

```kotlin
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.

```kotlin
@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:

```kotlin
@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:

```kotlin
// 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:

```kotlin
// 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

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.

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.

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()`.

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

* [Preload and cache for faster startup](/android-player/preload-and-cache-for-faster-startup) for the details behind `PreloadConfig`, `CacheConfig`, and `PrerenderConfig`.
* [Play a playlist](/android-player/manage-playlists) to drive the list behind your feed.
* [Monitor video data](/android-player/monitor-video-data) to measure engagement across the videos your feed plays.