媒体控件

Android 中的媒体控件位于“快捷设置”旁。来自多个应用的会话排列在一个可滑动的轮播界面中。轮播界面按以下顺序列出会话:

  • 在手机本地播放的会话流
  • 远程会话流,例如在外部设备上检测到的会话或投射会话
  • 可继续播放的以前的会话(按上次播放的顺序排列)

从 Android 13(API 级别 33)开始,为确保用户可以为播放媒体的应用访问一组丰富的媒体控件,媒体控件上的操作按钮派生自 Player 状态。

这样一来,您就可以在各种设备上呈现一组一致的媒体控件,并提供更出色的媒体控件体验。

图 1 分别展示了在手机和平板电脑设备上的显示效果。

媒体控件在手机和平板电脑设备上的显示方式,用一首示例歌曲演示按钮可能的显示效果
图 1 :手机和平板电脑设备上的媒体控件

系统会根据 Player 状态显示最多五个操作按钮,如下表所述。在紧凑模式下,系统只会显示前三个操作槽位。这与汽车、Google 助理和 Wear OS 等其他 Android 平台上的媒体控件的呈现方式一致。

槽位 条件 操作
1 playWhenReady 为 false 或当前播放状态STATE_ENDED 播放
playWhenReady 为 true,当前播放状态STATE_BUFFERING 加载旋转图标
playWhenReady 为 true,并且当前播放状态STATE_READY 暂停
2 可使用播放器命令 COMMAND_SEEK_TO_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM 上一页
无论是播放器指令COMMAND_SEEK_TO_PREVIOUS还是COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,系统均无法使用,且来自尚未放置的自定义布局的自定义指令无法填满插槽。 自定义
(Media3 尚不支持)PlaybackState extra 包含键 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREVtrue 布尔值。 未连接
3 可使用播放器命令 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM 下一步
无论是播放器指令COMMAND_SEEK_TO_NEXT还是COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,系统均无法使用,且来自尚未放置的自定义布局的自定义指令无法填满插槽。 自定义
(Media3 尚不支持)PlaybackState extra 包含键 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXTtrue 布尔值。 未连接
4 您可以使用自定义布局中尚未放置的自定义命令来填充该槽位。 自定义
5 自定义布局中尚未放置的自定义命令可用于填充广告位。 自定义

自定义命令会按照添加到自定义布局中的顺序执行。

自定义命令按钮

如需使用 Jetpack Media3 自定义系统媒体控件,您可以在实现 MediaSessionService 时相应地设置会话的自定义布局和控制器的可用命令:

  1. onCreate() 中,构建 MediaSession定义命令按钮的自定义布局

  2. MediaSession.Callback.onConnect() 中,通过在 ConnectionResult 中定义可用命令(包括自定义命令)来为控制器授权。

  3. MediaSession.Callback.onCustomCommand() 中,响应用户选择的自定义命令。

Kotlin

class PlaybackService : MediaSessionService() {
  private val customCommandFavorites = SessionCommand(ACTION_FAVORITES, Bundle.EMPTY)
  private var mediaSession: MediaSession? = null

  override fun onCreate() {
    super.onCreate()
    val favoriteButton =
      CommandButton.Builder()
        .setDisplayName("Save to favorites")
        .setIconResId(R.drawable.favorite_icon)
        .setSessionCommand(customCommandFavorites)
        .build()
    val player = ExoPlayer.Builder(this).build()
    // Build the session with a custom layout.
    mediaSession =
      MediaSession.Builder(this, player)
        .setCallback(MyCallback())
        .setCustomLayout(ImmutableList.of(favoriteButton))
        .build()
  }

