Selector de salida

El interruptor de salida es una función del SDK de Cast que permite la transferencia sin interrupciones entre la reproducción local y remota de contenido a partir de Android 13. El objetivo es ayudar a las apps emisoras a controlar de forma fácil y rápida dónde se reproduce el contenido. El selector de salida usa la biblioteca MediaRouter para cambiar la reproducción de contenido entre la bocina del teléfono, los dispositivos Bluetooth vinculados y los dispositivos remotos compatibles con Cast. Los casos prácticos se pueden desglosar en las siguientes situaciones:

Descarga y usa el siguiente ejemplo como referencia para implementar el interruptor de salida en tu app de audio. Consulta el archivo README.md incluido para obtener instrucciones sobre cómo ejecutar la muestra.

Descargar muestra

El selector de salida debe estar habilitado para admitir opciones locales y remotas y remotas a locales según los pasos que se indican en esta guía. No se necesitan pasos adicionales para admitir la transferencia entre las bocinas del dispositivo local y los dispositivos Bluetooth vinculados.

Las apps de audio son compatibles con Google Cast para audio en la configuración de la app receptora en la Consola para desarrolladores del SDK de Google Cast

IU del selector de salida

El selector de salida muestra los dispositivos locales y remotos que están disponibles, así como los estados actuales del dispositivo, incluso si se está conectando el dispositivo, el nivel de volumen actual. Si hay otros dispositivos además del actual, puedes hacer clic en otro para transferir la reproducción de contenido multimedia al dispositivo seleccionado.

Errores conocidos

  • Las sesiones multimedia creadas para la reproducción local se descartarán y se volverán a crear cuando se cambie a la notificación del SDK de Cast.

Puntos de entrada

Notificación multimedia

Si una app publica una notificación multimedia con MediaSession para la reproducción local (reproduciendo de forma local), en la esquina superior derecha de la notificación multimedia, se mostrará un chip de notificación con el nombre del dispositivo (como la bocina del teléfono) en el que se está reproduciendo el contenido. Cuando se presiona el chip de notificaciones, se abre la IU del sistema de diálogo del selector de salida.

Configuración del volumen

La IU del sistema de diálogo del selector de salida también se puede activar haciendo clic en los botones de volumen físico del dispositivo, presionando el ícono de configuración en la parte inferior y el texto "Reproducir <Nombre de la app> en <Dispositivo de transmisión>".

Resumen de los pasos

Requisitos previos

  1. Migra tu app para Android existente a AndroidX.
  2. Actualiza el archivo build.gradle de tu app a fin de usar la versión mínima requerida del SDK de Android Sender para el selector de salida:
    dependencies {
      ...
      implementation 'com.google.android.gms:play-services-cast-framework:21.2.0'
      ...
    }
  3. La app admite notificaciones multimedia.
  4. Dispositivo con Android 13

Cómo configurar las notificaciones multimedia

Para usar el selector de salida, se requiere que las apps de audio y video creen una notificación multimedia para mostrar el estado de reproducción y los controles del contenido multimedia para la reproducción local. Para ello, debes crear una MediaSession, configurar MediaStyle con el token de MediaSession y establecer los controles multimedia en la notificación.

Si actualmente no usas MediaStyle ni MediaSession, en el siguiente fragmento se muestra cómo configurarlos, y hay guías disponibles a fin de configurar las devoluciones de llamada de las sesiones multimedia para apps de audio y video:

Kotlin
// Create a media session. NotificationCompat.MediaStyle
// PlayerService is your own Service or Activity responsible for media playback.
val mediaSession = MediaSessionCompat(this, "PlayerService")

// Create a MediaStyle object and supply your media session token to it.
val mediaStyle = Notification.MediaStyle().setMediaSession(mediaSession.sessionToken)

// Create a Notification which is styled by your MediaStyle object.
// This connects your media session to the media controls.
// Don't forget to include a small icon.
val notification = Notification.Builder(this@PlayerService, CHANNEL_ID)
    .setStyle(mediaStyle)
    .setSmallIcon(R.drawable.ic_app_logo)
    .build()

// Specify any actions which your users can perform, such as pausing and skipping to the next track.
val pauseAction: Notification.Action = Notification.Action.Builder(
        pauseIcon, "Pause", pauseIntent
    ).build()
