メディア コントロール

Android のメディア コントロールは、クイック設定の近くに配置されています。複数のアプリからのセッションは、スワイプ可能なカルーセルに配列されます。カルーセルには、セッションが次の順序で表示されます。

  • スマートフォンでローカルに再生されるストリーム
  • リモート ストリーム(外部デバイスやキャスト セッションで検出されたストリームなど)
  • 以前の再開可能なセッション(最後に再生された順)

Android 13(API レベル 33)以降では、メディアを再生するアプリでユーザーが多様なメディア コントロールを利用できるようにするため、メディア コントロールのアクション ボタンは Player 状態から導出されます。

これにより、デバイス間で一貫したメディア コントロール セットと洗練されたメディア コントロール エクスペリエンスを提供できます。

図 1 に、スマートフォンとタブレットでの表示例をそれぞれ示します。

スマートフォンとタブレットでのメディア コントロールの表示例(ボタンがどのように表示されるかをサンプル トラックで例示しています)
図 1: スマートフォンとタブレットでのメディア コントロールの表示

次の表に示すように、Player の状態に基づいて最大 5 つのアクション ボタンが表示されます。コンパクト モードでは、最初の 3 つのアクション スロットのみが表示されます。これは、Auto、アシスタント、Wear OS といった他の Android プラットフォームにおけるメディア コントロールのレンダリング方法と適合します。

スロット 条件 アクション
1 playWhenReady が false の場合、または現在の再生状態STATE_ENDED の場合。 再生
playWhenReady が true で、現在の再生状態STATE_BUFFERING の場合。 読み込み中アイコン
playWhenReady が true で、現在の再生状態STATE_READY である。 一時停止
2 プレーヤー コマンド COMMAND_SEEK_TO_PREVIOUS または COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM を使用できます。 前へ
プレーヤー コマンド COMMAND_SEEK_TO_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM も使用できません。まだ配置されていないカスタム レイアウトのカスタム コマンドでスロットを埋めることができます。 カスタム
セッション エクストラには、キー EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREVtrue ブール値が含まれます。 なし
3 プレーヤー コマンド COMMAND_SEEK_TO_NEXT または COMMAND_SEEK_TO_NEXT_MEDIA_ITEM を使用できます。 次へ
プレーヤー コマンド COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM も使用できません。まだ配置されていないカスタム レイアウトのカスタム コマンドでスロットを埋めることができます。 カスタム
セッション エクストラには、キー EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXTtrue ブール値が含まれます。 なし
4 まだ配置されていないカスタム レイアウトのカスタム コマンドで、スロットを埋めることができます。 カスタム
5 まだ配置されていないカスタム レイアウトのカスタム コマンドで、スロットを埋めることができます。 カスタム

カスタム コマンドは、カスタム レイアウトに追加された順序で配置されます。

コマンドボタンをカスタマイズする

Jetpack Media3 でシステム メディア コントロールをカスタマイズするには、MediaSessionService を実装するときに、セッションのカスタム レイアウトとコントローラの使用可能なコマンドを設定します。

  1. onCreate()MediaSession をビルドし、コマンドボタンのカスタム レイアウトを定義します。

  2. MediaSession.Callback.onConnect() で、ConnectionResult で使用可能なコマンド(カスタム コマンドを含む)を定義して、コントローラを承認します。

  3. MediaSession.Callback.onCustomCommand() で、ユーザーが選択したカスタム コマンドに応答します。

Kotlin

class PlaybackService : MediaSessionService() {
  private val customCommandFavorites = SessionCommand(ACTION_FAVORITES, Bundle.EMPTY)
  private var mediaSession: MediaSession? = null

  override fun onCreate() {
    super.onCreate()
    val favoriteButton =
      CommandButton.Builder()
        .setDisplayName("Save to favorites")
        .setIconResId(R.drawable.favorite_icon)
        .setSessionCommand(customCommandFavorites)
        .build()
    val player = ExoPlayer.Builder(this).build()
    // Build the session with a custom layout.
    mediaSession =
      MediaSession.Builder(this, player)
        .setCallback(MyCallback())
        .setCustomLayout(ImmutableList.of(favoriteButton))
        .build()
  }

