メディア コントロール

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 メディア ボタンの設定にカスタムボタンが含まれている場合 CommandButton.SLOT_BACK カスタム
プレーヤー コマンド COMMAND_SEEK_TO_PREVIOUS または COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM が使用可能な場合。 前へ
カスタムボタンも、リストされているコマンドも使用できない場合。
3 メディアボタン の設定にカスタムボタンが含まれている場合 CommandButton.SLOT_FORWARD カスタム
プレーヤー コマンド COMMAND_SEEK_TO_NEXT または COMMAND_SEEK_TO_NEXT_MEDIA_ITEM が使用可能な場合。 次へ
カスタムボタンも、リストされているコマンドも使用できない場合。
4 メディアボタンの設定に、まだ配置されていない CommandButton.SLOT_OVERFLOW のカスタムボタンが含まれている場合。 カスタム
5 メディアボタンの設定に、まだ配置されていない CommandButton.SLOT_OVERFLOW のカスタムボタンが含まれている場合。 カスタム

カスタムのオーバーフロー ボタンは、 メディアボタンの設定に追加された順序で配置されます。

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

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

  1. 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(CommandButton.ICON_HEART_UNFILLED)
        .setDisplayName("Save to favorites")
        .setSessionCommand(customCommandFavorites)
        .build()
    val player = ExoPlayer.Builder(this).build()
    // Build the session with a custom layout.
    mediaSession =
      MediaSession.Builder(this, player)
        .setCallback(MyCallback())
        .setMediaButtonPreferences(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)
      .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(CommandButton.ICON_HEART_UNFILLED)
            .setDisplayName("Save to favorites")
            .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())
            .setMediaButtonPreferences(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)
          .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インターフェースで定義されたコマンドは、メディア セッションによって自動的に処理されます。

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

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

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

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

Media3 には、メディアの再開を簡単にサポートできる API が用意されています。この機能の実装方法については、 Media3 での再生の再開 をご覧ください。

従来のメディア API を使用する

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

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

  • 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 再生 state の現在の 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() の実装方法を示しています。

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

Android 13 より前の動作

下位互換性を維持するため、システム UI は、Android 13 をターゲットとして更新されていないアプリ または PlaybackState情報を含まないアプリに対し、通知アクションを使用する代替レイアウトを引き続き提供します。アクション ボタンは Notification.Action リストから導出され、MediaStyle 通知にアタッチされます。システムには、追加された順序で最大 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)
// 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(new MediaStyleNotificationHelper.MediaStyle(mediaSession)
.setShowActionsInCompactView(1 /* #1: pause button */))
.setContentTitle("Wonderful music")
.setContentText("My Awesome Band")
.setLargeIcon(albumArtBitmap)
.build();