Android 中的媒體控制選項位於快速設定附近。多個應用程式的工作階段會排列在可滑動瀏覽的輪轉介面中。輪轉介面會依照以下順序列出工作階段:
- 在手機上本機播放串流內容
- 遠端串流,例如在外部裝置或投放工作階段中偵測到的串流
- 依照上次播放的順序列出先前可續傳的工作階段
從 Android 13 (API 級別 33) 開始,為確保使用者能存取播放媒體的應用程式提供豐富的媒體控制項,媒體控制項上的動作按鈕會衍生自 Player
狀態。
這樣一來,您就能在各裝置上提供一致的媒體控制選項,並提供更精緻的媒體控制體驗。
圖 1 分別顯示在手機和平板電腦裝置上顯示的樣貌。
系統會根據 Player
狀態顯示最多五個動作按鈕,如下表所述。在精簡模式中,系統只會顯示前三個動作方塊。這與其他 Android 平台 (例如 Auto、Assistant 和 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 都無法使用,且自訂版面配置中尚未放置的自訂指令可用於填入版位。 |
自訂 | |
工作階段額外資料包含鍵 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 都無法使用,且自訂版面配置中尚未放置的自訂指令可用於填入版位。 |
自訂 | |
工作階段額外資料包含鍵 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 actions 包含 ACTION_SKIP_TO_PREVIOUS 。 |
自訂 | PlaybackState actions 不包含 ACTION_SKIP_TO_PREVIOUS ,而 PlaybackState custom actions 包含尚未放置的自訂動作。 |
|
空白 | PlaybackState extras 包含鍵 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 的 true 布林值。 |
|
3 | 繼續 | PlaybackState actions 包含 ACTION_SKIP_TO_NEXT 。 |
自訂 | PlaybackState actions 不包含 ACTION_SKIP_TO_NEXT ,而 PlaybackState custom actions 包含尚未放置的自訂動作。 |
|
空白 | PlaybackState extras 包含鍵 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); }