Building a Modern Video Player in Jetpack Compose with Media3 ExoPlayer
Video content is an essential part of modern Android apps, from social media platforms to educational applications. With the evolution of Android development, Jetpack Compose has revolutionized UI design, providing a declarative and concise way to build layouts. At the same time, Media3 ExoPlayer offers a robust solution for playing audio and video content efficiently. Combining these two powerful tools allows developers to create immersive, full-featured video players with minimal code. In this article, we will explore how to build a modern video player in Jetpack Compose using Media3 ExoPlayer, covering everything from initialization to full-screen playback.
Why Use Media3 ExoPlayer with Jetpack Compose?
ExoPlayer has long been the preferred solution for media playback on Android due to its flexibility, performance, and wide range of features. Media3 is the latest iteration of ExoPlayer, designed to integrate seamlessly with modern Android architecture components. When paired with Jetpack Compose, developers can:
- Create dynamic UIs that react to state changes naturally.
- Implement full-screen playback with ease.
- Manage multiple video streams or playlists efficiently.
- Customize controls like play, pause, seek, and volume.
Using Media3 ensures the video player is future-proof, optimized for performance, and compatible with the latest Android API levels.
Setting Up the Project
1. Add Dependencies
In your build.gradle (app) file:
To get started, include the following dependencies in your build.gradle file:
// ExoPlayer
implementation("androidx.media3:media3-exoplayer:1.8.0")
implementation("androidx.media3:media3-ui:1.8.0")
// Permission handling
implementation("com.google.accompanist:accompanist-permissions:0.37.3")
implementation ("androidx.navigation:navigation-compose:2.9.2")
implementation("io.coil-kt:coil-compose:2.4.0")In
AndroidManifest.xml add permissions:<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />This includes the core ExoPlayer library, UI components, and Jetpack Compose essentials.
2. Data Model
data class VideoItem(val uri: Uri, val name: String, val thumbnail: Bitmap?)3. Load Device Videos
We use MediaStore to fetch video files and thumbnails:
fun loadVideos(context: Context): List {
val videos = mutableListOf()
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME
)
context.contentResolver.query(
collection,
projection,
null,
null,
"${MediaStore.Video.Media.DATE_ADDED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val contentUri = ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id
)
val thumbnail: Bitmap? = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.contentResolver.loadThumbnail(
contentUri,
Size(200, 200), // thumbnail size
null
)
} else {
@Suppress("DEPRECATION")
MediaStore.Video.Thumbnails.getThumbnail(
context.contentResolver,
id,
MediaStore.Video.Thumbnails.MINI_KIND,
null
)
}
} catch (_: Exception) {
null
}
videos.add(VideoItem(contentUri, name, thumbnail))
}
}
return videos
} 4. Main App with Permission Handling
package com.codingbihar.videoplayer
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoApp() {
val context = LocalContext.current
val navController = rememberNavController()
// Pick correct permission for the Android version
val requiredPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_VIDEO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
// Initial permission check
val initialPermission = ContextCompat.checkSelfPermission(
context,
requiredPermission
) == PackageManager.PERMISSION_GRANTED
var hasPermission by remember { mutableStateOf(initialPermission) }
var permissionChecked by remember { mutableStateOf(false) } // track if check completed
var videos by remember { mutableStateOf>(emptyList()) }
// Launcher to request permission
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasPermission = granted
if (granted) {
videos = loadVideos(context)
}
}
// Ensure videos load after initial permission check
LaunchedEffect(hasPermission) {
if (hasPermission) {
videos = loadVideos(context)
}
permissionChecked = true
}
// If permission check not done yet, show loading
if (!permissionChecked) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
if (!hasPermission) {
// Show permission UI
Scaffold(topBar = { TopAppBar(title = { Text("Videos") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Permission required to access device videos")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { launcher.launch(requiredPermission) }) {
Text("Grant permission")
}
}
}
} else {
// Permission granted: show videos
NavHost(navController = navController, startDestination = "video_list") {
composable("video_list") {
VideoListScreen(navController, videos)
}
composable("player/{index}") { backStackEntry ->
val index = backStackEntry.arguments?.getString("index")?.toIntOrNull() ?: 0
if (videos.isNotEmpty()) {
VideoPlayerScreen(uris = videos.map { it.uri }, startIndex = index)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No videos")
}
}
}
}
}
}
5. Video List Screen
package com.codingbihar.videoplayer
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoListScreen(navController: NavHostController, videos: List) {
Scaffold(topBar = { TopAppBar(title = { Text("Device videos") }) }) { padding ->
if (videos.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text("No videos found")
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
itemsIndexed(videos) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { navController.navigate("player/$index") }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.thumbnail != null) {
Image(
bitmap = item.thumbnail.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
Text("No\nthumb", fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(item.uri.lastPathSegment ?: item.uri.toString(), maxLines = 1,
overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.headlineMedium)
}
}
}
}
}
}
} 6. Video Player Screen
Here you can embed Media3 PlayerView inside Compose:
package com.codingbihar.videoplayer
import android.content.res.Configuration
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoPlayerScreen(uris: List, startIndex: Int = 0) {
val context = LocalContext.current
var player by remember { mutableStateOf(null) }
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// create player
DisposableEffect(uris, startIndex) {
val exo = ExoPlayer.Builder(context).build().apply {
val items = uris.map { MediaItem.fromUri(it) }
setMediaItems(items, startIndex, C.TIME_UNSET)
prepare()
playWhenReady = true
}
player = exo
onDispose {
player?.release()
player = null
}
}
if (isLandscape) {
// Fullscreen mode
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
}
},
update = { it.player = player },
modifier = Modifier.fillMaxSize()
)
}
} else {
// Normal portrait mode with toolbar + controls
Scaffold(topBar = { TopAppBar(title = { Text("Player") }) }) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = true
keepScreenOn = true
}
},
update = { it.player = player },
modifier = Modifier.fillMaxSize()
)
}
}
}
} 🎉 Final Result:
On launch, the app asks for storage permission.
If granted, it lists all device videos with thumbnails.
Clicking a video opens the Media3 ExoPlayer with playback controls.
Player automatically pauses on background and releases when destroyed.
👉 This is a minimal, production-ready Compose video player app.
Next steps you can add:
Seekbar with progress updates
Playback speed control
Shuffle / repeat buttons
Double-tap to seek gestures
Fullscreen toggle



