输出切换器是 Cast SDK 的一项功能,可从 Android 13 开始在本地和远程播放内容之间实现无缝传输。其目标是帮助发送端应用轻松快速地控制内容的播放位置。
输出切换器使用
MediaRouter库在手机扬声器、已配对的蓝牙设备
和支持 Cast 的远程设备之间切换内容播放。用例可以细分为以下场景:
下载并使用 CastVideos-android 示例应用 ,了解如何在应用中实现输出切换器。
应启用输出切换器,以支持使用本指南中介绍的步骤实现本地到远程、远程到本地和远程到远程。无需执行其他步骤即可支持在本地设备扬声器和已配对的蓝牙设备之间进行传输。
输出切换器界面
输出切换器会显示可用的本地和远程设备,以及当前设备状态,包括设备是否已选中、是否正在连接以及当前音量级别。如果除了当前设备之外还有其他设备,点击其他设备可将媒体播放传输到所选设备。

已知问题
- 切换到 Cast SDK 通知时,为本地播放创建的媒体会话将被关闭并重新创建。
入口点
媒体通知
如果应用发布了带有
MediaSession的媒体通知以进行
本地播放(在本地播放),则媒体通知的右上角会显示一个通知芯片,其中包含当前播放内容的设备名称(例如手机扬声器),
当前正在播放内容。点按通知芯片会打开输出切换器对话框系统界面。

音量设置
您还可以通过以下方式触发输出切换器对话框系统界面:点击设备上的实体音量按钮、点按底部的设置图标,以及点按“在 <Cast 设备> 上播放 <应用名称>”文本。

步骤摘要
- 确保满足前提条件
- 在 AndroidManifest.xml 中启用输出切换器
- 更新 SessionManagerListener 以进行后台投放
- 添加对远程到远程的支持
- 设置 setRemoteToLocalEnabled 标志
- 继续在本地播放
前提条件
- 将现有 Android 应用迁移到 AndroidX。
- 更新应用的
build.gradle,以使用输出切换器所需的最低 Android 发送端 SDK 版本:dependencies { ... implementation 'com.google.android.gms:play-services-cast-framework:21.2.0' ... }
- 应用支持媒体通知。
- 运行 Android 13 的设备。
设置媒体通知
如需使用输出切换器,
音频和
视频应用
需要创建媒体通知,以显示其媒体的播放状态和
控件以进行本地播放。这需要创建
MediaSession,使用
MediaStyle
的令牌设置 MediaSession,并在
通知中设置媒体控件。
如果您目前未使用 MediaStyle 和 MediaSession,以下代码段展示了如何设置它们,并且提供了有关为音频和视频应用设置媒体会话回调的指南:
// 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(); }

此外,如需在通知中填充媒体信息,
您还需要将媒体的
元数据和播放状态
添加到 MediaSession。
如需将元数据添加到 MediaSession,请使用
setMetaData()
,并在
MediaMetadataCompat.Builder() 中为
您的媒体提供所有相关的
MediaMetadata 常量。
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() ); }

如需将播放状态添加到 MediaSession,请使用
setPlaybackState()
,并在
PlaybackStateCompat.Builder()中为您的媒体提供所有相关的
PlaybackStateCompat
常量。
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() ); }

