Master ExoPlayer in Jetpack Compose: Build a Pro Android Media Player
This guide walks you through integrating Google’s ExoPlayer library into a Jetpack Compose Android app, covering project setup, dependency management, ViewModel‑based player control, custom UI components, lifecycle handling, performance tips, and advanced customization options for building a robust, professional‑grade media player.
Introduction
In modern Android app development, media playback is a key factor for user experience. Whether short videos, educational content, or music, a smooth and feature‑rich player greatly improves user retention. ExoPlayer, Google’s officially recommended media library, offers high customizability and powerful features, making it the top choice for many developers.
Core Advantages of ExoPlayer
What is ExoPlayer and why choose it over the system MediaPlayer?
ExoPlayer was originally developed by the Google team as an independent library and later incorporated into the Jetpack suite as part of Media3. This integration brings lifecycle‑aware capabilities and simplifies adaptation for Android Auto, Wear OS, and other device form factors.
Broad format support : native handling of DASH, HLS, SmoothStreaming and other adaptive streaming protocols without extra plugins.
Highly customizable : almost every component—from rendering surface to control logic—can be replaced or customized.
Lifecycle aware : automatically pauses playback when the app goes to background and resumes when it returns to foreground.
Multi‑device adaptation : unified API works across phones, tablets, TV, cars and more.
Continuous updates : as a Jetpack component it is updated in sync with the Android platform, ensuring timely support for new features.
Key Features of Modern Media Playback
As the core component of the Media3 Jetpack library, ExoPlayer provides a set of essential capabilities for contemporary media playback:
Lifecycle‑aware playback : automatically pauses when the app goes to background and resumes when it returns to foreground, eliminating manual state‑management code.
Seamless Jetpack integration : naturally blends with Compose, ViewModel, Hilt and other Jetpack components, following Android best practices.
Cross‑platform consistency : delivers a uniform playback experience across different Android versions and devices, reducing compatibility issues.
Persistent playback state : together with the media3‑database module, it can store playback progress and queue state for a smoother user experience.
Project Setup and Dependency Configuration
Development Environment Preparation
First, make sure your development environment meets the following requirements:
Android Studio Flamingo or newer
Kotlin 1.8.0 or newer
Compose 1.4.0 or newer
Gradle 8.0 or newer
Create a new Jetpack Compose project using the “Empty Activity” template. We will incrementally add the required dependencies and components to build a fully functional media player.
Project Structure Design
To keep the code clear and maintainable, we adopt the following structure:
app/
├── src/main/java/com/example/exoplayerdemo/
│ ├── MainActivity.kt // Application entry point, hosts Compose content
│ ├── PlayerScreen.kt // Player UI composable
│ ├── PlayerViewModel.kt // Manages player state and business logic
│ ├── PlayerState.kt // Data class for player state
│ └── components/ // Custom UI components
│ ├── CustomPlayerControls.kt // Custom control bar
│ └── PlayerSurface.kt // Video rendering surface wrapper
└── src/main/res/ // Resource filesThis layout follows the single‑responsibility principle, separating UI, business logic and state management for easier future extension.
Dependency Integration Details
We use the Version Catalog (TOML) approach to centralize dependency versions, and Kotlin Gradle scripts for type‑safe configuration.
1. Configure Version Catalog
Add the following entries to gradle/libs.versions.toml:
[versions]
# Core dependencies
composeBom = "2023.10.01"
kotlin = "1.9.0"
agp = "8.1.0"
# Media3/ExoPlayer
media3 = "1.2.0"
# Jetpack components
lifecycleRuntimeKtx = "2.6.2"
viewModelCompose = "2.6.2"
hiltNavigationCompose = "1.1.0"
# Other libraries
materialIcons = "1.4.3"
[libraries]
# Compose BOM
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
# ExoPlayer / Media3
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
media3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
# ViewModel and lifecycle
lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewModelCompose" }
# Hilt
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Material Icons
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIcons" }2. Configure app‑level build.gradle.kts
Add the following dependencies to app/build.gradle.kts:
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.kotlinKapt)
alias(libs.plugins.hiltAndroid)
}
android {
namespace = "com.example.exoplayerdemo"
compileSdk = 34
defaultConfig {
applicationId = "com.example.exoplayerdemo"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.4.8" }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}
dependencies {
// Compose BOM for version alignment
implementation(platform(libs.compose.bom))
// Compose core
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
// ExoPlayer / Media3
implementation(libs.media3.exoplayer)
implementation(libs.media3.ui)
implementation(libs.media3.ui.compose)
implementation(libs.media3.session)
// ViewModel & lifecycle
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.viewmodel.compose)
// Hilt
implementation(libs.hilt.navigation.compose)
// Material Icons
implementation(libs.compose.material.icons.extended)
// Test dependencies
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}Implement Basic Player Functionality
ViewModel Design and Player Management
To keep the player state across configuration changes (e.g., screen rotation), we use a ViewModel to own the ExoPlayer instance and expose a state flow for the UI:
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val application: Application
) : AndroidViewModel(application) {
// Player instance
private val exoPlayer = ExoPlayer.Builder(application)
.build()
.apply {
repeatMode = Player.REPEAT_MODE_OFF
shuffleModeEnabled = false
}
// UI state flow
private val _uiState = MutableStateFlow<PlayerUiState>(PlayerUiState.Loading)
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
// Current media item
private var currentMediaItem: MediaItem? = null
init {
// Listen to player events
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
_uiState.value = when (state) {
Player.STATE_IDLE -> PlayerUiState.Idle
Player.STATE_BUFFERING -> PlayerUiState.Buffering
Player.STATE_READY -> PlayerUiState.Ready(
isPlaying = exoPlayer.isPlaying,
currentPosition = exoPlayer.currentPosition,
duration = exoPlayer.duration,
bufferPercentage = exoPlayer.bufferedPercentage
)
Player.STATE_ENDED -> PlayerUiState.Ended
else -> PlayerUiState.Error("Unknown playback state")
}
}
override fun onPlayerError(error: PlaybackException) {
_uiState.value = PlayerUiState.Error(error.message ?: "Playback error")
}
})
}
// Load media
fun loadMediaItem(url: String) {
val mediaItem = MediaItem.fromUri(url)
if (currentMediaItem != mediaItem) {
currentMediaItem = mediaItem
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
}
}
// Play / pause toggle
fun togglePlayback() {
if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
}
// Release resources
override fun onCleared() {
super.onCleared()
exoPlayer.release()
}
// Expose player to UI
fun getExoPlayer() = exoPlayer
// Player UI state sealed class
sealed interface PlayerUiState {
object Loading : PlayerUiState
object Idle : PlayerUiState
object Buffering : PlayerUiState
data class Ready(
val isPlaying: Boolean,
val currentPosition: Long,
val duration: Long,
val bufferPercentage: Int
) : PlayerUiState
object Ended : PlayerUiState
data class Error(val message: String) : PlayerUiState
}
}Player UI Implementation
The Compose UI consists of a video surface and a basic control bar:
@Composable
fun PlayerScreen(
modifier: Modifier = Modifier,
viewModel: PlayerViewModel = hiltViewModel(),
mediaUrl: String = "https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4"
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val exoPlayer = viewModel.getExoPlayer()
// Load media once
LaunchedEffect(Unit) { viewModel.loadMediaItem(mediaUrl) }
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { PlayerTopAppBar() }
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Video surface
PlayerSurface(
modifier = Modifier.fillMaxSize(),
exoPlayer = exoPlayer
)
// Show UI based on playback state
when (val state = uiState) {
is PlayerUiState.Loading,
is PlayerUiState.Buffering -> LoadingIndicator(modifier = Modifier.align(Alignment.Center))
is PlayerUiState.Ready -> PlayerControls(
modifier = Modifier.align(Alignment.BottomCenter),
isPlaying = state.isPlaying,
currentPosition = state.currentPosition,
duration = state.duration,
bufferPercentage = state.bufferPercentage,
onPlayPauseClick = { viewModel.togglePlayback() },
onProgressChanged = { position -> exoPlayer.seekTo(position) }
)
is PlayerUiState.Error -> ErrorView(
modifier = Modifier.align(Alignment.Center),
message = state.message,
onRetryClick = { viewModel.loadMediaItem(mediaUrl) }
)
else -> Unit
}
}
}
}Main Activity Configuration
Finally, set up the main Activity to host the Compose player:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ExoPlayerDemoTheme {
MaterialTheme(colorScheme = darkColorScheme()) {
PlayerScreen()
}
}
}
}
}Advanced Features and Best Practices
Handling Playback State and Errors
A robust player must gracefully manage various states and potential errors:
State management : define all possible states with a sealed class PlayerUiState so the UI can accurately reflect the current condition.
Error recovery : provide a retry mechanism that lets users reload media after a failure.
Buffering optimization : display buffering progress to keep users informed.
Lifecycle awareness : use collectAsStateWithLifecycle() to stop collecting when the app is in the background.
Performance Optimization Suggestions
To ensure smooth playback across devices, consider the following:
Hardware acceleration : ExoPlayer enables hardware decoding by default; you can further tune it with MediaCodecSelector.
Adaptive video quality : adjust bitrate based on network conditions to avoid stalls.
Player instance management : release the player promptly when not needed to prevent memory leaks.
UI rendering optimization : minimize re‑composition of the control bar and avoid heavy calculations during playback.
Custom Control Extensions
ExoPlayer’s strength lies in its extensibility. You can enhance the player by:
Implementing custom Renderer objects to support special media formats.
Adding advanced controls such as playback speed adjustment, audio track switching, and subtitle management.
Integrating gesture handling for volume, brightness, and seek adjustments.
Embedding Android’s picture‑in‑picture mode to enable multitasking.
Conclusion and Further Learning
This article detailed how to integrate ExoPlayer into a Jetpack Compose project, from project configuration to custom UI components, resulting in a fully functional video player. Using a ViewModel to manage player state guarantees persistence across configuration changes, while custom composables deliver a cohesive playback experience.
ExoPlayer offers many advanced capabilities such as playlist management, DRM‑protected content, and custom data sources. Future articles will explore these topics, helping you build video apps, e‑learning platforms, or social media products with professional‑grade media playback.
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
