Android のメディア コントロールは、クイック設定の近くにあります。複数のアプリからのセッションは、スワイプ可能なカルーセルに配列されます。カルーセルには、セッションが次の順序で表示されます。
- スマートフォンでローカルに再生されるストリーム
- リモート ストリーム(外部デバイスやキャスト セッションで検出されたストリームなど)
- 以前の再開可能なセッション(最後に再生された順)
Android 13(API レベル 33)以降では、メディアを再生するアプリでユーザーが多様なメディア コントロールを利用できるようにするため、メディア コントロールのアクション ボタンは Player
状態から導出されます。
これにより、デバイス間で一貫したメディア コントロール セットと洗練されたメディア コントロール エクスペリエンスを提供できます。
図 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_PREVIOUS も COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM も使用できません。まだ配置されていないカスタム レイアウトのカスタム コマンドでスロットを埋めることができます。 |
カスタム | |
(Media3 ではまだサポートされていません)PlaybackState エクストラには、キー EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV の true ブール値が含まれます。 |
なし | |
3 | プレーヤー コマンド COMMAND_SEEK_TO_NEXT または COMMAND_SEEK_TO_NEXT_MEDIA_ITEM を使用できます。 |
次へ |
プレーヤー コマンド COMMAND_SEEK_TO_NEXT も COMMAND_SEEK_TO_NEXT_MEDIA_ITEM も使用できません。まだ配置されていないカスタム レイアウトのカスタム コマンドでスロットを埋めることができます。 |
カスタム | |
(Media3 ではまだサポートされていません)PlaybackState エクストラには、キー EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT の true ブール値が含まれます。 |
なし | |
4 | まだ配置されていないカスタム レイアウトのカスタム コマンドを、スロットを埋めることができます。 | カスタム |
5 | まだ配置されていないカスタム レイアウトのカスタム コマンドで、スロットを埋めることができます。 | カスタム |
カスタム コマンドは、カスタム レイアウトに追加された順序で配置されます。
コマンドボタンをカスタマイズする
Jetpack Media3 でシステム メディア コントロールをカスタマイズするには、MediaSessionService
を実装するときに、セッションのカスタム レイアウトとコントローラの使用可能なコマンドを設定します。
onCreate()
でMediaSession
をビルドし、コマンドボタンのカスタム レイアウトを定義します。MediaSession.Callback.onConnect()
で、ConnectionResult
で使用可能なコマンド(カスタム コマンドを含む)を定義して、コントローラを承認します。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 ListenableFutureonCustomCommand( 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
通知が自動的に公開され、最新の状態に保たれます。
アクション ボタンに応答する
ユーザーがシステムのメディア コントロールでアクション ボタンをタップすると、システムの MediaController
が MediaSession
に再生コマンドを送信します。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 を使用してシステム メディア コントロールと統合する方法について説明します。
システムが MediaSession
の 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 | 再生 |
PlaybackState の現在の状態は次のいずれかです。
|
読み込み中アイコン |
PlaybackState の現在の状態は次のいずれかです。
|
|
一時停止 | 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()
を使用します。
再生の再開をサポートするには、アプリに MediaBrowserService
と MediaSession
を実装する必要があります。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); }