使用 MediaSession 控制和发布播放内容

媒体会话提供了一种与音频或视频播放器交互的通用方式。在 Media3 中,默认播放器是 ExoPlayer 类,它实现了 Player 接口。将媒体会话连接到播放器允许应用在外部发布媒体播放并接收来自外部来源的播放命令。

命令可能源自物理按钮,例如耳机或电视遥控器上的播放按钮。它们也可能来自具有媒体控制器的客户端应用,例如指示 Google 助理“暂停”。媒体会话将这些命令委托给媒体应用的播放器。

何时选择媒体会话

当您实现 MediaSession 时,您允许用户控制播放

  • 通过他们的耳机。用户通常可以在耳机上执行按钮或触摸交互以播放或暂停媒体或跳转到下一首或上一首曲目。
  • 通过与Google 助理对话。一种常见的模式是说“好的 Google,暂停”来暂停设备上当前正在播放的任何媒体。
  • 通过他们的Wear OS 手表。这允许在手机上播放时更轻松地访问最常用的播放控制。
  • 通过媒体控件。此轮播显示每个正在运行的媒体会话的控件。
  • 电视上。允许使用物理播放按钮、平台播放控制和电源管理(例如,如果电视、条形音箱或 A/V 接收器关闭或输入切换,则应停止应用中的播放)。
  • 以及需要影响播放的任何其他外部进程。

这对许多用例来说非常棒。特别是,在以下情况下,您应该认真考虑使用 MediaSession

  • 您正在流式传输长格式视频内容,例如电影或直播电视。
  • 您正在流式传输长格式音频内容,例如播客或音乐播放列表。
  • 您正在构建一个电视应用

但是,并非所有用例都适合 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 都是唯一的。会话 ID 可以在使用 MediaSession.Builder.setId(String id) 构建会话时设置。

如果您看到一个 IllegalStateException 导致您的应用崩溃,并显示错误消息 IllegalStateException: Session ID must be unique. ID=,则可能是之前创建的具有相同 ID 的实例在释放之前意外创建了一个会话。为了避免会话因编程错误而泄漏,此类情况会被检测到并通过抛出异常进行通知。

授予其他客户端控制权限

媒体会话是控制播放的关键。它使您能够将来自外部源的命令路由到执行媒体播放工作的播放器。这些来源可以是物理按钮,例如耳机或电视遥控器上的播放按钮,也可以是间接命令,例如指示 Google 助理“暂停”。同样,您可能希望授予 Android 系统访问权限以方便通知和锁屏控件,或授予 Wear OS 手表访问权限,以便您可以从表盘控制播放。外部客户端可以使用媒体控制器向您的媒体应用发出播放命令。这些命令由您的媒体会话接收,媒体会话最终会将命令委托给媒体播放器。

A diagram demonstrating the interaction between a MediaSession and MediaController.
图 1:媒体控制器有助于将命令从外部源传递到媒体会话。

当控制器即将连接到您的媒体会话时,将调用 onConnect() 方法。您可以使用提供的 ControllerInfo 决定是接受还是拒绝请求。在声明可用命令部分中查看接受连接请求的示例。

连接后,控制器可以向会话发送播放命令。然后,会话将这些命令委托给播放器。在 Player 接口中定义的播放和播放列表命令由会话自动处理。

其他回调方法允许您处理例如对自定义播放命令修改播放列表的请求。这些回调也包含一个 ControllerInfo 对象,因此您可以根据每个控制器的基础修改您对每个请求的响应方式。

修改播放列表

媒体会话可以根据ExoPlayer 播放列表指南中说明的内容直接修改其播放器的播放列表。如果控制器可以访问 COMMAND_SET_MEDIA_ITEMCOMMAND_CHANGE_MEDIA_ITEMS 其中之一,则控制器也可以修改播放列表。

在将新项目添加到播放列表时,播放器通常需要具有已定义 URIMediaItem 实例才能使其可播放。默认情况下,如果新添加的项目具有已定义的 URI,则会自动转发到播放器方法,例如 player.addMediaItem

如果您想自定义添加到播放器的 MediaItem 实例,您可以覆盖 onAddMediaItems()。当您希望支持请求没有定义 URI 的媒体的控制器时,需要执行此步骤。相反,MediaItem 通常设置以下字段之一或多个来描述请求的媒体

  • MediaItem.id:标识媒体的通用 ID。
  • MediaItem.RequestMetadata.mediaUri:可能使用自定义方案的请求 URI,不一定可以直接由播放器播放。
  • MediaItem.RequestMetadata.searchQuery:文本搜索查询,例如来自 Google 助理。
  • MediaItem.MediaMetadata:结构化元数据,如“标题”或“艺术家”。

有关完全新播放列表的更多自定义选项,您还可以覆盖 onSetMediaItems(),它允许您定义播放列表中的起始项目和位置。例如,您可以将单个请求的项目扩展到整个播放列表,并指示播放器从最初请求的项目的索引开始。在会话演示应用中可以找到此功能的 onSetMediaItems() 的示例实现

管理自定义布局和自定义命令

以下部分描述了如何向客户端应用宣传自定义命令按钮的自定义布局,以及如何授权控制器发送自定义命令。

定义会话的自定义布局

要指示客户端应用您希望向用户显示哪些播放控件,请在构建 MediaSession 时设置会话的自定义布局,在您服务的 onCreate() 方法中。

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 包装在 ForwardingPlayer 中。

Kotlin

val player = ExoPlayer.Builder(context).build()

val forwardingPlayer = object : ForwardingPlayer(player) {
  override fun play() {
    // Add custom logic
    super.play()
  }

  override fun setPlayWhenReady(playWhenReady: Boolean) {
    // Add custom logic
    super.setPlayWhenReady(playWhenReady)
  }
}

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

Java

ExoPlayer player = new ExoPlayer.Builder(context).build();

ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player) {
  @Override
  public void play() {
    // Add custom logic
    super.play();
  }

  @Override
  public void setPlayWhenReady(boolean playWhenReady) {
    // Add custom logic
    super.setPlayWhenReady(playWhenReady);
  }
};

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

有关 ForwardingPlayer 的更多信息,请参阅 ExoPlayer 指南中的 自定义 部分。

识别播放器命令的请求控制器

当对 Player 方法的调用源自 MediaController 时,您可以使用 MediaSession.controllerForCurrentRequest 识别源头并获取当前请求的 ControllerInfo

Kotlin

class CallerAwareForwardingPlayer(player: Player) :
  ForwardingPlayer(player) {

  override fun seekToNext() {
    Log.d(
      "caller",
      "seekToNext called from package ${session.controllerForCurrentRequest?.packageName}"
    )
    super.seekToNext()
  }
}

Java

public class CallerAwareForwardingPlayer extends ForwardingPlayer {
  public CallerAwareForwardingPlayer(Player player) {
    super(player);
  }

  @Override
  public void seekToNext() {
    Log.d(
        "caller",
        "seekToNext called from package: "
            + session.getControllerForCurrentRequest().getPackageName());
    super.seekToNext();
  }
}

响应媒体按钮

媒体按钮是 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.
              }
            });