Background playback with a MediaSessionService

It is often desirable to play media while an app is not in the foreground. For example, a music player generally keeps playing music when the user has locked their device or is using another app. The Media3 library provides a series of interfaces that allow you to support background playback.

Use a MediaSessionService

To enable background playback, you should contain the Player and MediaSession inside a separate Service. This allows the device to continue serving media even while your app is not in the foreground.

The MediaSessionService allows the media session to run separately
  from the app's activity
Figure 1: The MediaSessionService allows the media session to run separately from the app's activity

When hosting a player inside a Service, you should use a MediaSessionService. To do this, create a class that extends MediaSessionService and create your media session inside of it.

Using MediaSessionService makes it possible for external clients like Google Assistant, system media controls, or companion devices like Wear OS to discover your service, connect to it, and control playback, all without accessing your app's UI activity at all. In fact, there can be multiple client apps connected to the same MediaSessionService at the same time, each app with its own MediaController.

Implement the service lifecycle

You need to implement three lifecycle methods of your service:

  • onCreate() is called when the first controller is about to connect and the service is instantiated and started. It's the best place to build Player and MediaSession.
  • onTaskRemoved(Intent) is called when the user dismisses the app from the recent tasks. If playback is ongoing, the app can choose to keep the service running in the foreground. If the player is paused, the service is not in the foreground and needs to be stopped.
  • onDestroy() is called when the service is being stopped. All resources including player and session need to be released.

Kotlin

class PlaybackService : MediaSessionService() {
  private var mediaSession: MediaSession? = null

  // Create your player and media session in the onCreate lifecycle event
  override fun onCreate() {
    super.onCreate()
    val player = ExoPlayer.Builder(this).build()
    mediaSession = MediaSession.Builder(this, player).build()
  }

  // The user dismissed the app from the recent tasks
  override fun onTaskRemoved(rootIntent: Intent?) {
    val player = mediaSession?.player!!
    if (!player.playWhenReady
        || player.mediaItemCount == 0
        || player.playbackState == Player.STATE_ENDED) {
      // Stop the service if not playing, continue playing in the background
      // otherwise.
      stopSelf()
    }
  }

  // Remember to release the player and media session in onDestroy
  override fun onDestroy() {
    mediaSession?.run {
      player.release()
      release()
      mediaSession = null
    }
    super.onDestroy()
  }
}

Java

public class PlaybackService extends MediaSessionService {
  private MediaSession mediaSession = null;

  // Create your Player and MediaSession in the onCreate lifecycle event
  @Override
  public void onCreate() {
    super.onCreate();
    ExoPlayer player = new ExoPlayer.Builder(this).build();
    mediaSession = new MediaSession.Builder(this, player).build();
  }

  // The user dismissed the app from the recent tasks
  @Override
  public void onTaskRemoved(@Nullable Intent rootIntent) {
    Player player = mediaSession.getPlayer();
    if (!player.getPlayWhenReady()
        || player.getMediaItemCount() == 0
        || player.getPlaybackState() == Player.STATE_ENDED) {
      // Stop the service if not playing, continue playing in the background
      // otherwise.
      stopSelf();
    }
  }

  // Remember to release the player and media session in onDestroy
  @Override
  public void onDestroy() {
    mediaSession.getPlayer().release();
    mediaSession.release();
    mediaSession = null;
    super.onDestroy();
  }
}

As an alternative of keeping playback ongoing in the background, an app can stop the service in any case when the user dismisses the app:

Kotlin

override fun onTaskRemoved(rootIntent: Intent?) {
  val player = mediaSession.player
  if (player.playWhenReady) {
    // Make sure the service is not in foreground.
    player.pause()
  }
  stopSelf()
}

Java

@Override
public void onTaskRemoved(@Nullable Intent rootIntent) {
  Player player = mediaSession.getPlayer();
  if (player.getPlayWhenReady()) {
    // Make sure the service is not in foreground.
    player.pause();
  }
  stopSelf();
}

Provide access to the media session

Override the onGetSession() method to give other clients access to your media session that was built when the service was created.

Kotlin

class PlaybackService : MediaSessionService() {
  private var mediaSession: MediaSession? = null
  // [...] lifecycle methods omitted

  override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
    mediaSession
}

Java

public class PlaybackService extends MediaSessionService {
  private MediaSession mediaSession = null;
  // [...] lifecycle methods omitted

  @Override
  public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) {
    return mediaSession;
  }
}

Declare the service in the manifest

An app requires permission to run a foreground service. Add the FOREGROUND_SERVICE permission to the manifest, and if you target API 34 and above also FOREGROUND_SERVICE_MEDIA_PLAYBACK:

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

You must also declare your Service class in the manifest with an intent filter of MediaSessionService.

