媒體控制選項

你可以在 Android 的「快速設定」附近找到媒體控制項,多個應用程式的工作階段會以滑動式輪轉介面的方式排列。輪轉介面會按照以下順序列出工作階段:

  • 在手機上播放串流內容
  • 遠端串流,例如在外部裝置或投放工作階段偵測到的內容
  • 先前可續傳的工作階段 (按照上次遊玩順序排列)

自 Android 13 (API 級別 33) 起,為確保使用者能存取播放媒體的應用程式適用的豐富媒體控制項組合,則媒體控制項上的動作按鈕衍生自 Player 狀態。

如此一來,您就可以在各種裝置上提供一致的媒體控制項組合,以及更優異的媒體控制體驗。

圖 1 舉例說明此格式在手機和平板電腦上呈現的效果。

媒體控制項是依據在手機和平板電腦裝置上的顯示方式,從範例軌跡示範按鈕的顯示方式
圖 1: 手機和平板電腦上的媒體控制項

系統會根據 Player 狀態顯示最多五個動作按鈕,如下表所述。在精簡模式中,只會顯示前三個動作運算單元。這與在其他 Android 平台上 (例如 Auto、Google 助理和 Wear OS) 算繪媒體控制項的方式一致。

版位 條件 動作
1 playWhenReady 為 false,或目前的播放狀態STATE_ENDED 播放
playWhenReady 為 true,且目前的播放狀態STATE_BUFFERING 載入旋轉圖示
playWhenReady 為 true,且目前的播放狀態STATE_READY 暫停
2 可以使用播放器指令 COMMAND_SEEK_TO_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM 上一頁
無論是玩家指令 COMMAND_SEEK_TO_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,系統都不會使用。此外,您也可以透過自訂版面配置中尚未放置的自訂指令來填滿版位。 自訂
(Media3 尚未支援) PlaybackState extras 鍵包含鍵 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREVtrue 布林值。 未連接裝置
3 可以使用播放器指令 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM 下一步
無論是玩家指令 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM,系統都不會使用。此外,您也可以透過自訂版面配置中尚未放置的自訂指令來填滿版位。 自訂
(Media3 尚未支援) PlaybackState extras 鍵包含鍵 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 通知並即時更新。

回應動作按鈕

當使用者輕觸系統媒體控制項中的動作按鈕時,系統的 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,整合系統媒體控制項。

系統會從 MediaSessionMediaMetadata 擷取下列資訊,並在有可用資訊時顯示:

  • METADATA_KEY_ALBUM_ART_URI
  • METADATA_KEY_TITLE
  • METADATA_KEY_DISPLAY_TITLE
  • METADATA_KEY_ARTIST
  • METADATA_KEY_DURATION (如果時間長度未設定跳轉,未顯示進度)

為確保您擁有有效且正確的媒體控制通知,請將 METADATA_KEY_TITLEMETADATA_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「extras」包含鍵 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREVtrue 布林值。
3 下一步 PlaybackState 動作包含 ACTION_SKIP_TO_NEXT
自訂 PlaybackState 動作不含 ACTION_SKIP_TO_NEXT,且 PlaybackState自訂動作包含尚未放置的自訂動作。
未連接裝置 PlaybackState「extras」包含鍵 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXTtrue 布林值。
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()

如要支援繼續播放功能,應用程式必須實作 MediaBrowserServiceMediaSessionMediaSession 必須實作 onPlay() 回呼。

MediaBrowserService 實作

裝置啟動後,系統會尋找最近使用的五個媒體應用程式,並提供可用於從各個應用程式重新啟動遊戲的控制項。

系統會嘗試透過 SystemUI 的連線與 MediaBrowserService 聯絡。您的應用程式必須允許這類連線,否則無法支援繼續播放功能。

可以使用套件名稱 com.android.systemui 和簽章來識別及驗證 SystemUI 連線。SystemUI 是使用平台簽章簽署。您可以在 UAMP 應用程式中找到檢查平台簽章的範例。

為了支援繼續播放功能,MediaBrowserService 必須實作以下行為:

  • onGetRoot() 必須快速傳回非空值的根層級。其他複雜邏輯應在 onLoadChildren() 中處理

  • 在根媒體 ID 上呼叫 onLoadChildren() 時,結果必須包含 FLAG_PLAYABLE 子項。

  • 收到 EXTRA_RECENT 查詢時,MediaBrowserService 應傳回最近播放的媒體項目。傳回的值應為實際的媒體項目,而非一般函式。

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