Media streaming with ExoPlayer

1. Before you begin

973495692ed95e42.png

Screenshot: The YouTube Android app, which uses ExoPlayer as its video player.

ExoPlayer is an app-level media player built on top of low-level media APIs in Android. It is an open source project used by Google apps, including YouTube and Google TV. ExoPlayer is highly customizable and extensible, making it capable of many advanced use cases. It supports a variety of media formats, including adaptive formats such as DASH and SmoothStreaming.

Prerequisites

  • Moderate knowledge of Android development and Android Studio

What you'll do

  • Create an ExoPlayer instance, which prepares and plays media from a variety of sources.
  • Integrate ExoPlayer with the app's activity lifecycle to support backgrounding, foregrounding, and playback resumption in a single or multi-window environment.
  • Use MediaItems to create a playlist.
  • Play adaptive video streams, which adapt the media quality to the available bandwidth.
  • Register event listeners to monitor playback state and show how listeners can be used to measure the quality of playback.
  • Use standard ExoPlayer UI components, then customize them to your app's style.

What you'll need

  • The latest stable version of Android Studio, and knowledge of how to use it. Make sure your Android Studio, Android SDK, and Gradle plugin are up to date.
  • An Android device with JellyBean (4.1) or higher, ideally with Nougat (7.1) or higher as it supports multiple windows.

2. Get set up

Get the code

To get started, download the Android Studio project:

Alternatively, you can clone the GitHub repository:

git clone https://github.com/googlecodelabs/exoplayer-intro.git

Directory structure

Cloning or unzipping provides you with a root folder (exoplayer-intro), which contains a single gradle project with multiple modules; an app module and one for each step of this codelab, along with all the resources you need.

Import the project

  1. Start Android Studio.
  2. Choose File > New > Import Project*.*
  3. Select the root build.gradle file.

128162a042143d68.png

Screenshot: Project structure when importing

After the build finishes, you'll see six modules: the app module (of type application) and five modules with names exoplayer-codelab-N (where N is 00 to 04, each of type library). The app module is actually empty, having only a manifest. Everything from the currently specified exoplayer-codelab-N module is merged when the app is built using a gradle dependency in app/build.gradle.

app/build.gradle

dependencies {
   implementation project(":exoplayer-codelab-00")
}

Your media player Activity is kept in the exoplayer-codelab-N module. The reason for keeping it in a separate library module is so you can share it among APKs targeting different platforms, such as mobile and Android TV. It also allows you to take advantage of features, such as Dynamic Delivery, which allow your media playback feature to be installed only when the user needs it.

  1. Deploy and run the app to check everything is fine. The app should fill the screen with a black background.

9c330b9a6231f72a.png

Screenshot: Blank app running

3. Stream!

Add ExoPlayer dependency

ExoPlayer is part of the Jetpack Media3 library. Each release is uniquely identified by a string with the following format:

androidx.media3:media3-exoplayer:X.X.X

You can add ExoPlayer to your project simply by importing its classes and UI components. It's pretty small, having a shrunken footprint of about 70-to-300 kB depending on the included features and supported formats. The ExoPlayer library is split into modules to allow developers to import only the functionality they need. For more information about ExoPlayer's modular structure, see Add ExoPlayer modules.

  1. Open the build.gradle file of the exoplayer-codelab-00 module.
  2. Add the following lines to the dependencies section and sync the project.

exoplayer-codelab-00/build.gradle

def mediaVersion = "1.0.0-alpha03"
dependencies {
    [...]
   
    implementation "androidx.media3:media3-exoplayer:$mediaVersion"
    implementation "androidx.media3:media3-ui:$mediaVersion"
    implementation "androidx.media3:media3-exoplayer-dash:$mediaVersion"
}

Add the PlayerView element

  1. Open the layout resource file activity_player.xml from the exoplayer-codelab-00 module.
  2. Place the cursor inside the FrameLayout element.
  3. Start typing <PlayerView and let Android Studio autocomplete the PlayerView element.
  4. Use match_parent for the width and height.
  5. Declare the id as video_view.

