媒體控制選項

Android 中的媒體控制選項位於快速設定附近。多個應用程式的工作階段會排列在可滑動瀏覽的輪轉介面中。輪轉介面會依照以下順序列出工作階段:

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

從 Android 13 (API 級別 33) 開始,為確保使用者能存取播放媒體的應用程式提供豐富的媒體控制項,媒體控制項上的動作按鈕會衍生自 Player 狀態。

這樣一來,您就能在各裝置上提供一致的媒體控制選項,並提供更精緻的媒體控制體驗。

圖 1 分別顯示在手機和平板電腦裝置上顯示的樣貌。

媒體控制項在手機和平板電腦裝置上的顯示方式,使用示例曲目說明按鈕可能的顯示方式
圖 1:手機和平板電腦裝置上的媒體控制選項

系統會根據 Player 狀態顯示最多五個動作按鈕,如下表所述。在精簡模式中,系統只會顯示前三個動作方塊。這與其他 Android 平台 (例如 Auto、Assistant 和 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 都無法使用,且自訂版面配置中尚未放置的自訂指令可用於填入版位。 自訂
工作階段額外資料包含鍵 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 都無法使用,且自訂版面配置中尚未放置的自訂指令可用於填入版位。 自訂
工作階段額外資料包含鍵 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 actions 包含 ACTION_SKIP_TO_PREVIOUS
自訂 PlaybackState actions 不包含 ACTION_SKIP_TO_PREVIOUS,而 PlaybackState custom actions 包含尚未放置的自訂動作。
空白 PlaybackState extras 包含鍵 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREVtrue 布林值。
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_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()

如要支援播放續行功能,應用程式必須實作 MediaBrowserServiceMediaSession。您的 MediaSession 必須實作 onPlay() 回呼。

MediaBrowserService 實作

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

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

您可以使用套件名稱 com.android.systemui 和簽名,識別及驗證 SystemUI 的連線。SystemUI 已使用平台簽名簽署。如需平台簽章的檢查範例,請參閱 UAMP 應用程式

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

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

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

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

  • MediaBrowserService 必須提供適當的 MediaDescription,其中包含非空白的titlesubtitle。也應設定圖示 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);
}