Project 20: Video Streaming App with Media3 and HLS

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 14 min read · Updated

Adaptive streaming

  1. The server stores the video as small pieces at several qualities.
  2. The player first reads the playlist that lists them.
  3. ExoPlayer downloads each piece in the best quality your speed allows.
  4. The PlayerView draws the frames. If your network slows, quality drops instead of stopping.

Streaming video is harder than playing a file. Viewers have different networks, so a good service offers the same video at several qualities and lets the player switch between them as the connection changes. This is adaptive streaming, and the most common format on Android is HLS, recognised by the .m3u8 address. ExoPlayer from Media3 supports it with one extra library.

The screen part is also interesting. The player is a classic Android view, PlayerView, so you place it in Compose with AndroidView. The player holds heavy resources, so its lifecycle matters: it must pause when the app goes to the background and be released when the screen goes away. A DisposableEffect is the right tool.

Concepts in this project

  • Adaptive streaming: the player downloads small pieces of video and chooses a quality for each from your current speed.
  • ExoPlayer: setMediaItem, prepare, playWhenReady. It picks the right media source from the address.
  • PlayerView in Compose: AndroidView with a factory that creates the view and attaches the player.
  • DisposableEffect: registers the player listener and a lifecycle observer, and releases both in onDispose.
  • States: buffering shows a progress indicator, and an error shows a plain message.
  • Video list: tapping a card changes the selected video, which recreates the player for the new address.

Create the project

  1. New Empty Activity project Video, package com.example.video.
  2. Add the Media3 libraries, including the HLS one, from the Project 20 block, and the INTERNET permission.
  3. Replace the addresses in Videos.kt with streams you have the right to play.
  4. Create the files below.

`Gradle dependencies for projects 11 to 21 (use the block for this project)`

// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")

// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.

// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.

// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.

// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")

// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Video/app/src/main/java/com/example/video/
  Videos.kt         list of videos + duration format
  VideoPlayer.kt    ExoPlayer inside Compose, lifecycle safe
  VideoApp.kt       player, title, list
  MainActivity.kt
app/src/test/.../VideoTest.kt

The code

`app/src/main/java/com/example/video/Videos.kt`

package com.example.video

data class Video(val title: String, val description: String, val url: String)

// Replace these with video addresses you have the right to stream.
// HLS (.m3u8) addresses give adaptive streaming: the player switches quality with your speed.
object Videos {
    val all = listOf(
        Video("Big Buck Bunny (HLS)", "Open movie used for player tests.", "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"),
        Video("Sample MP4", "A plain MP4 file.", "https://example.com/video/sample.mp4"),
    )
}

fun formatDuration(ms: Long): String {
    if (ms < 0) return "--:--"
    val s = ms / 1000
    val h = s / 3600
    val m = (s % 3600) / 60
    val sec = s % 60
    val mm = m.toString().padStart(2, '0')
    val ss = sec.toString().padStart(2, '0')
    return if (h > 0) h.toString() + ":" + mm + ":" + ss else (m.toString() + ":" + ss)
}

`app/src/main/java/com/example/video/VideoPlayer.kt`

package com.example.video

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView

data class PlayerStatus(val buffering: Boolean = false, val error: String? = null)

@Composable
fun VideoPlayer(url: String, onStatus: (PlayerStatus) -> Unit) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val player = remember(url) {
        ExoPlayer.Builder(context).build().apply {
            setMediaItem(MediaItem.fromUri(url))
            prepare()
            playWhenReady = true
        }
    }
    var position by remember(url) { mutableStateOf(0L) }

    DisposableEffect(player) {
        val listener = object : Player.Listener {
            override fun onPlaybackStateChanged(state: Int) {
                onStatus(PlayerStatus(buffering = state == Player.STATE_BUFFERING))
            }

            override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
                onStatus(PlayerStatus(error = "This video cannot be played right now."))
            }
        }
        // pause when the app goes to the background, resume when it returns
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_PAUSE) {
                position = player.currentPosition
                player.pause()
            }
        }
        player.addListener(listener)
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
            player.removeListener(listener)
            player.release()
        }
    }

    AndroidView(
        modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
        factory = { ctx ->
            PlayerView(ctx).apply {
                this.player = player
                useController = true
            }
        },
    )
}

`app/src/main/java/com/example/video/VideoApp.kt`

package com.example.video

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun VideoApp() {
    var selected by remember { mutableStateOf(Videos.all.first()) }
    var status by remember { mutableStateOf(PlayerStatus()) }

    Column(Modifier.fillMaxSize()) {
        VideoPlayer(selected.url) { status = it }
        if (status.buffering) {
            CircularProgressIndicator(Modifier.padding(8.dp))
        }
        status.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(12.dp)) }
        Text(selected.title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(12.dp))
        Text(selected.description, modifier = Modifier.padding(horizontal = 12.dp))

        Text("More videos", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(12.dp))
        LazyColumn(
            contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            items(Videos.all, key = { it.url }) { v ->
                Card(
                    Modifier.fillMaxWidth().clickable {
                        status = PlayerStatus()
                        selected = v
                    },
                ) {
                    Column(Modifier.padding(12.dp)) {
                        Text(v.title)
                        Text(v.description, style = MaterialTheme.typography.bodySmall)
                    }
                }
            }
        }
    }
}

`app/src/main/java/com/example/video/MainActivity.kt`

package com.example.video

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                Surface(Modifier.systemBarsPadding()) {
                    VideoApp()
                }
            }
        }
    }
}

`app/src/test/java/com/example/video/VideoTest.kt`

package com.example.video

import org.junit.Assert.assertEquals
import org.junit.Test

class VideoTest {
    @Test
    fun durationFormat() {
        assertEquals("0:09", formatDuration(9_000))
        assertEquals("2:05", formatDuration(125_000))
        assertEquals("1:01:01", formatDuration(3_661_000))
        assertEquals("--:--", formatDuration(-1))
    }
}

How it works

  • remember(url) creates a new player for each selected video, so switching videos starts fresh.
  • The listener reports buffering and errors up to VideoApp, which shows a spinner or a message.
  • The lifecycle observer pauses the player when the activity pauses, so video does not keep playing with the screen off.
  • onDispose removes the listeners and releases the player, which frees the decoder and memory.

Expected output

The top of the screen shows the video player with the standard controls. The first video starts automatically after a moment of buffering with a small spinner. Drag the progress bar to jump. Tap another card in the list: the title changes and the new video starts. Press Home: the video pauses. If an address cannot be played, the message "This video cannot be played right now." appears in the error colour and the app stays usable. On a slow network the picture quality drops by itself instead of stopping, if the stream has several qualities.

Your task

  1. Build the app and play the HLS sample, then try your own address.
  2. Add a full-screen mode using a landscape activity or a Dialog.
  3. Remember the playback position for each video with DataStore.
  4. Add picture-in-picture support for the activity.

How to check it worked

  • Video pauses when the app goes to the background.
  • Leaving the screen releases the player.
  • You can explain what HLS and adaptive streaming are.

Frequently asked questions

HLS or DASH?

Both are adaptive formats. HLS is very common and works on Android and iOS. ExoPlayer supports both.

Why is the player created with remember(url)?

A player is tied to one video. Keying remember on the address creates a fresh player when the user picks another video.

Why release the player?

It holds decoders and memory. If you forget, the video may keep using resources, and the next player may fail to start.