activity_player.xml

<androidx.media3.ui.PlayerView
   android:id="@+id/video_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"/>

Going forward, you refer to this UI element as the video view.

  1. In the PlayerActivity, you can now obtain a reference to the view tree created from the XML file you just edited.

PlayerActivity.kt

private val viewBinding by lazy(LazyThreadSafetyMode.NONE) {
    ActivityPlayerBinding.inflate(layoutInflater)
}
  1. Set the root of your view tree as the content view of your Activity. Also check to see that the videoView property is visible on your viewBinding reference, and that its type is PlayerView.
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(viewBinding.root)
}

Create an ExoPlayer

To play streaming media, you need an ExoPlayer object. The simplest way of creating one is to use the ExoPlayer.Builder class. As the name suggests, this uses the builder pattern to build an ExoPlayer instance.

ExoPlayer is a convenient, all-purpose implementation of the Player interface.

Add a private method initializePlayer to create your ExoPlayer.

PlayerActivity.kt

private var player: ExoPlayer? = null
[...]
private fun initializePlayer() {
    player = ExoPlayer.Builder(this)
        .build()
        .also { exoPlayer ->
            viewBinding.videoView.player = exoPlayer
        }
}

Create a ExoPlayer.Builder using your context, then call build to create your ExoPlayer object. This is then assigned to player, which you need to declare as a member field. You then use the viewBinding.videoView.player mutable property to bind the player to its corresponding view.

Create a media item

Your player now needs some content to play. For this, you create a MediaItem. There are many different types of MediaItem, but you start by creating one for an MP3 file on the internet.

The simplest way to create a MediaItem is to use MediaItem.fromUri, which accepts the URI of a media file. Add the MediaItem to the player using player.setMediaItem.

  1. Add the following code to initializePlayer inside the also block:

PlayerActivity.kt

private fun initializePlayer() {
    [...]
        .also { exoPlayer ->
            [...]
            val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
            exoPlayer.setMediaItem(mediaItem)
        }
}

Note that R.string.media_url_mp3 is defined as https://storage.googleapis.com/exoplayer-test-media-0/play.mp3 in strings.xml.

Playing nice with the Activity lifecycle

Our player can hog a lot of resources including memory, CPU, network connections and hardware codecs. Many of these resources are in short supply, particularly for hardware codecs where there may only be one. It's important that you release those resources for other apps to use when you're not using them, such as when your app is put into the background.

Put another way, your player's lifecycle should be tied to the lifecycle of your app. To implement this, you need to override the four methods of PlayerActivity: onStart, onResume, onPause, and onStop.

  1. With PlayerActivity open, click Code menu > Override methods....
  2. Select onStart, onResume, onPause, and onStop.
  3. Initialize the player in the onStart or onResume callback depending on the API level.

PlayerActivity.kt

public override fun onStart() {
    super.onStart()
    if (Util.SDK_INT > 23) {
        initializePlayer()
    }
}

public override fun onResume() {
    super.onResume()
    hideSystemUi()
    if ((Util.SDK_INT <= 23 || player == null)) {
        initializePlayer()
    }
}

Android API level 24 and higher supports multiple windows. As your app can be visible, but not active in split window mode, you need to initialize the player in onStart. Android API level 23 and lower requires you to wait as long as possible until you grab resources, so you wait until onResume before initializing the player.

  1. Add the hideSystemUi method.

PlayerActivity.kt

@SuppressLint("InlinedApi")
private fun hideSystemUi() {
    WindowCompat.setDecorFitsSystemWindows(window, false)
    WindowInsetsControllerCompat(window, viewBinding.videoView).let { controller ->
        controller.hide(WindowInsetsCompat.Type.systemBars())
        controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    }
}