  private inner class MyCallback : MediaSession.Callback {
    override fun onConnect(
      session: MediaSession,
      controller: MediaSession.ControllerInfo
    ): ConnectionResult {
    // Set available player and session commands.
    return AcceptedResultBuilder(session)
      .setAvailablePlayerCommands(
        ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
          .remove(COMMAND_SEEK_TO_NEXT)
          .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
          .remove(COMMAND_SEEK_TO_PREVIOUS)
          .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
          .build()
      )
      .setAvailableSessionCommands(
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
          .add(customCommandFavorites)
          .build()
      )
      .build()
    }

    override fun onCustomCommand(
      session: MediaSession,
      controller: MediaSession.ControllerInfo,
      customCommand: SessionCommand,
      args: Bundle
    ): ListenableFuture {
      if (customCommand.customAction == ACTION_FAVORITES) {
        // Do custom logic here
        saveToFavorites(session.player.currentMediaItem)
        return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
      }
      return super.onCustomCommand(session, controller, customCommand, args)
    }
  }
}

Java

public class PlaybackService extends MediaSessionService {
  private static final SessionCommand CUSTOM_COMMAND_FAVORITES =
      new SessionCommand("ACTION_FAVORITES", Bundle.EMPTY);
  @Nullable private MediaSession mediaSession;

  public void onCreate() {
    super.onCreate();
    CommandButton favoriteButton =
        new CommandButton.Builder()
            .setDisplayName("Save to favorites")
            .setIconResId(R.drawable.favorite_icon)
            .setSessionCommand(CUSTOM_COMMAND_FAVORITES)
            .build();
    Player player = new ExoPlayer.Builder(this).build();
    // Build the session with a custom layout.
    mediaSession =
        new MediaSession.Builder(this, player)
            .setCallback(new MyCallback())
            .setCustomLayout(ImmutableList.of(favoriteButton))
            .build();
  }

  private static class MyCallback implements MediaSession.Callback {
    @Override
    public ConnectionResult onConnect(
        MediaSession session, MediaSession.ControllerInfo controller) {
      // Set available player and session commands.
      return new AcceptedResultBuilder(session)
          .setAvailablePlayerCommands(
              ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
                .remove(COMMAND_SEEK_TO_NEXT)
                .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
                .remove(COMMAND_SEEK_TO_PREVIOUS)
                .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
                .build())
          .setAvailableSessionCommands(
              ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
                .add(CUSTOM_COMMAND_FAVORITES)
                .build())
          .build();
    }

    public ListenableFuture onCustomCommand(
        MediaSession session,
        MediaSession.ControllerInfo controller,
        SessionCommand customCommand,
        Bundle args) {
      if (customCommand.customAction.equals(CUSTOM_COMMAND_FAVORITES.customAction)) {
        // Do custom logic here
        saveToFavorites(session.getPlayer().getCurrentMediaItem());
        return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
      }
      return MediaSession.Callback.super.onCustomCommand(
          session, controller, customCommand, args);
    }
  }
}

如需详细了解如何配置 MediaSession,以便系统等客户端可以连接到您的媒体应用,请参阅向其他客户端授予控制权

使用 Jetpack Media3 时,当您实现 MediaSession 时,您的 PlaybackState 会自动与媒体播放器保持同步。同样,当您实现 MediaSessionService 时,该库会自动为您发布 MediaStyle 通知并保持最新状态。

响应操作按钮

当用户点按系统媒体控件中的操作按钮时,系统的 MediaController 会向您的 MediaSession 发送播放命令。然后,MediaSession 会将这些命令委托给玩家。Media3 的 Player 接口中定义的命令由媒体会话自动处理。

如需有关如何响应自定义命令的指导,请参阅添加自定义命令

Android 13 之前的行为

为了向后兼容,系统界面会继续提供使用通知操作的备用布局,以支持不会更新为以 Android 13 为目标平台的应用或不包含 PlaybackState 信息的应用。这些操作按钮派生自 MediaStyle 通知所附加的 Notification.Action 列表。系统会按照添加顺序显示最多五项操作。在紧凑模式下,系统最多会显示三个按钮,具体取决于传递给 setShowActionsInCompactView() 的值。

自定义操作会按照添加到 PlaybackState 中的顺序执行。

以下代码示例说明了如何向 MediaStyle 通知添加操作:

Kotlin

import androidx.core.app.NotificationCompat
import androidx.media3.session.MediaStyleNotificationHelper