notification.addAction(pauseAction)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    // Create a media session. NotificationCompat.MediaStyle
    // PlayerService is your own Service or Activity responsible for media playback.
    MediaSession mediaSession = new MediaSession(this, "PlayerService");

    // Create a MediaStyle object and supply your media session token to it.
    Notification.MediaStyle mediaStyle = new Notification.MediaStyle().setMediaSession(mediaSession.getSessionToken());

    // Specify any actions which your users can perform, such as pausing and skipping to the next track.
    Notification.Action pauseAction = Notification.Action.Builder(pauseIcon, "Pause", pauseIntent).build();

    // Create a Notification which is styled by your MediaStyle object.
    // This connects your media session to the media controls.
    // Don't forget to include a small icon.
    String CHANNEL_ID = "CHANNEL_ID";
    Notification notification = new Notification.Builder(this, CHANNEL_ID)
        .setStyle(mediaStyle)
        .setSmallIcon(R.drawable.ic_app_logo)
        .addAction(pauseAction)
        .build();
}

Además, para propagar la notificación con la información de tu contenido multimedia, deberás agregar los metadatos y el estado de reproducción del contenido multimedia a MediaSession.

Para agregar metadatos a MediaSession, usa setMetaData() y proporciona todas las constantes MediaMetadata relevantes para tu contenido multimedia en MediaMetadataCompat.Builder().

Kotlin
mediaSession.setMetadata(MediaMetadataCompat.Builder()
    // Title
    .putString(MediaMetadata.METADATA_KEY_TITLE, currentTrack.title)

    // Artist
    // Could also be the channel name or TV series.
    .putString(MediaMetadata.METADATA_KEY_ARTIST, currentTrack.artist)

    // Album art
    // Could also be a screenshot or hero image for video content
    // The URI scheme needs to be "content", "file", or "android.resource".
    .putString(
        MediaMetadata.METADATA_KEY_ALBUM_ART_URI, currentTrack.albumArtUri)
    )

    // Duration
    // If duration isn't set, such as for live broadcasts, then the progress
    // indicator won't be shown on the seekbar.
    .putLong(MediaMetadata.METADATA_KEY_DURATION, currentTrack.duration)

    .build()
)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    mediaSession.setMetadata(
        new MediaMetadataCompat.Builder()
        // Title
        .putString(MediaMetadata.METADATA_KEY_TITLE, currentTrack.title)

        // Artist
        // Could also be the channel name or TV series.
        .putString(MediaMetadata.METADATA_KEY_ARTIST, currentTrack.artist)

        // Album art
        // Could also be a screenshot or hero image for video content
        // The URI scheme needs to be "content", "file", or "android.resource".
        .putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, currentTrack.albumArtUri)

        // Duration
        // If duration isn't set, such as for live broadcasts, then the progress
        // indicator won't be shown on the seekbar.
        .putLong(MediaMetadata.METADATA_KEY_DURATION, currentTrack.duration)

        .build()
    );
}

Para agregar el estado de reproducción a MediaSession, usa setPlaybackState() y proporciona todas las constantes PlaybackStateCompat relevantes para tu contenido multimedia en PlaybackStateCompat.Builder().

Kotlin
mediaSession.setPlaybackState(
    PlaybackStateCompat.Builder()
        .setState(
            PlaybackStateCompat.STATE_PLAYING,

            // Playback position
            // Used to update the elapsed time and the progress bar.
            mediaPlayer.currentPosition.toLong(),

            // Playback speed
            // Determines the rate at which the elapsed time changes.
            playbackSpeed
        )

        // isSeekable
        // Adding the SEEK_TO action indicates that seeking is supported
        // and makes the seekbar position marker draggable. If this is not
        // supplied seek will be disabled but progress will still be shown.
        .setActions(PlaybackStateCompat.ACTION_SEEK_TO)
        .build()
)
Java
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
    mediaSession.setPlaybackState(
        new PlaybackStateCompat.Builder()
            .setState(
                 PlaybackStateCompat.STATE_PLAYING,

                // Playback position
                // Used to update the elapsed time and the progress bar.
                mediaPlayer.currentPosition.toLong(),

                // Playback speed
                // Determines the rate at which the elapsed time changes.
                playbackSpeed
            )

        // isSeekable
        // Adding the SEEK_TO action indicates that seeking is supported
        // and makes the seekbar position marker draggable. If this is not
        // supplied seek will be disabled but progress will still be shown.
        .setActions(PlaybackStateCompat.ACTION_SEEK_TO)
        .build()
    );
}