hideSystemUi is a helper method called in onResume, which allows you to have a full-screen experience.

  1. Release resources with releasePlayer (which you create shortly) in onPause and onStop.

PlayerActivity.kt

public override fun onPause() {
    super.onPause()
    if (Util.SDK_INT <= 23) {
        releasePlayer()
    }
}


public override fun onStop() {
    super.onStop()
    if (Util.SDK_INT > 23) {
        releasePlayer()
    }
}

With API Level 23 and lower, there is no guarantee of onStop being called, so you have to release the player as early as possible in onPause. With API Level 24 and higher (which brought multi- and split-window mode), onStop is guaranteed to be called. In the paused state, your activity is still visible, so you wait to release the player until onStop.

You now need to create a releasePlayer method, which frees the player's resources and destroys it.

  1. Add the following code to the activity:

PlayerActivity.kt

private var playWhenReady = true
private var currentItem = 0
private var playbackPosition = 0L
[...]

private fun releasePlayer() {
    player?.let { exoPlayer ->
        playbackPosition = exoPlayer.currentPosition
        currentItem = exoPlayer.currentMediaItemIndex
        playWhenReady = exoPlayer.playWhenReady
        exoPlayer.release()
    }
    player = null
}

Before you release and destroy the player, store the following information:

  • Play/pause state using playWhenReady.
  • Current playback position using currentPosition.
  • Current media item index using currentMediaItemIndex.

This allows you to resume playback from where the user left off. All you need to do is supply this state information when you initialize your player.

Final preparation

All you need to do now is to supply the state information you saved in releasePlayer to your player during initialization.

  1. Add the following to initializePlayer:

PlayerActivity.kt

private fun initializePlayer() {
    [...]
    exoPlayer.playWhenReady = playWhenReady
    exoPlayer.seekTo(currentItem, playbackPosition)
    exoPlayer.prepare()
}

Here's what's happening:

  • playWhenReady tells the player whether to start playing as soon as all resources for playback have been acquired. Because playWhenReady is initially true, playback starts automatically the first time the app is run.
  • seekTo tells the player to seek to a certain position within a specific media item. Both currentItem and playbackPosition are initialized to zero so that playback starts from the very start the first time the app is run.
  • prepare tells the player to acquire all the resources required for playback.

Play audio

Finally, you are done! Start the app to play the MP3 file and see the embedded artwork.

16f18510848103d7.png

Screenshot: The app playing a single track.

Test the activity lifecycle

Test whether the app works in all the different states of the activity lifecycle.

  1. Start another app and put your app in the foreground again. Does it resume at the correct position?
  2. Pause the app, and move it to the background and then to the foreground again. Does it stick to a paused state when backgrounded in paused state?
  3. Rotate the app. How does it behave if you change the orientation from portrait to landscape and back?

Play video

If you want to play video, it's as easy as modifying the media item URI to an MP4 file.

  1. Change the URI in the initializePlayer to R.string.media_url_mp4.
  2. Start the app again and test the behaviour after being backgrounded with video playback as well.

PlayerActivity.kt

private fun initializePlayer() {
    [...]
    val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4))
    [...]
}

The PlayerView does it all. Instead of the artwork, the video is rendered full screen.

b1a45ab2c7cb818d.png

Screenshot: The app playing video.

You rock! You just created an app for full-screen media streaming on Android, complete with lifecycle management, saved state, and UI controls!

4. Create a playlist

Your current app plays a single media file, but what if you want to play more than one media file, one after the other? For that, you need a playlist.

Playlists can be created by adding more MediaItems to your player using addMediaItem. This allows seamless playback and buffering is handled in the background so the user doesn't see a buffering spinner when changing media items.

  1. Add the following code to initializePlayer:

PlayerActivity.kt

private void initializePlayer() {
    [...]
    exoPlayer.addMediaItem(mediaItem) // Existing code

    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
    exoPlayer.addMediaItem(secondMediaItem)
    [...]
}

