Getting Started with Video Feeds on Android
The Bitmovin Android SDK provides an experimental Video Feed API for building short-form video feeds. The API is UI-framework independent: your app owns the list, pager, gestures, and overlay UI, while the SDK manages reusable Player instances, source assignment, preloading, lifecycle forwarding, and per-item status.
Prerequisites
- Basic understanding of Android development with Kotlin
- Bitmovin Player Android SDK added to your project (see Getting Started guide)
- A set of playable
SourceConfigobjects - (optional) Jetpack Compose if you want to follow the UI example below
Overview
The Video Feed API is built around one controller for the whole feed:
VideoFeedPlayerControllerowns a small pool ofPlayerinstances and assigns them to feed items.VideoFeedItemdescribes one item in the feed.VideoFeedItemStateexposes the current item, assigned player, status, and error state.VideoFeedPlayerConfigconfigures the player pool, buffering policy, and fast-swipe behavior.
The controller is headless. It does not render UI or provide scrolling. Attach each non-null VideoFeedItemState.player to your own PlayerView, and call select(index) whenever the visible feed item changes.
Experimental APIThe Video Feed API is marked with
@ExperimentalBitmovinApi. Opt in at the usage site or module level.
Add the dependency
Add the Video Feed module next to your existing Bitmovin Player dependency:
dependencies {
implementation("com.bitmovin.player:player-video-feed:<bitmovin-player-version>")
}If you use the Compose PlayerView wrapper, also add the Compose UI module:
dependencies {
implementation("com.bitmovin.player:player-ui-web-compose:<bitmovin-player-version>")
}Create feed items
Each feed item needs a stable, unique ID and a SourceConfig.
import com.bitmovin.player.api.source.SourceConfig
import com.bitmovin.player.api.source.SourceType
import com.bitmovin.player.api.videofeed.VideoFeedItem
private fun createFeedItems(): List<VideoFeedItem> = listOf(
VideoFeedItem(
id = "clip-1",
sourceConfig = SourceConfig(
url = "https://example.com/clip-1.m3u8",
type = SourceType.Hls,
title = "Clip 1",
),
),
VideoFeedItem(
id = "clip-2",
sourceConfig = SourceConfig(
url = "https://example.com/clip-2.m3u8",
type = SourceType.Hls,
title = "Clip 2",
),
),
)Create the controller
Create one VideoFeedPlayerController for the whole feed, usually in a ViewModel. Keep the controller on the main thread and release it when the owner is cleared.
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.bitmovin.player.api.ExperimentalBitmovinApi
import com.bitmovin.player.api.videofeed.VideoFeedItem
import com.bitmovin.player.api.videofeed.VideoFeedItemState
import com.bitmovin.player.api.videofeed.VideoFeedPlayerConfig
import com.bitmovin.player.api.videofeed.VideoFeedPlayerController
import kotlinx.coroutines.flow.StateFlow
@OptIn(ExperimentalBitmovinApi::class)
class VideoFeedViewModel(application: Application) : AndroidViewModel(application) {
private val controller = VideoFeedPlayerController(
context = application.applicationContext,
items = createFeedItems(),
config = VideoFeedPlayerConfig(maxPlayers = 5),
)
val itemStates: StateFlow<List<VideoFeedItemState>> = controller.items
init {
// Optional: assign the first pooled player before the UI becomes visible.
controller.preload(0)
}
fun select(index: Int) {
controller.select(index)
}
fun appendItems(items: List<VideoFeedItem>) {
controller.appendItems(items)
}
fun play() = controller.play()
fun pause() = controller.pause()
fun retry() = controller.retry()
fun onStart() = controller.onStart()
fun onResume() = controller.onResume()
fun onPause() = controller.onPause()
fun onStop() = controller.onStop()
override fun onCleared() {
controller.release()
}
}Render the current player
Observe controller.items and attach each assigned Player to your UI. The controller owns the players, so the UI must not destroy them.
The following Compose example uses a pager, but the same approach works with any list or custom UI.
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.bitmovin.player.api.ExperimentalBitmovinApi
import com.bitmovin.player.api.ui.PlayerViewConfig
import com.bitmovin.player.api.ui.UiConfig
import com.bitmovin.player.api.ui.compose.wrapper.PlayerView
import com.bitmovin.player.api.videofeed.VideoFeedItemStatus
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalFoundationApi::class, ExperimentalBitmovinApi::class)
@Composable
fun VideoFeedScreen(viewModel: VideoFeedViewModel) {
val itemStates by viewModel.itemStates.collectAsState()
val pagerState = rememberPagerState(pageCount = { itemStates.size })
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect(viewModel::select)
}
VerticalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
val itemState = itemStates[page]
Box(Modifier.fillMaxSize()) {
itemState.player?.let { player ->
PlayerView(
player = player,
modifier = Modifier.fillMaxSize(),
playerViewConfig = PlayerViewConfig(uiConfig = UiConfig.Disabled),
)
}
when (itemState.status) {
VideoFeedItemStatus.Unassigned,
VideoFeedItemStatus.Loading,
-> Text("Loading…", modifier = Modifier.align(Alignment.Center))
VideoFeedItemStatus.Failed -> Text("Playback failed", modifier = Modifier.align(Alignment.Center))
VideoFeedItemStatus.Ready,
VideoFeedItemStatus.Rendered,
-> Unit
}
}
}
}Forward lifecycle events
Forward host lifecycle events to the controller so all pooled players stay in sync with the Activity or Fragment lifecycle.
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class VideoFeedLifecycleObserver(
private val viewModel: VideoFeedViewModel,
) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = viewModel.onStart()
override fun onResume(owner: LifecycleOwner) = viewModel.onResume()
override fun onPause(owner: LifecycleOwner) = viewModel.onPause()
override fun onStop(owner: LifecycleOwner) = viewModel.onStop()
}Append more items
For long or endless feeds, append more items before the user reaches the end. Appended items must use IDs that are unique across the whole controller feed.
fun onPageSelected(index: Int) {
if (index >= itemStates.value.lastIndex - 1) {
appendItems(createMoreFeedItems())
}
select(index)
}Handle playback controls and errors
Use the controller for feed-level playback actions. Use VideoFeedItemState.status and VideoFeedItemState.error for loading, failed, and retry UI.
fun onPlayClicked() = viewModel.play()
fun onPauseClicked() = viewModel.pause()
fun onRetryClicked() = viewModel.retry()Configure buffering and pool size
The default configuration works for most feeds. Tune maxPlayers if you need more or fewer pooled players.
import com.bitmovin.player.api.videofeed.VideoFeedBufferPolicy
import com.bitmovin.player.api.videofeed.VideoFeedPlayerConfig
val config = VideoFeedPlayerConfig(
maxPlayers = 5,
fastSwipeThresholdMs = 2_000L,
bufferPolicy = VideoFeedBufferPolicy(
currentForwardSeconds = 30.0,
currentBackwardSeconds = 4.0,
forwardSeconds = listOf(8.0, 5.0, 3.0),
previousForwardSeconds = listOf(4.0, 2.0),
fastSwipeFactor = 0.5,
),
)Fast-swipe mode is entered when consecutive selections happen within fastSwipeThresholdMs. While active, configured buffer targets are multiplied by fastSwipeFactor to reduce buffering work for quickly skipped items.
Conclusion
These instructions cover the basic integration flow. The API reference provides more details about the available configuration and state APIs.
References
Updated about 6 hours ago