Cómo crear servicios para el navegador multimedia

Tu app debe declarar el objeto MediaBrowserService con un filtro de intents en su manifiesto. Puedes elegir el nombre del servicio. En el siguiente ejemplo, el nombre de servicio elegido es MediaPlaybackService.

<service android:name=".MediaPlaybackService">
  <intent-filter>
    <action android:name="android.media.browse.MediaBrowserService" />
  </intent-filter>
</service>

Cómo inicializar la sesión multimedia

Cuando el servicio recibe el método de devolución de llamada del ciclo de vida de onCreate(), debe realizar estos pasos:

En el siguiente código de onCreate(), se demuestran estos pasos:

Kotlin

private const val MY_MEDIA_ROOT_ID = "media_root_id"
private const val MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id"

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private var mediaSession: MediaSessionCompat? = null
    private lateinit var stateBuilder: PlaybackStateCompat.Builder

    override fun onCreate() {
        super.onCreate()

        // Create a MediaSessionCompat
        mediaSession = MediaSessionCompat(baseContext, LOG_TAG).apply {

            // Enable callbacks from MediaButtons and TransportControls
            setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
                    or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
            )

            // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
            stateBuilder = PlaybackStateCompat.Builder()
                    .setActions(PlaybackStateCompat.ACTION_PLAY
                                    or PlaybackStateCompat.ACTION_PLAY_PAUSE
                    )
            setPlaybackState(stateBuilder.build())

            // MySessionCallback() has methods that handle callbacks from a media controller
            setCallback(MySessionCallback())

            // Set the session's token so that client activities can communicate with it.
            setSessionToken(sessionToken)
        }
    }
}

Java

public class MediaPlaybackService extends MediaBrowserServiceCompat {
    private static final String MY_MEDIA_ROOT_ID = "media_root_id";
    private static final String MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id";

    private MediaSessionCompat mediaSession;
    private PlaybackStateCompat.Builder stateBuilder;

    @Override
    public void onCreate() {
        super.onCreate();

        // Create a MediaSessionCompat
        mediaSession = new MediaSessionCompat(context, LOG_TAG);

        // Enable callbacks from MediaButtons and TransportControls
        mediaSession.setFlags(
              MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
              MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);

        // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
        stateBuilder = new PlaybackStateCompat.Builder()
                            .setActions(
                                PlaybackStateCompat.ACTION_PLAY |
                                PlaybackStateCompat.ACTION_PLAY_PAUSE);
        mediaSession.setPlaybackState(stateBuilder.build());

        // MySessionCallback() has methods that handle callbacks from a media controller
        mediaSession.setCallback(new MySessionCallback());

        // Set the session's token so that client activities can communicate with it.
        setSessionToken(mediaSession.getSessionToken());
    }
}

Administra las conexiones de clientes

Un objeto MediaBrowserService tiene dos métodos que controlan las conexiones de clientes: onGetRoot() controla el acceso al servicio y onLoadChildren() brinda la posibilidad de que un cliente compile y muestre un menú de la jerarquía de contenido de MediaBrowserService.

Cómo controlar las conexiones de clientes con onGetRoot()

El método onGetRoot() muestra el nodo raíz de la jerarquía de contenido. Si el método devuelve un valor nulo, se rechaza la conexión.

Para permitir que los clientes se conecten a tu servicio y exploren su contenido multimedia, onGetRoot() debe mostrar un valor no nulo de BrowserRoot, que es un ID raíz que representa tu jerarquía de contenido.

Para permitir que los clientes se conecten a tu MediaSession sin navegar, onGetRoot() igual debe mostrar un valor de BrowserRoot no nulo, pero el ID raíz debe representar una jerarquía de contenido vacía.

Una implementación típica de onGetRoot() podría verse de esta manera:

Kotlin

override fun onGetRoot(
        clientPackageName: String,
        clientUid: Int,
        rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot {

    // (Optional) Control the level of access for the specified package name.
    // You'll need to write your own logic to do this.
    return if (allowBrowsing(clientPackageName, clientUid)) {
        // Returns a root ID that clients can use with onLoadChildren() to retrieve
        // the content hierarchy.
        MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)
    } else {
        // Clients can connect, but this BrowserRoot is an empty hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        MediaBrowserServiceCompat.BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null)
    }
}

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {

    // (Optional) Control the level of access for the specified package name.
    // You'll need to write your own logic to do this.
    if (allowBrowsing(clientPackageName, clientUid)) {
        // Returns a root ID that clients can use with onLoadChildren() to retrieve
        // the content hierarchy.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    } else {
        // Clients can connect, but this BrowserRoot is an empty hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        return new BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null);
    }
}

