Media controls

Media controls in Android are located near the Quick Settings. Sessions from multiple apps are arranged in a swipeable carousel. The carousel lists sessions in this order:

  • Streams playing locally on the phone
  • Remote streams, such as those detected on external devices or cast sessions
  • Previous resumable sessions, in the order they were last played

Starting in Android 13 (API level 33), to ensure that users can access a rich set of media controls for apps playing media, action buttons on media controls are derived from the Player state.

This way, you can present a consistent set of media controls and a more polished media control experience across devices.

Figure 1 shows an example of how this looks on a phone and tablet device, respectively.

Media controls in terms of how they appear on phone and tablets devices,
            using an example of a sample track showing how the buttons may appear
Figure 1: Media controls on phone and tablet devices

The system displays up to five action buttons based on the Player state as described in the following table. In compact mode, only the first three action slots are displayed. This aligns with how media controls are rendered in other Android platforms such as Auto, Assistant, and Wear OS.

Slot Criteria Action
1 playWhenReady is false or the current playback stateis STATE_ENDED. Play
playWhenReady is true and the current playback state is STATE_BUFFERING. Loading spinner
playWhenReady is true and the current playback state is STATE_READY. Pause
2 Player command COMMAND_SEEK_TO_PREVIOUS or COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM is available. Previous
Neither player command COMMAND_SEEK_TO_PREVIOUS nor COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM is available, and a custom command from the custom layout that hasn't been placed yet is available to fill the slot. Custom
(not yet supported with Media3) PlaybackState extras include a true boolean value for key EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV. Empty
3 Player command COMMAND_SEEK_TO_NEXT or COMMAND_SEEK_TO_NEXT_MEDIA_ITEM is available. Next
Neither player command COMMAND_SEEK_TO_NEXT nor COMMAND_SEEK_TO_NEXT_MEDIA_ITEM is available, and a custom command from the custom layout that hasn't been placed yet is available to fill the slot. Custom
(not yet supported with Media3) PlaybackState extras include a true boolean value for key EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT. Empty
4 A custom command from the custom layout that hasn't been placed yet is available to fill the slot. Custom
5 A custom command from the custom layout that hasn't been placed yet is available to fill the slot. Custom

Custom commands are placed in the order in which they were added to the custom layout.

Customize command buttons

To customize system media controls with Jetpack Media3, you can set the custom layout of the session and the available commands of controllers accordingly, when implementing a MediaSessionService:

  1. In onCreate(), build a MediaSession and define the custom layout of command buttons.

  2. In MediaSession.Callback.onConnect(), authorize controllers by defining their available commands, including custom commands, in the ConnectionResult.

  3. In MediaSession.Callback.onCustomCommand(), respond to the custom command being selected by the user.

Kotlin

class PlaybackService : MediaSessionService() {
  private val customCommandFavorites = SessionCommand(ACTION_FAVORITES, Bundle.EMPTY)
  private var mediaSession: MediaSession? = null

  override fun onCreate() {
    super.onCreate()
    val favoriteButton =
      CommandButton.Builder()
        .setDisplayName("Save to favorites")
        .setIconResId(R.drawable.favorite_icon)
        .setSessionCommand(customCommandFavorites)
        .build()
    val player = ExoPlayer.Builder(this).build()
    // Build the session with a custom layout.
    mediaSession =
      MediaSession.Builder(this, player)
        .setCallback(MyCallback())
        .setCustomLayout(ImmutableList.of(favoriteButton))
        .build()
  }

  private inner class MyCallback : MediaSession.Callback {
    override fun onConnect(
      session: MediaSession,
      controller: MediaSession.ControllerInfo
    ): ConnectionResult {
    // Set available player and session commands.
    return AcceptedResultBuilder(session)
      .setAvailablePlayerCommands(
        ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
          .remove(COMMAND_SEEK_TO_NEXT)
          .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
          .remove(COMMAND_SEEK_TO_PREVIOUS)
          .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
          .build()
      )
      .setAvailableSessionCommands(
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
          .add(customCommandFavorites)
          .build()
      )
      .build()
    }

    override fun onCustomCommand(
      session: MediaSession,
      controller: MediaSession.ControllerInfo,
      customCommand: SessionCommand,
      args: Bundle
    ): ListenableFuture {
      if (customCommand.customAction == ACTION_FAVORITES) {
        // Do custom logic here
        saveToFavorites(session.player.currentMediaItem)
        return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
      }
      return super.onCustomCommand(session, controller, customCommand, args)
    }
  }
}

