媒体控件

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

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

从 Android 13(API 级别 33)开始,为了确保用户可以使用众多媒体控件来操控媒体播放应用,媒体控件上的操作按钮衍生自 Player 状态。

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

图 1 显示了在手机和平板电脑设备上的外观示例, 分别。

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

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

广告位 条件 操作
1 playWhenReady 为 false,或者当前 播放状态STATE_ENDED 播放
playWhenReady 为 true,并且当前 播放状态STATE_BUFFERING 加载旋转图标
playWhenReady 为 true,并且当前 播放状态STATE_READY 暂停
2 媒体按钮偏好设置包含自定义按钮CommandButton.SLOT_BACK 自定义
播放器命令 COMMAND_SEEK_TO_PREVIOUS COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM 可用。 上一首
自定义按钮和列出的命令均不可用。 未连接
3 媒体按钮 偏好设置包含自定义按钮 CommandButton.SLOT_FORWARD 自定义
播放器命令 COMMAND_SEEK_TO_NEXT COMMAND_SEEK_TO_NEXT_MEDIA_ITEM 可用。 下一首
自定义按钮和列出的命令均不可用。 未连接
4 媒体按钮偏好设置包含尚未放置的 CommandButton.SLOT_OVERFLOW 的自定义按钮 自定义
5 媒体按钮偏好设置包含尚未放置的 CommandButton.SLOT_OVERFLOW 的自定义按钮 自定义

自定义溢出按钮会按照添加到 媒体按钮偏好设置中的顺序放置。

自定义命令按钮

如需使用 Jetpack Media3 自定义系统媒体控件,您可以相应地设置 会话的媒体按钮偏好设置和 控制器的可用命令:

  1. 构建 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(CommandButton.ICON_HEART_UNFILLED)
        .setDisplayName("Save to favorites")
        .setSessionCommand(customCommandFavorites)
        .build()
    val player = ExoPlayer.Builder(this).build()
    // Build the session with a custom layout.
    mediaSession =
      MediaSession.Builder(this, player)
        .setCallback(MyCallback())
        .setMediaButtonPreferences(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)
      .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(CommandButton.ICON_HEART_UNFILLED)
            .setDisplayName("Save to favorites")
            .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())
            .setMediaButtonPreferences(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)
          .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接口中定义的命令。

如需了解如何 响应自定义命令,请参阅添加自定义命令

支持“继续播放媒体内容”功能

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

您可以使用“设置”应用中的 声音 > 媒体 选项开启和关闭“继续播放媒体内容”功能。用户还可以通过 点按在展开的轮播界面上滑动后显示的齿轮图标来访问“设置”。

Media3 提供了相关 API,可让您更轻松地支持“继续播放媒体内容”功能。如需了解如何实现此功能,请参阅使用 Media3 实现“继续播放媒体内容”功能文档。

使用旧版媒体 API

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

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

  • 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 子项。

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

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

Android 13 之前的行为

为了向后兼容,系统界面会继续提供使用通知操作的备用布局 以支持不会更新为以 Android 13 为目标平台的应用 或不包含 PlaybackState 信息的应用。操作按钮衍生自附加到通知的列表。Notification.ActionMediaStyle系统会按照添加顺序显示最多五项操作。在紧凑模式下,系统会显示最多三个按钮,具体取决于传递给 的值 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)
// 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(new MediaStyleNotificationHelper.MediaStyle(mediaSession)
.setShowActionsInCompactView(1 /* #1: pause button */))
.setContentTitle("Wonderful music")
.setContentText("My Awesome Band")
.setLargeIcon(albumArtBitmap)
.build();