Comportamiento de las notificaciones de la app de video

Las apps de video o audio que no admiten la reproducción local en segundo plano deben tener un comportamiento específico para las notificaciones multimedia a fin de evitar problemas con el envío de comandos multimedia en situaciones en las que no se admite la reproducción:

  • Publica la notificación multimedia cuando reproduzcas contenido multimedia de forma local y la app esté en primer plano.
  • Pausa la reproducción local y descarta la notificación cuando la app esté en segundo plano.
  • Cuando la app vuelva al primer plano, se debería reanudar la reproducción local y volver a publicar la notificación.

Cómo habilitar el selector de salida en AndroidManifest.xml

Para habilitar el selector de salida, se debe agregar MediaTransferReceiver al AndroidManifest.xml de la app. Si no es así, la función no se habilitará, y la marca de función de remoto a local tampoco será válida.

<application>
    ...
    <receiver
         android:name="androidx.mediarouter.media.MediaTransferReceiver"
         android:exported="true">
    </receiver>
    ...
</application>

El MediaTransferReceiver es un receptor de emisión que permite la transferencia de contenido multimedia entre dispositivos con IU del sistema. Consulta la referencia de MediaTransferReceiver para obtener más información.

De local a remoto

Cuando el usuario cambie la reproducción de local a remota, el SDK de Cast iniciará automáticamente la sesión de transmisión. Sin embargo, las apps deben controlar el cambio de local a remoto, por ejemplo, detener la reproducción local y cargar el contenido multimedia en el dispositivo de transmisión. Las apps deben escuchar el SessionManagerListener de Cast con las devoluciones de llamada onSessionStarted() y onSessionEnded(), y controlar la acción cuando reciben las devoluciones de llamada de transmisión SessionManager. Las apps deben asegurarse de que estas devoluciones de llamada sigan activas cuando se abra el diálogo Output Switcher y la app no esté en primer plano.

Actualiza SessionManagerListener para realizar una transmisión en segundo plano

La experiencia heredada de transmisión ya es compatible de local a remoto cuando la app está en primer plano. Una experiencia de transmisión típica comienza cuando los usuarios hacen clic en el ícono de Cast en la app y eligen un dispositivo para transmitir contenido multimedia. En este caso, la app debe registrarse en SessionManagerListener, en onCreate() o onStart(), y cancelar el registro del objeto de escucha en onStop() o onDestroy() de la actividad de la app.

Con la nueva experiencia de transmisión con el selector de salida, las apps pueden comenzar a transmitir cuando están en segundo plano. Esto es particularmente útil para apps de audio que publican notificaciones cuando se reproducen en segundo plano. Las apps pueden registrar los objetos de escucha SessionManager en el onCreate() del servicio y cancelar el registro en la onDestroy() del servicio. De esta manera, las apps siempre deben recibir las devoluciones de llamada locales a remotas (como onSessionStarted) cuando están en segundo plano.

Si la app usa MediaBrowserService, se recomienda registrar el SessionManagerListener allí.

Kotlin
class MyService : Service() {
    private var castContext: CastContext? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext
            .getSessionManager()
            .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext
                .getSessionManager()
                .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
        }
    }
}
Java
public class MyService extends Service {
  private CastContext castContext;

  @Override
  protected void onCreate() {
     castContext = CastContext.getSharedInstance(this);
     castContext
        .getSessionManager()
        .addSessionManagerListener(sessionManagerListener, CastSession.class);
  }

  @Override
  protected void onDestroy() {
    if (castContext != null) {
       castContext
          .getSessionManager()
          .removeSessionManagerListener(sessionManagerListener, CastSession.class);
    }
  }
}

Con esta actualización, la función de local a remoto actúa de la misma manera que la transmisión tradicional cuando la app está en segundo plano y no se requiere trabajo adicional para cambiar de dispositivos Bluetooth a dispositivos de transmisión.

Remoto a local

El selector de salida permite transferir de la reproducción remota a la bocina del teléfono o al dispositivo Bluetooth local. Para habilitar esto, configura la marca setRemoteToLocalEnabled como true en CastOptions.

Cuando el dispositivo emisor actual se une a una sesión existente con varios remitentes y la app necesite verificar si el contenido multimedia actual se puede transferir de forma local, las apps deben usar la devolución de llamada onTransferred del SessionTransferCallback para verificar el SessionState.

Establece la marca setRemoteToLocalEnabled

CastOptions proporciona un setRemoteToLocalEnabled para mostrar u ocultar la bocina del teléfono y los dispositivos Bluetooth locales como destinos de transferencia en el diálogo del interruptor de salida cuando hay una sesión de transmisión activa.

Kotlin
class CastOptionsProvider : OptionsProvider {
    fun getCastOptions(context: Context?): CastOptions {
        ...
        return Builder()
            ...
            .setRemoteToLocalEnabled(true)
            .build()
    }
}
Java
public class CastOptionsProvider implements OptionsProvider {
    @Override
    public CastOptions getCastOptions(Context context) {
        ...
        return new CastOptions.Builder()
            ...
            .setRemoteToLocalEnabled(true)
            .build()
  }
}

Continúa la reproducción de manera local

Las apps que admiten la función de remoto a local deben registrar el SessionTransferCallback para recibir una notificación cuando ocurre el evento, de modo que puedan comprobar si se debe permitir la transferencia de contenido multimedia y continuar con la reproducción de manera local.

CastContext#addSessionTransferCallback(SessionTransferCallback) permite que una app registre su SessionTransferCallback y detecte las devoluciones de llamada onTransferred y onTransferFailed cuando se transfiere un remitente a la reproducción local.

Después de que la app cancele el registro de su SessionTransferCallback, ya no recibirá SessionTransferCallback.

SessionTransferCallback es una extensión de las devoluciones de llamada de SessionManagerListener existentes y se activa después de que se activa onSessionEnded. Por lo tanto, el orden de las devoluciones de llamada de remota a local es el siguiente:

  1. onTransferring
  2. onSessionEnding
  3. onSessionEnded
  4. onTransferred

Como el interruptor de salida puede abrirse con el chip de notificaciones multimedia cuando la app está en segundo plano y se está transmitiendo, las apps deben controlar la transferencia al formato local de manera diferente dependiendo de si admiten o no la reproducción en segundo plano. En caso de que la transferencia falle, onTransferFailed se activará en cualquier momento en que se produzca el error.

Apps que admiten la reproducción en segundo plano

En el caso de las apps que admiten la reproducción en segundo plano (por lo general, las de audio), se recomienda usar un Service (por ejemplo, MediaBrowserService). Los servicios deben escuchar la devolución de llamada onTransferred y reanudar la reproducción de manera local cuando la app se ejecuta en primer o segundo plano.