var notification = NotificationCompat.Builder(context, CHANNEL_ID)
        // Show controls on lock screen even when user hides sensitive content.
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        // Add media control buttons that invoke intents in your media service
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent) // #0
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent) // #1
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent) // #2
        // Apply the media style template
        .setStyle(MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build()

Java

import androidx.core.app.NotificationCompat;
import androidx.media3.session.MediaStyleNotificationHelper;

NotificationCompat.Builder notification = new NotificationCompat.Builder(context, CHANNEL_ID)
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setSmallIcon(R.drawable.ic_stat_player)
        .addAction(R.drawable.ic_prev, "Previous", prevPendingIntent)
        .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent)
        .addAction(R.drawable.ic_next, "Next", nextPendingIntent)
        .setStyle(new MediaStyleNotificationHelper.MediaStyle(mediaSession)
                .setShowActionsInCompactView(1 /* #1: pause button */))
        .setContentTitle("Wonderful music")
        .setContentText("My Awesome Band")
        .setLargeIcon(albumArtBitmap)
        .build();

支持继续播放媒体内容

借助媒体恢复功能,用户无需启动相关应用即可在轮播界面中重新开始播放以前的会话。当播放开始后,用户可按常规方式与媒体控件互动。

您可以使用“设置”应用在声音 > 媒体选项下开启和关闭播放恢复功能。用户还可以点按展开后的轮播界面上显示的齿轮图标来访问“设置”。

Media3 提供了一些 API,可让您更轻松地支持媒体恢复。如需有关实现此功能的指导,请参阅使用 Media3 恢复播放文档。

使用旧版媒体 API

本部分介绍了如何使用旧版 MediaCompat API 与系统媒体控件集成。

系统会从 MediaSessionMediaMetadata 中检索以下信息,并在其可用时显示这些信息:

  • METADATA_KEY_ALBUM_ART_URI
  • METADATA_KEY_TITLE
  • METADATA_KEY_DISPLAY_TITLE
  • METADATA_KEY_ARTIST
  • METADATA_KEY_DURATION(如果未设置时长,进度条不会显示进度)

为确保媒体控件通知有效且准确,请将 METADATA_KEY_TITLEMETADATA_KEY_DISPLAY_TITLE 元数据的值设置为当前正在播放的媒体的标题。

媒体播放器会显示当前正在播放的媒体内容的已播时间,以及映射到 MediaSession PlaybackState 的进度条。

媒体播放器会显示当前正在播放的媒体内容的进度,以及映射到 MediaSession PlaybackState 的进度条。用户可以使用快进/快退条更改位置,并显示媒体内容的播放时长。为了启用进度条,您必须实现 PlaybackState.Builder#setActions 并包含 ACTION_SEEK_TO

槽位 操作 条件
1 播放 PlaybackState 的当前状态是以下状态之一:
  • STATE_NONE
  • STATE_STOPPED
  • STATE_PAUSED
  • STATE_ERROR
加载旋转图标 PlaybackState 的当前状态是以下状态之一:
  • STATE_CONNECTING
  • STATE_BUFFERING
暂停 PlaybackState 的当前状态不是以上任何状态。
2 上一首 PlaybackState 操作包含 ACTION_SKIP_TO_PREVIOUS
自定义 PlaybackState 操作不包含 ACTION_SKIP_TO_PREVIOUS,并且 PlaybackState 自定义操作包含尚未执行的自定义操作。
未连接 PlaybackState extra 包含键 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREVtrue 布尔值。
3 下一首 PlaybackState 操作包括 ACTION_SKIP_TO_NEXT
自定义 PlaybackState 操作不包含 ACTION_SKIP_TO_NEXT,并且 PlaybackState 自定义操作包含尚未执行的自定义操作。
未连接 PlaybackState extra 包含键 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXTtrue 布尔值。
4 自定义 PlaybackState 自定义操作包含尚未执行的自定义操作。
5 自定义 PlaybackState 自定义操作包含尚未执行的自定义操作。

