Jetpack Compose: Media Player Integration

Integrating a media player in Android applications using Jetpack Compose involves combining Compose’s declarative UI capabilities with Android’s media APIs. This approach allows developers to create a more modern, flexible, and maintainable media playback experience. This article explores how to integrate a media player using Jetpack Compose, covering essential aspects such as setting up the UI, handling media playback, and managing the player lifecycle.

Why Integrate a Media Player with Jetpack Compose?

  • Modern UI: Leverage Compose’s declarative UI paradigm for cleaner and more maintainable code.
  • Flexibility: Customize the media player UI to match your application’s design seamlessly.
  • Lifecycle Management: Effectively manage the media player lifecycle to prevent memory leaks and ensure smooth operation.

Setting Up a Basic Media Player UI in Compose

First, let’s create a basic UI for the media player using Jetpack Compose. This UI will include play/pause buttons, a seek bar, and possibly other controls.

Step 1: Add Dependencies

Ensure you have the necessary dependencies in your build.gradle file:

dependencies {
    implementation("androidx.compose.ui:ui:1.6.0")
    implementation("androidx.compose.material:material:1.6.0")
    implementation("androidx.media3:media3-exoplayer:1.2.1")
    implementation("androidx.media3:media3-ui:1.2.1")
}

These dependencies include:

  • androidx.compose.ui:ui and androidx.compose.material:material for building the UI.
  • androidx.media3:media3-exoplayer and androidx.media3:media3-ui for handling media playback with ExoPlayer.

Step 2: Create a Simple UI

Here’s a simple Composable function for the media player UI:

import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer

@Composable
fun MediaPlayerUI() {
    val context = LocalContext.current
    val exoPlayer = remember {
        ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("YOUR_MEDIA_URL")
            setMediaItem(mediaItem)
            prepare()
        }
    }

    var isPlaying by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Media Player")

        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = {
            if (isPlaying) {
                exoPlayer.pause()
            } else {
                exoPlayer.play()
            }
            isPlaying = !isPlaying
        }) {
            Text(text = if (isPlaying) "Pause" else "Play")
        }

        DisposableEffect(Unit) {
            onDispose {
                exoPlayer.release()
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun MediaPlayerUIPreview() {
    MediaPlayerUI()
}

In this code:

  • ExoPlayer is initialized with a media item. Replace "YOUR_MEDIA_URL" with the actual URL of the media you want to play.
  • A Button controls the playback state, toggling between play and pause.
  • A DisposableEffect is used to properly release the ExoPlayer when the Composable is disposed, preventing memory leaks.

Integrating ExoPlayer with Compose

ExoPlayer is a popular and versatile media player library for Android. It supports a wide range of formats and provides advanced features such as adaptive streaming. Integrating ExoPlayer into your Compose UI involves handling playback controls and managing the player’s lifecycle.

Step 1: Adding ExoPlayer Dependency

Make sure you have the ExoPlayer dependency in your build.gradle file (as shown in the previous section).

Step 2: Creating a PlayerView in Compose

You can use the AndroidView composable to integrate an ExoPlayer‘s PlayerView into your Compose UI. This approach allows you to use the standard ExoPlayer UI components within Compose.

import androidx.compose.runtime.*
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.media3.ui.PlayerView
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer

@Composable
fun ExoPlayerView() {
    val context = LocalContext.current
    val exoPlayer = remember {
        ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("YOUR_MEDIA_URL")
            setMediaItem(mediaItem)
            prepare()
            playWhenReady = true
        }
    }

    DisposableEffect(
        AndroidView(
            factory = {
                PlayerView(context).apply {
                    player = exoPlayer
                    useController = true // Show playback controls
                }
            },
            update = { view ->
                view.player = exoPlayer
            }
        )
    ) {
        onDispose {
            exoPlayer.release()
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ExoPlayerViewPreview() {
    ExoPlayerView()
}

Explanation:

  • We use AndroidView to incorporate the PlayerView (from androidx.media3.ui) into our Compose layout.
  • The factory lambda creates the PlayerView and sets the ExoPlayer instance.
  • useController = true enables the default playback controls provided by ExoPlayer.
  • In the DisposableEffect, the ExoPlayer is released when the composable is disposed of to prevent memory leaks.

Managing Media Player Lifecycle

Managing the lifecycle of the media player is crucial to prevent memory leaks and ensure smooth playback transitions. Using DisposableEffect, as demonstrated in the examples above, is one effective way to handle the player’s lifecycle.

Best Practices:

  • Release ExoPlayer: Always release the ExoPlayer instance when it’s no longer needed, typically when the composable is disposed.
  • Handle Background Playback: If you need background playback, consider using a Service in conjunction with ExoPlayer and managing audio focus properly.
  • Pause/Play on Activity Lifecycle Events: Consider pausing the player when the activity is paused (onPause) and resuming when the activity is resumed (onResume) to align with Android’s lifecycle best practices.

Customizing the Media Player UI

Jetpack Compose allows you to fully customize the media player UI. You can replace the standard ExoPlayer controls with your own composable functions.

Example: Custom Playback Controls


import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Pause
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer

@Composable
fun CustomMediaPlayerUI() {
    val context = LocalContext.current
    val exoPlayer = remember {
        ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("YOUR_MEDIA_URL")
            setMediaItem(mediaItem)
            prepare()
        }
    }

    var isPlaying by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Custom Media Player")

        Spacer(modifier = Modifier.height(16.dp))

        IconButton(onClick = {
            if (isPlaying) {
                exoPlayer.pause()
            } else {
                exoPlayer.play()
            }
            isPlaying = !isPlaying
        }) {
            Icon(
                imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                contentDescription = if (isPlaying) "Pause" else "Play"
            )
        }

        DisposableEffect(Unit) {
            onDispose {
                exoPlayer.release()
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CustomMediaPlayerUIPreview() {
    CustomMediaPlayerUI()
}

In this example, we’ve replaced the Button with an IconButton using Material Icons for play and pause. This is just a basic example; you can extend this approach to create more complex and visually appealing custom controls.

Handling Playback Events

To respond to playback events (e.g., play, pause, buffering, completion), you can use Player.Listener with ExoPlayer.


import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.Player

@Composable
fun EventDrivenMediaPlayerUI() {
    val context = LocalContext.current
    val exoPlayer = remember {
        ExoPlayer.Builder(context).build().apply {
            val mediaItem = MediaItem.fromUri("YOUR_MEDIA_URL")
            setMediaItem(mediaItem)
            prepare()
        }
    }

    var playbackState by remember { mutableStateOf(Player.STATE_IDLE) }

    val playerListener = object : Player.Listener {
        override fun onPlaybackStateChanged(state: Int) {
            playbackState = state
        }
    }

    LaunchedEffect(exoPlayer) {
        exoPlayer.addListener(playerListener)
    }

    DisposableEffect(Unit) {
        onDispose {
            exoPlayer.removeListener(playerListener)
            exoPlayer.release()
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Playback State: ${
            when (playbackState) {
                Player.STATE_IDLE -> "Idle"
                Player.STATE_BUFFERING -> "Buffering"
                Player.STATE_READY -> "Ready"
                Player.STATE_ENDED -> "Ended"
                else -> "Unknown"
            }
        }")
    }
}

@Preview(showBackground = true)
@Composable
fun EventDrivenMediaPlayerUIPreview() {
    EventDrivenMediaPlayerUI()
}

In this example:

  • A Player.Listener is created to listen for playback state changes.
  • onPlaybackStateChanged updates a playbackState variable, which triggers UI updates via Compose’s state management.
  • LaunchedEffect is used to add the listener when the exoPlayer is first created.
  • In the DisposableEffect, both the listener is removed and the ExoPlayer is released upon disposal.

Conclusion

Integrating a media player into Jetpack Compose applications involves combining Compose’s UI capabilities with media APIs like ExoPlayer. By following the steps outlined in this article, developers can create customizable, efficient, and lifecycle-aware media playback experiences. From setting up basic UIs to handling playback events and customizing the player UI, Jetpack Compose provides a flexible and modern approach to media player integration in Android.