<service
    android:name=".PlaybackService"
    android:foregroundServiceType="mediaPlayback"
    android:exported="true">
    <intent-filter>
        <action android:name="androidx.media3.session.MediaSessionService"/>
    </intent-filter>
</service>

You must define a foregroundServiceType that includes mediaPlayback when your app is running on a device with Android 10 (API level 29) and higher.

Control playback using a MediaController

In the Activity or Fragment containing your player UI, you can establish a link between the UI and your media session using a MediaController. Your UI uses the media controller to send commands from your UI to the player within the session. See the Create a MediaController guide for details on creating and using a MediaController.

Handle UI commands

The MediaSession receives commands from the controller through its MediaSession.Callback. Initializing a MediaSession creates a default implementation of MediaSession.Callback that automatically handles all commands a MediaController sends to your player.

Notification

A MediaSessionService automatically creates a MediaNotification for you that should work in most cases. By default, the published notification is a MediaStyle notification that stays updated with the latest information from your media session and displays playback controls. The MediaNotification is aware of your session and can be used to control playback for any other apps that are connected to the same session.

For example, a music streaming app using a MediaSessionService would create a MediaNotification that displays the title, artist, and album art for the current media item being played alongside playback controls based on your MediaSession configuration.

The required metadata can be provided in the media or declared as part of the media item as in the following snippet:

Kotlin

val mediaItem =
    MediaItem.Builder()
      .setMediaId("media-1")
      .setUri(mediaUri)
      .setMediaMetadata(
        MediaMetadata.Builder()
          .setArtist("David Bowie")
          .setTitle("Heroes")
          .setArtworkUri(artworkUri)
          .build()
      )
      .build()

mediaController.setMediaItem(mediaItem)
mediaController.prepare()
mediaController.play()

Java

MediaItem mediaItem =
    new MediaItem.Builder()
        .setMediaId("media-1")
        .setUri(mediaUri)
        .setMediaMetadata(
            new MediaMetadata.Builder()
                .setArtist("David Bowie")
                .setTitle("Heroes")
                .setArtworkUri(artworkUri)
                .build())
        .build();

mediaController.setMediaItem(mediaItem);
mediaController.prepare();
mediaController.play();

Apps can customize the command buttons of Android Media controls. Read more about customizing Android Media controls.

Notification customization

To customize the notification, create a MediaNotification.Provider with DefaultMediaNotificationProvider.Builder or by creating a custom implementation of the provider interface. Add your provider to your MediaSessionService with setMediaNotificationProvider.

Playback resumption

Media buttons are hardware buttons found on Android devices and other peripheral devices, such as the play or pause button on a Bluetooth headset. Media3 handles media button inputs for you when the service is running.

Declare the Media3 media button receiver

Media3 includes an API to enable users to resume playback after an app has terminated and even after the device has been restarted. By default, playback resumption is turned off. This means the user can't resume playback when your service isn't running. To opt-in, start by declaring the MediaButtonReceiver in your manifest:

<receiver android:name="androidx.media3.session.MediaButtonReceiver"
  android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.MEDIA_BUTTON" />
  </intent-filter>
</receiver>

Implement playback resumption callback

When playback resumption is requested by either a Bluetooth device or the Android System UI resumption feature, the onPlaybackResumption() callback method is called.

Kotlin

override fun onPlaybackResumption(
    mediaSession: MediaSession,
    controller: ControllerInfo
): ListenableFuture<MediaItemsWithStartPosition> {
  val settable = SettableFuture.create<MediaItemsWithStartPosition>()
  scope.launch {
    // Your app is responsible for storing the playlist and the start position
    // to use here
    val resumptionPlaylist = restorePlaylist()
    settable.set(resumptionPlaylist)
  }
  return settable
}

Java

@Override
public ListenableFuture<MediaItemsWithStartPosition> onPlaybackResumption(
    MediaSession mediaSession,
    ControllerInfo controller
) {
  SettableFuture<MediaItemsWithStartPosition> settableFuture = SettableFuture.create();
  settableFuture.addListener(() -> {
    // Your app is responsible for storing the playlist and the start position
    // to use here
    MediaItemsWithStartPosition resumptionPlaylist = restorePlaylist();
    settableFuture.set(resumptionPlaylist);
  }, MoreExecutors.directExecutor());
  return settableFuture;
}

If you've stored other parameters such as playback speed, repeat mode, or shuffle mode, onPlaybackResumption() is a good place to configure the player with these parameters before Media3 prepares the player and starts playback when the callback completes.

Advanced controller configuration and backward compatibility

A common scenario is using a MediaController in the app UI for controlling playback and displaying the playlist. At the same time, the session is exposed to external clients like Android media controls and Assistant on mobile or TV, Wear OS for watches and Android Auto in cars. The Media3 session demo app is an example of an app that implements such a scenario.

