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

メディア セッションは、オーディオ プレーヤーや動画プレーヤーをユニバーサルに操作する方法を提供します。Media3 のデフォルト プレーヤーは、Player インターフェースを実装する ExoPlayer クラスです。メディア セッションをプレーヤーに接続すると、アプリはメディアの再生を外部にアドバタイズし、外部ソースから再生コマンドを受信できます。

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

メディア セッションを選択する場合

MediaSession を実装すると、ユーザーが再生を制御できるようになります。

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

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

  • 映画やライブテレビなどの長尺動画コンテンツをストリーミングしている。
  • ポッドキャストやミュージック プレイリストなどの長時間の音声コンテンツをストリーミングしている。
  • TV アプリを作成している。

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

  • ユーザーのエンゲージメントとインタラクションが極めて重要なショート動画を表示している。
  • アクティブな動画が 1 つではない(ユーザーがリストをスクロールしていて、複数の動画が画面に同時に表示される場合など)。
  • ユーザーに積極的に視聴してほしい、1 回限りの紹介動画または説明動画を再生している。
  • コンテンツがプライバシーに配慮されていて、外部プロセスがメディア メタデータにアクセスしないようにするには(ブラウザのシークレット モードなど)

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

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

メディア セッションは、それが管理するプレーヤーとともに存在します。メディア セッションは、Context オブジェクトと Player オブジェクトを使用して構築できます。メディア セッションとそれに関連付けられたプレーヤーを所有する ServiceActivity または 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 ライブラリは、プレーヤーの状態を使用してメディア セッションを自動的に更新します。そのため、プレーヤーからセッションへのマッピングを手動で処理する必要はありません。

これは、プレーヤー自体とは独立して PlaybackStateCompat を作成して維持する(たとえばエラーを示す)必要があった従来のアプローチからの脱却です。

一意のセッション 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 インスタンスが必要になります。デフォルトでは、新しく追加されたアイテムは、player.addMediaItem などのプレーヤー メソッドに URI が定義されている場合、自動的に転送されます。

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

  • MediaItem.id: メディアを識別する汎用 ID。
  • MediaItem.RequestMetadata.mediaUri: カスタム スキーマを使用できるリクエスト URI。プレーヤーで直接再生できるとは限りません。
  • MediaItem.RequestMetadata.searchQuery: テキスト検索クエリ(Google アシスタントなど)。
  • MediaItem.MediaMetadata: 構造化されたメタデータ(「タイトル」、「アーティスト」など)。

まったく新しい再生リストの他のカスタマイズ オプションが必要な場合は、onSetMediaItems() をオーバーライドして、再生リストの開始アイテムと位置を定義できます。たとえば、リクエストされた 1 つのアイテムを再生リスト全体に拡張し、最初にリクエストしたアイテムのインデックスから開始するようにプレーヤーに指示できます。この機能を使用した onSetMediaItems() の実装例は、セッション デモアプリにあります。

カスタム レイアウトとカスタム コマンドを管理する

以降のセクションでは、カスタム コマンド ボタンのカスタム レイアウトをクライアント アプリにアドバタイズし、コントローラがカスタム コマンドを送信することを承認する方法について説明します。

セッションのカスタム レイアウトを定義する

ユーザーに表示する再生コントロールをクライアント アプリに示すには、サービスの onCreate() メソッドで MediaSession を作成するときに、セッションのカスタム レイアウトを設定します。

Kotlin

override fun onCreate() {
  super.onCreate()

  val likeButton = CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build()
  val favoriteButton = CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(SessionCommand(SAVE_TO_FAVORITES, Bundle()))
    .build()

  session =
    MediaSession.Builder(this, player)
      .setCallback(CustomMediaSessionCallback())
      .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
      .build()
}

Java

@Override
public void onCreate() {
  super.onCreate();

  CommandButton likeButton = new CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build();
  CommandButton favoriteButton = new CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
    .build();

  Player player = new ExoPlayer.Builder(this).build();
  mediaSession =
      new MediaSession.Builder(this, player)
          .setCallback(new CustomMediaSessionCallback())
          .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
          .build();
}

利用可能なプレーヤーとカスタム コマンドを宣言する

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

メディア セッションに接続するときに MediaController で使用できるカスタム セッション コマンドを定義できます。そのためには、MediaSession.Callback.onConnect() をオーバーライドします。onConnect コールバック メソッドで MediaController からの接続リクエストを受け入れるときに、使用可能なコマンドのセットを構成して返します。

Kotlin

private inner 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 inner 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 プロパティを使用します。これにより、特定のコマンドがシステム、独自のアプリ、他のクライアント アプリから実行された場合に、そのコマンドに対するアプリの動作を調整できます。

ユーザー操作後にカスタム レイアウトを更新する

カスタム コマンドなどのプレーヤー操作を処理した後で、コントローラ UI に表示されるレイアウトを更新したい場合があります。典型的な例は、このボタンに関連付けられたアクションをトリガーした後にアイコンを変更する切り替えボタンです。レイアウトを更新するには、MediaSession.setCustomLayout を使用します。

Kotlin

val removeFromFavoritesButton = CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(SessionCommand(REMOVE_FROM_FAVORITES, Bundle()))
  .build()
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton))

Java

CommandButton removeFromFavoritesButton = new CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(new SessionCommand(REMOVE_FROM_FAVORITES, new Bundle()))
  .build();
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton));

再生コマンドの動作をカスタマイズする

Player インターフェースで定義されたコマンド(play()seekToNext() など)の動作をカスタマイズするには、PlayerForwardingPlayer でラップします。

Kotlin

val player = ExoPlayer.Builder(context).build()

val forwardingPlayer = object : ForwardingPlayer(player) {
  override fun play() {
    // Add custom logic
    super.play()
  }

  override fun setPlayWhenReady(playWhenReady: Boolean) {
    // Add custom logic
    super.setPlayWhenReady(playWhenReady)
  }
}

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

Java

ExoPlayer player = new ExoPlayer.Builder(context).build();

ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player) {
  @Override
  public void play() {
    // Add custom logic
    super.play();
  }

  @Override
  public void setPlayWhenReady(boolean playWhenReady) {
    // Add custom logic
    super.setPlayWhenReady(playWhenReady);
  }
};

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

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

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

Player メソッドの呼び出しが MediaController から行われた場合は、MediaSession.controllerForCurrentRequest で送信元を特定し、現在のリクエストの ControllerInfo を取得できます。

Kotlin

class CallerAwareForwardingPlayer(player: Player) :
  ForwardingPlayer(player) {

  override fun seekToNext() {
    Log.d(
      "caller",
      "seekToNext called from package ${session.controllerForCurrentRequest?.packageName}"
    )
    super.seekToNext()
  }
}

Java

public class CallerAwareForwardingPlayer extends ForwardingPlayer {
  public CallerAwareForwardingPlayer(Player player) {
    super(player);
  }

  @Override
  public void seekToNext() {
    Log.d(
        "caller",
        "seekToNext called from package: "
            + session.getControllerForCurrentRequest().getPackageName());
    super.seekToNext();
  }
}

メディアボタンに反応する

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

アプリは MediaSession.Callback.onMediaButtonEvent(Intent) をオーバーライドすることで、デフォルトの動作をオーバーライドできます。そのような場合、アプリはすべての API の詳細を独自に処理できる(または処理する必要があります)。