Output Switcher is a feature of the Cast SDK that enables seamless transferring
between local and remote playback of content starting with Android 13. The goal
is to help sender apps easily and quickly control where the content is playing.
Output Switcher uses the
MediaRouter
library to
switch the content playback among the phone speaker, paired Bluetooth devices,
and remote Cast-enabled devices. Use cases can be broken down into the following
scenarios:
Download and use the sample below for reference on how to implement Output Switcher in your Audio app. See the included README.md for instructions regarding how to run the sample.
Output Switcher should be enabled to support local-to-remote and remote-to-local using the steps covered in this guide. There are no additional steps needed to support the transfer between the local device speakers and paired Bluetooth devices.
Audio apps are apps that support Google Cast for Audio in the Receiver App settings in the Google Cast SDK Developer Console
Output Switcher UI
The Output Switcher displays the local and remote devices that are available as well as the current device states, including if the device is selected, is connecting, the current volume level. If there are other devices in addition to the current device, clicking on other device allows you to transfer the media playback to the selected device.
Known issues
- Media Sessions created for local playback will be dismissed and recreated when switching to the Cast SDK notification.
Entry points
Media notification
If an app posts a media notification with
MediaSession
for
local playback (playing locally), the top-right corner of the media notification
displays a notification chip with the device name (such as phone speaker) that
the content is currently being played on. Tapping on the notification chip opens
the Output Switcher dialog system UI.
Volume settings
The Output Switcher dialog system UI can also be triggered by clicking the physical volume buttons on the device, tapping the settings icon at the bottom, and tapping the "Play <App Name> on <Cast Device>" text.
Summary of steps
- Ensure prerequisites are met
- Enable Output Switcher in AndroidManifest.xml
- Update SessionManagerListener for background casting
- Set the setRemoteToLocalEnabled flag
- Continue playback locally
Prerequisites
- Migrate your existing Android app to AndroidX.
- Update your app's
build.gradle
to use the minimum required version of the Android Sender SDK for the Output Switcher:dependencies { ... implementation 'com.google.android.gms:play-services-cast-framework:21.2.0' ... }
- App supports media notifications.
- Device running Android 13.
Set up Media Notifications
To use the Output Switcher,
audio and
video apps
are required to create a media notification to display the playback status and
controls for their media for local playback. This requires creating a
MediaSession
,
setting the
MediaStyle
with the MediaSession
's token, and setting the media controls on the
notification.
If you are not currently using a MediaStyle
and MediaSession
, the snippet
below shows how to set them up and guides are available for setting up the media
session callbacks for
audio and
video
apps:
// 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(); }
Additionally, to populate the notification with the information for your media,
you will need to add your media's
metadata and playback state
to the MediaSession
.
To add metadata to the MediaSession
, use
setMetaData()
and provide all of the relevant
MediaMetadata
constants for
your media in the
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() ); }
To add the playback state to the MediaSession
, use
setPlaybackState()
and provide all of the relevant
PlaybackStateCompat
constants for your media in the
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() ); }
Video app notification behavior
Video apps or audio apps that do not support local playback in the background should have specific behavior for media notifications to avoid issues with sending media commands in situations that playback is not supported:
- Post the media notification when playing media locally and the app is in the foreground.
- Pause local playback and dismiss the notification when the app is in the background.
- When the app moves back to the foreground, local playback should resume and the notification should be reposted.
Enable Output Switcher in AndroidManifest.xml
To enable the Output Switcher, the
MediaTransferReceiver
needs to be added to the app's AndroidManifest.xml
. If it is not, the feature
will not be enabled and the remote-to-local feature flag will also be invalid.
<application>
...
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true">
</receiver>
...
</application>
The
MediaTransferReceiver
is a broadcast receiver that enables media transfer among devices with system
UI. See the MediaTransferReceiver
reference
for more information.
Local-to-remote
When the user switches playback from local to remote, the Cast SDK will start
the Cast session automatically. However, apps need to handle switching from
local to remote, for example stop the local playback
and load the media on the Cast device. Apps should listen to the Cast
SessionManagerListener
,
using the
onSessionStarted()
and
onSessionEnded()
callbacks, and handle the action when receiving the Cast
SessionManager
callbacks. Apps should ensure that these callbacks are still alive when
the Output Switcher dialog is opened and the app is not in the foreground.
Update SessionManagerListener for background casting
The legacy Cast experience already supports local-to-remote when the app is
in foreground. A typical Cast experience starts when users click on the Cast
icon in the app and pick a device to stream media. In this case, the app needs
to register to the
SessionManagerListener
,
in onCreate()
or
onStart()
and unregister the listener in
onStop()
or
onDestroy()
of the app's activity.
With the new experience of casting using the Output Switcher, apps can start
casting when they are in the background. This is particularly useful for audio
apps that post notifications when playing in the background. Apps can
register the SessionManager
listeners in the onCreate()
of the service
and unregister in the onDestroy()
of the service. In this way, apps should
always receive the local-to-remote callbacks (such as onSessionStarted
) when
the app is in the background.
If the app uses the
MediaBrowserService
,
it is recommended to register the SessionManagerListener
there.
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); } } }
With this update, local-to-remote acts the same as traditional casting when the app is in the background and extra work is not required for switching from Bluetooth devices to Cast devices.
Remote-to-local
The Output Switcher provides the ability to transfer from remote playback to the
phone speaker or local Bluetooth device. This can be enabled by setting the
setRemoteToLocalEnabled
flag to true
on the CastOptions
.
For cases where the current sender device joins an existing session with
multiple senders and the app needs to check if the current media is allowed to
be transferred locally, apps should use the onTransferred
callback of the
SessionTransferCallback
to check the SessionState
.
Set the setRemoteToLocalEnabled flag
The CastOptions
provides a setRemoteToLocalEnabled
to show or hide the
phone speaker and local Bluetooth devices as transfer-to targets in the Output
Switcher dialog when there is an active Cast session.
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() } }
Continue playback locally
Apps that support remote-to-local should register the SessionTransferCallback
to get notified when the event occurs so they can check if media should be
allowed to transfer and continue playback locally.
CastContext#addSessionTransferCallback(SessionTransferCallback)
allows an
app to register its SessionTransferCallback
and listen for onTransferred
and
onTransferFailed
callbacks when a sender is transferred to local playback.
After the app unregisters its SessionTransferCallback
, the app will no longer
receive SessionTransferCallback
s.
The SessionTransferCallback
is an extension of the existing
SessionManagerListener
callbacks and is triggered after onSessionEnded
is triggered. Hence, the order
of remote-to-local callbacks is:
onTransferring
onSessionEnding
onSessionEnded
onTransferred
Since the Output Switcher can be opened by the media notification chip when
the app is in the background and casting, apps need to handle the transfer to
local differently depending on if they support background playback or not. In
the case of a failed transfer, onTransferFailed
will fire at any time the
error occurs.
Apps that support background playback
For apps that support playback in the background (typically audio apps), it is
recommended to use a Service
(for example MediaBrowserService
). Services
should listen to the onTransferred
callback and resume playback locally both
when the app is in the foreground or background.
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 that do not support background playback
For apps that do not support background playback (typically video apps), it is
recommended to listen to the onTransferred
callback and resume playback
locally if the app is in the foreground.
If the app is in the background, it should pause playback and should store the
necessary information from SessionState
(e.g. media metadata and playback
position). When the app is foregrounded from the background, the local playback
should continue with the stored information.
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. } } }