MediaSession を使用して再生を制御およびアドバタイズする

MediaSession は、音声や動画のプレーヤーを操作するための汎用的な手段を提供します。Media3 では、デフォルトのプレーヤーは ExoPlayer クラスで、Player インターフェースを実装しています。メディア セッションをプレーヤーに接続すると、アプリはメディアの再生を外部に宣伝し、外部ソースから再生コマンドを受信できます。

コマンドは、ヘッドセットの再生ボタンやテレビのリモコンなどの物理ボタンから発信される場合があります。また、メディア コントローラを備えたクライアント アプリ(Google アシスタントに「一時停止」を指示するアプリなど)から送信されることもあります。メディア セッションは、これらのコマンドをメディアアプリのプレーヤーに委任します。

メディア セッションを選択するタイミング

MediaSession を実装すると、ユーザーは再生を制御できます。

  • ヘッドフォンで。多くの場合、ヘッドフォンにボタンやタップ操作があり、ユーザーがメディアの再生や一時停止、次のトラックや前のトラックへの移動を行うことができます。
  • Google アシスタントに話しかける。一般的なパターンは、「OK Google, 一時停止して」と話しかけて、デバイスで現在再生中のメディアを一時停止することです。
  • Wear OS スマートウォッチで。これにより、スマートフォンで再生中に、最も一般的な再生コントロールに簡単にアクセスできるようになります。
  • メディア コントロール。このカルーセルには、実行中の各メディア セッションのコントロールが表示されます。
  • [テレビ] に移動します。物理的な再生ボタン、プラットフォームの再生コントロール、電源管理によるアクションを許可します(たとえば、テレビ、サウンドバー、A/V レシーバーの電源がオフになった場合や入力が切り替わった場合、アプリで再生が停止する必要があります)。
  • 再生に影響を与える必要がある他の外部プロセス。

これは多くのユースケースに適しています。特に、次の場合は MediaSession の使用を強く検討してください。

  • 映画やライブテレビなどの長尺動画コンテンツをストリーミングしている。
  • ポッドキャストや音楽プレイリストなどの長尺オーディオ コンテンツをストリーミングしている。
  • テレビアプリを作成している。

ただし、すべてのユースケースが MediaSession に適しているわけではありません。次のような場合は、Player のみを使用することをおすすめします。

  • ショート フォーム コンテンツを表示している場合、外部コントロールやバックグラウンド再生は必要ありません。
  • アクティブな動画が 1 つもない(ユーザーがリストをスクロールしていて、画面に複数の動画が表示されているなど)。
  • 1 回限りの紹介動画や説明動画を再生している場合、外部再生コントロールを必要とせずにユーザーが積極的に視聴することを想定しています。
  • コンテンツがプライバシーに関連するもので、外部プロセスがメディア メタデータにアクセスすることを望まない(ブラウザのシークレット モードなど)。

ユースケースが上記のいずれにも当てはまらない場合は、ユーザーがコンテンツをアクティブに操作していないときにアプリが再生を続行しても問題ないかどうかを検討してください。答えが「はい」の場合は、MediaSession を選択することをおすすめします。答えが「いいえ」の場合は、代わりに Player を使用することをおすすめします。

メディア セッションを作成する

メディア セッションは、それが管理するプレーヤーとともに存在します。メディア セッションは、Context オブジェクトと Player オブジェクトを使用して作成できます。メディア セッションの作成と初期化は、必要に応じて行います。たとえば、Activity または FragmentonStart() または onResume() ライフサイクル メソッド、またはメディア セッションと関連プレーヤーを所有する ServiceonCreate() メソッドなどです。

メディア セッションを作成するには、次のように Player を初期化して MediaSession.Builder に渡します。

Kotlin

val player = ExoPlayer.Builder(context).build()
val mediaSession = MediaSession.Builder(context, player).build()

Java

ExoPlayer player = new ExoPlayer.Builder(context).build();
MediaSession mediaSession = new MediaSession.Builder(context, player).build();

状態の自動処理