Java

public class PlaybackService extends MediaSessionService {
  private static final SessionCommand CUSTOM_COMMAND_FAVORITES =
      new SessionCommand("ACTION_FAVORITES", Bundle.EMPTY);
  @Nullable private MediaSession mediaSession;

  public void onCreate() {
    super.onCreate();
    CommandButton favoriteButton =
        new CommandButton.Builder()
            .setDisplayName("Save to favorites")
            .setIconResId(R.drawable.favorite_icon)
            .setSessionCommand(CUSTOM_COMMAND_FAVORITES)
            .build();
    Player player = new ExoPlayer.Builder(this).build();
    // Build the session with a custom layout.
    mediaSession =
        new MediaSession.Builder(this, player)
            .setCallback(new MyCallback())
            .setCustomLayout(ImmutableList.of(favoriteButton))
            .build();
  }

  private static class MyCallback implements MediaSession.Callback {
    @Override
    public ConnectionResult onConnect(
        MediaSession session, MediaSession.ControllerInfo controller) {
      // Set available player and session commands.
      return new AcceptedResultBuilder(session)
          .setAvailablePlayerCommands(
              ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
                .remove(COMMAND_SEEK_TO_NEXT)
                .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
                .remove(COMMAND_SEEK_TO_PREVIOUS)
                .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
                .build())
          .setAvailableSessionCommands(
              ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
                .add(CUSTOM_COMMAND_FAVORITES)
                .build())
          .build();
    }

    public ListenableFuture onCustomCommand(
        MediaSession session,
        MediaSession.ControllerInfo controller,
        SessionCommand customCommand,
        Bundle args) {
      if (customCommand.customAction.equals(CUSTOM_COMMAND_FAVORITES.customAction)) {
        // Do custom logic here
        saveToFavorites(session.getPlayer().getCurrentMediaItem());
        return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
      }
      return MediaSession.Callback.super.onCustomCommand(
          session, controller, customCommand, args);
    }
  }
}

To learn more about configuring your MediaSession so that clients like the system can connect to your media app, see Grant control to other clients.

With Jetpack Media3, when you implement a MediaSession, your PlaybackState is automatically kept up-to-date with the media player. Similarly, when you implement a MediaSessionService, the library automatically publishes a MediaStyle notification for you and keeps it up-to-date.

Respond to action buttons

When a user taps an action button in the system media controls, the system's MediaController sends a playback command to your MediaSession. The MediaSession then delegates those commands down to the player. Commands defined in Media3's Player interface are automatically handled by the media session.

Refer to Add custom commands for guidance on how to respond to a custom command.

Pre-Android 13 Behavior

For backward compatibility, System UI continues to provide an alternate layout that uses notification actions for apps that don't update to target Android 13, or that don't include PlaybackState information. The action buttons are derived from the Notification.Action list attached to the MediaStyle notification. The system displays up to five actions in the order in which they were added. In compact mode, up to three buttons are shown, determined by the values passed into setShowActionsInCompactView().

Custom actions are placed in the order in which they were added to the PlaybackState.

The following code example illustrates how to add actions to the MediaStyle notification :

Kotlin

import androidx.core.app.NotificationCompat
import androidx.media3.session.MediaStyleNotificationHelper

