使用 MediaSession 控制及放送廣告

媒體工作階段提供與音訊或影片播放器互動的通用方式。在 Media3 中,預設播放器是 ExoPlayer 類別,會實作 Player 介面。將媒體工作階段連結至播放器,可讓應用程式在外部宣傳媒體播放,並接收來自外部來源的播放指令。

指令可能來自實體按鈕,例如耳機或電視遙控器上的播放按鈕。也可能來自具有媒體控制器的用戶端應用程式,例如向 Google 助理下達「暫停」指令。媒體工作階段會將這些指令委派給媒體應用程式的播放器。

選擇媒體工作階段的時機

實作 MediaSession 後,使用者就能控制播放:

  • 透過耳機。使用者通常可以透過耳機上的按鈕或觸控互動,播放或暫停媒體,或前往下一首或上一首曲目。
  • 與 Google 助理交談。常見的模式是說出「Ok Google,暫停」,暫停裝置上目前播放的任何媒體。
  • 透過 Wear OS 手錶。這樣一來,使用者在手機上播放內容時,就能更輕鬆地存取最常用的播放控制項。
  • 透過媒體遙控器。這個輪轉介面會顯示每個執行中媒體工作階段的控制項。
  • 電視。允許使用實體播放按鈕、平台播放控制項和電源管理功能 (例如,如果電視、單件式環繞劇院或 A/V 接收器關閉或輸入來源切換,應用程式應停止播放)。
  • 透過 Android Auto 媒體遙控器。這樣一來,駕駛人就能安全地控制播放。
  • 以及任何其他需要影響播放的外部程序。

這項功能適用於多種用途。在下列情況下,強烈建議您使用 MediaSession:

  • 串流播放長篇影片內容,例如電影或電視直播。
  • 您正在串流播放長篇音訊內容,例如 Podcast 或音樂播放清單。
  • 您正在建構 TV 應用程式。

不過,並非所有用途都適合 MediaSession。在下列情況下,您可能只想使用 Player:

  • 您要顯示短篇內容,不需要外部控制項或背景播放功能。
  • 沒有單一播放中的影片,例如使用者捲動清單,畫面上同時顯示多部影片。
  • 您正在播放一次性介紹或說明影片,並預期使用者會主動觀看,不需要外部播放控制項。
  • 您的內容涉及隱私,您不希望外部程序存取媒體中繼資料 (例如瀏覽器的無痕模式)。

如果您的用途不符合上述任何一項,請考慮是否能接受應用程式在使用者未主動與內容互動時繼續播放。如果答案為「是」,建議您選擇 MediaSession。如果答案是否定的,建議您改用 Player。

建立媒體工作階段

媒體工作階段會與其管理的播放器並存。您可以使用 Context 和 Player 物件建構媒體工作階段。您應在需要時建立並初始化媒體工作階段,例如 Activity 或 Fragment 的 onStart() 或 onResume() 生命週期方法,或擁有媒體工作階段及其相關聯播放器的 Service 的 onCreate() 方法。

如要建立媒體工作階段,請初始化 Player,並提供給 MediaSession.Builder,如下所示:

Kotlin

val player = ExoPlayer.Builder(context).build()
val mediaSession = MediaSession.Builder(context, player).build()

Java

ExoPlayer player = new ExoPlayer.Builder(context).build();
MediaSession mediaSession = new MediaSession.Builder(context, player).build();

自動處理狀態

Media3 程式庫會使用播放器的狀態,自動更新媒體工作階段。因此,您不需要手動處理從播放器到工作階段的對應。

這與平台媒體工作階段不同,在平台媒體工作階段中,您需要獨立於播放器建立及維護 PlaybackState,例如指出任何錯誤。

不重複工作階段 ID

根據預設,MediaSession.Builder 會建立工作階段,並以空字串做為工作階段 ID。如果應用程式只打算建立單一工作階段例項 (這是最常見的情況),這樣就足夠了。