添加标准操作

以下代码示例展示了如何添加 PlaybackState 标准和自定义操作。

对于播放、暂停、上一个和下一个,请在媒体会话的 PlaybackState 中设置这些操作。

Kotlin

val session = MediaSessionCompat(context, TAG)
val playbackStateBuilder = PlaybackStateCompat.Builder()
val style = NotificationCompat.MediaStyle()

// For this example, the media is currently paused:
val state = PlaybackStateCompat.STATE_PAUSED
val position = 0L
val playbackSpeed = 1f
playbackStateBuilder.setState(state, position, playbackSpeed)

// And the user can play, skip to next or previous, and seek
val stateActions = PlaybackStateCompat.ACTION_PLAY
    or PlaybackStateCompat.ACTION_PLAY_PAUSE
    or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    or PlaybackStateCompat.ACTION_SEEK_TO // adding the seek action enables seeking with the seekbar
playbackStateBuilder.setActions(stateActions)

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build())
style.setMediaSession(session.sessionToken)
notificationBuilder.setStyle(style)

Java

MediaSessionCompat session = new MediaSessionCompat(context, TAG);
PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder();
NotificationCompat.MediaStyle style = new NotificationCompat.MediaStyle();

// For this example, the media is currently paused:
int state = PlaybackStateCompat.STATE_PAUSED;
long position = 0L;
float playbackSpeed = 1f;
playbackStateBuilder.setState(state, position, playbackSpeed);

// And the user can play, skip to next or previous, and seek
long stateActions = PlaybackStateCompat.ACTION_PLAY
    | PlaybackStateCompat.ACTION_PLAY_PAUSE
    | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
    | PlaybackStateCompat.ACTION_SKIP_TO_NEXT
    | PlaybackStateCompat.ACTION_SEEK_TO; // adding this enables the seekbar thumb
playbackStateBuilder.setActions(stateActions);

// ... do more setup here ...

session.setPlaybackState(playbackStateBuilder.build());
style.setMediaSession(session.getSessionToken());
notificationBuilder.setStyle(style);

如果您不希望在前一个或下一个槽位中显示任何按钮,请勿添加 ACTION_SKIP_TO_PREVIOUSACTION_SKIP_TO_NEXT,而是向会话添加 extra:

Kotlin

session.setExtras(Bundle().apply {
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true)
    putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true)
})

Java

Bundle extras = new Bundle();
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV, true);
extras.putBoolean(SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT, true);
session.setExtras(extras);

添加自定义操作

对于您希望在媒体控件上显示的其他操作,您可以改为创建 PlaybackStateCompat.CustomAction 并将其添加到 PlaybackState。这些操作会按添加的顺序显示。

Kotlin

val customAction = PlaybackStateCompat.CustomAction.Builder(
    "com.example.MY_CUSTOM_ACTION", // action ID
    "Custom Action", // title - used as content description for the button
    R.drawable.ic_custom_action
).build()

playbackStateBuilder.addCustomAction(customAction)

Java

PlaybackStateCompat.CustomAction customAction = new PlaybackStateCompat.CustomAction.Builder(
        "com.example.MY_CUSTOM_ACTION", // action ID
        "Custom Action", // title - used as content description for the button
        R.drawable.ic_custom_action
).build();

playbackStateBuilder.addCustomAction(customAction);

响应 PlaybackState 操作

当用户点按按钮时,SystemUI 会使用 MediaController.TransportControls 将命令发回给 MediaSession。您需要注册一个能够正确响应这些事件的回调。

Kotlin

val callback = object: MediaSession.Callback() {
    override fun onPlay() {
        // start playback
    }

    override fun onPause() {
        // pause playback
    }

    override fun onSkipToPrevious() {
        // skip to previous
    }

    override fun onSkipToNext() {
        // skip to next
    }

    override fun onSeekTo(pos: Long) {
        // jump to position in track
    }

    override fun onCustomAction(action: String, extras: Bundle?) {
        when (action) {
            CUSTOM_ACTION_1 -> doCustomAction1(extras)
            CUSTOM_ACTION_2 -> doCustomAction2(extras)
            else -> {
                Log.w(TAG, "Unknown custom action $action")
            }
        }
    }

}

