输出切换器是 Cast SDK 的一项功能,从 Android 13 开始,可实现内容在本地和远程播放之间无缝转移。其目标是帮助发送方应用轻松快速地控制内容播放的位置。输出切换器使用 MediaRouter
库在手机扬声器、配对的蓝牙设备以及支持 Cast 的远程设备之间切换内容播放。使用场景可以细分为以下几种:
下载并使用以下示例,了解如何在音频应用中实现输出切换器。有关如何运行示例的说明,请参阅随附的 README.md。
应按照本指南中介绍的步骤启用输出切换器,以支持本地到远程和远程到本地。无需执行额外的步骤,即可支持在本地设备扬声器和配对的蓝牙设备之间传输数据。
音频应用是指 Google Cast SDK 开发者控制台的“接收器应用”设置中支持适用于音频的 Google Cast 的应用。
输出切换器界面
输出切换器会显示可用的本地和远程设备以及当前设备状态(包括当前设备状态,包括设备是否处于选中状态)、当前音量。如果除当前设备外还有其他设备,则通过点击其他设备可以将媒体播放传输到所选设备。
已知问题
- 当切换到 Cast SDK 通知时,为本地播放创建的媒体会话将被关闭并重新创建。
入口点
媒体通知
如果应用使用 MediaSession
发布针对本地播放(在本地播放)的媒体通知,则媒体通知的右上角会显示一个通知条状标签,其中包含当前正在播放内容的设备名称(例如手机扬声器)。点按通知条状标签会打开“输出切换器”对话框系统界面。
音量设置
通过以下方法也可以触发“输出切换器”对话框系统界面:点击设备上的物理音量按钮,点按底部的设置图标,然后点按“Play <App Name> on <Cast Device>”(在 <投射设备> 上播放 <App Name>)文本。
步骤总结
- 确保满足前提条件
- 在 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
,使用 MediaSession
的令牌设置 MediaStyle
,并在通知上设置媒体控件。
如果您目前没有使用 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>
MediaTransferReceiver
是一个广播接收器,支持在具有系统界面的设备之间传输媒体。如需了解详情,请参阅 MediaTransferReceiver 参考文档。
本地到远程
当用户从本地播放切换到远程播放时,Cast SDK 会自动启动 Cast 会话。不过,应用需要处理从本地到远程的切换,例如停止本地播放并在 Cast 设备上加载媒体。应用应使用 onSessionStarted()
和 onSessionEnded()
回调监听 Cast SessionManagerListener
,并在收到 Cast SessionManager
回调时处理该操作。当“输出切换器”对话框打开且未在前台运行时,应用应确保这些回调仍处于活动状态。
更新 SessionManagerListener 以实现后台投放
当应用在前台运行时,旧版 Cast 体验已经支持从本地到远程。当用户点击应用中的 Cast 图标并选择要流式传输媒体内容的设备时,即开始典型的 Cast 体验。在这种情况下,应用需要在 onCreate()
或 onStart()
中注册 SessionManagerListener
,并在应用 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); } } }
经过此次更新,当应用在后台运行时,本地到远程访问的作用与传统投射相同,并且从蓝牙设备切换到投射设备不需要执行额外的工作。
远程到本地
输出切换器提供了从远程播放转移到手机扬声器或本地蓝牙设备的功能。您可以通过将 CastOptions
上的 setRemoteToLocalEnabled
标志设置为 true
来启用此功能。
如果当前发送器设备加入了与多个发送者之间的现有会话,并且应用需要检查是否允许在本地传输当前媒体,应用应使用 SessionTransferCallback
的 onTransferred
回调来检查 SessionState
。
设置 setRemoteToLocalEnabled 标志
CastOptions
提供了一个 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
。
SessionTransferCallback
是现有 SessionManagerListener
回调的扩展,会在触发 onSessionEnded
后触发。因此,远程到本地回调的顺序如下:
onTransferring
onSessionEnding
onSessionEnded
onTransferred
由于当应用在后台运行和正在投射时,媒体通知芯片可以打开输出切换器,因此应用需要以不同方式处理传输到本地的操作,具体取决于应用是否支持后台播放。如果传输失败,每当发生错误时都会触发 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. } } }