如果應用程式想同時管理多個工作階段執行個體,就必須確保每個工作階段的工作階段 ID 都不重複。使用 MediaSession.Builder.setId(String id) 建構工作階段時,可以設定工作階段 ID。

如果 IllegalStateException 導致應用程式當機,並顯示錯誤訊息 IllegalStateException: Session ID must be unique. ID=,則可能是先前建立的相同 ID 例項尚未釋出,就意外建立了工作階段。為避免工作階段因程式設計錯誤而外洩,系統會偵測這類情況,並擲回例外狀況來通知。

將控制權授予其他用戶端

媒體工作階段是控制播放的關鍵。這可讓您將外部來源的指令轉送至播放媒體的播放器。這些來源可以是實體按鈕,例如耳機或電視遙控器上的播放按鈕,也可以是間接指令,例如指示 Google 助理「暫停」。同樣地,您可能也想授予 Android 系統存取權,以便使用通知和螢幕鎖定控制項,或授予 Wear OS 手錶存取權,以便透過錶面控制播放。外部用戶端可以使用媒體控制器,向媒體應用程式發出播放指令。這些指令會由媒體工作階段接收,最終委派給媒體播放器。

這張圖表說明 MediaSession 和 MediaController 之間的互動。
圖 1:媒體控制器可協助將指令從外部來源傳遞至媒體工作階段。
。

當控制器即將連線至媒體工作階段時,系統會呼叫 onConnect() 方法。您可以使用提供的 ControllerInfo 決定要接受還是拒絕要求。如需接受連線要求的範例,請參閱「宣告自訂指令」一節。

連線後,控制器可以將播放指令傳送至工作階段。接著,工作階段會將這些指令委派給播放器。工作階段會自動處理 Player 介面中定義的播放和播放清單指令。

其他回呼方法可讓您處理自訂指令和修改播放清單等要求。這些回呼同樣包含 ControllerInfo 物件,因此您可以根據每個控制器,修改回應各項要求的方式。

修改播放清單

媒體工作階段可以直接修改播放器的播放清單,詳情請參閱 ExoPlayer 播放清單指南。如果控制器可使用 COMMAND_SET_MEDIA_ITEM 或 COMMAND_CHANGE_MEDIA_ITEMS,也可以修改播放清單。

將新項目加入播放清單時,播放器通常需要MediaItem例項,並定義 URI,才能播放這些項目。根據預設,如果新加入的項目已定義 URI,系統會自動將其轉送至 player.addMediaItem 等播放器方法。

如要自訂新增至播放器的 MediaItem 執行個體,可以覆寫 onAddMediaItems()。如要支援要求媒體但未定義 URI 的控制器,就必須執行這個步驟。MediaItem 通常會設定下列一或多個欄位,描述要求的媒體:

  • MediaItem.id:識別媒體的一般 ID。
  • MediaItem.RequestMetadata.mediaUri:要求 URI,可能使用自訂結構定義,且不一定能直接由播放器播放。
  • MediaItem.RequestMetadata.searchQuery:文字搜尋查詢,例如來自 Google 助理。
  • MediaItem.MediaMetadata:結構化中繼資料,例如「標題」或「藝人」。

如要進一步自訂全新播放清單,您也可以覆寫 onSetMediaItems(),定義播放清單中的起始項目和位置。舉例來說,您可以將單一請求項目展開為整個播放清單,並指示播放器從原始請求項目的索引開始播放。您可以在工作階段示範應用程式中,找到採用這項功能的 onSetMediaItems() 實作範例。

管理媒體按鈕偏好設定

每個控制器 (例如系統 UI、Android Auto 或 Wear OS) 都可以自行決定要向使用者顯示哪些按鈕。如要指出要向使用者顯示哪些播放控制項,您可以在 MediaSession 上指定媒體按鈕偏好設定。這些偏好設定包含 CommandButton 執行個體的已排序清單,每個執行個體都會定義使用者介面中按鈕的偏好設定。