session.setCallback(callback)

Java

MediaSession.Callback callback = new MediaSession.Callback() {
    @Override
    public void onPlay() {
        // start playback
    }

    @Override
    public void onPause() {
        // pause playback
    }

    @Override
    public void onSkipToPrevious() {
        // skip to previous
    }

    @Override
    public void onSkipToNext() {
        // skip to next
    }

    @Override
    public void onSeekTo(long pos) {
        // jump to position in track
    }

    @Override
    public void onCustomAction(String action, Bundle extras) {
        if (action.equals(CUSTOM_ACTION_1)) {
            doCustomAction1(extras);
        } else if (action.equals(CUSTOM_ACTION_2)) {
            doCustomAction2(extras);
        } else {
            Log.w(TAG, "Unknown custom action " + action);
        }
    }
};

媒体继续播放

如需让您的播放器应用显示在快捷设置区域,您必须创建包含有效 MediaSession 令牌的 MediaStyle 通知。

如需显示 MediaStyle 通知的标题,请使用 NotificationBuilder.setContentTitle()

如需显示媒体播放器的品牌图标,请使用 NotificationBuilder.setSmallIcon()

如需支持“继续播放媒体内容”功能,应用必须实现 MediaBrowserServiceMediaSession。您的 MediaSession 必须实现 onPlay() 回调。

MediaBrowserService 实现

设备启动后,系统会查找最近使用过的五个媒体应用,并提供可用于在每个应用中重新开始播放媒体内容的控件。

系统会尝试通过来自 SystemUI 的连接与您的 MediaBrowserService 联系。您的应用必须允许此类连接,否则无法支持“继续播放媒体内容”功能。

您可以使用软件包名称 com.android.systemui 和签名来标识和验证来自 SystemUI 的连接。SystemUI 使用平台签名进行签名。您可以在 UAMP 应用中找到有关如何检查平台签名的示例。

为了支持“继续播放媒体内容”功能,MediaBrowserService 必须实现以下行为:

  • onGetRoot() 必须快速返回非 null 的根。其他复杂逻辑应在 onLoadChildren() 中处理

  • 对根媒体 ID 调用 onLoadChildren() 时,结果必须包含 FLAG_PLAYABLE 子项。

  • 收到 EXTRA_RECENT 查询时,MediaBrowserService 应返回最近播放的媒体项。返回的值应为实际媒体项而非通用函数。

  • MediaBrowserService 必须提供适当的 MediaDescription 以及非空标题副标题。它还应设置一个图标 URI图标位图

以下代码示例说明了如何实现 onGetRoot()

Kotlin

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?
): BrowserRoot? {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your 
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        rootHints?.let {
            if (it.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                val extras = Bundle().apply {
                    putBoolean(BrowserRoot.EXTRA_RECENT, true)
                }
                return BrowserRoot(MY_RECENTS_ROOT_ID, extras)
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return BrowserRoot(MY_MEDIA_ROOT_ID, null)
    }
    // Return an empty tree to disallow browsing.
    return BrowserRoot(MY_EMPTY_ROOT_ID, null)

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {
    ...
    // Verify that the specified package is SystemUI. You'll need to write your
    // own logic to do this.
    if (isSystem(clientPackageName, clientUid)) {
        if (rootHints != null) {
            if (rootHints.getBoolean(BrowserRoot.EXTRA_RECENT)) {
                // Return a tree with a single playable media item for resumption.
                Bundle extras = new Bundle();
                extras.putBoolean(BrowserRoot.EXTRA_RECENT, true);
                return new BrowserRoot(MY_RECENTS_ROOT_ID, extras);
            }
        }
        // You can return your normal tree if the EXTRA_RECENT flag is not present.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    }
    // Return an empty tree to disallow browsing.
    return new BrowserRoot(MY_EMPTY_ROOT_ID, null);
}