Media3 ライブラリは、プレーヤーの状態を使用してメディア セッションを自動的に更新します。そのため、プレーヤーからセッションへのマッピングを手動で処理する必要はありません。

これは、エラーを示すなど、プレーヤー自体から独立して PlaybackState を作成して維持する必要があったプラットフォーム メディア セッションとは異なります。

一意のセッション ID

デフォルトでは、MediaSession.Builder はセッション ID として空の文字列を持つセッションを作成します。アプリが 1 つのセッション インスタンスのみを作成する場合(これが最も一般的なケースです)は、これで十分です。

アプリが複数のセッション インスタンスを同時に管理する場合は、各セッションのセッション ID が一意であることを確認する必要があります。セッション ID は、MediaSession.Builder.setId(String id) を使用してセッションを作成するときに設定できます。

IllegalStateException がアプリをクラッシュさせ、エラー メッセージ IllegalStateException: Session ID must be unique. ID= が表示される場合は、同じ ID の以前に作成されたインスタンスが解放される前に、セッションが予期せず作成された可能性があります。プログラミング エラーによってセッションが漏洩しないように、このようなケースは検出され、例外をスローして通知されます。

他のクライアントに管理権限を付与する

メディア セッションは、再生を制御するための鍵となります。これにより、外部ソースからメディアの再生を行うプレーヤーにコマンドを転送できます。これらのソースには、ヘッドセットやテレビのリモコンの再生ボタンなどの物理ボタンや、Google アシスタントに「一時停止」を指示するなどの間接的なコマンドがあります。同様に、通知やロック画面での操作を容易にするために Android システムへのアクセス権を付与したり、ウォッチフェイスから再生を操作できるように Wear OS スマートウォッチへのアクセス権を付与したりすることもできます。外部クライアントは、メディア コントローラを使用してメディアアプリに再生コマンドを送信できます。これらのコマンドはメディア セッションによって受信され、最終的にメディア プレーヤーにコマンドが委任されます。

MediaSession と MediaController の間のインタラクションを示す図。
図 1: メディア コントローラは、外部ソースからメディア セッションへのコマンドの受け渡しを容易にします。

コントローラがメディア セッションに接続しようとすると、onConnect() メソッドが呼び出されます。提供された ControllerInfo を使用して、リクエストを承認するか拒否するかを決定できます。接続リクエストを承認する例については、カスタム コマンドを宣言するをご覧ください。

接続すると、コントローラはセッションに再生コマンドを送信できます。セッションは、これらのコマンドをプレーヤーに委任します。Player インターフェースで定義された再生コマンドと再生リスト コマンドは、セッションによって自動的に処理されます。

他のコールバック メソッドを使用すると、カスタム コマンドのリクエストやプレイリストの変更などを処理できます。これらのコールバックにも同様に ControllerInfo オブジェクトが含まれているため、コントローラごとに各リクエストへの応答方法を変更できます。

再生リストを変更する

メディア セッションは、再生リストの ExoPlayer ガイドで説明されているように、プレーヤーの再生リストを直接変更できます。コントローラは、COMMAND_SET_MEDIA_ITEM または COMMAND_CHANGE_MEDIA_ITEMS がコントローラで使用可能な場合、再生リストを変更することもできます。

通常、再生リストに新しいアイテムを追加する場合、再生できるようにするには、定義された URI を持つ MediaItem インスタンスが必要です。デフォルトでは、URI が定義されている場合、新しく追加されたアイテムは player.addMediaItem などのプレーヤー メソッドに自動的に転送されます。

プレーヤーに追加された MediaItem インスタンスをカスタマイズする場合は、onAddMediaItems() をオーバーライドできます。この手順は、定義された URI なしでメディアをリクエストするコントローラをサポートする場合に必要です。通常、MediaItem には、リクエストされたメディアを記述する次のフィールドが 1 つ以上設定されています。

  • MediaItem.id: メディアを識別する汎用 ID。
  • MediaItem.RequestMetadata.mediaUri: カスタム スキーマを使用する可能性があり、プレーヤーで直接再生できるとは限らないリクエスト URI。
  • MediaItem.RequestMetadata.searchQuery: テキスト検索クエリ(Google アシスタントなど)。
  • MediaItem.MediaMetadata: タイトルやアーティストなどの構造化メタデータ。