定義指令按鈕

CommandButton 執行個體可用來定義媒體按鈕偏好設定。每個按鈕都會定義所需 UI 元素的三個面向:

  1. 圖示:定義視覺外觀。建立 CommandButton.Builder 時,圖示必須設為其中一個預先定義的常數。請注意,這並非實際的點陣圖或圖片資源。一般常數可協助控制器選擇適當的資源,在自己的 UI 中呈現一致的外觀和風格。如果預先定義的圖示常數都不符合您的用途,可以改用 setCustomIconResId。
  2. Command:定義使用者與按鈕互動時觸發的動作。您可以將 setPlayerCommand 用於 Player.Command,或將 setSessionCommand 用於預先定義或自訂的 SessionCommand。
  3. Slot:定義按鈕在控制器 UI 中的位置。這個欄位為選填,系統會根據「Icon」和「Command」自動設定。舉例來說,您可以指定按鈕應顯示在 UI 的「前進」導覽區域,而非預設的「溢位」區域。

Kotlin

val button =
  CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_15)
    .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
    .setSlots(CommandButton.SLOT_FORWARD)
    .build()

Java

CommandButton button =
    new CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD_15)
        .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
        .setSlots(CommandButton.SLOT_FORWARD)
        .build();

解析媒體按鈕偏好設定時,系統會套用下列演算法:

  1. 針對媒體按鈕偏好設定中的每個 CommandButton,將按鈕放在第一個可用且允許的版位。
  2. 如果中央、前進和後退版位沒有填入按鈕,請為這個版位新增預設按鈕。

您可以使用 CommandButton.DisplayConstraints,根據 UI 顯示限制,預覽媒體按鈕偏好設定的解析方式。

設定媒體按鈕偏好設定

如要設定媒體按鈕偏好設定,最簡單的方法是在建構 MediaSession 時定義清單。或者,您也可以覆寫 MediaSession.Callback.onConnect,為每個連線的控制器自訂媒體按鈕偏好設定。

Kotlin

val mediaSession =
  MediaSession.Builder(context, player)
    .setMediaButtonPreferences(ImmutableList.of(likeButton, favoriteButton))
    .build()

Java

MediaSession mediaSession =
    new MediaSession.Builder(context, player)
        .setMediaButtonPreferences(ImmutableList.of(likeButton, favoriteButton))
        .build();

在使用者互動後更新媒體按鈕偏好設定

處理完與播放器的互動後,您可能需要更新控制器 UI 中顯示的按鈕。常見的例子是切換鈕,在觸發與此按鈕相關聯的動作後,會變更圖示和動作。如要更新媒體按鈕偏好設定,可以使用 MediaSession.setMediaButtonPreferences 更新所有控制器或特定控制器的偏好設定:

Kotlin

// Handle "favoritesButton" action, replace by opposite button
mediaSession.setMediaButtonPreferences(ImmutableList.of(likeButton, removeFromFavoritesButton))

Java

// Handle "favoritesButton" action, replace by opposite button
mediaSession.setMediaButtonPreferences(ImmutableList.of(likeButton, removeFromFavoritesButton));

新增自訂指令及自訂預設行為

可用的播放器指令可透過自訂指令擴充,也可以攔截傳入的播放器指令和媒體按鈕,變更預設行為。

宣告及處理自訂指令

媒體應用程式可以定義自訂指令,例如用於媒體按鈕偏好設定。舉例來說,您可以實作按鈕,讓使用者將媒體項目儲存至收藏項目清單。MediaController 會傳送自訂指令,MediaSession.Callback 則會接收指令。

如要定義自訂指令,您必須覆寫 MediaSession.Callback.onConnect(),為每個已連結的控制器設定可用的自訂指令。

Kotlin

private class CustomMediaSessionCallback : MediaSession.Callback {

