使用 MediaSession 控制和通告播放

媒体会话提供了一种与音频或视频播放器互动的方式。在 Media3 中,默认播放器是 ExoPlayer 类,该类实现了 Player 接口。将媒体会话连接到播放器后,应用可以向外部通告媒体播放,并接收来自外部来源的播放命令。

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

何时选择媒体会话

实现 MediaSession 后,您便可允许用户控制播放:

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

这对于许多应用场景都非常有用。具体而言,在以下情况下,您应强烈考虑使用 MediaSession:

  • 您正在在线播放长视频内容,例如电影或直播电视。
  • 您正在在线播放长音频内容,例如播客或音乐播放列表。
  • 您正在构建电视应用。

不过,并非所有用例都适合 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,则控制器还可以修改播放列表。

向播放列表中添加新内容时,播放器通常需要具有已定义 URI 的 MediaItem 实例,才能播放这些内容。默认情况下,新添加的内容项如果定义了 URI,则会自动转发到 player.addMediaItem 等播放器方法。

如果您想自定义添加到播放器的 MediaItem 实例,可以替换 onAddMediaItems()。如果您想支持请求未定义 URI 的媒体的控制器,则需要执行此步骤。相反,MediaItem 通常会设置以下一个或多个字段来描述所请求的媒体:

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

如需为全新播放列表提供更多自定义选项,您还可以替换 onSetMediaItems(),以便在播放列表中定义起始项和位置。例如,您可以将单个请求的内容扩展到整个播放列表,并指示播放器从最初请求的内容的索引开始播放。您可以在会话演示应用中找到具有此功能的 onSetMediaItems() 的示例实现。

管理媒体按钮偏好设置

每个控制器(例如系统界面、Android Auto 或 Wear OS)都可以自行决定向用户显示哪些按钮。如需指明您要向用户显示哪些播放控件,您可以在 MediaSession 上指定媒体按钮偏好设置。这些偏好设置由 CommandButton 实例的有序列表组成,每个实例都定义了界面中按钮的偏好设置。

定义命令按钮

CommandButton 实例用于定义媒体按钮偏好设置。每个按钮都定义了所需界面元素的三个方面:

  1. 图标,用于定义视觉外观。创建 CommandButton.Builder 时,必须将图标设置为预定义的常量之一。请注意,这不是实际的位图或图片资源。通用常量可帮助控制器选择适当的资源,以便在其自己的界面中实现一致的外观和风格。如果预定义的图标常量都不适合您的应用场景,您可以改用 setCustomIconResId。
  2. Command,用于定义用户与按钮互动时触发的操作。您可以将 setPlayerCommand 用于 Player.Command,或将 setSessionCommand 用于预定义或自定义 SessionCommand。
  3. Slot,用于定义按钮在控制器界面中的放置位置。此字段是可选字段,系统会根据图标和命令自动设置。例如,它允许指定应在界面的“前进”导航区域(而非默认的“溢出”区域)中显示按钮。

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 生成预览,了解如何根据界面显示限制条件解析媒体按钮偏好设置。

设置媒体按钮偏好

设置媒体按钮偏好设置的最简单方法是在构建 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();

在用户互动后更新媒体按钮偏好设置

处理与播放器的互动后,您可能需要更新控制器界面中显示的按钮。一个典型的例子是切换按钮,它在触发与此按钮关联的操作后会更改其图标和操作。如需更新媒体按钮偏好设置,您可以使用 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 指南。

识别玩家命令的请求控制器

当对 Player 方法的调用源自 MediaController 时,您可以使用 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)。

自定义严重错误

为了向用户提供本地化且有意义的信息,您可以自定义来自实际播放器的严重播放错误的错误代码、出错提示和错误 extra。这可以通过在构建会话时使用 ForwardingPlayer 来实现:

Kotlin

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

Java

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

转发播放器可以使用 ForwardingSimpleBasePlayer 拦截错误并自定义错误代码、消息或 extra。同样,您也可以生成原始播放器中不存在的新错误:

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