How to display videos using ExoPlayer on android with Jetpack Compose

Mun Bonecci
5 min readJan 10, 2024

--

For this tutorial, I decided to show you a simple way to use ExoPlayer with Jetpack Compose.

ExoPlayer is the default implementation of this interface in Media3. Compared to Android's MediaPlayer API, it adds additional conveniences such as support for multiple streaming protocols, default audio and video renderers, and components that handle media buffering. ExoPlayer is easy to customize and extend, and can be updated through Play Store application updates.

Add the following dependency in your build.gradle(Module: app) file.

// in .kts
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")

Get the current context with LocalContext.current .

    // Get current context
val context = LocalContext.current

Create an instance of ExoPlayer in your Composable or ViewModel.

val exoPlayer = ExoPLayer.Builder(context).build()

You should manage the ExoPlayer’s lifecycle to release resources when not needed. You can use the DisposableEffect andLaunchedEffect to handle the lifecycle events.

    // Set MediaSource to ExoPlayer
LaunchedEffect(mediaSource) {
exoPlayer.setMediaItem(mediaSource)
exoPlayer.prepare()
}

// Manage lifecycle events
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}

Android view to display the ExoPlayer and its controls.

AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
}
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp) // Set your desired height
)

Create a const EXAMPLE_VIDEO_URIto define the url from the sample video.

const val EXAMPLE_VIDEO_URI = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"

Add the internet permission in the manifest.file

 <uses-permission android:name="android.permission.INTERNET" />

This is the complete code that we have created previously.

/**
* Composable function that displays an ExoPlayer to play a video using Jetpack Compose.
*
* @OptIn annotation to UnstableApi is used to indicate that the API is still experimental and may
* undergo changes in the future.
*
* @see EXAMPLE_VIDEO_URI Replace with the actual URI of the video to be played.
*/
@OptIn(UnstableApi::class)
@Composable
fun ExoPlayerView() {
// Get the current context
val context = LocalContext.current

// Initialize ExoPlayer
val exoPlayer = ExoPlayer.Builder(context).build()

// Create a MediaSource
val mediaSource = remember(EXAMPLE_VIDEO_URI) {
MediaItem.fromUri(EXAMPLE_VIDEO_URI)
}

// Set MediaSource to ExoPlayer
LaunchedEffect(mediaSource) {
exoPlayer.setMediaItem(mediaSource)
exoPlayer.prepare()
}

// Manage lifecycle events
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}

// Use AndroidView to embed an Android View (PlayerView) into Compose
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
}
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp) // Set your desired height
)
}

If you want custom UI controls, you can create Composables with buttons for play, pause, seek, etc., and update the exoPlayer accordingly.

To use test this feature, simply include ExoPlayeView() in your Compose UI:

ExoPlayerView()

Run your code and see the results.

Below you will find the url of the repository with this example.

But, there is another sample project who consists of two screens. The first is a list of videos and the second is the detail of the selected video. But it also contains certain settings to hide video controls and other slightly more advanced settings.

/**
* Composable function that displays a video player using ExoPlayer with Jetpack Compose.
*
* @param video The [VideoResultEntity] representing the video to be played.
* @param playingIndex State that represents the current playing index.
* @param onVideoChange Callback function invoked when the video changes.
* @param isVideoEnded Callback function to determine whether the video has ended.
* @param modifier Modifier for styling and positioning.
*
* @OptIn annotation to UnstableApi is used to indicate that the API is still experimental and may
* undergo changes in the future.
*
* @SuppressLint annotation is used to suppress lint warning for the usage of OpaqueUnitKey.
*
* @ExperimentalAnimationApi annotation is used for the experimental Animation API usage.
*/
@OptIn(UnstableApi::class)
@SuppressLint("OpaqueUnitKey")
@ExperimentalAnimationApi
@Composable
fun VideoPlayer(
video: VideoResultEntity,
playingIndex: State<Int>,
onVideoChange: (Int) -> Unit,
isVideoEnded: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
// Get the current context
val context = LocalContext.current

// Mutable state to control the visibility of the video title
val visible = remember { mutableStateOf(true) }

// Mutable state to hold the video title
val videoTitle = remember { mutableStateOf(video.name) }

// Create a list of MediaItems for the ExoPlayer
val mediaItems = arrayListOf<MediaItem>()
mediaItems.add(
MediaItem.Builder()
.setUri(video.video)
.setMediaId(video.id.toString())
.setTag(video)
.setMediaMetadata(MediaMetadata.Builder().setDisplayTitle(video.name).build())
.build()
)

// Initialize ExoPlayer
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
this.setMediaItems(mediaItems)
this.prepare()
this.addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
// Hide video title after playing for 200 milliseconds
if (player.contentPosition >= 200) visible.value = false
}

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
// Callback when the video changes
onVideoChange(this@apply.currentPeriodIndex)
visible.value = true
videoTitle.value = mediaItem?.mediaMetadata?.displayTitle.toString()
}

override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
// Callback when the video playback state changes to STATE_ENDED
if (playbackState == ExoPlayer.STATE_ENDED) {
isVideoEnded.invoke(true)
}
}
})
}
}

// Seek to the specified index and start playing
exoPlayer.seekTo(playingIndex.value, C.TIME_UNSET)
exoPlayer.playWhenReady = true

// Add a lifecycle observer to manage player state based on lifecycle events
LocalLifecycleOwner.current.lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_START -> {
// Start playing when the Composable is in the foreground
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
}

Lifecycle.Event.ON_STOP -> {
// Pause the player when the Composable is in the background
exoPlayer.pause()
}

else -> {
// Nothing
}
}
}
})

// Column Composable to contain the video player
Column(modifier = modifier.background(Color.Black)) {
// DisposableEffect to release the ExoPlayer when the Composable is disposed
DisposableEffect(
AndroidView(
modifier = modifier
.testTag(VIDEO_PLAYER_TAG),
factory = {
// AndroidView to embed a PlayerView into Compose
PlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Set resize mode to fill the available space
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
// Hide unnecessary player controls
setShowNextButton(false)
setShowPreviousButton(false)
setShowFastForwardButton(false)
setShowRewindButton(false)
}
})
) {
// Dispose the ExoPlayer when the Composable is disposed
onDispose {
exoPlayer.release()
}
}
}
}

VideoPlayer() composable function contains:

MediaItems and ExoPlayer Setup:

  • A list of MediaItems is created to hold the video information.
  • The ExoPlayer is configured with the media items, prepared, and listeners are added to handle events like video changes and playback state changes.

Lifecycle Management:

  • A lifecycle observer is added to manage the player state based on Composable lifecycle events. The player starts playing when the Composable is in the foreground and pauses when it’s in the background.

AndroidView and PlayerView Integration:

  • The AndroidView composable is used to embed an Android PlayerView into Jetpack Compose.
  • The PlayerView is configured with the ExoPlayer instance, layout parameters, and properties like resize mode and visibility of player controls.

DisposableEffect for Cleanup:

  • A DisposableEffect is used to release the ExoPlayer resources when the Composable is disposed.

Overall, the VideoPlayer Composable encapsulates the logic for initializing and managing an ExoPlayer to play a video within a Jetpack Compose UI.

I invite you to test and review the code more carefully in the following repository.

--

--

Mun Bonecci

As an Android developer, I'm passionate about evolving tech. I thrive on continuous learning, staying current with trends, and contributing in these fields.