  private inner class MyCallback : MediaSession.Callback {
    override fun onConnect(
      session: MediaSession,
      controller: MediaSession.ControllerInfo
    ): ConnectionResult {
    // Set available player and session commands.
    return AcceptedResultBuilder(session)
      .setAvailablePlayerCommands(
        ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
          .remove(COMMAND_SEEK_TO_NEXT)
          .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
          .remove(COMMAND_SEEK_TO_PREVIOUS)
          .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
          .build()
      )
      .setAvailableSessionCommands(
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
          .add(customCommandFavorites)
          .build()
      )
      .build()
    }

    override fun onCustomCommand(
      session: MediaSession,
      controller: MediaSession.ControllerInfo,
      customCommand: SessionCommand,
      args: Bundle
    ): ListenableFuture {
      if (customCommand.customAction == ACTION_FAVORITES) {
        // Do custom logic here
        saveToFavorites(session.player.currentMediaItem)
        return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
      }
      return super.onCustomCommand(session, controller, customCommand, args)
    }
  }
}

Java

public class PlaybackService extends MediaSessionService {
  private static final SessionCommand CUSTOM_COMMAND_FAVORITES =
      new SessionCommand("ACTION_FAVORITES", Bundle.EMPTY);
  @Nullable private MediaSession mediaSession;

  public void onCreate() {
    super.onCreate();
    CommandButton favoriteButton =
        new CommandButton.Builder()
            .setDisplayName("Save to favorites")
            .setIconResId(R.drawable.favorite_icon)
            .setSessionCommand(CUSTOM_COMMAND_FAVORITES)
            .build();
    Player player = new ExoPlayer.Builder(this).build();
    // Build the session with a custom layout.
    mediaSession =
        new MediaSession.Builder(this, player)
            .setCallback(new MyCallback())
            .setCustomLayout(ImmutableList.of(favoriteButton))
            .build();
  }

  private static class MyCallback implements MediaSession.Callback {
    @Override
    public ConnectionResult onConnect(
        MediaSession session, MediaSession.ControllerInfo controller) {
      // Set available player and session commands.
      return new AcceptedResultBuilder(session)
          .setAvailablePlayerCommands(
              ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
                .remove(COMMAND_SEEK_TO_NEXT)
                .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
                .remove(COMMAND_SEEK_TO_PREVIOUS)
                .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
                .build())
          .setAvailableSessionCommands(
              ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
                .add(CUSTOM_COMMAND_FAVORITES)
                .build())
          .build();
    }

    public ListenableFuture onCustomCommand(
        MediaSession session,
        MediaSession.ControllerInfo controller,
        SessionCommand customCommand,
        Bundle args) {
      if (customCommand.customAction.equals(CUSTOM_COMMAND_FAVORITES.customAction)) {
        // Do custom logic here
        saveToFavorites(session.getPlayer().getCurrentMediaItem());
        return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
      }
      return MediaSession.Callback.super.onCustomCommand(
          session, controller, customCommand, args);
    }
  }
}

システムなどのクライアントがメディアアプリに接続できるように MediaSession を構成する方法については、他のクライアントに制御権を付与するをご覧ください。

Jetpack Media3 では、MediaSession を実装すると、PlaybackState がメディア プレーヤーと自動的に同期されます。同様に、MediaSessionService を実装すると、ライブラリが自動的に MediaStyle 通知を公開し、最新の状態に保ちます。

アクション ボタンに応答する

ユーザーがシステムのメディア コントロールでアクション ボタンをタップすると、システムの MediaControllerMediaSession に再生コマンドを送信します。MediaSession は、これらのコマンドをプレーヤーに委任します。Media3 の Player インターフェースで定義されたコマンドは、メディア セッションによって自動的に処理されます。

カスタム コマンドに応答する方法については、カスタム コマンドを追加するをご覧ください。

Android 13 より前の動作

