出力スイッチャー

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

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

サンプルをダウンロード

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

オーディオ アプリとは、Google Cast SDK Developer Console のレシーバー アプリの設定で Google Cast for Audio をサポートするアプリです。

出力スイッチャー UI

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

既知の問題

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

エントリ ポイント

メディアに関する通知

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

音量の設定

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

ステップの概要

前提条件

  1. 既存の Android アプリを AndroidX に移行します。
  2. 出力スイッチャーに必要な最小バージョンの Android Sender SDK を使用するようにアプリの build.gradle を更新します。
    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 定数を 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 で出力スイッチャーを有効にする

出力スイッチャーを有効にするには、アプリの AndroidManifest.xmlMediaTransferReceiver を追加する必要があります。有効になっていない場合、機能は有効になりません。また、リモートからローカルへの機能フラグも無効になります。

<application>
    ...
    <receiver
         android:name="androidx.mediarouter.media.MediaTransferReceiver"
         android:exported="true">
    </receiver>
    ...
</application>

MediaTransferReceiver は、システム UI でデバイス間のメディア転送を可能にするブロードキャスト レシーバです。詳しくは、MediaTransferReceiver のリファレンスをご覧ください。

ローカルからリモート

ユーザーがローカルからリモートの再生に切り替えると、Cast SDK は自動的にキャスト セッションを開始します。ただし、ローカルの再生を停止してキャスト デバイスにメディアを読み込むなど、ローカルからリモートへの切り替えを処理する必要があります。アプリは onSessionStarted() コールバックと onSessionEnded() コールバックを使用してキャスト SessionManagerListener をリッスンし、キャスト SessionManager コールバックを受信したときにアクションを処理する必要があります。アプリは、出力スイッチャー ダイアログが開いていて、アプリがフォアグラウンドにないときに、これらのコールバックがまだ有効であることを確認する必要があります。

バックグラウンド キャスト用の SessionManagerListener を更新

以前のキャストでは、アプリがフォアグラウンドにある場合、ローカルからリモートへのキャストがすでにサポートされています。一般的なキャスト エクスペリエンスは、ユーザーがアプリのキャスト アイコンをクリックし、メディアをストリーミングするデバイスを選択すると開始されます。この場合、アプリは 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 フラグを設定する

CastOptions には、アクティブなキャスト セッションがある場合に、[Output Switcher] ダイアログの転送先としてスマートフォン スピーカーとローカル Bluetooth デバイスを表示または非表示にする 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 を登録し、送信者がローカル再生に転送されたときに 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.
    }
  }
}