メディア コントロール

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 のいずれも使用できません。また、スロットを埋めるために、まだ配置されていないカスタム レイアウトのカスタム コマンドを使用できます。 カスタム
(Media3 ではまだサポートされていません)PlaybackState エクストラには、キー 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 のいずれも使用できません。また、スロットを埋めるために、まだ配置されていないカスタム レイアウトのカスタム コマンドを使用できます。 カスタム
(Media3 ではまだサポートされていません)PlaybackState エクストラには、キー 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_PREVIOUSACTION_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 を実装する必要があります。MediaSession には onPlay() コールバックを実装する必要があります。

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);
}