Android 中的媒體控制項位於「快速設定」附近。多個應用程式的工作階段會排列在可滑動的輪轉介面中。輪轉介面會按照下列順序列出工作階段:
- 手機本機播放的串流
- 遠端串流,例如透過外部裝置或投放工作階段偵測到的串流內容
- 先前支援續傳的工作階段,依上次播放順序排序
從 Android 13 (API 級別 33) 開始,為了確保使用者能針對播放媒體的應用程式存取豐富的媒體控制項,媒體控制項上的動作按鈕都是衍生自 Player
狀態。
如此一來,您就能為各種裝置提供一致的媒體控制項組合,並享有更優質的媒體控制體驗。
圖 1 分別顯示其在手機和平板電腦上的顯示情形範例。
系統會根據 Player
狀態顯示最多五個動作按鈕,如下表所述。在精簡模式下,系統只會顯示前三個動作位置。這和媒體控制項在其他 Android 平台 (例如 Auto、Google 助理和 Wear OS) 中的顯示方式相同。
版位 | 條件 | 動作 |
---|---|---|
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
清單。系統會根據新增的順序顯示最多五個動作。在精簡模式下,最多會顯示三個按鈕,視傳遞至 setShowActionsInCompactView()
的值而定。
自訂動作會按照新增至 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
導入
裝置啟動後,系統會尋找最近使用的五個媒體應用程式,並提供控制項,可用於從每個應用程式重新啟動播放作業。
系統會嘗試透過 SystemUI 的連線聯絡 MediaBrowserService
。您的應用程式必須允許這類連線,否則無法支援繼續播放。
您可以使用套件名稱 com.android.systemui
和簽章來識別及驗證 SystemUI 連線。SystemUI 是以平台簽章簽署。如需如何檢查平台簽章的範例,請參閱 UAMP 應用程式。
為了支援播放繼續功能,MediaBrowserService
必須實作下列行為:
onGetRoot()
必須快速傳回非空值的根層級。其他複雜的邏輯則應在onLoadChildren()
中處理在根媒體 ID 上呼叫
onLoadChildren()
時,結果必須包含 FLAG_PLAYABLE 子項。MediaBrowserService
收到 EXTRA_RECENT 查詢時,應傳回最近播放的媒體項目。傳回的值應為實際媒體項目,而非一般函式。MediaBrowserService
必須提供適當的 MediaDescription,其中包含非空白的 title 和subtitle。此外,您還必須設定圖示 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); }