En algunos casos, es posible que quieras controlar quién puede conectarse a tu MediaBrowserService. Una forma es usar una lista de control de acceso (LCA) que especifique qué conexiones se permiten o, de forma alternativa, enumere qué conexiones se deben prohibir. Para ver un ejemplo de cómo implementar una ACL que permita conexiones específicas, consulta la clase PackageValidator en la app de ejemplo de Universal Android Music Player.

Deberías proporcionar diferentes jerarquías de contenido según el tipo de cliente que realice la búsqueda. En particular, Android Auto limita la forma en la que los usuarios interactúan con las apps de audio. Para obtener más información, consulta Cómo reproducir audio en Auto. Puedes mirar el objeto clientPackageName en el momento de la conexión para determinar el tipo de cliente y mostrar un objeto BrowserRoot distinto según el cliente (o rootHints si hay).

Cómo enviar contenido con onLoadChildren()

Luego de que el cliente se conecta, puede desviar la jerarquía de contenido realizando llamadas repetidas a MediaBrowserCompat.subscribe() para compilar una representación local de la IU. El método subscribe() envía la devolución de llamada onLoadChildren() al servicio, el cual muestra una lista de objetos MediaBrowser.MediaItem.

Cada MediaItem tiene una string de ID único, que es un token opaco. Cuando un cliente quiere abrir un submenú o reproducir un elemento, pasa el ID. Tu servicio es responsable de asociar el ID con el nodo de menú o elemento de contenido apropiado.

Una implementación simple de onLoadChildren() podría verse de esta manera:

Kotlin

override fun onLoadChildren(
        parentMediaId: String,
        result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>
) {
    //  Browsing not allowed
    if (MY_EMPTY_MEDIA_ROOT_ID == parentMediaId) {
        result.sendResult(null)
        return
    }

    // Assume for example that the music catalog is already loaded/cached.

    val mediaItems = emptyList<MediaBrowserCompat.MediaItem>()

    // Check if this is the root menu:
    if (MY_MEDIA_ROOT_ID == parentMediaId) {
        // Build the MediaItem objects for the top level,
        // and put them in the mediaItems list...
    } else {
        // Examine the passed parentMediaId to see which submenu we're at,
        // and put the children of that menu in the mediaItems list...
    }
    result.sendResult(mediaItems)
}

Java

@Override
public void onLoadChildren(final String parentMediaId,
    final Result<List<MediaItem>> result) {

    //  Browsing not allowed
    if (TextUtils.equals(MY_EMPTY_MEDIA_ROOT_ID, parentMediaId)) {
        result.sendResult(null);
        return;
    }

    // Assume for example that the music catalog is already loaded/cached.

    List<MediaItem> mediaItems = new ArrayList<>();

    // Check if this is the root menu:
    if (MY_MEDIA_ROOT_ID.equals(parentMediaId)) {
        // Build the MediaItem objects for the top level,
        // and put them in the mediaItems list...
    } else {
        // Examine the passed parentMediaId to see which submenu we're at,
        // and put the children of that menu in the mediaItems list...
    }
    result.sendResult(mediaItems);
}

Nota: Los objetos MediaItem que muestra MediaBrowserService no deben contener mapas de bits de íconos. En cambio, llama a setIconUri() para usar un Uri cuando compiles la MediaDescription para cada elemento.

Para ver un ejemplo de cómo implementar onLoadChildren(), consulta la app de ejemplo de Universal Android Music Player.

Ciclo de vida del servicio de exploración multimedia

El comportamiento de un servicio de Android depende de si está iniciado o vinculado a uno o más clientes. Después de crear un servicio, se puede iniciar o vincular, o ambas opciones. En todos estos estados, es completamente funcional y puede realizar las tareas para las que se diseñó. La diferencia es la duración del servicio. No se destruye un servicio vinculado hasta que todos sus clientes vinculados se hayan desvinculado. Se puede detener y destruir de manera explícita un servicio iniciado (suponiendo que ya no está vinculado a ningún cliente).

Cuando un MediaBrowser que se ejecuta en otra actividad se conecta a un MediaBrowserService, vincula la actividad al servicio, con lo cual el servicio se vincula (pero no se inicia). Este comportamiento predeterminado está integrado en la clase MediaBrowserServiceCompat.

Un servicio que solo está vinculado (y no iniciado) se destruye cuando se desvinculan todos sus clientes. Si se desconecta la actividad de tu IU en este punto, se destruye el servicio. Esto no es un problema si aún no reprodujiste música. Sin embargo, cuando se inicie la reproducción, es posible que el usuario espere seguir escuchando la música, incluso después de cambiar a otra app. No es la idea destruir el reproductor cuando desvinculas la IU para usar otra app.

Por tal motivo, debes llamar a startService() para asegurarte de que el servicio esté iniciado cuando comience a reproducir contenido. Un servicio iniciado se debe detener de manera explícita, ya sea que esté vinculado o no. De esa manera garantizas que tu reproductor siga funcionando incluso si se desvincula la actividad de la IU que lo controla.

