使用 MediaSession 控制及放送廣告

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

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

選擇媒體工作階段的時機

實作 MediaSession 時,您可以讓使用者控制播放作業:

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

這非常適合許多用途。特別是下列情況,您應考慮使用 MediaSession

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

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

  • 您正在顯示短片內容,使用者參與度和互動性至關重要。
  • 沒有單一活動影片,例如使用者正在捲動清單,且畫面上同時顯示多部影片。
  • 您正在播放一次性介紹或說明影片,並希望使用者主動觀看。
  • 您的內容屬於隱私敏感內容,且您不希望外部程序存取媒體中繼資料 (例如瀏覽器的無痕模式)

如果您的用途不符合上述任何情況,請考慮在使用者未積極與內容互動時,是否允許應用程式繼續播放內容。如果答案是肯定的,您可能要選擇 MediaSession。如果答案為否,您可能需要改用 Player

建立媒體工作階段

媒體工作階段會與其管理的播放器一同存在。您可以使用 ContextPlayer 物件建立媒體工作階段。您應在需要時建立及初始化媒體工作階段,例如 ActivityFragmentonStart()onResume() 生命週期方法,或是擁有媒體工作階段及其相關播放器的 ServiceonCreate() 方法。

如要建立媒體工作階段,請初始化 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 程式庫會使用播放器的狀態自動更新媒體工作階段。因此,您不需要手動處理從玩家到工作階段的對應。

這與舊版方法不同,舊版方法需要在播放器本身之外獨立建立及維護 PlaybackStateCompat,例如用於指出任何錯誤。

專屬工作階段 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_ITEMCOMMAND_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:結構化中繼資料,例如「title」或「artist」。

如要為全新播放清單提供更多自訂選項,您可以另外覆寫 onSetMediaItems(),藉此定義播放清單中的開始項目和位置。舉例來說,您可以將單一要求的項目擴展至整個播放清單,並指示播放器從原本要求的項目索引開始播放。您可以在工作階段示範應用程式中,找到onSetMediaItems() 使用這項功能的實作範例。

管理自訂版面配置和自訂指令

以下各節將說明如何向用戶端應用程式宣傳自訂指令按鈕的自訂版面配置,以及授權控制器傳送自訂指令。

定義工作階段的自訂版面配置

如要向用戶端應用程式指出要向使用者顯示哪些播放控制項,請在服務的 onCreate() 方法中建立 MediaSession 時,設定工作階段的自訂版面配置

Kotlin

override fun onCreate() {
  super.onCreate()

  val likeButton = CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build()
  val favoriteButton = CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(SessionCommand(SAVE_TO_FAVORITES, Bundle()))
    .build()

  session =
    MediaSession.Builder(this, player)
      .setCallback(CustomMediaSessionCallback())
      .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
      .build()
}

Java

@Override
public void onCreate() {
  super.onCreate();

  CommandButton likeButton = new CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build();
  CommandButton favoriteButton = new CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
    .build();

  Player player = new ExoPlayer.Builder(this).build();
  mediaSession =
      new MediaSession.Builder(this, player)
          .setCallback(new CustomMediaSessionCallback())
          .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
          .build();
}

宣告可用的播放器和自訂指令

媒體應用程式可以定義自訂指令,例如可用於自訂版面配置的指令。舉例來說,您可能會想實作按鈕,讓使用者將媒體項目儲存至最愛項目清單。MediaController 會傳送自訂指令,而 MediaSession.Callback 會接收這些指令。

您可以定義 MediaController 連線至媒體工作階段時,可使用的自訂工作階段指令。您可以透過覆寫 MediaSession.Callback.onConnect() 來達成此目的。在 onConnect 回呼方法中,接受來自 MediaController 的連線要求時,設定並傳回可用指令集:

Kotlin

private inner class CustomMediaSessionCallback: MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  override fun onConnect(
    session: MediaSession,
    controller: MediaSession.ControllerInfo
  ): MediaSession.ConnectionResult {
    val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
        .add(SessionCommand(SAVE_TO_FAVORITES, Bundle.EMPTY))
        .build()
    return AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build()
  }
}

Java

class CustomMediaSessionCallback implements MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  @Override
  public ConnectionResult onConnect(
    MediaSession session,
    ControllerInfo controller) {
    SessionCommands sessionCommands =
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
            .add(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
            .build();
    return new AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build();
  }
}

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

Kotlin

private inner class CustomMediaSessionCallback: MediaSession.Callback {
  ...
  override fun onCustomCommand(
    session: MediaSession,
    controller: MediaSession.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)
      )
    }
    ...
  }
}

Java

class CustomMediaSessionCallback 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)
      );
    }
    ...
  }
}

您可以使用傳入 Callback 方法的 MediaSession.ControllerInfo 物件 packageName 屬性,追蹤哪個媒體控制器提出要求。這可讓您針對來自系統、您自己的應用程式或其他用戶端應用程式的特定指令,調整應用程式的回應行為。

在使用者互動後更新自訂版面配置

處理自訂指令或與玩家進行任何其他互動後,您可能會想要更新控制器 UI 中顯示的版面配置。典型範例是切換按鈕,在觸發與此按鈕相關聯的動作後,會變更圖示。如要更新版面配置,您可以使用 MediaSession.setCustomLayout

Kotlin

val removeFromFavoritesButton = CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(SessionCommand(REMOVE_FROM_FAVORITES, Bundle()))
  .build()
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton))

Java

