输出切换器

输出切换器是 Cast SDK 的一项功能,用于从 Android 13 开始的内容在本地和远程播放之间无缝切换。旨在帮助发送者应用轻松快速地控制内容的播放位置。输出切换器使用 MediaRouter 库在手机扬声器、已配对的蓝牙设备和支持 Cast 的远程设备之间切换内容播放。用例可分为以下情形:

下载并使用以下示例,了解有关如何在音频应用中实现输出切换器的参考。有关如何运行该示例的说明,请参阅随附的 README.md

下载示例

应按照本指南中介绍的步骤启用输出切换器,以支持从本地到远程以及从远程到本地。您无需执行额外的步骤即可支持本地设备扬声器和已配对蓝牙设备之间的传输。

音频应用是指在 Google Cast SDK 开发者控制台的“接收器应用设置”中支持 Google Cast 音频版的应用

输出切换器界面

输出切换器会显示可用的本地和远程设备以及当前设备状态,包括是否选择了设备、正在连接以及当前的音量。如果除了当前设备还有其他设备,点击其他设备可让您将媒体播放传输到所选设备。

已知问题

  • 切换到 Cast SDK 通知时,为本地播放创建的媒体会话将关闭并重新创建。

入口点

媒体通知

如果应用发布带有 MediaSession 的媒体通知以进行本地播放(在本地播放),媒体通知的右上角会显示一个通知条状标签,其中包含当前正在播放内容的设备名称(如手机扬声器)。点按通知条状标签即可打开“输出切换器”对话框的系统界面。

音量设置

您也可以通过以下操作触发输出切换器对话框系统界面:点击设备上的物理音量按钮,点按底部的设置图标,然后点按“在 <Cast Device> 上播放 <应用名称>”文本。

步骤总结

前提条件

  1. 将您现有的 Android 应用迁移到 AndroidX。
  2. 更新应用的 build.gradle,以使用输出切换器所需的最低 Android 发送器 SDK 版本:
    dependencies {
      ...
      implementation 'com.google.android.gms:play-services-cast-framework:21.2.0'
      ...
    }
  3. 应用支持媒体通知。
  4. 设备搭载 Android 13。

设置媒体通知

如需使用输出切换器,音频视频应用需要创建媒体通知,以显示其媒体的播放状态和用于本地播放的控件。这需要创建 MediaSession,使用 MediaSession 的令牌设置 MediaStyle,并在通知上设置媒体控件。

如果您目前未使用 MediaStyleMediaSession,以下代码段展示了如何进行设置,并提供了有关为音频视频应用设置媒体会话回调的指南:

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();
}

此外,如需使用媒体的信息填充通知,您需要将媒体的元数据和播放状态添加到 MediaSession

如需向 MediaSession 添加元数据,请使用 setMetaData(),并在 MediaMetadataCompat.Builder() 中提供媒体的所有相关 MediaMetadata 常量。

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()
    );
}

如需将播放状态添加到 MediaSession,请使用 setPlaybackState(),并在 PlaybackStateCompat.Builder() 中提供媒体的所有相关 PlaybackStateCompat 常量。

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()
    );
}

视频应用通知行为

不支持在后台本地播放的视频应用或音频应用应针对媒体通知采用特定行为,以避免在不支持播放的情况下发送媒体命令时出现问题:

  • 在本地播放媒体且应用在前台运行时发布媒体通知。
  • 当应用在后台运行时,暂停本地播放并关闭通知。
  • 当应用返回前台时,应恢复本地播放,并且应重新发布通知。

在 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

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);
    }
  }
}

经过此次更新,当应用在后台运行时,“从本地到远程”的投放方式与传统投放方式相同,从蓝牙设备切换到投放设备时,您无需执行额外的操作。

从远程到本地

输出切换器能够从远程播放传输到手机扬声器或本地蓝牙设备。在 CastOptions 上将 setRemoteToLocalEnabled 标志设置为 true 即可启用此功能。

如果当前发送器设备加入与多个发送器的现有会话,并且应用需要检查是否允许在本地传输当前媒体,应用应使用 SessionTransferCallbackonTransferred 回调来检查 SessionState

设置 setRemoteToLocalEnabled 标志

CastOptions 提供了一个 setRemoteToLocalEnabled,以便在存在处于活动状态的 Cast 会话时,在“输出切换器”对话框中显示或隐藏手机扬声器和本地蓝牙设备作为传输目标。

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()
  }
}

在本地继续播放

支持从远程到本地的应用应注册 SessionTransferCallback,以便在事件发生时收到通知,以便检查是否应允许媒体在本地传输并继续播放。

CastContext#addSessionTransferCallback(SessionTransferCallback) 允许应用注册其 SessionTransferCallback,并在发送者传输到本地播放时监听 onTransferredonTransferFailed 回调。

应用取消注册其 SessionTransferCallback 后,将不会再收到 SessionTransferCallback

SessionTransferCallback 是现有 SessionManagerListener 回调的扩展,在触发 onSessionEnded 后触发。因此,从远程到本地回调的顺序是:

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

由于当应用在后台运行并进行投放时,媒体通知条状标签可以打开输出切换器,因此应用需要根据是否支持后台播放功能以不同的方式处理向本地的传输。如果转移失败,无论何时出现错误,系统都会触发 onTransferFailed

支持后台播放的应用

对于支持在后台播放的应用(通常是音频应用),建议使用 Service(例如 MediaBrowserService)。当应用处于前台或后台时,服务都应监听 onTransferred 回调并在本地恢复播放。

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.
        }
    }
}

不支持后台播放的应用

对于不支持后台播放的应用(通常为视频应用),建议监听 onTransferred 回调,如果应用在前台运行,则建议在本地继续播放。

如果应用在后台运行,则应暂停播放并存储来自 SessionState 的必要信息(例如媒体元数据和播放位置)。当应用从后台进入前台时,本地播放应使用存储的信息继续。

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.
    }
  }
}