Mobile Development 20 min read

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.

AndroidPub
AndroidPub
AndroidPub
Master ExoPlayer in Jetpack Compose: Build a Pro Android 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 files

This 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.

AndroidKotlinJetpack ComposeExoPlayerMedia Playback
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.