视频应用通知行为
不支持在后台进行本地播放的视频应用或音频应用应具有特定的媒体通知行为,以避免在不支持播放的情况下发送媒体命令时出现问题:
- 在本地播放媒体且应用在前台运行时,发布媒体通知。
- 当应用在后台运行时,暂停本地播放并关闭通知。
- 当应用返回到前台时,应恢复本地播放并重新发布通知。
在 AndroidManifest.xml 中启用输出切换器
如需启用输出切换器,
MediaTransferReceiver
需要添加到应用的 AndroidManifest.xml。否则,该功能将不会启用,并且远程到本地功能标志也将无效。
<application>
...
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true">
</receiver>
...
</application>
The
MediaTransferReceiver
是一种广播接收器,可在具有系统
界面的设备之间实现媒体传输。如需了解详情,请参阅 MediaTransferReceiver
参考文档
。
本地到远程
当用户将播放从本地切换到远程时,Cast SDK 将自动启动 Cast 会话。不过,应用需要处理从本地到远程的切换,例如停止本地播放并在投放设备上加载媒体。应用应使用
onSessionStarted()
和
onSessionEnded()
回调监听 Cast
SessionManagerListener,并在收到 Cast
SessionManager
回调时处理该操作。应用应确保在打开输出切换器对话框且应用不在前台时,这些回调仍处于活跃状态。
更新 SessionManagerListener 以进行后台投放
当应用在前台运行时,旧版 Cast 体验已支持本地到远程。典型的 Cast 体验从用户点击应用中的 Cast 图标并选择要流式传输媒体的设备开始。在这种情况下,应用需要在
注册到
SessionManagerListener,
在 onCreate() 或
onStart()
中,并在应用 activity 的
onStop()
或
onDestroy()
中取消注册监听器。
借助使用输出切换器进行投放的新体验,应用可以在后台开始投放。这对于在后台播放时发布通知的音频应用尤其有用。应用可以在服务的 onCreate() 中注册 SessionManager
监听器,并在服务的 onDestroy()
中取消注册。当应用在后台运行时,应用应始终接收本地到远程回调(例如
onSessionStarted)
。
如果应用使用
MediaBrowserService,建议在此处注册 SessionManagerListener
。
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); } } }
通过此更新,当应用在后台运行时,本地到远程的行为与传统投放相同,并且无需执行额外的工作即可从蓝牙设备切换到 Cast 设备。
远程到本地
输出切换器提供了从远程播放传输到手机扬声器或本地蓝牙设备的功能。您可以通过在 CastOptions 上将
setRemoteToLocalEnabled
标志设置为 true 来启用此功能。
对于当前发送端设备加入具有
多个发送端的现有会话的情况,如果应用需要检查是否允许在本地
传输当前媒体,则应用应使用 onTransferred
回调来检查 SessionState。SessionTransferCallback
设置 setRemoteToLocalEnabled 标志
The CastOptions.Builder
提供了一个 setRemoteToLocalEnabled,用于在有活跃的 Cast 会话时,在输出切换器对话框中显示或隐藏手机扬声器和本地蓝牙设备作为传输目标
。
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() } }
继续在本地播放
支持远程到本地的应用应注册 SessionTransferCallback
,以便在发生该事件时收到通知,从而检查是否应
允许传输媒体并继续在本地播放。
CastContext#addSessionTransferCallback(SessionTransferCallback)
允许应用注册其 SessionTransferCallback
,并在发送端
传输到本地播放时监听 onTransferred 和 onTransferFailed 回调。
应用取消注册其 SessionTransferCallback,
后,应用将不再接收 SessionTransferCallback
s。
SessionTransferCallback
是现有 SessionManagerListener
回调的扩展,并在触发 onSessionEnded 后触发。远程到本地回调的顺序为:
onTransferringonSessionEndingonSessionEndedonTransferred
由于当应用在后台投放时,可以通过媒体通知芯片打开输出切换器,因此应用需要根据是否支持后台播放来以不同的方式处理传输到本地。如果传输失败,onTransferFailed将在发生错误时随时触发。
支持后台播放的应用
对于支持在后台播放的应用(通常是音频应用),建议使用 Service(例如 MediaBrowserService)。服务应监听 onTransferred 回调,并在应用在前台或后台运行时恢复在本地播放。
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. } } }
不支持后台播放的应用
对于不支持后台播放的应用(通常是视频应用),建议监听 onTransferred
回调,并在应用在前台运行时恢复在本地播放。
如果应用在后台运行,则应暂停播放,并应存储
SessionState
中的必要信息(例如媒体元数据和播放位置)。当应用从后台进入前台时,本地播放应使用存储的信息继续播放。
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. } } }
远程到远程
输出切换器支持使用流扩展功能扩展到多个支持 Cast 的扬声器设备,以用于音频应用。
音频应用是指在 接收器应用 设置中支持 Google Cast for Audio 的应用,这些设置位于 Google Cast SDK Developer Console

