El selector 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 con rapidez y facilidad dónde se reproduce el contenido.
La app de cambio 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 compatibles con Cast remotos. Los casos de uso se pueden desglosar en las siguientes situaciones:
Descarga y usa el siguiente ejemplo como referencia para implementar el selector de salida en tu app de audio. Consulta el archivo README.md incluido si necesitas instrucciones para ejecutar el ejemplo.
El selector de salida debe estar habilitado para admitir asistencia de local a remoto y de remoto a local mediante los pasos que se abordan en esta guía. No se requieren pasos adicionales para admitir la transferencia entre las bocinas del dispositivo local y los dispositivos Bluetooth vinculados.
Las apps de audio son apps 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 disponibles, así como los estados actual del dispositivo, incluido si el dispositivo está seleccionado, se está conectando y el nivel de volumen actual. Si hay otros dispositivos además del dispositivo 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 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 (se reproduce localmente), la esquina superior derecha de la notificación muestra 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, luego, el texto "Reproducir <Nombre de la app> en <Dispositivo de transmisión>".
Resumen de los pasos
- Asegúrate de que se cumplan los requisitos previos
- Cómo habilitar el selector de salida en AndroidManifest.xml
- Actualiza SessionManagerListener para la transmisión en segundo plano
- Configura la marca setRemoteToLocalEnabled
- Continúa con la reproducción local
Requisitos previos
- Migra tu app para Android existente a AndroidX.
- Actualiza el
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' ... }
- La app admite notificaciones multimedia.
- Dispositivo con Android 13.
Cómo configurar las notificaciones multimedia
A fin de usar el selector de salida, se requieren apps de audio y video a fin de crear una notificación multimedia a fin de mostrar el estado de la reproducción y los controles para su reproducción local. Para ello, debes crear una MediaSession
, configurar MediaStyle
con el token de MediaSession
y configurar los controles multimedia de la notificación.
Si actualmente no usas MediaStyle
y MediaSession
, en el siguiente fragmento, se muestra cómo configurarlos, y hay guías disponibles para configurar las devoluciones de llamada de la sesión multimedia de las apps de audio y video:
// 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)
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 del contenido multimedia, deberás agregar su metadatos y estado de reproducción a MediaSession
.
Para agregar metadatos a MediaSession
, usa setMetaData()
y proporciona todas las constantes MediaMetadata
relevantes para tu contenido multimedia en MediaMetadataCompat.Builder()
.
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() )
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()
.
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() )
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 apps 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 relacionados 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 regresa al primer plano, se debe reanudar la reproducción local y se debe volver a publicar la notificación.
Habilita el selector de salida en AndroidManifest.xml
Para habilitar el selector de salida, se debe agregar MediaTransferReceiver
al AndroidManifest.xml
de la app. De lo contrario, no se habilitará y la marca de función de remota a local tampoco será válida.
<application>
...
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true">
</receiver>
...
</application>
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.
Local a remoto
Cuando el usuario cambia la reproducción de local a remota, el SDK de Cast iniciará la sesión de transmisión automáticamente. 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 las devoluciones de llamada SessionManagerListener
de Cast mediante las devoluciones de llamada onSessionStarted()
y onSessionEnded()
, y administrar la acción cuando reciben las devoluciones de llamadas de SessionManager
. Las apps deben asegurarse de que estas devoluciones de llamada sigan activas cuando se abra el diálogo del Selector de salida y no esté en primer plano.
Actualiza SessionManagerListener para la transmisión en segundo plano
La experiencia de transmisión heredada 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 para transmitir en la app y eligen un dispositivo a fin de transmitir contenido multimedia. En este caso, la app debe registrarse en el 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 mediante el selector de salida, las apps pueden comenzar a transmitir cuando están en segundo plano. Esto es particularmente útil para las 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 el onDestroy()
del servicio. De esta manera, las apps siempre deben recibir las devoluciones de llamada de local a remoto (como onSessionStarted
) cuando están en segundo plano.
Si la app usa MediaBrowserService
, se recomienda registrar la SessionManagerListener
allí.
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) } } }
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 ninguna tarea adicional para cambiar de dispositivos Bluetooth a dispositivos de transmisión.
De remota a local
El selector de salida permite transferir la reproducción remota a la bocina del teléfono o al dispositivo Bluetooth local. Para ello, configura la marca setRemoteToLocalEnabled
como true
en CastOptions
.
Para los casos en los que el dispositivo remitente actual se una a una sesión existente con varios remitentes y la app deba verificar si el contenido multimedia actual puede transferirse de forma local, las apps deben usar la devolución de llamada onTransferred
de SessionTransferCallback
a fin de verificar el SessionState
.
Configura la marca setRemoteToLocalEnabled
CastOptions
proporciona un setRemoteToLocalEnabled
para mostrar u ocultar el altavoz del teléfono y los dispositivos Bluetooth locales como objetivos de transferencia en el diálogo del selector de salida cuando hay una sesión de transmisión activa.
class CastOptionsProvider : OptionsProvider { fun getCastOptions(context: Context?): CastOptions { ... return Builder() ... .setRemoteToLocalEnabled(true) .build() } }
public class CastOptionsProvider implements OptionsProvider { @Override public CastOptions getCastOptions(Context context) { ... return new CastOptions.Builder() ... .setRemoteToLocalEnabled(true) .build() } }
Continuar la reproducción de forma local
Las apps que admiten la conexión remota a local deben registrar el SessionTransferCallback
para recibir una notificación cuando se produzca el evento a fin de que puedan verificar si se debe
transferir y continuar la reproducción de forma local.
CastContext#addSessionTransferCallback(SessionTransferCallback)
permite que una app registre su SessionTransferCallback
y escuche las devoluciones de llamada de onTransferred
y onTransferFailed
cuando un remitente se transfiere a la reproducción local.
Una vez que la app anule el registro de su SessionTransferCallback
, dejará de recibir SessionTransferCallback
.
El SessionTransferCallback
es una extensión de las devoluciones de llamada existentes de SessionManagerListener
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:
onTransferring
onSessionEnding
onSessionEnded
onTransferred
Dado que el chip de notificación de contenido multimedia puede abrir el selector de salida cuando la app está en segundo plano y transmitiendo, las apps deben controlar la transferencia a las ubicaciones locales de manera diferente, ya sea que admitan la reproducción en segundo plano o no. En el caso de una transferencia con errores, se activará onTransferFailed
en cualquier momento en que se produzca el error.
Apps compatibles con la reproducción en segundo plano
Para las apps que admiten la reproducción en segundo plano (por lo general, apps de audio), se recomienda usar un Service
(por ejemplo, MediaBrowserService
). Los servicios deben escuchar la devolución de llamada de onTransferred
y reanudar la reproducción de manera local cuando la app esté en primer o segundo plano.
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. } } }
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, apps de video), se recomienda escuchar la devolución de llamada onTransferred
y reanudar la reproducción de forma local si la app se ejecuta en primer plano.
Si la app está en segundo plano, debe pausar la reproducción y almacenar la información necesaria de SessionState
(p.ej., los metadatos multimedia y la posición de reproducción). Cuando la app está en primer plano, la reproducción local debería continuar con la información almacenada.
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. } } }
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. } } }