輸出切換器是 Cast SDK 的一項功能,可從 Android 13 開始,在本機和遠端播放內容之間順暢傳輸。目標是協助傳送者應用程式輕鬆快速地控制內容的播放位置。輸出切換器使用 MediaRouter
程式庫,在手機喇叭、配對的藍牙裝置和支援 Cast 的遠端裝置間切換內容播放。用途可細分為以下情境:
如要瞭解如何在音訊應用程式中實作輸出切換器,請下載並使用以下範例。如需瞭解如何執行範例,請參閱隨附的 README.md。
您應按照本指南說明的步驟啟用輸出切換器,以便支援本機對遠端和遠端之間的本機之間。您無需採取額外步驟,即可支援本機裝置喇叭與配對的藍牙裝置之間的傳輸作業。
音訊應用程式是在 Google Cast SDK 開發人員控制台的接收端應用程式設定中,支援 Google Cast for Audio 的應用程式
輸出端切換器 UI
輸出切換器會顯示可用的本機和遠端裝置,以及目前裝置狀態,包括如果選取裝置且正在連線,以及目前的音量。如果除了目前的裝置之外,還有其他裝置可讓您將媒體播放轉移到所選裝置。
已知問題
- 切換至 Cast SDK 通知時,系統會關閉並重新建立為本機播放建立的媒體工作階段。
進入點
媒體通知
如果應用程式發布含有 MediaSession
的媒體通知來在本機播放 (在本機播放),媒體通知的右上角會顯示通知方塊,其中包含正在播放內容的裝置名稱 (例如手機喇叭)。只要輕觸通知方塊,即可開啟輸出切換器對話方塊系統 UI。
音量設定
您也可以透過以下方式觸發輸出切換器對話方塊 UI:按一下裝置的實際音量按鈕、輕觸底部的設定圖示,然後輕觸 <Cast Device> 文字上的「Play <App Name>」文字。
步驟摘要
- 確認符合必要條件
- 在 AndroidManifest.xml 中啟用輸出切換器
- 更新背景投放功能的 SessionManagerListener
- 設定 setRemoteToLocalEnabled 標記
- 繼續在本機播放
先備知識
- 將現有的 Android 應用程式遷移至 AndroidX。
- 更新應用程式的
build.gradle
,使用輸出端切換器的 Android Sender 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
是一種廣播接收器,可在具備系統 UI 的裝置之間傳輸媒體。詳情請參閱 MediaTransferReceiver 參考資料。
本機對遠端
當使用者從本機切換至遠端播放時,Cast SDK 會自動啟動投放工作階段。不過,應用程式需要處理從本機切換至遠端的作業,例如停止本機播放並在投放裝置上載入媒體。應用程式應使用 onSessionStarted()
和 onSessionEnded()
回呼監聽 Cast SessionManagerListener
,並在收到 Cast SessionManager
回呼時處理動作。當「輸出切換器」對話方塊開啟,且應用程式不在前景執行時,應用程式應確保這些回呼仍維持有效。
更新 SessionManagerListener 用於背景投放
舊版 Cast 服務已可在應用程式於前景運作時支援本機對遠端功能。當使用者點選應用程式中的「投放」圖示,並選擇要串流媒體的裝置時,一般的投放體驗就會開始運作。在此情況下,應用程式需要在 onCreate()
或 onStart()
中註冊 SessionManagerListener
,然後在應用程式活動的 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
,可在有運作中的投放工作階段時,在輸出端切換器對話方塊中顯示或隱藏手機喇叭和本機藍牙裝置,做為轉接目標。
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. } } }