Cómo reproducir contenido en segundo plano con un MediaSessionService

A menudo, es conveniente reproducir contenido multimedia mientras una app no está en primer plano. Por ejemplo, un reproductor de música suele seguir reproduciendo música cuando el usuario bloquea su dispositivo o usa otra app. La biblioteca de Media3 proporciona una serie de interfaces que te permiten admitir la reproducción en segundo plano.

Usa un MediaSessionService

Para habilitar la reproducción en segundo plano, debes incluir Player y MediaSession dentro de un Service independiente. Esto permite que el dispositivo siga entregando contenido multimedia incluso cuando la app no está en primer plano.

MediaSessionService permite que la sesión multimedia se ejecute de forma independiente de la actividad de la app.
Figura 1: El MediaSessionService permite que la sesión multimedia se ejecute de forma independiente de la actividad de la app

Cuando alojes un reproductor dentro de un Service, debes usar un MediaSessionService. Para ello, crea una clase que extienda MediaSessionService y crea tu sesión multimedia dentro de ella.

El uso de MediaSessionService permite que los clientes externos, como el Asistente de Google, los controles multimedia del sistema, los botones multimedia de los dispositivos periféricos o los dispositivos complementarios, como Wear OS, descubran tu servicio, se conecten a él y controlen la reproducción, todo sin acceder a la actividad de la IU de tu app. De hecho, puede haber varias apps cliente conectadas al mismo MediaSessionService al mismo tiempo, cada una con su propio MediaController.

Implementa el ciclo de vida del servicio

Debes implementar dos métodos de ciclo de vida de tu servicio:

  • Se llama a onCreate() cuando el primer controlador está a punto de conectarse y se crea una instancia del servicio y se inicia. Es el mejor lugar para desarrollar Player y MediaSession.
  • Se llama a onDestroy() cuando se detiene el servicio. Se deben liberar todos los recursos, incluidos el reproductor y la sesión.

De manera opcional, puedes anular onTaskRemoved(Intent) para personalizar lo que sucede cuando el usuario descarta la app de las tareas recientes. De forma predeterminada, el servicio se deja en ejecución si la reproducción está en curso y, de lo contrario, se detiene.

Kotlin

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

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

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

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

Java

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();
  }

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

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

Como alternativa para mantener la reproducción en segundo plano, puedes detener el servicio en cualquier caso cuando el usuario descarte la app:

Kotlin

@OptIn(UnstableApi::class)
override fun onTaskRemoved(rootIntent: Intent?) {
  pauseAllPlayersAndStopSelf()
}

Java

@OptIn(markerClass = UnstableApi.class)
@Override
public void onTaskRemoved(@Nullable Intent rootIntent) {
  pauseAllPlayersAndStopSelf();
}

Para cualquier otra implementación manual de onTaskRemoved, puedes usar isPlaybackOngoing() para verificar si la reproducción se considera en curso y se inicia el servicio en primer plano.

Proporciona acceso a la sesión multimedia

Anula el método onGetSession() para dar acceso a otros clientes a tu sesión multimedia que se creó cuando se creó el servicio.

Kotlin

class PlaybackService : MediaSessionService() {

  // [...] lifecycle methods omitted

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

Java

class PlaybackService extends MediaSessionService {