まったく新しい再生リストのカスタマイズ オプションをさらに追加するには、onSetMediaItems() をオーバーライドして、再生リスト内の開始アイテムと位置を定義します。たとえば、リクエストされた 1 つのアイテムを再生リスト全体に拡張し、元のリクエストされたアイテムのインデックスから再生を開始するようにプレーヤーに指示できます。この機能を含む onSetMediaItems() のサンプル実装は、セッションのデモアプリで確認できます。

メディアボタンの設定を管理する

システム UI、Android Auto、Wear OS などのすべてのコントローラは、ユーザーに表示するボタンを独自に決定できます。ユーザーに表示する再生コントロールを指定するには、MediaSessionメディアボタンの設定を指定します。これらの設定は、CommandButton インスタンスの順序付きリストで構成され、それぞれがユーザー インターフェースのボタンの設定を定義します。

コマンドボタンを定義する

CommandButton インスタンスは、メディアボタンの設定を定義するために使用されます。すべてのボタンは、目的の UI 要素の 3 つの要素を定義します。

  1. アイコン: 外観を定義します。CommandButton.Builder を作成するときに、アイコンを事前定義された定数に設定する必要があります。これは実際のビットマップや画像リソースではありません。汎用定数は、コントローラが独自の UI 内で一貫した外観と操作感を実現するために適切なリソースを選択するのに役立ちます。事前定義されたアイコン定数でユースケースに合うものがない場合は、代わりに setCustomIconResId を使用できます。
  2. コマンド: ユーザーがボタンを操作したときにトリガーされるアクションを定義します。Player.Command には setPlayerCommand を使用し、事前定義またはカスタムの SessionCommand には setSessionCommand を使用できます。
  3. スロット: コントローラ UI でボタンを配置する場所を定義します。このフィールドは省略可能で、[アイコン] と [コマンド] に基づいて自動的に設定されます。たとえば、デフォルトの「オーバーフロー」領域ではなく、UI の「前方」ナビゲーション領域にボタンを表示するように指定できます。

Kotlin

val button =
  CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_15)
    .setSessionCommand(SessionCommand(CUSTOM_ACTION_ID, Bundle.EMPTY))
    .setSlots(CommandButton.SLOT_FORWARD)
    .build()

Java

CommandButton button =
    new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_15)
        .setSessionCommand(new SessionCommand(CUSTOM_ACTION_ID, Bundle.EMPTY))
        .setSlots(CommandButton.SLOT_FORWARD)
        .build();

メディアボタンの設定が解決されると、次のアルゴリズムが適用されます。

  1. メディアボタンの設定CommandButton ごとに、使用可能な最初のスロットにボタンを配置します。
  2. 中央、前方、後方のスロットのいずれかにボタンが配置されていない場合は、このスロットにデフォルトのボタンを追加します。

CommandButton.DisplayConstraints を使用すると、UI ディスプレイの制約に応じてメディアボタンの設定が解決される方法のプレビューを生成できます。

メディアボタンの設定を行う

メディアボタンの設定を最も簡単に行う方法は、MediaSession の作成時にリストを定義することです。または、MediaSession.Callback.onConnect をオーバーライドして、接続されている各コントローラのメディアボタンの設定をカスタマイズすることもできます。

Kotlin

val mediaSession =
  MediaSession.Builder(context, player)
    .setMediaButtonPreferences(ImmutableList.of(likeButton, favoriteButton))
    .build()

Java

MediaSession mediaSession =
  new MediaSession.Builder(context, player)
      .setMediaButtonPreferences(ImmutableList.of(likeButton, favoriteButton))
      .build();

ユーザー操作後にメディアボタンの設定を更新する