  // Configure commands available to the controller in onConnect()
  override fun onConnectAsync(
    session: MediaSession,
    controller: ControllerInfo,
  ): ListenableFuture<ConnectionResult> {
    val sessionCommands =
      ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
        .add(SessionCommand(SAVE_TO_FAVORITES, Bundle.EMPTY))
        .build()
    return Futures.immediateFuture(
      AcceptedResultBuilder(session, controller)
        .setAvailableSessionCommands(sessionCommands)
        .build()
    )
  }
}

Java

private static class CustomMediaSessionCallback implements MediaSession.Callback {

  // Configure commands available to the controller in onConnect()
  @Override
  public ListenableFuture<ConnectionResult> onConnectAsync(
      MediaSession session, ControllerInfo controller) {
    SessionCommands sessionCommands =
        ConnectionResult.DEFAULT_SESSION_COMMANDS
            .buildUpon()
            .add(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
            .build();
    return Futures.immediateFuture(
        new AcceptedResultBuilder(session, controller)
            .setAvailableSessionCommands(sessionCommands)
            .build());
  }
}

如要接收來自 MediaController 的自訂指令要求,請覆寫 Callback 中的 onCustomCommand() 方法。

Kotlin

private class CustomCallback : MediaSession.Callback {
  // ...
  override fun onCustomCommand(
    session: MediaSession,
    controller: ControllerInfo,
    customCommand: SessionCommand,
    args: Bundle,
  ): ListenableFuture<SessionResult> {
    if (customCommand.customAction == SAVE_TO_FAVORITES) {
      // Do custom logic here
      saveToFavorites(session.player.currentMediaItem)
      return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
    }
    // ...
    return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
  }
}

Java

private static class CustomCallback implements MediaSession.Callback {
  // ...
  @Override
  public ListenableFuture<SessionResult> onCustomCommand(
      MediaSession session,
      ControllerInfo controller,
      SessionCommand customCommand,
      Bundle args) {
    if (customCommand.customAction.equals(SAVE_TO_FAVORITES)) {
      // Do custom logic here
      saveToFavorites(session.getPlayer().getCurrentMediaItem());
      return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
    }
    // ...
    return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
  }
}

您可以透過傳入 Callback 方法的 MediaSession.ControllerInfo 物件 packageName 屬性,追蹤哪個媒體控制器正在提出要求。這樣一來,您就能根據指定指令的來源 (系統、自己的應用程式或其他用戶端應用程式),調整應用程式的行為。

自訂預設播放器指令

所有預設指令和狀態處理都會委派給 MediaSession 上的 Player。如要自訂 Player 介面中定義的指令行為 (例如 play() 或 seekToNext()),請先將 Player 包裝在 ForwardingSimpleBasePlayer 中,再傳遞至 MediaSession:

Kotlin

val forwardingPlayer =
  object : ForwardingSimpleBasePlayer(player) {
    // Customizations
  }

val mediaSession = MediaSession.Builder(context, forwardingPlayer).build()

Java

ForwardingSimpleBasePlayer forwardingPlayer = new ForwardingSimpleBasePlayer(player) {
      // Customizations
    };

MediaSession mediaSession = new MediaSession.Builder(context, forwardingPlayer).build();

如要進一步瞭解 ForwardingSimpleBasePlayer,請參閱 ExoPlayer 的自訂指南。

識別播放器指令的要求控制器

當 MediaController 發出對 Player 方法的呼叫時,您可以使用 MediaSession.controllerForCurrentRequest 識別來源,並取得目前要求的 ControllerInfo:

Kotlin

private class CallerAwarePlayer(player: Player) : ForwardingSimpleBasePlayer(player) {
  private lateinit var session: MediaSession

  override fun handleSeek(
    mediaItemIndex: Int,
    positionMs: Long,
    seekCommand: Int,
  ): ListenableFuture<*> {
    Log.d(
      "caller",
      "seek operation from package ${session.controllerForCurrentRequest?.packageName}",
    )
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand)
  }
}

