使用 MediaSession 控制和通告播放

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

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

何时选择媒体会话

实现 MediaSession 时,您可以允许用户控制播放:

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

这在许多用例中都非常有用。具体而言,在以下情况下,强烈建议您考虑使用 MediaSession

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

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

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

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

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

如需为全新播放列表提供更多自定义选项,您还可以替换 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 属性来跟踪发出请求的媒体控制器。这样一来,如果来自系统、您自己的应用或其他客户端应用的命令都来自于指定命令,那么您就可以定制应用的行为。

在用户进行互动后更新自定义布局

处理自定义命令或与播放器的任何其他互动后,您可能需要更新控制器界面中显示的布局。一个典型的示例就是切换按钮,当触发与此按钮相关的操作后,该按钮会更改其图标。如需更新布局,您可以使用 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 指南中的自定义部分。

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

MediaController 发起对 Player 方法的调用时,您可以使用 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 细节。