下位互換性を維持するため、システム UI は、Android 13 をターゲットとして更新されていないアプリまたは PlaybackState 情報を含まないアプリに対し、通知アクションを使用する代替レイアウトを引き続き提供します。アクション ボタンは、MediaStyle 通知にアタッチされた Notification.Action リストから派生します。最大 5 つのアクションが、追加された順序で表示されます。コンパクト モードでは、setShowActionsInCompactView() に渡された値によって、最大 3 つのボタンが表示されます。

カスタム アクションは、PlaybackState に追加された順序で配置されます。

次のコード例は、MediaStyle 通知にアクションを追加する方法を示しています。

Kotlin

import androidx.core.app.NotificationCompat
import androidx.media3.session.MediaStyleNotificationHelper

var notification = NotificationCompat.Builder(context, CHANNEL_ID)
        // Show controls on lock screen even when user hides sensitive content.
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        // Add media control buttons that invoke intents in your media service
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent) // #0
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent) // #1
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent) // #2
        // Apply the media style template
        .setStyle(MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build()

Java

import androidx.core.app.NotificationCompat;
import androidx.media3.session.MediaStyleNotificationHelper;

NotificationCompat.Builder notification = new NotificationCompat.Builder(context, CHANNEL_ID)
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent)
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent)
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent)
        .setStyle(new MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build();

メディアの再開をサポートする

メディアの再開を使用すると、ユーザーはアプリを起動しなくてもカルーセルから以前のセッションを再開できます。再生が開始されると、ユーザーは通常の方法でメディア コントロールを操作できます。

再生再開機能は、設定アプリの [サウンド > メディア] オプションでオンまたはオフにできます。展開したカルーセルをスワイプした後に表示される歯車アイコンをタップして、設定にアクセスすることもできます。

Media3 には、メディアの再開を簡単にサポートできる API が用意されています。この機能の実装に関するガイダンスについては、Media3 による再生の再開のドキュメントをご覧ください。

以前のメディア API の使用

このセクションでは、以前の MediaCompat API を使用してシステム メディア コントロールと統合する方法について説明します。

システムが MediaSessionMediaMetadata から以下の情報を取得して表示します。

  • METADATA_KEY_ALBUM_ART_URI
  • METADATA_KEY_TITLE
  • METADATA_KEY_DISPLAY_TITLE
  • METADATA_KEY_ARTIST
  • METADATA_KEY_DURATION(所要時間が設定されていない場合は、シークバーに進行状況が表示されません)

有効で正確なメディア コントロール通知を確実に受け取るには、METADATA_KEY_TITLE または METADATA_KEY_DISPLAY_TITLE メタデータの値を、現在再生中のメディアのタイトルに設定します。

メディア プレーヤーには、現在再生中のメディアの経過時間と、MediaSession PlaybackState にマッピングされたシークバーが表示されます。

メディア プレーヤーには、現在再生中のメディアの進行状況と、MediaSession PlaybackState にマッピングされたシークバーが表示されます。シークバーを使用すると、ユーザーは位置を変更でき、メディア アイテムの経過時間を表示できます。シークバーを有効にするには、PlaybackState.Builder#setActions を実装して ACTION_SEEK_TO を含める必要があります。

スロット アクション 条件
1 再生 PlaybackState の現在の状態は次のいずれかです。
  • STATE_NONE
  • STATE_STOPPED
  • STATE_PAUSED
  • STATE_ERROR
読み込み中アイコン PlaybackState の現在の状態は次のいずれかです。
  • STATE_CONNECTING
  • STATE_BUFFERING
一時停止 PlaybackState の現在の状態は上記以外です。
2 前へ PlaybackState アクションACTION_SKIP_TO_PREVIOUS が含まれます。
カスタム PlaybackState アクションACTION_SKIP_TO_PREVIOUS が含まれません。PlaybackState カスタム アクションには、まだ配置されていないカスタム アクションが含まれます。
なし PlaybackState エクストラには、SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV キーの true ブール値が含まれます。
3 次へ PlaybackState アクションACTION_SKIP_TO_NEXT が含まれます。
カスタム PlaybackState アクションACTION_SKIP_TO_NEXT が含まれません。PlaybackState カスタム アクションには、まだ配置されていないカスタム アクションが含まれます。
なし PlaybackState エクストラには、SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT キーの true ブール値が含まれます。
4 カスタム PlaybackState カスタム アクションには、まだ配置されていないカスタム アクションが含まれます。
5 カスタム PlaybackState カスタム アクションには、まだ配置されていないカスタム アクションが含まれます。

標準アクションを追加する

次のコード例は、PlaybackState の標準アクションとカスタム アクションを追加する方法を示しています。

再生、一時停止、前へ、次への場合は、メディア セッションの PlaybackState でこれらのアクションを設定します。

Kotlin

val session = MediaSessionCompat(context, TAG)
val playbackStateBuilder = PlaybackStateCompat.Builder()
val style = NotificationCompat.MediaStyle()

// For this example, the media is currently paused:
val state = PlaybackStateCompat.STATE_PAUSED
val position = 0L
val playbackSpeed = 1f
playbackStateBuilder.setState(state, position, playbackSpeed)

// And the user can play, skip to next or previous, and seek
val stateActions = PlaybackStateCompat.ACTION_PLAY
    or PlaybackStateCompat.ACTION_PLAY_PAUSE
    or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    or PlaybackStateCompat.ACTION_SEEK_TO // adding the seek action enables seeking with the seekbar
playbackStateBuilder.setActions(stateActions)

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build())
style.setMediaSession(session.sessionToken)
notificationBuilder.setStyle(style)

