Media sessions provide a universal way of interacting with an audio or video
player. In Media3, the default player is the ExoPlayer
class, which implements
the Player
interface. Connecting the media session to the player allows an app
to advertise media playback externally and to receive playback commands from
external sources.
Commands may originate from physical buttons such as the play button on a headset or TV remote control. They might also come from client apps that have a media controller, such as instructing "pause" to Google Assistant. The media session delegates these commands to the media app's player.
When to choose a media session
When you implement MediaSession
, you allow users to control playback:
- Through their headphones. There are often buttons or touch interactions a user can perform on their headphones to play or pause media or go to the next or previous track.
- By talking to the Google Assistant. A common pattern is to say "OK Google, pause" to pause any media that is currently playing on the device.
- Through their Wear OS watch. This allows for easier access to the most common playback controls while playing on their phone.
- Through the Media controls. This carousel shows controls for each running media session.
- On TV. Allows actions with physical playback buttons, platform playback control, and power management (for example if the TV, soundbar or A/V receiver switches off or the input is switched, playback should stop in the app).
- And any other external processes that need to influence playback.
This is great for many use cases. In particular, you should strongly consider
using MediaSession
when:
- You're streaming long-form video content, such as movies or live TV.
- You're streaming long-form audio content, such as podcasts or music playlists.
- You're building a TV app.
However, not all use cases fit well with the MediaSession
. You might want to
use just the Player
in the following cases:
- You're showing short-form content, where user engagement and interaction is crucial.
- There isn't a single active video, such as user is scrolling through a list and multiple videos are displayed on screen at the same time.
- You're playing a one-off introduction or explanation video, which you expect your user to actively watch.
- Your content is privacy-sensitive and you don't want external processes to access the media metadata (for example incognito mode in a browser)
If your use case does not fit any of those listed above, consider whether you're
okay with your app continuing playback when the user is not actively engaging
with the content. If the answer is yes, you probably want to choose
MediaSession
. If the answer is no, you probably want to use the Player
instead.
Create a media session
A media session lives alongside the player that it manages. You can construct a
media session with a Context
and a Player
object. You should create and
initialize a media session when it is needed, such as the onStart()
or
onResume()
lifecycle method of the Activity
or Fragment
, or onCreate()
method of the Service
that owns the media session and its associated player.
To create a media session, initialize a Player
and supply it to
MediaSession.Builder
like this:
Kotlin
val player = ExoPlayer.Builder(context).build() val mediaSession = MediaSession.Builder(context, player).build()
Java
ExoPlayer player = new ExoPlayer.Builder(context).build(); MediaSession mediaSession = new MediaSession.Builder(context, player).build();
Automatic state handling
The Media3 library automatically updates the media session using the player's state. As such, you don't need to manually handle the mapping from player to session.
This is a break from the legacy approach where you needed to create and maintain
a PlaybackStateCompat
independently from the player itself, for example to
indicate any errors.
Unique session ID
By default, MediaSession.Builder
creates a session with an empty string as
the session ID. This is sufficient if an app intends to only create a single
session instance, which is the most common case.
If an app wants to manage multiple session instances at the same time, the app
has to ensure that the session ID of each session is unique. The session ID can
be set when building the session with MediaSession.Builder.setId(String id)
.
If you see an IllegalStateException
crashing your app with the error
message IllegalStateException: Session ID must be unique. ID=
then it is
likely that a session has been unexpectedly created before a previously created
instance with the same ID has been released. To avoid sessions to be leaked by a
programming error, such cases are detected and notified by throwing an
exception.
Grant control to other clients
The media session is the key to controlling playback. It enables you to route commands from external sources to the player that does the work of playing your media. These sources can be physical buttons such as the play button on a headset or TV remote control, or indirect commands such as instructing "pause" to Google Assistant. Likewise, you may wish to grant access to the Android system to facilitate notification and lock screen controls, or to a Wear OS watch so that you can control playback from the watchface. External clients can use a media controller to issue playback commands to your media app. These are received by your media session, which ultimately delegates commands to the media player.
When a controller is about to connect to your media session, the
onConnect()
method is called. You can use the provided ControllerInfo
to decide whether to accept
or reject
the request. See an example of accepting a connection request in the Declare
available commands section.
After connecting, a controller can send playback commands to the session. The
session then delegates those commands down to the player. Playback and playlist
commands defined in the Player
interface are automatically handled by the
session.
Other callback methods allow you to handle, for example, requests for
custom playback commands and
modifying the playlist).
These callbacks similarly include a ControllerInfo
object so you can modify
how you respond to each request on a per-controller basis.
Modify the playlist
A media session can directly modify the playlist of its player as explained in
the
ExoPlayer guide for playlists.
Controllers are also able to modify the playlist if either
COMMAND_SET_MEDIA_ITEM
or COMMAND_CHANGE_MEDIA_ITEMS
is available to the controller.
When adding new items to the playlist, the player typically requires MediaItem
instances with a
defined URI
to make them playable. By default, newly added items are automatically forwarded
to player methods like player.addMediaItem
if they have a URI defined.
If you want to customize the MediaItem
instances added to the player, you can
override
onAddMediaItems()
.
This step is needed when you want to support controllers that request media
without a defined URI. Instead, the MediaItem
typically has
one or more of the following fields set to describe the requested media:
MediaItem.id
: A generic ID identifying the media.MediaItem.RequestMetadata.mediaUri
: A request URI that may use a custom schema and is not necessarily directly playable by the player.MediaItem.RequestMetadata.searchQuery
: A textual search query, for example from Google Assistant.MediaItem.MediaMetadata
: Structured metadata like 'title' or 'artist'.
For more customization options for completely new playlists, you can
additionally override
onSetMediaItems()
that lets you define the start item and position in the playlist. For example,
you can expand a single requested item to an entire playlist and instruct the
player to start at the index of the originally requested item. A
sample implementation of onSetMediaItems()
with this feature can be found in the session demo app.
Manage custom layout and custom commands
The following sections describe how to advertise a custom layout of custom command buttons to client apps and authorize controllers to send the custom commands.
Define custom layout of the session
To indicate to client apps which playback controls you want to surface to the
user, set the custom layout of the session
when building the MediaSession
in the onCreate()
method of your
service.
Kotlin
override fun onCreate() { super.onCreate() val likeButton = CommandButton.Builder() .setDisplayName("Like") .setIconResId(R.drawable.like_icon) .setSessionCommand(SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) .build() val favoriteButton = CommandButton.Builder() .setDisplayName("Save to favorites") .setIconResId(R.drawable.favorite_icon) .setSessionCommand(SessionCommand(SAVE_TO_FAVORITES, Bundle())) .build() session = MediaSession.Builder(this, player) .setCallback(CustomMediaSessionCallback()) .setCustomLayout(ImmutableList.of(likeButton, favoriteButton)) .build() }
Java
@Override public void onCreate() { super.onCreate(); CommandButton likeButton = new CommandButton.Builder() .setDisplayName("Like") .setIconResId(R.drawable.like_icon) .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING)) .build(); CommandButton favoriteButton = new CommandButton.Builder() .setDisplayName("Save to favorites") .setIconResId(R.drawable.favorite_icon) .setSessionCommand(new SessionCommand(SAVE_TO_FAVORITES, new Bundle())) .build(); Player player = new ExoPlayer.Builder(this).build(); mediaSession = new MediaSession.Builder(this, player) .setCallback(new CustomMediaSessionCallback()) .setCustomLayout(ImmutableList.of(likeButton, favoriteButton)) .build(); }
Declare available player and custom commands
Media applications can define custom commands that for instance can be used in
a custom layout. For example, you might wish to implement buttons that allow the
user to save a media item to a list of favorite items. The MediaController
sends custom commands and the MediaSession.Callback
receives them.
You can define which custom session commands are available to a
MediaController
when it connects to your media session. You achieve this by
overriding MediaSession.Callback.onConnect()
. Configure and return
the set of available commands when accepting a connection request from a
MediaController
in the onConnect
callback method:
Kotlin
private inner class CustomMediaSessionCallback: MediaSession.Callback { // Configure commands available to the controller in onConnect() override fun onConnect( session: MediaSession, controller: MediaSession.ControllerInfo ): MediaSession.ConnectionResult { val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() .add(SessionCommand(SAVE_TO_FAVORITES, Bundle.EMPTY)) .build() return AcceptedResultBuilder(session) .setAvailableSessionCommands(sessionCommands) .build() } }
Java
class CustomMediaSessionCallback implements MediaSession.Callback { // Configure commands available to the controller in onConnect() @Override public ConnectionResult onConnect( MediaSession session, ControllerInfo controller) { SessionCommands sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() .add(new SessionCommand(SAVE_TO_FAVORITES, new Bundle())) .build(); return new AcceptedResultBuilder(session) .setAvailableSessionCommands(sessionCommands) .build(); } }
To receive custom command requests from a MediaController
, override the
onCustomCommand()
method in the Callback
.
Kotlin
private inner class CustomMediaSessionCallback: MediaSession.Callback { ... override fun onCustomCommand( session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture<SessionResult> { if (customCommand.customAction == SAVE_TO_FAVORITES) { // Do custom logic here saveToFavorites(session.player.currentMediaItem) return Futures.immediateFuture( SessionResult(SessionResult.RESULT_SUCCESS) ) } ... } }
Java
class CustomMediaSessionCallback implements MediaSession.Callback { ... @Override public ListenableFuture<SessionResult> onCustomCommand( MediaSession session, ControllerInfo controller, SessionCommand customCommand, Bundle args ) { if(customCommand.customAction.equals(SAVE_TO_FAVORITES)) { // Do custom logic here saveToFavorites(session.getPlayer().getCurrentMediaItem()); return Futures.immediateFuture( new SessionResult(SessionResult.RESULT_SUCCESS) ); } ... } }
You can track which media controller is making a request by using the
packageName
property of the MediaSession.ControllerInfo
object that is
passed into Callback
methods. This allows you to tailor your app's
behavior in response to a given command if it originates from the system, your
own app, or other client apps.
Update custom layout after a user interaction
After handling a custom command or any other interaction with your player, you
may want to update the layout displayed in the controller UI. A typical example
is a toggle button that changes its icon after triggering the action associated
with this button. To update the layout, you can use
MediaSession.setCustomLayout
:
Kotlin
val removeFromFavoritesButton = CommandButton.Builder() .setDisplayName("Remove from favorites") .setIconResId(R.drawable.favorite_remove_icon) .setSessionCommand(SessionCommand(REMOVE_FROM_FAVORITES, Bundle())) .build() mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton))
Java
CommandButton removeFromFavoritesButton = new CommandButton.Builder() .setDisplayName("Remove from favorites") .setIconResId(R.drawable.favorite_remove_icon) .setSessionCommand(new SessionCommand(REMOVE_FROM_FAVORITES, new Bundle())) .build(); mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton));
Customize playback command behavior
To customize the behavior of a command defined in the Player
interface, such
as play()
or seekToNext()
, wrap your Player
in a
ForwardingSimpleBasePlayer
before passing it to MediaSession
.
Kotlin
val player = (logic to build a Player instance) val forwardingPlayer = object : ForwardingSimpleBasePlayer(player) { // Customizations } val mediaSession = MediaSession.Builder(context, forwardingPlayer).build()
Java
ExoPlayer player = (logic to build a Player instance) ForwardingSimpleBasePlayer forwardingPlayer = new ForwardingSimpleBasePlayer(player) { // Customizations }; MediaSession mediaSession = new MediaSession.Builder(context, forwardingPlayer).build();
For more information about ForwardingSimpleBasePlayer
, see the ExoPlayer guide
on
Customization.
Identify the requesting controller of a player command
When a call to a Player
method is originated by a MediaController
, you can
identify the source of origin with MediaSession.controllerForCurrentRequest
and acquire the ControllerInfo
for the current request:
Kotlin
class CallerAwarePlayer(player: Player) : ForwardingSimpleBasePlayer(player) { override fun handleSeek( mediaItemIndex: Int, positionMs: Long, seekCommand: Int, ): ListenableFuture<*> { Log.d( "caller", "seek operation from package ${session.controllerForCurrentRequest?.packageName}", ) return super.handleSeek(mediaItemIndex, positionMs, seekCommand) } }
Java
public class CallerAwarePlayer extends ForwardingSimpleBasePlayer { public CallerAwarePlayer(Player player) { super(player); } @Override protected ListenableFuture<?> handleSeek( int mediaItemIndex, long positionMs, int seekCommand) { Log.d( "caller", "seek operation from package: " + session.getControllerForCurrentRequest().getPackageName()); return super.handleSeek(mediaItemIndex, positionMs, seekCommand); } }
Respond to media buttons
Media buttons are hardware buttons found on Android devices and other peripheral
devices, such as the play/pause button on a Bluetooth headset. Media3 handles
media button events for you when they arrive at the session and calls the
appropriate Player
method on the session player.
An app can override the default behaviour by overriding
MediaSession.Callback.onMediaButtonEvent(Intent)
. In such a case the app
can/needs to handle all API specifics on its own.
Error handling and reporting
There are two types of errors that a session emits and reports to controllers. Fatal errors report a technical playback failure of the session player that interrupts playback. Fatal errors are reported to the controller automatically when they occur. Nonfatal errors are non-technical or policy errors that don't interrupt playback and are sent to controllers by the application manually.
Fatal playback errors
A fatal playback error is reported to the session by the player and then
reported to controllers to call through
Player.Listener.onPlayerError(PlaybackException)
and
Player.Listener.onPlayerErrorChanged(@Nullable PlaybackException)
.
In such a case, the playback state is transitioned to STATE_IDLE
and
MediaController.getPlaybackError()
returns the PlaybackException
that caused
the transition. A controller can inspect the PlayerException.errorCode
to get
information about the reason for the error.
For interoperability, a fatal error is replicated to the PlaybackStateCompat
of the platform session by transitioning its state to STATE_ERROR
and setting
error code and message according to the PlaybackException
.
Customization of a fatal error
To provide localized and meaningful information to the user, the error code,
error message and error extras of a fatal playback error can be customized by
using a ForwardingPlayer
when building the session:
Kotlin
val forwardingPlayer = ErrorForwardingPlayer(player) val session = MediaSession.Builder(context, forwardingPlayer).build()
Java
Player forwardingPlayer = new ErrorForwardingPlayer(player); MediaSession session = new MediaSession.Builder(context, forwardingPlayer).build();
The forwarding player registers a Player.Listener
to the actual player
and intercepts callbacks that report an error. A customized
PlaybackException
is then delegated to the listeners that
are registered on the forwarding player. For this to work, the forwarding player
overrides Player.addListener
and Player.removeListener
to have access to the
listeners with which to send customized error code, message or extras:
Kotlin
class ErrorForwardingPlayer(private val context: Context, player: Player) : ForwardingPlayer(player) { private val listeners: MutableList<Player.Listener> = mutableListOf() private var customizedPlaybackException: PlaybackException? = null init { player.addListener(ErrorCustomizationListener()) } override fun addListener(listener: Player.Listener) { listeners.add(listener) } override fun removeListener(listener: Player.Listener) { listeners.remove(listener) } override fun getPlayerError(): PlaybackException? { return customizedPlaybackException } private inner class ErrorCustomizationListener : Player.Listener { override fun onPlayerErrorChanged(error: PlaybackException?) { customizedPlaybackException = error?.let { customizePlaybackException(it) } listeners.forEach { it.onPlayerErrorChanged(customizedPlaybackException) } } override fun onPlayerError(error: PlaybackException) { listeners.forEach { it.onPlayerError(customizedPlaybackException!!) } } private fun customizePlaybackException( error: PlaybackException, ): PlaybackException { val buttonLabel: String val errorMessage: String when (error.errorCode) { PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> { buttonLabel = context.getString(R.string.err_button_label_restart_stream) errorMessage = context.getString(R.string.err_msg_behind_live_window) } // Apps can customize further error messages by adding more branches. else -> { buttonLabel = context.getString(R.string.err_button_label_ok) errorMessage = context.getString(R.string.err_message_default) } } val extras = Bundle() extras.putString("button_label", buttonLabel) return PlaybackException(errorMessage, error.cause, error.errorCode, extras) } override fun onEvents(player: Player, events: Player.Events) { listeners.forEach { it.onEvents(player, events) } } // Delegate all other callbacks to all listeners without changing arguments like onEvents. } }
Java
private static class ErrorForwardingPlayer extends ForwardingPlayer { private final Context context; private List<Player.Listener> listeners; @Nullable private PlaybackException customizedPlaybackException; public ErrorForwardingPlayer(Context context, Player player) { super(player); this.context = context; listeners = new ArrayList<>(); player.addListener(new ErrorCustomizationListener()); } @Override public void addListener(Player.Listener listener) { listeners.add(listener); } @Override public void removeListener(Player.Listener listener) { listeners.remove(listener); } @Nullable @Override public PlaybackException getPlayerError() { return customizedPlaybackException; } private class ErrorCustomizationListener implements Listener { @Override public void onPlayerErrorChanged(@Nullable PlaybackException error) { customizedPlaybackException = error != null ? customizePlaybackException(error, context) : null; for (int i = 0; i < listeners.size(); i++) { listeners.get(i).onPlayerErrorChanged(customizedPlaybackException); } } @Override public void onPlayerError(PlaybackException error) { for (int i = 0; i < listeners.size(); i++) { listeners.get(i).onPlayerError(checkNotNull(customizedPlaybackException)); } } private PlaybackException customizePlaybackException( PlaybackException error, Context context) { String buttonLabel; String errorMessage; switch (error.errorCode) { case PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW: buttonLabel = context.getString(R.string.err_button_label_restart_stream); errorMessage = context.getString(R.string.err_msg_behind_live_window); break; // Apps can customize further error messages by adding more case statements. default: buttonLabel = context.getString(R.string.err_button_label_ok); errorMessage = context.getString(R.string.err_message_default); break; } Bundle extras = new Bundle(); extras.putString("button_label", buttonLabel); return new PlaybackException(errorMessage, error.getCause(), error.errorCode, extras); } @Override public void onEvents(Player player, Events events) { for (int i = 0; i < listeners.size(); i++) { listeners.get(i).onEvents(player, events); } } // Delegate all other callbacks to all listeners without changing arguments like onEvents. } }
Nonfatal errors
Nonfatal errors that do not originate from a technical exception can be sent by an app to all or to a specific controller:
Kotlin
val sessionError = SessionError( SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED, context.getString(R.string.error_message_authentication_expired), ) // Sending a nonfatal error to all controllers. mediaSession.sendError(sessionError) // Interoperability: Sending a nonfatal error to the media notification controller to set the // error code and error message in the playback state of the platform media session. mediaSession.mediaNotificationControllerInfo?.let { mediaSession.sendError(it, sessionError) }
Java
SessionError sessionError = new SessionError( SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED, context.getString(R.string.error_message_authentication_expired)); // Sending a nonfatal error to all controllers. mediaSession.sendError(sessionError); // Interoperability: Sending a nonfatal error to the media notification controller to set the // error code and error message in the playback state of the platform media session. ControllerInfo mediaNotificationControllerInfo = mediaSession.getMediaNotificationControllerInfo(); if (mediaNotificationControllerInfo != null) { mediaSession.sendError(mediaNotificationControllerInfo, sessionError); }
A nonfatal error sent to the media notification controller is replicated to the
PlaybackStateCompat
of the platform session. Thereby, only the error code and
the error message is set to the PlaybackStateCompat
accordingly, while
PlaybackStateCompat.state
is not changed to STATE_ERROR
.
Receive nonfatal errors
A MediaController
receives a nonfatal error by implementing
MediaController.Listener.onError
:
Kotlin
val future = MediaController.Builder(context, sessionToken) .setListener(object : MediaController.Listener { override fun onError(controller: MediaController, sessionError: SessionError) { // Handle nonfatal error. } }) .buildAsync()
Java
MediaController.Builder future = new MediaController.Builder(context, sessionToken) .setListener( new MediaController.Listener() { @Override public void onError(MediaController controller, SessionError sessionError) { // Handle nonfatal error. } });