輸出切換器

輸出切換器是 Cast SDK 的一項功能,可讓您在本機和遠端播放內容 (從 Android 13 開始) 之間流暢切換。我們的目標是協助傳送者應用程式輕鬆快速地控制內容的播放位置。輸出切換器會使用 MediaRouter 程式庫,在手機喇叭、配對的藍牙裝置和支援投放功能的遠端裝置之間切換內容播放。用途可細分為以下情境:

請下載並參考以下範例,瞭解如何在音訊應用程式中實作輸出切換器。請參閱隨附的 README.md,瞭解執行範例的操作說明。

下載範例

您應依照本指南所涵蓋的步驟,啟用輸出切換器以支援本機對遠端和遠端連線至本機。您不需要執行其他步驟,即可在本機裝置喇叭和配對的藍牙裝置間轉移資料。

音訊應用程式是指在 Google Cast SDK 開發人員主控台中的接收器應用程式設定中,支援 Google Cast for Audio 的應用程式。

輸出端切換器 UI

輸出切換器會顯示可用的本機和遠端裝置,以及目前裝置的狀態,包括是否正在連線裝置,以及目前的音量水平。如果目前裝置還還有其他裝置,按一下其他裝置即可將媒體播放內容傳輸到所選裝置。

已知問題

  • 切換到 Cast SDK 通知時,系統會關閉針對本機播放建立的媒體工作階段,並重新建立。

進入點

媒體通知

如果應用程式發布了包含 MediaSession 以進行本機播放 (在本機播放) 的媒體通知,媒體通知的右上角會顯示通知方塊,指出目前正在播放內容的裝置名稱 (例如手機喇叭)。輕觸通知方塊,開啟輸出切換器對話方塊系統 UI。

音量設定

您也可以按一下裝置上的實體音量按鈕、輕觸底部的設定圖示,然後輕觸文字上的「在 <投放裝置> 上播放 <應用程式名稱>」,也可以觸發「輸出切換器」對話方塊系統 UI。

步驟摘要

必要條件

  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 的裝置。

設定媒體通知

如要使用輸出切換器,audio影片應用程式必須建立媒體通知,才能顯示在本機播放的媒體播放狀態和控制項。方法是建立 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 是廣播接收器,可在配備系統 UI 的裝置之間傳輸媒體。詳情請參閱 MediaTransferReceiver 參考資料

本機對遠端

使用者將播放內容從本機切換至遠端時,Cast SDK 會自動啟動投放工作階段。但是,應用程式需要處理從本機切換至遠端模式,例如停止本機播放,並在投放裝置上載入媒體。應用程式應使用 onSessionStarted()onSessionEnded() 回呼監聽 Cast SessionManagerListener,並在收到 Cast SessionManager 回呼時處理動作。當「輸出切換器」對話方塊開啟,且應用程式不在前景時,應用程式應確保這些回呼仍然有效。

更新 SessionManagerListener 以進行背景投放

舊版 Cast 體驗可以在應用程式於前景運作時支援本機對遠端。一般的投放體驗是從應用程式中的「投放」圖示開始,然後選擇要串流播放媒體的裝置。在此情況下,應用程式必須在 onCreate()onStart() 中註冊 SessionManagerListener,並在應用程式活動的 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,以便在有進行中的投放工作階段時,在輸出切換器對話方塊中將手機喇叭和本機藍牙裝置當做傳輸目標。

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