Para detener un servicio iniciado, llama a Context.stopService() o a stopSelf(). El sistema detiene y destruye el servicio lo antes posible. Sin embargo, si uno o más clientes siguen vinculados al servicio, la llamada para detenerlo se demora hasta que todos sus clientes se hayan desvinculado.

El ciclo de vida de MediaBrowserService está controlado por la forma en que se crea, el número de clientes vinculados a él y las llamadas que recibe de las devoluciones de llamada de la sesión multimedia. En síntesis:

  • Se crea el servicio cuando se inicia en respuesta a un botón multimedia o cuando se vincula una actividad a él (después de conectarse a través de su MediaBrowser).
  • La devolución de llamada onPlay() de la sesión multimedia debe incluir código que llame a startService(). Esto garantiza que se inicie y se siga ejecutando el servicio, incluso si se desvinculan todas las actividades MediaBrowser de la IU que estaban vinculadas.
  • La devolución de llamada onStop() debe llamar a stopSelf(). Si el servicio está iniciado, esta acción lo detiene. Además, se destruye el servicio si no hay actividades vinculadas a él. De lo contrario, permanece vinculado hasta que se desvinculan todas sus actividades. (Si se recibe una llamada startService() posterior antes de que se destruya el servicio, se cancela la detención pendiente).

El siguiente diagrama de flujo demuestra cómo se administra el ciclo de vida de un servicio. El contador de variables realiza un seguimiento del número de clientes vinculados:

Ciclo de vida del servicio

Cómo usar notificaciones MediaStyle con un servicio en primer plano

Cuando se está reproduciendo un servicio, debe ejecutarse en primer plano. De este modo, el sistema sabe que el servicio está realizando una función útil y que no se debe cerrar si el sistema tiene poca memoria. Un servicio en primer plano debe mostrar una notificación para que el usuario esté al tanto y tenga la opción de controlarlo. La devolución de llamada onPlay() debe colocar el servicio en primer plano. (Ten en cuenta que este es un significado especial de "primer plano". Si bien Android considera que el servicio está en primer plano para los fines de administración de procesos, desde el punto de vista del usuario, el reproductor se está ejecutando en segundo plano y hay otra app visible en el "primer plano" de la pantalla).

Cuando un servicio se ejecuta en primer plano, debe mostrar una notificación, idealmente con uno o más controles de transporte. La notificación también debe incluir información útil proveniente de los metadatos de la sesión.

Compila y muestra la notificación cuando el reproductor comience a reproducir contenido. El mejor lugar para hacerlo es dentro del método MediaSessionCompat.Callback.onPlay().

En el siguiente ejemplo, se usa el objeto NotificationCompat.MediaStyle, que está diseñado para apps de contenido multimedia. Indica cómo compilar una notificación que muestre metadatos y controles de transporte. El método de conveniencia getController() te permite crear un controlador multimedia directamente desde la sesión multimedia.

Kotlin

// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder

// Get the session's metadata
val controller = mediaSession.controller
val mediaMetadata = controller.metadata
val description = mediaMetadata.description

val builder = NotificationCompat.Builder(context, channelId).apply {
    // Add the metadata for the currently playing track
    setContentTitle(description.title)
    setContentText(description.subtitle)
    setSubText(description.description)
    setLargeIcon(description.iconBitmap)

    // Enable launching the player by clicking the notification
    setContentIntent(controller.sessionActivity)

    // Stop the service when the notification is swiped away
    setDeleteIntent(
            MediaButtonReceiver.buildMediaButtonPendingIntent(
                    context,
                    PlaybackStateCompat.ACTION_STOP
            )
    )

    // Make the transport controls visible on the lockscreen
    setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

    // Add an app icon and set its accent color
    // Be careful about the color
    setSmallIcon(R.drawable.notification_icon)
    color = ContextCompat.getColor(context, R.color.primaryDark)

    // Add a pause button
    addAction(
            NotificationCompat.Action(
                    R.drawable.pause,
                    getString(R.string.pause),
                    MediaButtonReceiver.buildMediaButtonPendingIntent(
                            context,
                            PlaybackStateCompat.ACTION_PLAY_PAUSE
                    )
            )
    )

    // Take advantage of MediaStyle features
    setStyle(android.support.v4.media.app.NotificationCompat.MediaStyle()
            .setMediaSession(mediaSession.sessionToken)
            .setShowActionsInCompactView(0)

            // Add a cancel button
            .setShowCancelButton(true)
            .setCancelButtonIntent(
                    MediaButtonReceiver.buildMediaButtonPendingIntent(
                            context,
                            PlaybackStateCompat.ACTION_STOP
                    )
            )
    )
}