プレーヤーとのインタラクションを処理した後、コントローラ UI に表示されるボタンを更新できます。典型的な例としては、このボタンに関連付けられたアクションをトリガーした後にアイコンとアクションを変更する切り替えボタンがあります。メディアボタンの設定を更新するには、MediaSession.setMediaButtonPreferences を使用して、すべてのコントローラまたは特定のコントローラの設定を更新します。

Kotlin

// Handle "favoritesButton" action, replace by opposite button
mediaSession.setMediaButtonPreferences(
  ImmutableList.of(likeButton, removeFromFavoritesButton))

Java

// Handle "favoritesButton" action, replace by opposite button
mediaSession.setMediaButtonPreferences(
    ImmutableList.of(likeButton, removeFromFavoritesButton));

カスタム コマンドを追加してデフォルトの動作をカスタマイズする

使用可能なプレーヤー コマンドはカスタム コマンドで拡張できます。また、受信したプレーヤー コマンドとメディアボタンをインターセプトして、デフォルトの動作を変更することもできます。

カスタム コマンドを宣言して処理する

メディア アプリケーションでは、メディアボタンの設定で使用できるカスタム コマンドを定義できます。たとえば、ユーザーがメディア アイテムをお気に入りのアイテムのリストに保存できるようにするボタンを実装できます。MediaController がカスタム コマンドを送信し、MediaSession.Callback が受信します。

カスタム コマンドを定義するには、MediaSession.Callback.onConnect() をオーバーライドして、接続されている各コントローラで使用可能なカスタム コマンドを設定する必要があります。

Kotlin

private class CustomMediaSessionCallback: MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  override fun onConnect(
    session: MediaSession,
    controller: MediaSession.ControllerInfo
  ): MediaSession.ConnectionResult {
    val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
        .add(SessionCommand(SAVE_TO_FAVORITES, Bundle.EMPTY))
        .build()
    return AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build()
  }
}

Java

class CustomMediaSessionCallback implements MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  @Override
  public ConnectionResult onConnect(
    MediaSession session,
    ControllerInfo controller) {
    SessionCommands sessionCommands =
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
            .add(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
            .build();
    return new AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build();
  }
}

MediaController からカスタム コマンド リクエストを受信するには、CallbackonCustomCommand() メソッドをオーバーライドします。

Kotlin

private class CustomMediaSessionCallback: MediaSession.Callback {
  ...
  override fun onCustomCommand(
    session: MediaSession,
    controller: MediaSession.ControllerInfo,
    customCommand: SessionCommand,
    args: Bundle
  ): ListenableFuture<SessionResult> {
    if (customCommand.customAction == SAVE_TO_FAVORITES) {
      // Do custom logic here
      saveToFavorites(session.player.currentMediaItem)
      return Futures.immediateFuture(
        SessionResult(SessionResult.RESULT_SUCCESS)
      )
    }
    ...
  }
}

Java

class CustomMediaSessionCallback implements MediaSession.Callback {
  ...
  @Override
  public ListenableFuture<SessionResult> onCustomCommand(
    MediaSession session, 
    ControllerInfo controller,
    SessionCommand customCommand,
    Bundle args
  ) {
    if(customCommand.customAction.equals(SAVE_TO_FAVORITES)) {
      // Do custom logic here
      saveToFavorites(session.getPlayer().getCurrentMediaItem());
      return Futures.immediateFuture(
        new SessionResult(SessionResult.RESULT_SUCCESS)
      );
    }
    ...
  }
}

Callback メソッドに渡される MediaSession.ControllerInfo オブジェクトの packageName プロパティを使用して、リクエストを行っているメディア コントローラを追跡できます。これにより、システム、独自のアプリ、または他のクライアント アプリから発信された特定のコマンドに応じて、アプリの動作を調整できます。

デフォルトのプレーヤー コマンドをカスタマイズする

デフォルト コマンドと状態処理はすべて、MediaSession にある Player に委任されます。Player インターフェースで定義されたコマンドの動作(play()seekToNext() など)をカスタマイズするには、PlayerForwardingSimpleBasePlayer でラップしてから MediaSession に渡します。

