媒体控件

Android 中的媒体控件位于快速设置附近。来自多个应用的会话排列在一个可滑动的轮播中。轮播按以下顺序列出会话

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

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

这样,您可以在设备之间提供一致的媒体控件集和更完善的媒体控件体验。

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

Media controls in terms of how they appear on phone and tablets devices,
            using an example of a sample track showing how the buttons may appear
图 1:手机和平板电脑设备上的媒体控件

系统根据下表中描述的 Player 状态显示最多五个操作按钮。在紧凑模式下,仅显示前三个操作栏位。这与媒体控件在其他 Android 平台(如 Auto、Assistant 和 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_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM 均不可用,并且来自自定义布局的尚未放置的自定义命令可用以填充栏位。 自定义
会话额外数据 包括键 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREVtrue 布尔值。
3 播放器命令 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM 可用。 下一首
播放器命令 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM 均不可用,并且来自自定义布局的尚未放置的自定义命令可用以填充栏位。 自定义
会话额外数据 包括键 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 之前的行为

为了向后兼容,系统 UI 继续提供备用布局,该布局使用通知操作,用于未更新为以 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_PREVIOUSPlaybackState 自定义操作 包含尚未放置的自定义操作。
PlaybackState 额外数据 包括键 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREVtrue 布尔值。
3 下一首 PlaybackState 操作 包括 ACTION_SKIP_TO_NEXT
自定义 PlaybackState 操作 不包含 ACTION_SKIP_TO_NEXTPlaybackState 自定义操作 包含尚未放置的自定义操作。
PlaybackState 额外数据 包括键 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.TransportControlsMediaSession发送命令。您需要注册一个回调函数,以便能够正确地响应这些事件。

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。您的应用必须允许此类连接,否则无法支持播放恢复。

来自 SystemUI 的连接可以使用包名com.android.systemui和签名进行识别和验证。SystemUI 使用平台签名进行签名。有关如何针对平台签名进行检查的示例,请参见UAMP 应用

为了支持播放恢复,您的MediaBrowserService必须实现以下行为

  • onGetRoot()必须快速返回一个非空根。其他复杂逻辑应在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);
}