androidengineers.Book a session

Media & Graphics

Exercise: Adaptive Bitrate Playback

exercise60 minHard

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:

  1. Plays an HLS stream with adaptive bitrate selection
  2. Displays current bitrate and selected quality variant
  3. Offers a manual quality override UI
  4. 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

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Adaptive Bitrate Playback | Android System Design | Android Engineers