  // [...] lifecycle methods omitted

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

Declara el servicio en el manifiesto

Una app requiere los permisos FOREGROUND_SERVICE y FOREGROUND_SERVICE_MEDIA_PLAYBACK para ejecutar un servicio en primer plano de reproducción:

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

También debes declarar tu clase Service en el manifiesto con un filtro de intents de MediaSessionService y un foregroundServiceType que incluya mediaPlayback.

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

Controla la reproducción con una MediaController

En la actividad o el fragmento que contiene la IU del reproductor, puedes establecer un vínculo entre la IU y la sesión multimedia con un MediaController. La IU usa el controlador multimedia para enviar comandos desde la IU al reproductor dentro de la sesión. Consulta la guía Crea un MediaController para obtener detalles sobre cómo crear y usar un MediaController.

Controla los comandos de MediaController

El MediaSession recibe comandos del controlador a través de su MediaSession.Callback. Cuando inicializas un MediaSession, se crea una implementación predeterminada de MediaSession.Callback que controla automáticamente todos los comandos que un MediaController envía a tu reproductor.

Notificación

Un MediaSessionService crea automáticamente un MediaNotification para ti que debería funcionar en la mayoría de los casos. De forma predeterminada, la notificación publicada es una notificación de MediaStyle que se mantiene actualizada con la información más reciente de tu sesión multimedia y muestra los controles de reproducción. El MediaNotification conoce tu sesión y se puede usar para controlar la reproducción de cualquier otra app que esté conectada a la misma sesión.

Por ejemplo, una app de transmisión de música que use un MediaSessionService crearía un MediaNotification que muestre el título, el artista y la imagen del álbum del elemento multimedia que se está reproduciendo, junto con los controles de reproducción basados en tu configuración de MediaSession.

Los metadatos requeridos se pueden proporcionar en el contenido multimedia o declararse como parte del elemento multimedia, como en el siguiente fragmento:

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();

Ciclo de vida de las notificaciones

La notificación se crea en cuanto el Player tiene instancias de MediaItem en su lista de reproducción.

Todas las actualizaciones de notificaciones se realizan automáticamente según el estado de Player y MediaSession.

La notificación no se puede quitar mientras se ejecuta el servicio en primer plano. Para quitar la notificación de inmediato, debes llamar a Player.release() o borrar la playlist con Player.clearMediaItems().

Si el reproductor se pausa, se detiene o falla durante más de 10 minutos sin más interacciones del usuario, el servicio se quita automáticamente del estado de servicio en primer plano para que el sistema pueda destruirlo. Puedes implementar la reanudación de la reproducción para permitir que un usuario reinicie el ciclo de vida del servicio y reanude la reproducción más adelante.

Personalización de notificaciones

Los metadatos sobre el elemento que se está reproduciendo se pueden personalizar modificando el MediaItem.MediaMetadata. Si quieres actualizar los metadatos de un elemento existente, puedes usar Player.replaceMediaItem para actualizarlos sin interrumpir la reproducción.

También puedes personalizar algunos de los botones que se muestran en la notificación estableciendo preferencias de botones multimedia personalizados para los controles multimedia de Android. Obtén más información para personalizar los controles multimedia de Android.

Para personalizar aún más la notificación, crea un objeto MediaNotification.Provider con DefaultMediaNotificationProvider.Builder o crea una implementación personalizada de la interfaz del proveedor. Agrega tu proveedor a tu MediaSessionService con setMediaNotificationProvider.

Reanudación de la reproducción

Después de que se haya finalizado el MediaSessionService, incluso después de que se haya reiniciado el dispositivo, es posible ofrecer la reanudación de la reproducción para que los usuarios reinicien el servicio y reanuden la reproducción donde la dejaron. De forma predeterminada, la reanudación de la reproducción está desactivada. Esto significa que el usuario no puede reanudar la reproducción cuando el servicio no se está ejecutando. Para habilitar esta función, debes declarar un receptor de botones de contenido multimedia y, luego, implementar el método onPlaybackResumption.

Declara el receptor de botones multimedia de Media3

Para comenzar, declara el MediaButtonReceiver en tu manifiesto:

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

Implementa la devolución de llamada de reanudación de la reproducción

Cuando un dispositivo Bluetooth o la función de reanudación de la IU del sistema Android solicitan la reanudación de la reproducción, se llama al método de devolución de llamada onPlaybackResumption().

Kotlin

override fun onPlaybackResumption(
  mediaSession: MediaSession,
  controller: MediaSession.ControllerInfo,
  isForPlayback: Boolean,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
  val settableFuture = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
  settableFuture.addListener(
    {
      // Your app is responsible for storing the playlist, metadata (like title
      // and artwork) of the current item and the start position to use here.
      val resumptionPlaylist = restorePlaylist()
      settableFuture.set(resumptionPlaylist)
    },
    MoreExecutors.directExecutor(),
  )
  return settableFuture
}

Java

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

Si almacenaste otros parámetros, como la velocidad de reproducción, el modo de repetición o el modo aleatorio, onPlaybackResumption() es un buen lugar para configurar el reproductor con estos parámetros antes de que Media3 prepare el reproductor y comience la reproducción cuando se complete la devolución de llamada.

Se llama a este método durante el tiempo de inicio para crear la notificación de reanudación de la IU del sistema Android después de reiniciar el dispositivo con isForPlayback establecido en false. Para una notificación enriquecida, se recomienda completar los campos MediaMetadata, como title y artworkData o artworkUri, del elemento actual con valores disponibles de forma local, ya que es posible que el acceso a la red aún no esté disponible. También puedes agregar MediaConstants.EXTRAS_KEY_COMPLETION_STATUS y MediaConstants.EXTRAS_KEY_COMPLETION_PERCENTAGE a MediaMetadata.extras para indicar la posición de reanudación de la reproducción.

Configuración avanzada del controlador y retrocompatibilidad

Una situación común es usar un MediaController en la IU de la app para controlar la reproducción y mostrar la playlist. Al mismo tiempo, la sesión se expone a clientes externos, como los controles multimedia de Android y Asistente en dispositivos móviles o TVs, Wear OS para relojes y Android Auto en vehículos. La app de demostración de sesiones de Media3 es un ejemplo de una app que implementa esa situación.

Estos clientes externos pueden usar APIs como MediaControllerCompat de la biblioteca heredada de AndroidX o android.media.session.MediaController de la plataforma de Android. Media3 es totalmente retrocompatible con la biblioteca heredada y proporciona interoperabilidad con la API de la plataforma de Android.

Identifica los controladores de confianza

Cualquier app puede intentar conectarse a tu sesión o biblioteca multimedia. Si quieres restringir el acceso a los controladores del sistema, los controladores con permiso de control de contenido multimedia y tu propia app, puedes usar ControllerInfo.isTrusted() para realizar una verificación de acceso básica. Como alternativa, puedes identificar controladores más específicos, como el controlador de notificaciones multimedia o los controladores de Android Auto, como se describe en las siguientes secciones.

Usa el controlador de notificaciones de contenido multimedia

Es importante comprender que estos controladores heredados y de plataforma comparten el mismo estado, y la visibilidad no se puede personalizar por controlador (por ejemplo, los PlaybackState.getActions() y PlaybackState.getCustomActions() disponibles). Puedes usar el controlador de notificaciones de contenido multimedia para configurar el estado establecido en la sesión multimedia de la plataforma para que sea compatible con estos controladores heredados y de plataforma.

Por ejemplo, una app puede proporcionar una implementación de MediaSession.Callback.onConnect() para establecer los comandos disponibles y las preferencias de los botones de contenido multimedia específicamente para la sesión de la plataforma de la siguiente manera:

Kotlin

override fun onConnectAsync(
  session: MediaSession,
  controller: MediaSession.ControllerInfo,
): ListenableFuture<ConnectionResult> {
  if (session.isMediaNotificationController(controller)) {
    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 button preferences and commands to configure the platform session.
    return immediateFuture(
      AcceptedResultBuilder(session, controller)
        .setMediaButtonPreferences(listOf(seekBackButton, seekForwardButton))
        .setAvailablePlayerCommands(playerCommands)
        .build()
    )
  }
  // Default commands with default button preferences for all other controllers.
  return immediateFuture(AcceptedResultBuilder(session, controller).build())
}

Java

@Override
public ListenableFuture<ConnectionResult> onConnectAsync(
    MediaSession session, MediaSession.ControllerInfo controller) {
  if (session.isMediaNotificationController(controller)) {
    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 button preferences and commands to configure the platform session.
    return immediateFuture(
        new AcceptedResultBuilder(session, controller)
            .setMediaButtonPreferences(ImmutableList.of(seekBackButton, seekForwardButton))
            .setAvailablePlayerCommands(playerCommands)
            .build());
  }
  // Default commands with default button preferences for all other controllers.
  return immediateFuture(new AcceptedResultBuilder(session, controller).build());
}

Autoriza a Android Auto para que envíe comandos personalizados

Cuando se usa un MediaLibraryService y para admitir Android Auto con la app para dispositivos móviles, el controlador de Android Auto requiere comandos disponibles adecuados; de lo contrario, Media3 rechazaría los comandos personalizados entrantes de ese controlador:

Kotlin

override fun onConnectAsync(
  session: MediaSession,
  controller: MediaSession.ControllerInfo,
): ListenableFuture<ConnectionResult> {
  val sessionCommands =
    ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(customCommand).build()
  if (session.isMediaNotificationController(controller)) {
    // ... See above.
  } else if (session.isAutoCompanionController(controller)) {
    // Available commands to accept incoming custom commands from Auto.
    return immediateFuture(
      AcceptedResultBuilder(session, controller)
        .setAvailableSessionCommands(sessionCommands)
        .build()
    )
  }
  // Default commands for all other controllers.
  return immediateFuture(AcceptedResultBuilder(session, controller).build())
}

Java

@Override
public ListenableFuture<ConnectionResult> onConnectAsync(
    MediaSession session, MediaSession.ControllerInfo controller) {
  SessionCommands sessionCommands =
      ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(customCommand).build();
  if (session.isMediaNotificationController(controller)) {
    // ... See above.
  } else if (session.isAutoCompanionController(controller)) {
    // Available commands to accept incoming custom commands from Auto.
    return immediateFuture(
        new AcceptedResultBuilder(session, controller)
            .setAvailableSessionCommands(sessionCommands)
            .build());
  }
  // Default commands for all other controllers.
  return immediateFuture(new AcceptedResultBuilder(session, controller).build());
}

La app de demostración de la sesión tiene un módulo de Automotive que demuestra la compatibilidad con Automotive OS, que requiere un APK independiente.