// Display the notification and place the service in the foreground
startForeground(id, builder.build())

Java

// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder

// Get the session's metadata
MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();

NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);

builder
    // Add the metadata for the currently playing track
    .setContentTitle(description.getTitle())
    .setContentText(description.getSubtitle())
    .setSubText(description.getDescription())
    .setLargeIcon(description.getIconBitmap())

    // Enable launching the player by clicking the notification
    .setContentIntent(controller.getSessionActivity())

    // Stop the service when the notification is swiped away
    .setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context,
       PlaybackStateCompat.ACTION_STOP))

    // Make the transport controls visible on the lockscreen
    .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

    // Add an app icon and set its accent color
    // Be careful about the color
    .setSmallIcon(R.drawable.notification_icon)
    .setColor(ContextCompat.getColor(context, R.color.primaryDark))

    // Add a pause button
    .addAction(new NotificationCompat.Action(
        R.drawable.pause, getString(R.string.pause),
        MediaButtonReceiver.buildMediaButtonPendingIntent(context,
            PlaybackStateCompat.ACTION_PLAY_PAUSE)))

    // Take advantage of MediaStyle features
    .setStyle(new MediaStyle()
        .setMediaSession(mediaSession.getSessionToken())
        .setShowActionsInCompactView(0)

        // Add a cancel button
       .setShowCancelButton(true)
       .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context,
           PlaybackStateCompat.ACTION_STOP)));

// Display the notification and place the service in the foreground
startForeground(id, builder.build());

Cuando uses notificaciones MediaStyle, ten en cuenta el comportamiento de esta configuración de NotificationCompat:

  • Si usas setContentIntent(), tu servicio se inicia automáticamente cuando el usuario hace clic en la notificación, lo cual es una función útil.
  • En una situación "no segura", como la pantalla de bloqueo, la visibilidad predeterminada del contenido de la notificación es VISIBILITY_PRIVATE. Es probable que quieras ver los controles de transporte en la pantalla de bloqueo; si es así, usa VISIBILITY_PUBLIC.
  • Ten cuidado cuando establezcas el color de fondo. En una notificación normal en la versión 5.0 o en versiones posteriores de Android, el color se aplica solamente al fondo del ícono pequeño de la app. Sin embargo, para las notificaciones MediaStyle anteriores a Android 7.0, el color se usa en todo el fondo de la notificación. Prueba el color de fondo. Usa un tono que sea suave para la vista; evita los colores extremadamente brillantes o fluorescentes.

Esta configuración solo está disponible cuando usas NotificationCompat.MediaStyle:

  • Usa setMediaSession() para asociar la notificación con tu sesión. De este modo, las apps de terceros y los dispositivos complementarios pueden acceder a la sesión y controlarla.
  • Usa setShowActionsInCompactView() para agregar hasta 3 acciones que se mostrarán en la ContentView de tamaño estándar de la notificación. (Aquí se especifica el botón de pausa).
  • En Android 5.0 (nivel de API 21) y versiones posteriores, puedes deslizar una notificación para detener el reproductor cuando el servicio no se esté ejecutando en primer plano. Esto no era posible en versiones anteriores. Para permitir que los usuarios quiten la notificación y detengan la reproducción en versiones previas a Android 5.0 (nivel de API 21), puedes llamar a setShowCancelButton(true) y a setCancelButtonIntent() para agregar un botón de cancelar en la esquina superior derecha de la notificación.

Cuando agregues los botones para pausar y cancelar, necesitarás adjuntar un PendingIntent a la acción de reproducción. El método MediaButtonReceiver.buildMediaButtonPendingIntent() hace la tarea de convertir una acción de PlaybackState en un PendingIntent.

Habilita la navegación multimedia con AVRCP

Además de las apps personalizadas, como Android Auto, la capa de Bluetooth del sistema también actúa como cliente de tu MediaBrowserService para facilitar la exploración inalámbrica remota del catálogo (AVRCP).

En Android 16 y Android 17, la plataforma requiere que las apps que no usan Media3 expongan una actividad específica con un filtro de intents para que se valide la navegación.

Agrega este filtro de intents específico a una actividad exportada en tu AndroidManifest.xml. Ten en cuenta que CATEGORY_DEFAULT se omite intencionalmente para evitar que tu app aparezca en los menús genéricos "Abrir con" para archivos de audio locales:

<activity
    android:name=".BluetoothValidationActivity"
    android:exported="true"
    android:theme="@android:style/Theme.NoDisplay"
    android:excludeFromRecents="true"
    android:noHistory="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="content" />
    <data android:host="media" />
    <!-- Specific path check used by Bluetooth stack for validation -->
    <data android:pathPrefix="/internal/audio/media/" />
    <data android:mimeType="audio/*" />
  </intent-filter>
</activity>