Java

MediaSessionCompat session = new MediaSessionCompat(context, TAG);
PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder();
NotificationCompat.MediaStyle style = new NotificationCompat.MediaStyle();

// For this example, the media is currently paused:
int state = PlaybackStateCompat.STATE_PAUSED;
long position = 0L;
float playbackSpeed = 1f;
playbackStateBuilder.setState(state, position, playbackSpeed);

// And the user can play, skip to next or previous, and seek
long stateActions = PlaybackStateCompat.ACTION_PLAY
    | PlaybackStateCompat.ACTION_PLAY_PAUSE
    | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    | PlaybackStateCompat.ACTION_SEEK_TO; // adding this enables the seekbar thumb
playbackStateBuilder.setActions(stateActions);

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build());
style.setMediaSession(session.getSessionToken());
notificationBuilder.setStyle(style);

前のスロットまたは次のスロットにボタンを配置しない場合は、ACTION_SKIP_TO_PREVIOUS または ACTION_SKIP_TO_NEXT を追加せず、代わりにセッションにエクストラを追加します。

Kotlin

session.setExtras(Bundle().apply {
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true)
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true)
})

Java

Bundle extras = new Bundle();
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true);
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true);
session.setExtras(extras);

カスタム アクションを追加する

メディア コントロールに表示する他のアクションについては、PlaybackStateCompat.CustomAction を作成して、代わりに PlaybackState に追加します。これらのアクションは、追加された順序で表示されます。

Kotlin

val customAction = PlaybackStateCompat.CustomAction.Builder(
    "com.example.MY_CUSTOM_ACTION", // action ID
    "Custom Action", // title - used as content description for the button
    R.drawable.ic_custom_action
).build()

playbackStateBuilder.addCustomAction(customAction)

Java

PlaybackStateCompat.CustomAction customAction = new PlaybackStateCompat.CustomAction.Builder(
        "com.example.MY_CUSTOM_ACTION", // action ID
        "Custom Action", // title - used as content description for the button
        R.drawable.ic_custom_action
).build();

playbackStateBuilder.addCustomAction(customAction);

PlaybackState アクションへの対応

ユーザーがボタンをタップすると、SystemUI は MediaController.TransportControls を使用して MediaSession にコマンドを送信します。これらのイベントに適切に応答できるコールバックを登録する必要があります。

Kotlin

