媒体控制

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 均不可用,并且自定义布局中的自定义命令尚未放置,可用于填充插槽。 自定义
(Media3 尚不支持) PlaybackState extras 包含键 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 均不可用,并且自定义布局中的自定义命令尚未放置,可用于填充插槽。 自定义
(Media3 尚不支持) PlaybackState extras 包含键 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 actions 包含 ACTION_SKIP_TO_PREVIOUS
自定义 PlaybackState actions 不包含 ACTION_SKIP_TO_PREVIOUS,且 PlaybackState custom actions 包含尚未放置的自定义操作。
PlaybackState extras 包含键 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREVtrue 布尔值。
3 下一个 PlaybackState actions 包含 ACTION_SKIP_TO_NEXT
自定义 PlaybackState actions 不包含 ACTION_SKIP_TO_NEXT,且 PlaybackState custom actions 包含尚未放置的自定义操作。
PlaybackState extras 包含键 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 建立连接。您的应用必须允许此类连接,否则它将无法支持播放恢复。

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