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:
AndroidViewwith 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
- New Empty Activity project
Video, packagecom.example.video. - Add the Media3 libraries, including the HLS one, from the Project 20 block, and the INTERNET permission.
- Replace the addresses in
Videos.ktwith streams you have the right to play. - 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.
onDisposeremoves 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
- Build the app and play the HLS sample, then try your own address.
- Add a full-screen mode using a landscape activity or a
Dialog. - Remember the playback position for each video with DataStore.
- 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.