Kotlin
class MyService : Service() {
    private var castContext: CastContext? = null
    private var sessionTransferCallback: SessionTransferCallback? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
        sessionTransferCallback = MySessionTransferCallback()
        castContext.addSessionTransferCallback(sessionTransferCallback)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback)
            }
        }
    }

    class MySessionTransferCallback : SessionTransferCallback() {
        fun onTransferring(@SessionTransferCallback.TransferType transferType: Int) {
            // Perform necessary steps prior to onTransferred
        }

        fun onTransferred(@SessionTransferCallback.TransferType transferType: Int,
                          sessionState: SessionState?) {
            if (transferType == SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.
                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        fun onTransferFailed(@SessionTransferCallback.TransferType transferType: Int,
                             @SessionTransferCallback.TransferFailedReason transferFailedReason: Int) {
            // Handle transfer failure.
        }
    }
}
Java
public class MyService extends Service {
    private CastContext castContext;
    private SessionTransferCallback sessionTransferCallback;

    @Override
    protected void onCreate() {
        castContext = CastContext.getSharedInstance(this);
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession.class);
        sessionTransferCallback = new MySessionTransferCallback();
        castContext.addSessionTransferCallback(sessionTransferCallback);
    }

    @Override
    protected void onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession.class);
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback);
            }
        }
    }

    public static class MySessionTransferCallback extends SessionTransferCallback {
        public MySessionTransferCallback() {}

        @Override
        public void onTransferring(@SessionTransferCallback.TransferType int transferType) {
            // Perform necessary steps prior to onTransferred
        }

        @Override
        public void onTransferred(@SessionTransferCallback.TransferType int transferType,
                                  SessionState sessionState) {
            if (transferType==SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.
                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        @Override
        public void onTransferFailed(@SessionTransferCallback.TransferType int transferType,
                                     @SessionTransferCallback.TransferFailedReason int transferFailedReason) {
            // Handle transfer failure.
        }
    }
}

Apps que no admiten la reproducción en segundo plano

En el caso de las apps que no admiten la reproducción en segundo plano (por lo general, las de video), se recomienda escuchar la devolución de llamada onTransferred y reanudar la reproducción de forma local si la app está en primer plano.

Si la app está en segundo plano, debería pausar la reproducción y almacenar la información necesaria de SessionState (p.ej., metadatos multimedia y posición de reproducción). Cuando la app se ejecuta en primer plano en segundo plano, la reproducción local debe continuar con la información almacenada.

Kotlin
class MyActivity : AppCompatActivity() {
    private var castContext: CastContext? = null
    private var sessionTransferCallback: SessionTransferCallback? = null
    protected fun onCreate() {
        castContext = CastContext.getSharedInstance(this)
        castContext.getSessionManager()
                   .addSessionManagerListener(sessionManagerListener, CastSession::class.java)
        sessionTransferCallback = MySessionTransferCallback()
        castContext.addSessionTransferCallback(sessionTransferCallback)
    }

    protected fun onDestroy() {
        if (castContext != null) {
            castContext.getSessionManager()
                       .removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
            if (sessionTransferCallback != null) {
                castContext.removeSessionTransferCallback(sessionTransferCallback)
            }
        }
    }

    class MySessionTransferCallback : SessionTransferCallback() {
        fun onTransferring(@SessionTransferCallback.TransferType transferType: Int) {
            // Perform necessary steps prior to onTransferred
        }

        fun onTransferred(@SessionTransferCallback.TransferType transferType: Int,
                          sessionState: SessionState?) {
            if (transferType == SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
                // Remote stream is transferred to the local device.

                // Retrieve information from the SessionState to continue playback on the local player.
            }
        }

        fun onTransferFailed(@SessionTransferCallback.TransferType transferType: Int,
                             @SessionTransferCallback.TransferFailedReason transferFailedReason: Int) {
            // Handle transfer failure.
        }
    }
}
Java
public class MyActivity extends AppCompatActivity {
  private CastContext castContext;
  private SessionTransferCallback sessionTransferCallback;

  @Override
  protected void onCreate() {
     castContext = CastContext.getSharedInstance(this);
     castContext
        .getSessionManager()
        .addSessionManagerListener(sessionManagerListener, CastSession.class);
     sessionTransferCallback = new MySessionTransferCallback();
     castContext.addSessionTransferCallback(sessionTransferCallback);
  }

  @Override
  protected void onDestroy() {
    if (castContext != null) {
       castContext
          .getSessionManager()
          .removeSessionManagerListener(sessionManagerListener, CastSession.class);
      if (sessionTransferCallback != null) {
         castContext.removeSessionTransferCallback(sessionTransferCallback);
      }
    }
  }

  public static class MySessionTransferCallback extends SessionTransferCallback {
    public MySessionTransferCallback() {}

    @Override
    public void onTransferring(@SessionTransferCallback.TransferType int transferType) {
        // Perform necessary steps prior to onTransferred
    }

    @Override
    public void onTransferred(@SessionTransferCallback.TransferType int transferType,
                               SessionState sessionState) {
      if (transferType==SessionTransferCallback.TRANSFER_TYPE_FROM_REMOTE_TO_LOCAL) {
        // Remote stream is transferred to the local device.

        // Retrieve information from the SessionState to continue playback on the local player.
      }
    }

    @Override
    public void onTransferFailed(@SessionTransferCallback.TransferType int transferType,
                                 @SessionTransferCallback.TransferFailedReason int transferFailedReason) {
      // Handle transfer failure.
    }
  }
}