媒體控制選項

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

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

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

如此一來,就能呈現一致的媒體控制項組合,以及強化細節 跨裝置的媒體控制體驗

圖 1 舉例說明此應用程式在手機和平板電腦上的外觀。 。

媒體控制項在手機和平板電腦上的顯示方式
            透過範例軌跡範例,呈現按鈕外觀
圖 1: 手機和平板電腦上的媒體控制項

系統會根據 Player 狀態顯示最多五個動作按鈕,如 使用。在密集模式下,系統只會顯示前三個動作 版位。這與媒體控制項在其他 Google 服務中的呈現方式一致 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 實作

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

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

可以使用套件名稱來識別和驗證 SystemUI 的連線 com.android.systemui和簽章。SystemUI 是透過平台簽署 簽章。以下舉例說明如何檢查平台簽章 位於 UAMP 應用程式中。

為了支援繼續播放功能,MediaBrowserService 必須 實作這些行為

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

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

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

  • MediaBrowserService」必須提供適當的 MediaDescription 含有非空白 title副標題。 也應該設定 圖示 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);
}