使用扬声器进行流扩展
使用输出切换器的音频应用能够在 Cast 会话期间使用流扩展功能将音频扩展到多个支持 Cast 的扬声器设备。
此功能受 Cast 平台支持,如果应用使用默认界面,则无需进行任何进一步更改。如果使用自定义界面,应用应更新界面以反映应用正在投放至群组。

如需在流扩展期间获取新的扩展组名称,
请使用
CastSession#addCastListener
注册
Cast.Listener。
然后在 onDeviceNameChanged 回调期间调用
CastSession#getCastDevice()
。
class MyActivity : Activity() { private var mCastSession: CastSession? = null private lateinit var mCastContext: CastContext private lateinit var mSessionManager: SessionManager private val mSessionManagerListener: SessionManagerListener<CastSession> = SessionManagerListenerImpl() private val mCastListener = CastListener() private inner class SessionManagerListenerImpl : SessionManagerListener<CastSession?> { override fun onSessionStarting(session: CastSession?) {} override fun onSessionStarted(session: CastSession?, sessionId: String) { addCastListener(session) } override fun onSessionStartFailed(session: CastSession?, error: Int) {} override fun onSessionSuspended(session: CastSession?, reason Int) { removeCastListener() } override fun onSessionResuming(session: CastSession?, sessionId: String) {} override fun onSessionResumed(session: CastSession?, wasSuspended: Boolean) { addCastListener(session) } override fun onSessionResumeFailed(session: CastSession?, error: Int) {} override fun onSessionEnding(session: CastSession?) {} override fun onSessionEnded(session: CastSession?, error: Int) { removeCastListener() } } private inner class CastListener : Cast.Listener() { override fun onDeviceNameChanged() { mCastSession?.let { val castDevice = it.castDevice val deviceName = castDevice.friendlyName // Update UIs with the new cast device name. } } } private fun addCastListener(castSession: CastSession) { mCastSession = castSession mCastSession?.addCastListener(mCastListener) } private fun removeCastListener() { mCastSession?.removeCastListener(mCastListener) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mCastContext = CastContext.getSharedInstance(this) mSessionManager = mCastContext.sessionManager mSessionManager.addSessionManagerListener(mSessionManagerListener, CastSession::class.java) } override fun onDestroy() { super.onDestroy() mSessionManager.removeSessionManagerListener(mSessionManagerListener, CastSession::class.java) } }
public class MyActivity extends Activity { private CastContext mCastContext; private CastSession mCastSession; private SessionManager mSessionManager; private SessionManagerListener<CastSession> mSessionManagerListener = new SessionManagerListenerImpl(); private Cast.Listener mCastListener = new CastListener(); private class SessionManagerListenerImpl implements SessionManagerListener<CastSession> { @Override public void onSessionStarting(CastSession session) {} @Override public void onSessionStarted(CastSession session, String sessionId) { addCastListener(session); } @Override public void onSessionStartFailed(CastSession session, int error) {} @Override public void onSessionSuspended(CastSession session, int reason) { removeCastListener(); } @Override public void onSessionResuming(CastSession session, String sessionId) {} @Override public void onSessionResumed(CastSession session, boolean wasSuspended) { addCastListener(session); } @Override public void onSessionResumeFailed(CastSession session, int error) {} @Override public void onSessionEnding(CastSession session) {} @Override public void onSessionEnded(CastSession session, int error) { removeCastListener(); } } private class CastListener extends Cast.Listener { @Override public void onDeviceNameChanged() { if (mCastSession == null) { return; } CastDevice castDevice = mCastSession.getCastDevice(); String deviceName = castDevice.getFriendlyName(); // Update UIs with the new cast device name. } } private void addCastListener(CastSession castSession) { mCastSession = castSession; mCastSession.addCastListener(mCastListener); } private void removeCastListener() { if (mCastSession != null) { mCastSession.removeCastListener(mCastListener); } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mCastContext = CastContext.getSharedInstance(this); mSessionManager = mCastContext.getSessionManager(); mSessionManager.addSessionManagerListener(mSessionManagerListener, CastSession.class); } @Override protected void onDestroy() { super.onDestroy(); mSessionManager.removeSessionManagerListener(mSessionManagerListener, CastSession.class); } }
测试远程到远程
如需测试该功能,请执行以下操作: