出力スイッチャー

出力スイッチャーは Cast SDK の機能であり、Android 13 以降のコンテンツのローカル再生とリモート再生をシームレスに転送できます。目標は、センダーアプリがコンテンツの再生場所を簡単かつ迅速に制御できるようにすることです。出力スイッチャーは、MediaRouter ライブラリを使用して、スマートフォンのスピーカー、ペア設定された Bluetooth デバイス、リモートの Cast 対応デバイス間でコンテンツの再生を切り替えます。ユースケースは、次のシナリオに分類されます。

オーディオ アプリに出力スイッチャーを実装する方法については、以下のサンプルをダウンロードして使用します。サンプルの実行方法については、付属の README.md をご覧ください。

サンプルをダウンロード

このガイドで説明する手順に沿って、出力スイッチャーを有効にして、ローカルからリモートへの変換とリモートからローカルへの変換をサポートする必要があります。ローカル デバイスのスピーカーとペア設定された Bluetooth デバイス間の転送をサポートするために必要な追加の手順はありません。

オーディオ アプリは、Google Cast SDK デベロッパー コンソールのレシーバー アプリの設定で Google Cast for Audio をサポートしているアプリです。

出力切り替え UI

出力スイッチャーには、使用可能なローカル デバイスとリモート デバイスのほか、現在のデバイスの状態(デバイスが選択されているかどうか、接続中かどうか、現在の音量レベルなど)が表示されます。現在のデバイスに加えて他のデバイスがある場合は、他のデバイスをクリックすると、選択したデバイスにメディア再生を転送できます。

既知の問題

  • Cast SDK 通知に切り替えると、ローカル再生用に作成されたメディア セッションは破棄され、再作成されます。

エントリ ポイント

メディア通知

アプリがローカル再生(ローカルでの再生)のために MediaSession でメディア通知を送信すると、メディア通知の右上に、現在コンテンツが再生されているデバイス名(スマートフォンのスピーカーなど)を示す通知チップが表示されます。通知チップをタップすると [出力スイッチャー]ダイアログのシステム UI が開きます

音量の設定

出力スイッチャー ダイアログのシステム UI は、デバイスの物理的な音量ボタンをクリックし、下部にある設定アイコンをタップして、「<キャスト デバイス> で <アプリ名> を再生」というテキストをタップすることでもトリガーできます。

ステップの概要

前提条件

  1. 既存の Android アプリを AndroidX に移行します。
  2. アプリの build.gradle を更新して、出力スイッチャーに必要最小限のバージョンの Android Sender 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() を使用し、メディアに関連するすべての MediaMetadata 定数を MediaMetadataCompat.Builder() に指定します。

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 定数を PlaybackStateCompat.Builder() に指定します。

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() コールバックを使用してキャスト SessionManagerListener をリッスンし、キャスト SessionManager コールバックを受信したときにアクションを処理する必要があります。[Output Switcher] ダイアログが開かれていて、アプリがフォアグラウンドにない場合、アプリは、これらのコールバックが存続していることを確認する必要があります。

バックグラウンド キャスト用に 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);
    }
  }
}

このアップデートにより、アプリがバックグラウンドで実行されている場合、ローカルからリモートへのキャストは従来のキャストと同じように動作し、Bluetooth デバイスからキャスト デバイスに切り替える際に追加の作業は必要ありません。

リモートからローカル

出力スイッチャーは、リモート再生からスマートフォンのスピーカーまたはローカルの Bluetooth デバイスに転送する機能を提供します。これを有効にするには、CastOptionssetRemoteToLocalEnabled フラグを true に設定します。

現在のセンダーデバイスが複数のセンダーとの既存のセッションに参加し、現在のメディアのローカル転送が許可されているかどうかをアプリで確認する必要がある場合、アプリは SessionTransferCallbackonTransferred コールバックを使用して SessionState を確認する必要があります。

setRemoteToLocalEnabled フラグを設定する

CastOptionssetRemoteToLocalEnabled を備えており、アクティブなキャスト セッションがある場合に、スマートフォン スピーカーとローカル Bluetooth デバイスを、出力スイッチャー ダイアログで転送先として表示 / 非表示にできます。

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 を登録し、センダーがローカルの再生に転送されたときに onTransferred コールバックと onTransferFailed コールバックをリッスンできます。

アプリが SessionTransferCallback の登録を解除すると、アプリは SessionTransferCallback を受け取らなくなります。

SessionTransferCallback は、既存の SessionManagerListener コールバックの拡張で、onSessionEnded がトリガーされた後にトリガーされます。したがって、リモートからローカルへのコールバックの順序は次のようになります。

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

アプリがバックグラウンドでキャストしているときに出力スイッチャーをメディア通知チップで開くことができるため、ローカルへの転送を、バックグラウンド再生に対応しているかどうかに応じて異なる方法で処理する必要があります。転送が失敗した場合は、エラーが発生するたびに onTransferFailed が起動します。

バックグラウンド再生に対応しているアプリ

バックグラウンドでの再生をサポートするアプリ(通常はオーディオ アプリ)の場合、ServiceMediaBrowserService など)を使用することをおすすめします。アプリがフォアグラウンドとバックグラウンドのどちらで実行されている場合でも、サービスは 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.
    }
  }
}