Kotlin

val player = (logic to build a Player instance)

val forwardingPlayer = object : ForwardingSimpleBasePlayer(player) {
  // Customizations
}

val mediaSession = MediaSession.Builder(context, forwardingPlayer).build()

Java

ExoPlayer player = (logic to build a Player instance)

ForwardingSimpleBasePlayer forwardingPlayer =
    new ForwardingSimpleBasePlayer(player) {
      // Customizations
    };

MediaSession mediaSession =
  new MediaSession.Builder(context, forwardingPlayer).build();

ForwardingSimpleBasePlayer の詳細については、カスタマイズに関する ExoPlayer ガイドをご覧ください。

プレーヤー コマンドをリクエストしたコントローラを特定する

Player メソッドの呼び出しが MediaController によって開始された場合は、MediaSession.controllerForCurrentRequest を使用して送信元を特定し、現在のリクエストの ControllerInfo を取得できます。

Kotlin

class CallerAwarePlayer(player: Player) :
  ForwardingSimpleBasePlayer(player) {

  override fun handleSeek(
    mediaItemIndex: Int,
    positionMs: Long,
    seekCommand: Int,
  ): ListenableFuture<*> {
    Log.d(
      "caller",
      "seek operation from package ${session.controllerForCurrentRequest?.packageName}",
    )
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand)
  }
}

Java

public class CallerAwarePlayer extends ForwardingSimpleBasePlayer {
  public CallerAwarePlayer(Player player) {
    super(player);
  }

  @Override
  protected ListenableFuture<?> handleSeek(
        int mediaItemIndex, long positionMs, int seekCommand) {
    Log.d(
        "caller",
        "seek operation from package: "
            + session.getControllerForCurrentRequest().getPackageName());
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand);
  }
}

メディアボタンの処理をカスタマイズする

メディアボタンとは、Android デバイスや周辺デバイスにあるハードウェア ボタンのことです。たとえば、Bluetooth ヘッドセットの再生/一時停止ボタンなどです。Media3 は、セッションに到着したメディアボタン イベントを処理し、セッション プレーヤーで適切な Player メソッドを呼び出します。

受信したすべてのメディアボタン イベントを、対応する Player メソッドで処理することをおすすめします。より高度なユースケースでは、メディアボタン イベントを MediaSession.Callback.onMediaButtonEvent(Intent) でインターセプトできます。

エラー処理と報告

セッションがエミットしてコントローラに報告するエラーには、次の 2 種類があります。致命的なエラーは、再生を中断するセッション プレーヤーの技術的な再生障害を報告します。致命的なエラーは、発生すると自動的にコントローラに報告されます。致命的でないエラーは、技術的でないエラーまたはポリシーエラーで、再生を中断せず、アプリによってコントローラに手動で送信されます。

致命的な再生エラー

致命的な再生エラーは、プレーヤーからセッションに報告され、Player.Listener.onPlayerError(PlaybackException)Player.Listener.onPlayerErrorChanged(@Nullable PlaybackException) を介してコントローラに報告されます。

この場合、再生ステータスは STATE_IDLE に遷移し、MediaController.getPlaybackError() は遷移の原因となった PlaybackException を返します。コントローラは PlayerException.errorCode を検査して、エラーの原因に関する情報を取得できます。

相互運用性を確保するため、致命的なエラーは、状態を STATE_ERROR に移行し、PlaybackException に従ってエラーコードとメッセージを設定することで、プラットフォーム セッションに複製されます。

致命的なエラーのカスタマイズ

ローカライズされた有意な情報をユーザーに提供するには、セッションのビルド時に ForwardingPlayer を使用して、致命的な再生エラーのエラーコード、エラー メッセージ、エラーの追加情報をカスタマイズします。

Kotlin

val forwardingPlayer = ErrorForwardingPlayer(player)
val session = MediaSession.Builder(context, forwardingPlayer).build()

Java

Player forwardingPlayer = new ErrorForwardingPlayer(player);
MediaSession session =
    new MediaSession.Builder(context, forwardingPlayer).build();