Java

private static final class CallerAwarePlayer extends ForwardingSimpleBasePlayer {
  private MediaSession session;

  public CallerAwarePlayer(Player player) {
    super(player);
  }

  @Override
  protected ListenableFuture<?> handleSeek(int mediaItemIndex, long positionMs, int seekCommand) {
    Log.d(
        "caller",
        "seek operation from package: "
            + session.getControllerForCurrentRequest().getPackageName());
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand);
  }
}

自訂媒體按鈕處理

媒體按鈕是 Android 裝置和其他周邊裝置上的硬體按鈕,例如藍牙頭戴式耳機上的播放/暫停按鈕。當媒體按鈕事件到達工作階段時,Media3 會為您處理這些事件,並在工作階段播放器上呼叫適當的 Player 方法。

建議您在對應的 Player 方法中處理所有傳入的媒體按鈕事件。如需更進階的用途,可在 MediaSession.Callback.onMediaButtonEvent(Intent) 中攔截媒體按鈕事件。

錯誤處理和回報

工作階段會發出兩種錯誤,並回報給控制器。嚴重錯誤會回報工作階段播放器發生技術性播放失敗,導致播放中斷。發生嚴重錯誤時,系統會自動回報給控制器。非致命錯誤是指非技術或政策錯誤,不會中斷播放,且由應用程式手動傳送至控制器。

嚴重播放錯誤

播放器會向工作階段回報嚴重播放錯誤,然後向控制器回報,以便透過 Player.Listener.onPlayerError(PlaybackException) 和 Player.Listener.onPlayerErrorChanged(@Nullable PlaybackException) 呼叫。

在這種情況下,播放狀態會轉換為 STATE_IDLE,且 MediaController.getPlaybackError() 會傳回導致轉換的 PlaybackException。控制器可以檢查 PlayerException.errorCode,取得錯誤原因的相關資訊。

設定自訂播放器錯誤

除了播放器回報的嚴重錯誤外,應用程式也可以使用 MediaSession.setPlaybackException(PlaybackException),在 MediaSession 層級設定自訂 PlaybackException。這樣一來,應用程式就能向連結的控制器發出錯誤狀態信號。您可以為所有連結的控制器或特定 ControllerInfo 設定例外狀況。

當應用程式使用這個 API 設定 PlaybackException 時:

  • 系統會通知已連結的 MediaController 執行個體。系統會使用提供的例外狀況,叫用控制器上的 Listener.onPlayerError(PlaybackException) 和 Listener.onPlayerErrorChanged(@Nullable PlaybackException) 回呼。

  • MediaController.getPlayerError() 方法會傳回應用程式設定的 PlaybackException。

  • 受影響控制器的播放狀態會變更為 Player.STATE_IDLE。

  • 系統會移除可用的指令,只保留 COMMAND_GET_TIMELINE 等讀取指令 (如果已授予)。舉例來說,Timeline 的狀態會凍結在例外狀況套用至控制器時的狀態。嘗試變更播放器狀態的指令 (例如 COMMAND_PLAY) 會遭到移除,直到應用程式移除指定控制器的播放例外狀況為止。

如要清除先前設定的自訂 PlaybackException,並還原正常的播放器狀態回報,應用程式可以呼叫 MediaSession.setPlaybackException(/* playbackException= */ null) 或 MediaSession.setPlaybackException(ControllerInfo, /* playbackException= */ null)。

自訂嚴重錯誤

如要向使用者提供本地化且有意義的資訊,您可以自訂實際播放器發生的嚴重播放錯誤,包括錯誤代碼、錯誤訊息和錯誤額外資訊。建構工作階段時,可以使用 ForwardingPlayer 達到這個目的:

Kotlin

val session = MediaSession.Builder(context, ErrorForwardingPlayer(context, player)).build()

Java

MediaSession session =
    new MediaSession.Builder(context, new ErrorForwardingPlayer(context, player)).build();