var notification = NotificationCompat.Builder(context, CHANNEL_ID)
        // Show controls on lock screen even when user hides sensitive content.
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        // Add media control buttons that invoke intents in your media service
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent) // #0
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent) // #1
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent) // #2
        // Apply the media style template
        .setStyle(MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build()

Java

import androidx.core.app.NotificationCompat;
import androidx.media3.session.MediaStyleNotificationHelper;

NotificationCompat.Builder notification = new NotificationCompat.Builder(context, CHANNEL_ID)
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent)
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent)
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent)
        .setStyle(new MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build();

Support media resumption

Media resumption allows users to restart previous sessions from the carousel without having to start the app. When playback begins, the user interacts with the media controls in the usual way.

The playback resumption feature can be turned on and off using the Settings app, under the Sound > Media options. The user can also access Settings by tapping the gear icon that appears after swiping on the expanded carousel.

Media3 offers APIs to make it easier to support media resumption. See the Playback resumption with Media3 documentation for guidance on implementing this feature.

Using the legacy media APIs

This section explains how to integrate with the system media controls using the legacy MediaCompat APIs.

The system retrieves the following information from the MediaSession's MediaMetadata, and displays it when it is available:

  • METADATA_KEY_ALBUM_ART_URI
  • METADATA_KEY_TITLE
  • METADATA_KEY_DISPLAY_TITLE
  • METADATA_KEY_ARTIST
  • METADATA_KEY_DURATION (If the duration isn't set the seek bar doesn't show progress)

To ensure you have a valid and accurate media control notification, set the value of the METADATA_KEY_TITLE or METADATA_KEY_DISPLAY_TITLE metadata to the title of the media currently being played.

The media player shows the elapsed time for the currently playing media, along with a seek bar which is mapped to the MediaSession PlaybackState.

The media player shows the progress for the currently playing media, along with a seek bar which is mapped to the MediaSession PlaybackState. The seek bar allows users to change the position and displays the elapsed time for the media item. In order for the seek bar to be enabled, you must implement PlaybackState.Builder#setActions and include ACTION_SEEK_TO.

Slot Action Criteria
1 Play Current state of the PlaybackState is the one of the following:
  • STATE_NONE
  • STATE_STOPPED
  • STATE_PAUSED
  • STATE_ERROR
Loading spinner Current state of the PlaybackState is one of the following:
  • STATE_CONNECTING
  • STATE_BUFFERING
Pause Current state of the PlaybackState is none of the above.
2 Previous PlaybackState actions include ACTION_SKIP_TO_PREVIOUS.
Custom PlaybackState actions don't include ACTION_SKIP_TO_PREVIOUS and PlaybackState custom actions include a custom action that hasn't been placed yet.
Empty PlaybackState extras include a true boolean value for key SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV.
3 Next PlaybackState actions include ACTION_SKIP_TO_NEXT.
Custom PlaybackState actions don't include ACTION_SKIP_TO_NEXT and PlaybackState custom actions include a custom action that hasn't been placed yet.
Empty PlaybackState extras include a true boolean value for key SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT.
4 Custom PlaybackState custom actions include a custom action that hasn't been placed yet.
5 Custom PlaybackState custom actions include a custom action that hasn't been placed yet.

Add standard actions

The following code examples illustrate how to add PlaybackState standard and custom actions.

For play, pause, previous, and next, set these actions in the PlaybackState for the media session.

Kotlin

val session = MediaSessionCompat(context, TAG)
val playbackStateBuilder = PlaybackStateCompat.Builder()
val style = NotificationCompat.MediaStyle()

// For this example, the media is currently paused:
val state = PlaybackStateCompat.STATE_PAUSED
val position = 0L
val playbackSpeed = 1f
playbackStateBuilder.setState(state, position, playbackSpeed)

// And the user can play, skip to next or previous, and seek
val stateActions = PlaybackStateCompat.ACTION_PLAY
    or PlaybackStateCompat.ACTION_PLAY_PAUSE
    or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    or PlaybackStateCompat.ACTION_SEEK_TO // adding the seek action enables seeking with the seekbar
playbackStateBuilder.setActions(stateActions)

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build())
style.setMediaSession(session.sessionToken)
notificationBuilder.setStyle(style)

Java

MediaSessionCompat session = new MediaSessionCompat(context, TAG);
PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder();
NotificationCompat.MediaStyle style = new NotificationCompat.MediaStyle();

// For this example, the media is currently paused:
int state = PlaybackStateCompat.STATE_PAUSED;
long position = 0L;
float playbackSpeed = 1f;
playbackStateBuilder.setState(state, position, playbackSpeed);

// And the user can play, skip to next or previous, and seek
long stateActions = PlaybackStateCompat.ACTION_PLAY
    | PlaybackStateCompat.ACTION_PLAY_PAUSE
    | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    | PlaybackStateCompat.ACTION_SEEK_TO; // adding this enables the seekbar thumb
playbackStateBuilder.setActions(stateActions);

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build());
style.setMediaSession(session.getSessionToken());
notificationBuilder.setStyle(style);

If you don't want any buttons in the previous or next slots, don't add ACTION_SKIP_TO_PREVIOUS or ACTION_SKIP_TO_NEXT, and instead add extras to the session:

Kotlin

session.setExtras(Bundle().apply {
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true)
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true)
})

Java

Bundle extras = new Bundle();
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true);
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true);
session.setExtras(extras);

Add custom actions

For other actions you want to show on the media controls, you can create a PlaybackStateCompat.CustomAction and add that to the PlaybackState instead. These actions are shown in the order they were added.

Kotlin

val customAction = PlaybackStateCompat.CustomAction.Builder(
    "com.example.MY_CUSTOM_ACTION", // action ID
    "Custom Action", // title - used as content description for the button
    R.drawable.ic_custom_action
).build()

