媒体控件

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

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

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

这样,您就可以呈现一组一致的媒体控件, 跨设备媒体控制体验。

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

<ph type="x-smartling-placeholder">
</ph> 媒体控件在手机和平板电脑设备上的显示方式,用一首示例歌曲演示按钮可能的显示效果
图 1 :手机和平板电脑设备上的媒体控件

根据 Player 状态,系统最多会显示五个操作按钮: 如下表所示。在紧凑模式下,只有前三项操作 。这与其他媒体控件的 Android 平台,例如 Auto、Google 助理和 Wear OS。

槽位 条件 操作
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 信息的应用。操作按钮包括 派生自附加到 MediaStyleNotification.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,并改为向 会话:

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