轉送播放器可以使用 ForwardingSimpleBasePlayer 攔截錯誤,並自訂錯誤代碼、訊息或額外項目。同樣地,您也可以產生原始播放器中不存在的新錯誤:

Kotlin

private class ErrorForwardingPlayer(private val context: Context, player: Player) :
  ForwardingSimpleBasePlayer(player) {

  override fun getState(): State {
    var state = super.getState()
    if (state.playerError != null) {
      state =
        state.buildUpon().setPlayerError(customizePlaybackException(state.playerError!!)).build()
    }
    return state
  }

  private fun customizePlaybackException(error: PlaybackException): PlaybackException {
    val buttonLabel: String
    val errorMessage: String
    when (error.errorCode) {
      PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
        buttonLabel = context.getString(R.string.err_button_label_restart_stream)
        errorMessage = context.getString(R.string.err_msg_behind_live_window)
      }
      else -> {
        buttonLabel = context.getString(R.string.err_button_label_ok)
        errorMessage = context.getString(R.string.err_message_default)
      }
    }
    val extras = Bundle()
    extras.putString("button_label", buttonLabel)
    return PlaybackException(errorMessage, error.cause, error.errorCode, extras)
  }
}

Java

private static class ErrorForwardingPlayer extends ForwardingSimpleBasePlayer {

  private final Context context;

  public ErrorForwardingPlayer(Context context, Player player) {
    super(player);
    this.context = context;
  }

  @Override
  protected State getState() {
    State state = super.getState();
    if (state.playerError != null) {
      state =
          state.buildUpon().setPlayerError(customizePlaybackException(state.playerError)).build();
    }
    return state;
  }

  private PlaybackException customizePlaybackException(PlaybackException error) {
    String buttonLabel;
    String errorMessage;
    switch (error.errorCode) {
      case PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW:
        buttonLabel = context.getString(R.string.err_button_label_restart_stream);
        errorMessage = context.getString(R.string.err_msg_behind_live_window);
        break;
      default:
        buttonLabel = context.getString(R.string.err_button_label_ok);
        errorMessage = context.getString(R.string.err_message_default);
        break;
    }
    Bundle extras = new Bundle();
    extras.putString("button_label", buttonLabel);
    return new PlaybackException(errorMessage, error.getCause(), error.errorCode, extras);
  }
}

非致命錯誤

如果非致命錯誤並非源自技術例外狀況,應用程式可以將錯誤傳送至所有或特定控制器:

Kotlin

val sessionError =
  SessionError(
    SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
    context.getString(R.string.error_message_authentication_expired),
  )

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError)

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
mediaSession.mediaNotificationControllerInfo?.let { mediaSession.sendError(it, sessionError) }

Java

SessionError sessionError =
    new SessionError(
        SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
        context.getString(R.string.error_message_authentication_expired));

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError);

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
ControllerInfo mediaNotificationControllerInfo =
    mediaSession.getMediaNotificationControllerInfo();
if (mediaNotificationControllerInfo != null) {
  mediaSession.sendError(mediaNotificationControllerInfo, sessionError);
}

當非重大錯誤傳送至媒體通知控制器時,錯誤代碼和錯誤訊息會複製到平台媒體工作階段,而 PlaybackState.state 不會變更為 STATE_ERROR。

接收非致命錯誤

MediaController 透過實作 MediaController.Listener.onError 接收非致命錯誤:

Kotlin

val future =
  MediaController.Builder(context, sessionToken)
    .setListener(
      object : MediaController.Listener {
        override fun onError(controller: MediaController, sessionError: SessionError) {
          // Handle nonfatal error.
        }
      }
    )
    .buildAsync()

Java

MediaController.Builder future =
    new MediaController.Builder(context, sessionToken)
        .setListener(
            new MediaController.Listener() {
              @Override
              public void onError(MediaController controller, SessionError sessionError) {
                // Handle nonfatal error.
              }
            });