CommandButton removeFromFavoritesButton = new CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(new SessionCommand(REMOVE_FROM_FAVORITES, new Bundle()))
  .build();
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton));

自訂播放指令行為

如要自訂 Player 介面中定義的指令行為 (例如 play()seekToNext()),請先將 Player 包裝在 ForwardingSimpleBasePlayer 中,再傳遞至 MediaSession

Kotlin

val player = (logic to build a Player instance)

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

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

Java

ExoPlayer player = (logic to build a Player instance)

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

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

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

找出玩家指令的請求控制器

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

Kotlin

class CallerAwarePlayer(player: Player) :
  ForwardingSimpleBasePlayer(player) {

  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

public class CallerAwarePlayer extends ForwardingSimpleBasePlayer {
  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 方法。

應用程式可以覆寫 MediaSession.Callback.onMediaButtonEvent(Intent) 來覆寫預設行為。在這種情況下,應用程式可以/需要自行處理所有 API 特定事項。

錯誤處理和回報

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

嚴重播放錯誤

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

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

為了達到互通性,系統會將致命錯誤複製到平台工作階段的 PlaybackStateCompat,方法是將其狀態轉換為 STATE_ERROR,並根據 PlaybackException 設定錯誤代碼和訊息。

自訂致命錯誤

如要為使用者提供經過本地化的有意義資訊,您可以在建構工作階段時使用 ForwardingPlayer,自訂致命播放錯誤的錯誤代碼、錯誤訊息和錯誤額外資訊:

Kotlin

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

Java

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

轉送播放器會向實際播放器註冊 Player.Listener,並攔截回報錯誤的回呼。接著,系統會將自訂的 PlaybackException 委派給在轉寄播放器上註冊的監聽器。為使這項功能正常運作,轉寄播放器會覆寫 Player.addListenerPlayer.removeListener,以便存取可用於傳送自訂錯誤代碼、訊息或額外內容的事件監聽器:

Kotlin

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

  private val listeners: MutableList<Player.Listener> = mutableListOf()

  private var customizedPlaybackException: PlaybackException? = null

  init {
    player.addListener(ErrorCustomizationListener())
  }

  override fun addListener(listener: Player.Listener) {
    listeners.add(listener)
  }

  override fun removeListener(listener: Player.Listener) {
    listeners.remove(listener)
  }

  override fun getPlayerError(): PlaybackException? {
    return customizedPlaybackException
  }

  private inner class ErrorCustomizationListener : Player.Listener {

    override fun onPlayerErrorChanged(error: PlaybackException?) {
      customizedPlaybackException = error?.let { customizePlaybackException(it) }
      listeners.forEach { it.onPlayerErrorChanged(customizedPlaybackException) }
    }

    override fun onPlayerError(error: PlaybackException) {
      listeners.forEach { it.onPlayerError(customizedPlaybackException!!) }
    }

    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)
        }
        // Apps can customize further error messages by adding more branches.
        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)
    }

    override fun onEvents(player: Player, events: Player.Events) {
      listeners.forEach {
        it.onEvents(player, events)
      }
    }
    // Delegate all other callbacks to all listeners without changing arguments like onEvents.
  }
}

Java

private static class ErrorForwardingPlayer extends ForwardingPlayer {

  private final Context context;
  private List<Player.Listener> listeners;
  @Nullable private PlaybackException customizedPlaybackException;

  public ErrorForwardingPlayer(Context context, Player player) {
    super(player);
    this.context = context;
    listeners = new ArrayList<>();
    player.addListener(new ErrorCustomizationListener());
  }

  @Override
  public void addListener(Player.Listener listener) {
    listeners.add(listener);
  }

  @Override
  public void removeListener(Player.Listener listener) {
    listeners.remove(listener);
  }

  @Nullable
  @Override
  public PlaybackException getPlayerError() {
    return customizedPlaybackException;
  }

  private class ErrorCustomizationListener implements Listener {

    @Override
    public void onPlayerErrorChanged(@Nullable PlaybackException error) {
      customizedPlaybackException =
          error != null ? customizePlaybackException(error, context) : null;
      for (int i = 0; i < listeners.size(); i++) {
        listeners.get(i).onPlayerErrorChanged(customizedPlaybackException);
      }
    }

    @Override
    public void onPlayerError(PlaybackException error) {
      for (int i = 0; i < listeners.size(); i++) {
        listeners.get(i).onPlayerError(checkNotNull(customizedPlaybackException));
      }
    }

    private PlaybackException customizePlaybackException(
        PlaybackException error, Context context) {
      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;
        // Apps can customize further error messages by adding more case statements.
        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);
    }

    @Override
    public void onEvents(Player player, Events events) {
      for (int i = 0; i < listeners.size(); i++) {
        listeners.get(i).onEvents(player, events);
      }
    }
    // Delegate all other callbacks to all listeners without changing arguments like onEvents.
  }
}

非致命錯誤

應用程式可以將技術例外狀況所導致的非致命錯誤傳送至所有或特定控制器:

Kotlin

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

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

// Interoperability: Sending a nonfatal error to the media notification controller 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));

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

// Interoperability: Sending a nonfatal error to the media notification controller 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);
}

傳送至媒體通知控制器的非致命錯誤會複製至平台工作階段的 PlaybackStateCompat。因此,只有錯誤代碼和錯誤訊息會依據 PlaybackStateCompat 進行設定,而 PlaybackStateCompat.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.
              }
            });