val callback = object: MediaSession.Callback() {
    override fun onPlay() {
        // start playback
    }

    override fun onPause() {
        // pause playback
    }

    override fun onSkipToPrevious() {
        // skip to previous
    }

    override fun onSkipToNext() {
        // skip to next
    }

    override fun onSeekTo(pos: Long) {
        // jump to position in track
    }

    override fun onCustomAction(action: String, extras: Bundle?) {
        when (action) {
            CUSTOM_ACTION_1 -> doCustomAction1(extras)
            CUSTOM_ACTION_2 -> doCustomAction2(extras)
            else -> {
                Log.w(TAG, "Unknown custom action $action")
            }
        }
    }

}

session.setCallback(callback)

Java

MediaSession.Callback callback = new MediaSession.Callback() {
    @Override
    public void onPlay() {
        // start playback
    }

    @Override
    public void onPause() {
        // pause playback
    }

    @Override
    public void onSkipToPrevious() {
        // skip to previous
    }

    @Override
    public void onSkipToNext() {
        // skip to next
    }

    @Override
    public void onSeekTo(long pos) {
        // jump to position in track
    }

    @Override
    public void onCustomAction(String action, Bundle extras) {
        if (action.equals(CUSTOM_ACTION_1)) {
            doCustomAction1(extras);
        } else if (action.equals(CUSTOM_ACTION_2)) {
            doCustomAction2(extras);
        } else {
            Log.w(TAG, "Unknown custom action " + action);
        }
    }
};

メディアの再開

クイック設定領域にプレーヤー アプリが表示されるようにするには、有効な MediaSession トークンを使用して MediaStyle 通知を作成する必要があります。

MediaStyle 通知のタイトルを表示するには、NotificationBuilder.setContentTitle() を使用します。

メディア プレーヤーのブランド アイコンを表示するには、NotificationBuilder.setSmallIcon() を使用します。

再生の再開をサポートするには、アプリに MediaBrowserServiceMediaSession を実装する必要があります。MediaSessiononPlay() コールバックを実装する必要があります。

MediaBrowserService の実装

デバイスが起動すると、システムは最近使用された 5 つのメディアアプリを特定し、各アプリから再生を再開するためのコントロールを提供します。

システムは SystemUI 経由の接続によって MediaBrowserService へのアクセスを試みます。アプリでは、そのような接続を許可しなければなりません。許可しない場合、再生の再開はサポートされません。

SystemUI 経由の接続は、パッケージ名 com.android.systemui と署名によって検出し、確認できます。SystemUI はプラットフォーム署名によって署名されます。プラットフォーム署名に従ってチェックを行う方法の例については、UAMP アプリを確認してください。

再生の再開をサポートするには、MediaBrowserService に次の動作を実装する必要があります。

  • onGetRoot() は null 以外のルートをすばやく返す必要があります。その他の複雑なロジックは onLoadChildren() が処理するようにします。

  • ルートのメディア ID で onLoadChildren() を呼び出した場合、その結果には、子である FLAG_PLAYABLE が含まれていなければなりません。

  • MediaBrowserService は、EXTRA_RECENT クエリを受け取ったときに、直近に再生されたメディア アイテムを返す必要があります。返される値は、汎用的な関数ではなく、実際のメディア アイテムでなければなりません。

  • MediaBrowserService は、タイトルサブタイトルが空でない、適切な MediaDescription を指定する必要があります。またアイコンの URI またはアイコンのビットマップも設定します。

以下のコード例は、onGetRoot() の実装方法を示しています。

Kotlin

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?
): BrowserRoot? {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your 
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        rootHints?.let {
            if (it.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                val extras = Bundle().apply {
                    putBoolean(BrowserRoot.EXTRA_RECENT, true)
                }
                return BrowserRoot(MY_RECENTS_ROOT_ID, extras)
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return BrowserRoot(MY_MEDIA_ROOT_ID, null)
    }
    // Return an empty tree to disallow browsing.
    return BrowserRoot(MY_EMPTY_ROOT_ID, null)

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        if (rootHints != null) {
            if (rootHints.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                Bundle extras = new Bundle();
                extras.putBoolean(BrowserRoot.EXTRA_RECENT, true);
                return new BrowserRoot(MY_RECENTS_ROOT_ID, extras);
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    }
    // Return an empty tree to disallow browsing.
    return new BrowserRoot(MY_EMPTY_ROOT_ID, null);
}