転送プレーヤーは ForwardingSimpleBasePlayer を使用してエラーをインターセプトし、エラーコード、メッセージ、エクストラをカスタマイズできます。同様に、元のプレーヤーに存在しない新しいエラーを生成することもできます。

Kotlin

class ErrorForwardingPlayer (private val context: Context, player: Player) :
    ForwardingSimpleBasePlayer(player) {

  override fun getState(): State {
    var state = super.getState()
    if (state.playerError != null) {
      state =
        state.buildUpon()
          .setPlayerError(customizePlaybackException(state.playerError!!))
          .build()
    }
    return state
  }

  fun customizePlaybackException(error: PlaybackException): PlaybackException {
    val buttonLabel: String
    val errorMessage: String
    when (error.errorCode) {
      PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
        buttonLabel = context.getString(R.string.err_button_label_restart_stream)
        errorMessage = context.getString(R.string.err_msg_behind_live_window)
      }
      else -> {
        buttonLabel = context.getString(R.string.err_button_label_ok)
        errorMessage = context.getString(R.string.err_message_default)
      }
    }
    val extras = Bundle()
    extras.putString("button_label", buttonLabel)
    return PlaybackException(errorMessage, error.cause, error.errorCode, extras)
  }
}

Java

class ErrorForwardingPlayer extends ForwardingSimpleBasePlayer {

  private final Context context;

  public ErrorForwardingPlayer(Context context, Player player) {
    super(player);
    this.context = context;
  }

  @Override
  protected State getState() {
    State state = super.getState();
    if (state.playerError != null) {
      state =
          state.buildUpon()
              .setPlayerError(customizePlaybackException(state.playerError))
              .build();
    }
    return state;
  }

  private PlaybackException customizePlaybackException(PlaybackException error) {
    String buttonLabel;
    String errorMessage;
    switch (error.errorCode) {
      case PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW:
        buttonLabel = context.getString(R.string.err_button_label_restart_stream);
        errorMessage = context.getString(R.string.err_msg_behind_live_window);
        break;
      default:
        buttonLabel = context.getString(R.string.err_button_label_ok);
        errorMessage = context.getString(R.string.err_message_default);
        break;
    }
    Bundle extras = new Bundle();
    extras.putString("button_label", buttonLabel);
    return new PlaybackException(errorMessage, error.getCause(), error.errorCode, extras);
  }
}

致命的でないエラー

技術的な例外から発生していない致命的でないエラーは、アプリからすべてのコントローラまたは特定のコントローラに送信できます。

Kotlin

val sessionError = SessionError(
  SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
  context.getString(R.string.error_message_authentication_expired),
)

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError)

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
mediaSession.mediaNotificationControllerInfo?.let {
  mediaSession.sendError(it, sessionError)
}

Java

SessionError sessionError = new SessionError(
    SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
    context.getString(R.string.error_message_authentication_expired));

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError);

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
ControllerInfo mediaNotificationControllerInfo =
    mediaSession.getMediaNotificationControllerInfo();
if (mediaNotificationControllerInfo != null) {
  mediaSession.sendError(mediaNotificationControllerInfo, sessionError);
}

致命的でないエラーがメディア通知コントローラに送信されると、エラーコードとエラー メッセージがプラットフォーム メディア セッションに複製されますが、PlaybackState.stateSTATE_ERROR に変更されません。

致命的でないエラーを受け取る

MediaController は、MediaController.Listener.onError を実装することで致命的でないエラーを受け取ります。

Kotlin

val future = MediaController.Builder(context, sessionToken)
  .setListener(object : MediaController.Listener {
    override fun onError(controller: MediaController, sessionError: SessionError) {
      // Handle nonfatal error.
    }
  })
  .buildAsync()

Java

MediaController.Builder future =
    new MediaController.Builder(context, sessionToken)
        .setListener(
            new MediaController.Listener() {
              @Override
              public void onError(MediaController controller, SessionError sessionError) {
                // Handle nonfatal error.
              }
            });