Check how the player controls behave. You can use d92346ced6303230.pngand e9346dea9156c627.png to navigate the sequence of media items.

8385e35505ef5983.png

Screenshot: Playback controls showing a next and previous button

That's pretty handy! For more information, see the developer documentation on Media Items and Playlists, and this article about the Playlist API.

5. Adaptive streaming

Adaptive streaming is a technique for streaming media by varying the quality of the stream based on the available network bandwidth. This allows the user to experience the best-quality media that their bandwidth allows.

Typically, the same media content is split into multiple tracks with different qualities (bit rates and resolutions). The player chooses a track based on the available network bandwidth.

Each track is split into chunks of a given duration, typically between 2 and 10 seconds. This allows the player to quickly switch between tracks as available bandwidth changes. The player is responsible for stitching these chunks together for seamless playback.

Adaptive track selection

At the heart of adaptive streaming is selecting the most appropriate track for the current environment. Update your app to play adaptive streaming media by using adaptive track selection.

  1. Update initializePlayer with the following code:

PlayerActivity.kt

private fun initializePlayer() {
    val trackSelector = DefaultTrackSelector(this).apply {
        setParameters(buildUponParameters().setMaxVideoSizeSd())
    }
    player = ExoPlayer.Builder(this)
        .setTrackSelector(trackSelector)
        .build()
    [...]
}

First, create a DefaultTrackSelector, which is responsible for choosing tracks in the media item. Then, tell your trackSelector to only pick tracks of standard definition or lower—a good way of saving your user's data at the expense of quality. Lastly, pass your trackSelector to your builder so that it is used when building the ExoPlayer instance.

Build an adaptive MediaItem

DASH is a widely used adaptive streaming format. To stream DASH content, you need to create a MediaItem as before. However, this time, we must use a MediaItem.Builder rather than fromUri.

This is because fromUri uses the file extension to determine the underlying media format but our DASH URI does not have a file extension so we must supply a MIME type of APPLICATION_MPD when constructing the MediaItem.

  1. Update initializePlayer as follows:

PlayerActivity.kt

private void initializePlayer() {
    [...]

    // Replace this line...
    val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4));

    // ... with this
     val mediaItem = MediaItem.Builder()
         .setUri(getString(R.string.media_url_dash))
         .setMimeType(MimeTypes.APPLICATION_MPD)
         .build()

    // Keep this line
    exoPlayer.setMediaItem(mediaItem)

    // Remove the following lines
    val secondMediaItem = MediaItem.fromUri(getString(R.string.media_url_mp3))
    exoPlayer.addMediaItem(secondMediaItem)
}
  1. Restart the app and see adaptive video streaming with DASH in action. It's pretty easy with ExoPlayer!

Other adaptive streaming formats

HLS (MimeTypes.APPLICATION_M3U8) and SmoothStreaming (MimeTypes.APPLICATION_SS) are other commonly used adaptive streaming formats, both of which are supported by ExoPlayer. For more information about constructing other adaptive media sources, see the ExoPlayer demo app.

6. Listening for events

In the previous steps, you learned how to stream progressive and adaptive media streams. ExoPlayer is doing a lot of work for you behind the scenes, including the following:

  • Allocating memory
  • Downloading container files
  • Extracting metadata from the container
  • Decoding data
  • Rendering video, audio, and text to the screen and loudspeakers

Sometimes, it's useful to know what ExoPlayer is doing at runtime in order to understand and improve the playback experience for your users.

For example, you might want to reflect playback state changes in the user interface by doing the following:

  • Displaying a loading spinner when the player goes into a buffering state
  • Showing an overlay with "watch next" options when the track has ended

ExoPlayer offers several listener interfaces that provide callbacks for useful events. You use a listener to log what state the player is in.

Listen up

  1. Create a TAG constant outside the PlayerActivity class, which you use for logging later.

PlayerActivity.kt