These external clients may use APIs like MediaControllerCompat of the legacy AndroidX library or android.media.session.MediaController of the Android framework. Media3 is fully backward compatible with the legacy library and provides interoperability with the Android framework API.

Use the media notification controller

It's important to understand that these legacy or framework controllers read the same values from the framework PlaybackState.getActions() and PlaybackState.getCustomActions(). To determine actions and custom actions of the framework session, an app can use the media notification controller and set its available commands and custom layout. The service connects the media notification controller to your session, and the session uses the ConnectionResult returned by your callback's onConnect() to configure actions and custom actions of the framework session.

Given a mobile-only scenario, an app can provide an implementation of MediaSession.Callback.onConnect() to set available commands and custom layout specifically for the framework session as follows:

Kotlin

override fun onConnect(
  session: MediaSession,
  controller: MediaSession.ControllerInfo
): ConnectionResult {
  if (session.isMediaNotificationController(controller)) {
    val sessionCommands =
      ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
        .add(customCommandSeekBackward)
        .add(customCommandSeekForward)
        .build()
    val playerCommands =
      ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
        .remove(COMMAND_SEEK_TO_PREVIOUS)
        .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
        .remove(COMMAND_SEEK_TO_NEXT)
        .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
        .build()
    // Custom layout and available commands to configure the legacy/framework session.
    return AcceptedResultBuilder(session)
      .setCustomLayout(
        ImmutableList.of(
          createSeekBackwardButton(customCommandSeekBackward),
          createSeekForwardButton(customCommandSeekForward))
      )
      .setAvailablePlayerCommands(playerCommands)
      .setAvailableSessionCommands(sessionCommands)
      .build()
  }
  // Default commands with default custom layout for all other controllers.
  return AcceptedResultBuilder(session).build()
}

Java

@Override
public ConnectionResult onConnect(
    MediaSession session, MediaSession.ControllerInfo controller) {
  if (session.isMediaNotificationController(controller)) {
    SessionCommands sessionCommands =
        ConnectionResult.DEFAULT_SESSION_COMMANDS
            .buildUpon()
            .add(customCommandSeekBackward)
            .add(customCommandSeekForward)
            .build();
    Player.Commands playerCommands =
        ConnectionResult.DEFAULT_PLAYER_COMMANDS
            .buildUpon()
            .remove(COMMAND_SEEK_TO_PREVIOUS)
            .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
            .remove(COMMAND_SEEK_TO_NEXT)
            .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
            .build();
    // Custom layout and available commands to configure the legacy/framework session.
    return new AcceptedResultBuilder(session)
        .setCustomLayout(
            ImmutableList.of(
                createSeekBackwardButton(customCommandSeekBackward),
                createSeekForwardButton(customCommandSeekForward)))
        .setAvailablePlayerCommands(playerCommands)
        .setAvailableSessionCommands(sessionCommands)
        .build();
  }
  // Default commands without default custom layout for all other controllers.
  return new AcceptedResultBuilder(session).build();
}

Authorize Android Auto to send custom commands

When using a MediaLibraryService and to support Android Auto with the mobile app, the Android Auto controller requires appropriate available commands, otherwise Media3 would deny incoming custom commands from that controller:

Kotlin

override fun onConnect(
  session: MediaSession,
  controller: MediaSession.ControllerInfo
): ConnectionResult {
  val sessionCommands =
    ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
      .add(customCommandSeekBackward)
      .add(customCommandSeekForward)
      .build()
  if (session.isMediaNotificationController(controller)) {
    // [...] See above.
  } else if (session.isAutoCompanionController(controller)) {
    // Available session commands to accept incoming custom commands from Auto.
    return AcceptedResultBuilder(session)
      .setAvailableSessionCommands(sessionCommands)
      .build()
  }
  // Default commands with default custom layout for all other controllers.
  return AcceptedResultBuilder(session).build()
}

Java

@Override
public ConnectionResult onConnect(
    MediaSession session, MediaSession.ControllerInfo controller) {
  SessionCommands sessionCommands =
      ConnectionResult.DEFAULT_SESSION_COMMANDS
          .buildUpon()
          .add(customCommandSeekBackward)
          .add(customCommandSeekForward)
          .build();
  if (session.isMediaNotificationController(controller)) {
    // [...] See above.
  } else if (session.isAutoCompanionController(controller)) {
    // Available commands to accept incoming custom commands from Auto.
    return new AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build();
  }
  // Default commands without default custom layout for all other controllers.
  return new AcceptedResultBuilder(session).build();
}

The session demo app has an automotive module, that demonstrates support for Automotive OS that requires a separate APK.