Build a video player that automatically adapts streaming quality to network conditions, shows bandwidth statistics, lets users manually override quality, and handles DRM-protected content.
Goal
A VideoPlayerActivity that:
- Plays an HLS stream with adaptive bitrate selection
- Displays current bitrate and selected quality variant
- Offers a manual quality override UI
- Handles network interruptions gracefully
Step 1: Dependencies and Layout
// build.gradle.kts
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-exoplayer-hls:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
<!-- activity_video_player.xml -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:use_artwork="true"
app:use_controller="true" />
<LinearLayout
android:id="@+id/statsOverlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:background="#80000000"
android:padding="8dp"
android:orientation="vertical">
<TextView android:id="@+id/bitrateText" android:textColor="@color/white" />
<TextView android:id="@+id/qualityText" android:textColor="@color/white" />
<TextView android:id="@+id/bufferText" android:textColor="@color/white" />
</LinearLayout>
</FrameLayout>
Step 2: Player Setup with Adaptive Track Selection
class VideoPlayerActivity : AppCompatActivity() {
private lateinit var player: ExoPlayer
private lateinit var trackSelector: DefaultTrackSelector
private lateinit var binding: ActivityVideoPlayerBinding
private val statsRunnable = object : Runnable {
override fun run() {
updateStatsOverlay()
handler.postDelayed(this, 1000)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityVideoPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
setupPlayer()
setupQualityButton()
}
private fun setupPlayer() {
trackSelector = DefaultTrackSelector(this).apply {
setParameters(
buildUponParameters()
// ABR (Adaptive Bitrate) strategy
.setMaxVideoBitrate(Int.MAX_VALUE) // auto — no max
.setMinVideoBitrate(0)
.setForceHighestSupportedBitrate(false) // let ABR decide
.setPreferredAudioLanguage("en")
.build()
)
}
player = ExoPlayer.Builder(this)
.setTrackSelector(trackSelector)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(
15_000, // min buffer (ms)
60_000, // max buffer
2_500, // buffer for playback start
5_000 // buffer after rebuffer
)
.build()
)
.build()
binding.playerView.player = player
binding.playerView.keepScreenOn = true
val mediaItem = MediaItem.Builder()
.setUri(HLS_STREAM_URL)
.setMediaId("demo_stream")
.build()
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
player.addListener(PlayerEventListener())
}
private inner class PlayerEventListener : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_BUFFERING -> binding.bufferText.text = "Buffering…"
Player.STATE_READY -> binding.bufferText.text = "Ready"
Player.STATE_ENDED -> binding.bitrateText.text = "Playback ended"
else -> {}
}
}
override fun onPlayerError(error: PlaybackException) {
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
showRetryDialog("Network error — check connection")
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
showError("Stream unavailable (HTTP ${error.errorCodeName})")
else ->
showError("Playback error: ${error.message}")
}
}
}
}
Step 3: Stats Overlay
private fun updateStatsOverlay() {
val videoFormat = player.videoFormat
val audioFormat = player.audioFormat
if (videoFormat != null) {
val bitrateMbps = videoFormat.bitrate / 1_000_000f
binding.bitrateText.text = "%.1f Mbps".format(bitrateMbps)
binding.qualityText.text = "${videoFormat.width}×${videoFormat.height} " +
"(${videoFormat.frameRate.toInt()} fps)"
}
val bufferedMs = player.totalBufferedDuration
binding.bufferText.text = "Buffer: %.1f s".format(bufferedMs / 1000f)
}
override fun onStart() {
super.onStart()
handler.post(statsRunnable)
}
override fun onStop() {
super.onStop()
handler.removeCallbacks(statsRunnable)
player.release()
}
Step 4: Manual Quality Override
private fun setupQualityButton() {
binding.playerView.setCustomErrorMessage(null)
// Long press on player view → show quality picker
binding.playerView.setOnLongClickListener {
showQualityPicker()
true
}
}
private fun showQualityPicker() {
val trackGroups = player.currentTracks.groups
val videoGroup = trackGroups.firstOrNull {
it.type == C.TRACK_TYPE_VIDEO && it.isSupported
} ?: return
val qualities = (0 until videoGroup.length).map { i ->
val format = videoGroup.getTrackFormat(i)
"${format.width}×${format.height} (${format.bitrate / 1000} kbps)"
}.toTypedArray() + arrayOf("Auto (ABR)")
AlertDialog.Builder(this)
.setTitle("Select Quality")
.setItems(qualities) { _, index ->
if (index == qualities.size - 1) {
// Reset to auto
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setForceHighestSupportedBitrate(false)
.clearSelectionOverrides()
)
} else {
// Force specific track
val override = TrackSelectionOverride(videoGroup.mediaTrackGroup, index)
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setOverrideForType(override)
)
}
}
.show()
}
Step 5: Retry on Network Recovery
private fun showRetryDialog(message: String) {
AlertDialog.Builder(this)
.setTitle("Playback stopped")
.setMessage(message)
.setPositiveButton("Retry") { _, _ ->
player.prepare() // re-prepare from last position
player.play()
}
.setNegativeButton("Cancel") { _, _ -> finish() }
.show()
}
Verification Checklist
[ ] Stream loads and plays without buffer stalls
[ ] Quality overlay shows correct resolution and bitrate
[ ] Long-press shows quality picker with all available variants
[ ] Selecting a quality variant immediately changes the playing track
[ ] Selecting Auto returns to ABR behavior
[ ] Force airplane mode → shows retry dialog
[ ] Turn airplane mode off → retry succeeds from same position
[ ] Buffer display shows > 0 seconds while playing