private const val TAG = "PlayerActivity"
  1. Implement the Player.Listener interface in a factory function outside the PlayerActivity class. This is used to inform you about important player events, including errors and playback state changes.
  2. Override onPlaybackStateChanged by adding the following code:

PlayerActivity.kt

private fun playbackStateListener() = object : Player.Listener {
    override fun onPlaybackStateChanged(playbackState: Int) {
        val stateString: String = when (playbackState) {
            ExoPlayer.STATE_IDLE -> "ExoPlayer.STATE_IDLE      -"
            ExoPlayer.STATE_BUFFERING -> "ExoPlayer.STATE_BUFFERING -"
            ExoPlayer.STATE_READY -> "ExoPlayer.STATE_READY     -"
            ExoPlayer.STATE_ENDED -> "ExoPlayer.STATE_ENDED     -"
            else -> "UNKNOWN_STATE             -"
        }
        Log.d(TAG, "changed state to $stateString")
    }
}
  1. Declare a private member of type Player.Listener in the PlayerActivity.

PlayerActivity.kt

class PlayerActivity : AppCompatActivity() {
    [...]

    private val playbackStateListener: Player.Listener = playbackStateListener()
}

onPlaybackStateChanged is called when the playback state changes. The new state is given by the playbackState parameter.

The player can be in one of the following four states:

State

Description

ExoPlayer.STATE_IDLE

The player has been instantiated, but has not yet been prepared.

ExoPlayer.STATE_BUFFERING

The player is not able to play from the current position because not enough data has been buffered.

ExoPlayer.STATE_READY

The player is able to immediately play from the current position. This means the player will start playing media automatically if the player's playWhenReady property is true. If it is false, the player is paused.

ExoPlayer.STATE_ENDED

The player has finished playing the media.

Register your listener

To have your callbacks called, you need to register your playbackStateListener with the player. Do that in initializePlayer.

  1. Register the listener before the play is prepared.

PlayerActivity.kt

private void initializePlayer() {
    [...]
    exoPlayer.seekTo(currentWindow, playbackPosition)
    exoPlayer.addListener(playbackStateListener)
    [...]
}

Again, you need to tidy up to avoid dangling references from the player which could cause a memory leak.

  1. Remove the listener in releasePlayer:

PlayerActivity.kt

private void releasePlayer() {
    player?.let { exoPlayer ->
        [...]
        exoPlayer.removeListener(playbackStateListener)
        exoPlayer.release()
    }
    player = null
}
  1. Open logcat and run the app.
  2. Use the UI controls to seek, pause, and resume playback. You should see the playback state change in the logs.

Go deeper

ExoPlayer offers a number of other listeners, which are useful in understanding the user's playback experience. There are listeners for audio and video, as well as an AnalyticsListener, which contains the callbacks from all the listeners. Some of the most important methods are the following:

  • onRenderedFirstFrame is called when the first frame of a video is rendered. With this, you can calculate how long the user had to wait to see meaningful content on the screen.
  • onDroppedVideoFrames is called when video frames have been dropped. Dropped frames indicate that playback is janky and the user experience is likely to be poor.
  • onAudioUnderrun is called when there has been an audio underrun. Underruns cause audible glitches in the sound and are more noticeable than dropped video frames.

AnalyticsListener can be added to the player with addListener. There are corresponding methods for the audio and video listeners as well.

The Player.Listener interface also includes the more general onEvents callback that is triggered for any state change in the player. Some cases in which you may find this useful include when responding to multiple changes in state at the same time or when responding the same way to multiple different state changes. Check the reference documentation for more examples of when you might want to use the onEvents callback instead of individual state change callbacks.

Think about what events are important to your app and your users. For more information, see Listening for player events. That's it for event listeners!

7. Congratulations

Congratulations! You learned a lot about integrating ExoPlayer with your app.

Learn more

To learn more about ExoPlayer, check out the developer guide and source code, and subscribe to the ExoPlayer blog.