playbackStateBuilder.addCustomAction(customAction)

Java

PlaybackStateCompat.CustomAction customAction = new PlaybackStateCompat.CustomAction.Builder(
        "com.example.MY_CUSTOM_ACTION", // action ID
        "Custom Action", // title - used as content description for the button
        R.drawable.ic_custom_action
).build();

playbackStateBuilder.addCustomAction(customAction);

Responding to PlaybackState actions

When a user taps on a button, SystemUI uses MediaController.TransportControls to send a command back to the MediaSession. You need to register a callback that can respond properly to these events.

Kotlin

val callback = object: MediaSession.Callback() {
    override fun onPlay() {
        // start playback
    }

    override fun onPause() {
        // pause playback
    }

    override fun onSkipToPrevious() {
        // skip to previous
    }

    override fun onSkipToNext() {
        // skip to next
    }

    override fun onSeekTo(pos: Long) {
        // jump to position in track
    }

    override fun onCustomAction(action: String, extras: Bundle?) {
        when (action) {
            CUSTOM_ACTION_1 -> doCustomAction1(extras)
            CUSTOM_ACTION_2 -> doCustomAction2(extras)
            else -> {
                Log.w(TAG, "Unknown custom action $action")
            }
        }
    }

}

session.setCallback(callback)

Java

MediaSession.Callback callback = new MediaSession.Callback() {
    @Override
    public void onPlay() {
        // start playback
    }

    @Override
    public void onPause() {
        // pause playback
    }

    @Override
    public void onSkipToPrevious() {
        // skip to previous
    }

    @Override
    public void onSkipToNext() {
        // skip to next
    }

    @Override
    public void onSeekTo(long pos) {
        // jump to position in track
    }

    @Override
    public void onCustomAction(String action, Bundle extras) {
        if (action.equals(CUSTOM_ACTION_1)) {
            doCustomAction1(extras);
        } else if (action.equals(CUSTOM_ACTION_2)) {
            doCustomAction2(extras);
        } else {
            Log.w(TAG, "Unknown custom action " + action);
        }
    }
};

Media Resumption

To make your player app appear in the quick setting settings area, you must create a MediaStyle notification with a valid MediaSession token.

To display the title for the MediaStyle notification, use NotificationBuilder.setContentTitle().

To display the brand icon for the media player, use NotificationBuilder.setSmallIcon().

To support playback resumption, apps must implement a MediaBrowserService and MediaSession. Your MediaSession must implement the onPlay() callback.

MediaBrowserService implementation

After the device boots, the system looks for the five most recently used media apps, and provides controls that can be used to restart playing from each app.

The system attempts to contact your MediaBrowserService with a connection from SystemUI. Your app must allow such connections, otherwise it cannot support playback resumption.

Connections from SystemUI can be identified and verified using the package name com.android.systemui and signature. The SystemUI is signed with the platform signature. An example of how to check against the platform signature can be found in the UAMP app.

In order to support playback resumption, your MediaBrowserService must implement these behaviors:

  • onGetRoot() must return a non-null root quickly. Other complex logic should be handled in onLoadChildren()

  • When onLoadChildren() is called on the root media ID, the result must contain a FLAG_PLAYABLE child.

  • MediaBrowserService should return the most recently played media item when they receive an EXTRA_RECENT query. The value returned should be an actual media item rather than generic function.

  • MediaBrowserService must provide an appropriate MediaDescription with a non-empty title and subtitle. It should also set an icon URI or an icon bitmap.

The following code examples illustrate how to implement onGetRoot().

Kotlin

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?
): BrowserRoot? {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your 
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        rootHints?.let {
            if (it.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                val extras = Bundle().apply {
                    putBoolean(BrowserRoot.EXTRA_RECENT, true)
                }
                return BrowserRoot(MY_RECENTS_ROOT_ID, extras)
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return BrowserRoot(MY_MEDIA_ROOT_ID, null)
    }
    // Return an empty tree to disallow browsing.
    return BrowserRoot(MY_EMPTY_ROOT_ID, null)

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        if (rootHints != null) {
            if (rootHints.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                Bundle extras = new Bundle();
                extras.putBoolean(BrowserRoot.EXTRA_RECENT, true);
                return new BrowserRoot(MY_RECENTS_ROOT_ID, extras);
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    }
    // Return an empty tree to disallow browsing.
    return new BrowserRoot(MY_EMPTY_ROOT_ID, null);
}