Media streaming with ExoPlayer

1. Before you begin

973495692ed95e42.png

Screenshot: The YouTube Android app

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

Prerequisites

  • Moderate knowledge of Android development and Android Studio

What you'll do

  • Create a SimpleExoPlayer 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

  • Android Studio version 3.5 or higher
  • 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:

Download zip

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

111b190903697765.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 an open source project hosted on GitHub. Each release is distributed through Google Maven, which is one of the default package repositories used by Android Studio and Gradle. Each release is uniquely identified by a string with the following format:

com.google.android.exoplayer: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 in to 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 player-lib module.
  2. Add the following lines to the dependencies section and sync the project.

exoplayer-codelab-00/build.gradle

dependencies {
   [...]
   
implementation 'com.google.android.exoplayer:exoplayer-core:2.12.0'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.12.0'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.12.0'

}

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

<com.google.android.exoplayer2.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 SimpleExoPlayer.Builder class. As the name suggests, this uses the builder pattern to build a SimpleExoPlayer instance.

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

Add a private method initializePlayer to create your SimpleExoPlayer.

PlayerActivity.kt

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

Create a SimpleExoPlayer.Builder using your context, then call build to create your SimpleExoPlayer 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 >= 24) {
   initializePlayer()
 }
}

public override fun onResume() {
 super.onResume()
 hideSystemUi()
 if ((Util.SDK_INT < 24 || 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 24 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() {
 viewBinding.videoView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LOW_PROFILE
     or View.SYSTEM_UI_FLAG_FULLSCREEN
     or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
     or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
     or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
     or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
}

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 < 24) {
   releasePlayer()
 }
}


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

With API Level 24 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 currentWindow = 0
private var playbackPosition = 0L
[...]

private fun releasePlayer() {
    player?.run {
        playbackPosition = this.currentPosition
        currentWindow = this.currentWindowIndex
        playWhenReady = this.playWhenReady
        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 window index using currentWindowIndex. For more information about windows, see Timeline.

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(currentWindow, 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 window. Both currentWindow 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 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 1f79fee4d082870f.pngand 39627002c03ce320.png to navigate the sequence of media items.

7b5c034dafabe1bd.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 = SimpleExoPlayer.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 SimpleExoPlayer 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()

  // Also 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.EventListener 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.EventListener {
    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.EventListener in the PlayerActivity.

PlayerActivity.kt

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

    private val playbackStateListener: Player.EventListener = 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?.run {
   [...]
   removeListener(playbackStateListener)
   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 addAnalyticsListener. There are corresponding methods for the audio and video listeners as well.

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. Customize the user interface

So far, you've been using ExoPlayer's PlayerControlView to display a playback controller to the user.

bcfe17eebcad9e13.png

Screenshot: Default playback controller

What if you want to change the functionality or look and feel of these controls? Luckily, these controls are highly customizable.

The first simple customization is to not use the controller at all. This can be done easily by using the use_controller attribute on the PlayerView element inside activity_player.xml.

  1. Set use_controller to false and the control does not show up anymore:

activity_player.xml

<com.google.android.exoplayer2.ui.PlayerView
   [...]
   app:use_controller="false"/>
  1. Add the following namespace to your FrameLayout:

activity_player.xml

<FrameLayout
  [...] 
  xmlns:app="http://schemas.android.com/apk/res-auto">

Try it now.

Customize the behaviour

PlayerControlView has several attributes which affect its behaviour. For instance, you can use show_timeout to customize the delay in milliseconds before the control is hidden after the user last interacted with it. To do so:

  1. Remove app:use_controller="false".
  2. Change the player view to use show_timeout:

activity_player.xml

<com.google.android.exoplayer2.ui.PlayerView
   android:id="@+id/video_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   app:show_timeout="10000"/>

The attributes of the PlayerControlView can also be set programmatically.

Customize the appearance

Well, that's a good start. But what if you want to have the PlayerControlView look different or change which buttons are displayed? The implementation of PlayerControlView does not assume that any buttons exist, so it's easy to remove them and add new ones.

Look at how you can customize the PlayerControlView.

  1. Create a new layout file custom_player_control_view.xml in the folder player-lib/res/layout/.
  2. From the context menu of the layout folder, choose New - Layout resource file and name it custom_player_control_view.xml.

ae1e3795726d4e4e.png

Screenshot: The layout file for the player control view has been created.

  1. Copy the original layout file from here into custom_player_control_view.xml.
  2. Remove the ImageButton elements with the id @id/exo_prev and @id/exo_next.

To use your custom layout, you need to set the attribute app:controller_layout_id of the PlayerView element in the activity_player.xml file.

  1. Use the layout ID of your custom file like in the following code snippet:

activity_player.xml

<com.google.android.exoplayer2.ui.PlayerView  
   android:id="@+id/video_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   app:controller_layout_id="@layout/custom_player_control_view"/>
  1. Start the app again. The player control view no longer has the previous and next buttons.

89e6535a22c8e321.png

Screenshot: Custom player control view with no previous or next buttons

You can apply whatever changes you like in the layout file. By default, the colors of the Android theme are chosen. You can override this to match the design of your app.

  1. Add a android:tint attribute to each ImageButton element:

custom_player_control_view.xml

<ImageButton android:id="@id/exo_rew"
   android:tint="#FF00A6FF"
   style="@style/ExoMediaButton.Rewind"/>
  1. Change all android:textColor attributes you find in your custom file to the same color: #FF00A6FF.

custom_player_control_view.xml

<TextView android:id="@id/exo_position"
   [...]
   android:textColor="#FF00A6FF"/>
<TextView android:id="@id/exo_duration"
   [...]
   android:textColor="#FF00A6FF"/>
  1. Run the app. Now you have beautiful, colored UI components!

e9835d65d6dd0634.png

Screenshot: Tinted buttons and text view

Override the default style

You just created a custom layout file and referenced it using controller_layout_id in activity_player.xml.

Another approach is to override the default layout file which PlayerControlView uses. The source code of PlayerControlView tells us that it uses R.layout.exo_player_control_view for layout. If you create our own layout file with the same filename, the PlayerControlView uses your file instead.

  1. Remove the controller_layout_id attribute you just added.
  2. Delete the file custom_player_control_view.xml.

The PlayerView in activity_player.xml should now look like this:

activity_player.xml

<com.google.android.exoplayer2.ui.PlayerView
   android:id="@+id/video_view"
   android:layout_width="match_parent"
   android:layout_height="match_parent"/>
  1. Create a file named exo_player_control_view.xml in the res/layout folder of your library module player-lib.
  2. Insert the following code into exo_player_control_view.xml to add a play button, a pause button, and an ImageView with a logo:

exo_player**_control_view.xml**

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:layout_gravity="bottom"
   android:layoutDirection="ltr"
   android:background="#CC000000"
   android:orientation="vertical">

 <LinearLayout
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:gravity="center"
   android:paddingTop="4dp"
   android:orientation="horizontal">

   <ImageButton android:id="@id/exo_play"
      style="@style/ExoMediaButton.Play"/>

   <ImageButton android:id="@id/exo_pause"
      style="@style/ExoMediaButton.Pause"/>

 </LinearLayout>

 <ImageView
     android:contentDescription="@string/logo"
     android:src="@drawable/google_logo"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"/>

</LinearLayout>

This demonstrates how you can add your own elements here and mix them with standard control elements. ExoPlayerView now uses your custom control and all logic for hiding and showing when interacting with the control is preserved.

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