出力スイッチャーは Cast SDK の機能であり、Android 13 以降のコンテンツのローカル再生とリモート再生をシームレスに転送できます。目標は、センダーアプリがコンテンツの再生場所を簡単かつ迅速に制御できるようにすることです。出力スイッチャーは、MediaRouter
ライブラリを使用して、スマートフォンのスピーカー、ペア設定された Bluetooth デバイス、リモートの Cast 対応デバイス間でコンテンツの再生を切り替えます。ユースケースは、次のシナリオに分類されます。
オーディオ アプリに出力スイッチャーを実装する方法については、以下のサンプルをダウンロードして使用します。サンプルの実行方法については、付属の README.md をご覧ください。
このガイドで説明する手順に沿って、出力スイッチャーを有効にして、ローカルからリモートへの変換とリモートからローカルへの変換をサポートする必要があります。ローカル デバイスのスピーカーとペア設定された Bluetooth デバイス間の転送をサポートするために必要な追加の手順はありません。
オーディオ アプリは、Google Cast SDK デベロッパー コンソールのレシーバー アプリの設定で Google Cast for Audio をサポートしているアプリです。
出力切り替え UI
出力スイッチャーには、使用可能なローカル デバイスとリモート デバイスのほか、現在のデバイスの状態(デバイスが選択されているかどうか、接続中かどうか、現在の音量レベルなど)が表示されます。現在のデバイスに加えて他のデバイスがある場合は、他のデバイスをクリックすると、選択したデバイスにメディア再生を転送できます。
既知の問題
- Cast SDK 通知に切り替えると、ローカル再生用に作成されたメディア セッションは破棄され、再作成されます。
エントリ ポイント
メディア通知
アプリがローカル再生(ローカルでの再生)のために MediaSession
でメディア通知を送信すると、メディア通知の右上に、現在コンテンツが再生されているデバイス名(スマートフォンのスピーカーなど)を示す通知チップが表示されます。通知チップをタップすると
[出力スイッチャー]ダイアログのシステム UI が開きます
音量の設定
出力スイッチャー ダイアログのシステム UI は、デバイスの物理的な音量ボタンをクリックし、下部にある設定アイコンをタップして、「<キャスト デバイス> で <アプリ名> を再生」というテキストをタップすることでもトリガーできます。
ステップの概要
- 前提条件が満たされていることを確認する
- 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()
を使用し、メディアに関連するすべての MediaMetadata
定数を MediaMetadataCompat.Builder()
に指定します。
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
定数を PlaybackStateCompat.Builder()
に指定します。
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()
コールバックを使用してキャスト SessionManagerListener
をリッスンし、キャスト SessionManager
コールバックを受信したときにアクションを処理する必要があります。[Output Switcher] ダイアログが開かれていて、アプリがフォアグラウンドにない場合、アプリは、これらのコールバックが存続していることを確認する必要があります。
バックグラウンド キャスト用に 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); } } }
このアップデートにより、アプリがバックグラウンドで実行されている場合、ローカルからリモートへのキャストは従来のキャストと同じように動作し、Bluetooth デバイスからキャスト デバイスに切り替える際に追加の作業は必要ありません。
リモートからローカル
出力スイッチャーは、リモート再生からスマートフォンのスピーカーまたはローカルの Bluetooth デバイスに転送する機能を提供します。これを有効にするには、CastOptions
で setRemoteToLocalEnabled
フラグを true
に設定します。
現在のセンダーデバイスが複数のセンダーとの既存のセッションに参加し、現在のメディアのローカル転送が許可されているかどうかをアプリで確認する必要がある場合、アプリは SessionTransferCallback
の onTransferred
コールバックを使用して SessionState
を確認する必要があります。
setRemoteToLocalEnabled フラグを設定する
CastOptions
は setRemoteToLocalEnabled
を備えており、アクティブなキャスト セッションがある場合に、スマートフォン スピーカーとローカル Bluetooth デバイスを、出力スイッチャー ダイアログで転送先として表示 / 非表示にできます。
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. } } }