미디어 제어

Android의 미디어 컨트롤은 빠른 설정 근처에 있습니다. 여러 앱의 세션이 스와이프할 수 있는 캐러셀에 정렬됩니다. 캐러셀에는 세션이 다음 순서로 나열됩니다.

  • 휴대전화에서 로컬로 재생되는 스트림
  • 외부 기기나 전송 세션에서 감지된 것과 같은 원격 스트림
  • 마지막으로 재생된 순서로 재개 가능한 이전 세션

Android 13 (API 수준 33)부터 사용자가 미디어를 재생하는 앱의 다양한 미디어 컨트롤 세트에 액세스할 수 있도록 미디어 컨트롤의 작업 버튼이 Player 상태에서 파생됩니다.

이렇게 하면 여러 기기에서 일관된 미디어 컨트롤 집합과 더 세련된 미디어 제어 환경을 제공할 수 있습니다.

그림 1은 스마트폰과 태블릿 기기에서 각각 어떻게 표시되는지 보여줍니다.

스마트폰과 태블릿 기기에 표시되는 방식과 관련된 미디어 컨트롤. 버튼이 표시될 수 있는 방식을 보여주는 샘플 트랙의 예시를 사용함
그림 1: 스마트폰 및 태블릿 기기의 미디어 제어

시스템은 다음 표에 설명된 대로 Player 상태를 기반으로 최대 5개의 작업 버튼을 표시합니다. 압축 모드에서는 처음 작업 슬롯 3개만 표시됩니다. 이는 Auto, 어시스턴트, Wear OS와 같은 다른 Android 플랫폼에서 미디어 컨트롤이 렌더링되는 방식과 일치합니다.

슬롯 기준 조치
1 playWhenReady이 false이거나 현재 재생 상태STATE_ENDED입니다. 재생
playWhenReady이 true이고 현재 재생 상태STATE_BUFFERING입니다. 로딩 스피너
playWhenReady이 true이고 현재 재생 상태STATE_READY입니다. 일시중지
2 플레이어 명령어 COMMAND_SEEK_TO_PREVIOUS 또는 COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM을 사용할 수 있습니다. 이전
플레이어 명령어 COMMAND_SEEK_TO_PREVIOUSCOMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM를 모두 사용할 수 없으며, 아직 배치되지 않은 맞춤 레이아웃의 맞춤 명령어를 슬롯을 채울 수 있습니다. 맞춤식
(아직 Media3에서는 지원되지 않음) PlaybackState 추가에는 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV 키의 true 불리언 값이 포함됩니다. 비어 있음
3 플레이어 명령어 COMMAND_SEEK_TO_NEXT 또는 COMMAND_SEEK_TO_NEXT_MEDIA_ITEM을 사용할 수 있습니다. 다음
플레이어 명령어 COMMAND_SEEK_TO_NEXTCOMMAND_SEEK_TO_NEXT_MEDIA_ITEM를 모두 사용할 수 없으며, 아직 배치되지 않은 맞춤 레이아웃의 맞춤 명령어를 슬롯을 채울 수 있습니다. 맞춤식
(아직 Media3에서는 지원되지 않음) PlaybackState 추가에는 EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT 키의 true 불리언 값이 포함됩니다. 비어 있음
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 알림을 게시하고 최신 상태로 유지합니다.

작업 버튼에 응답

사용자가 시스템 미디어 컨트롤의 작업 버튼을 탭하면 시스템의 MediaControllerMediaSession에 재생 명령어를 전송합니다. 그런 다음 MediaSession는 이러한 명령어를 플레이어에 위임합니다. Media3의 Player 인터페이스에 정의된 명령어는 미디어 세션에 의해 자동으로 처리됩니다.

커스텀 명령어에 응답하는 방법에 대한 안내는 커스텀 명령어 추가를 참고하세요.

Android 13 이전 동작

이전 버전과의 호환성을 위해 시스템 UI는 Android 13을 타겟팅하도록 업데이트되지 않거나 PlaybackState 정보를 포함하지 않는 앱의 알림 작업을 사용하는 대체 레이아웃을 계속 제공합니다. 작업 버튼은 MediaStyle 알림에 연결된 Notification.Action 목록에서 파생됩니다. 시스템은 추가된 순서대로 최대 5개의 작업을 표시합니다. 압축 모드에서는 setShowActionsInCompactView()에 전달된 값에 따라 결정된 버튼이 최대 3개 표시됩니다.

맞춤 작업은 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_TITLE 또는 METADATA_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 extras에는 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 키의 true 불리언 값이 포함됩니다.
3 다음 PlaybackState 작업에는 ACTION_SKIP_TO_NEXT가 포함됩니다.
맞춤식 PlaybackState 작업에는 ACTION_SKIP_TO_NEXT이 포함되지 않고 PlaybackState 맞춤 작업에는 아직 배치되지 않은 맞춤 작업이 포함됩니다.
비어 있음 PlaybackState extras에는 SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT 키의 true 불리언 값이 포함됩니다.
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_PREVIOUS 또는 ACTION_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을 구현해야 합니다. MediaSessiononPlay() 콜백을 구현해야 합니다.

MediaBrowserService 구현

기기가 부팅되면 시스템에서는 가장 최근에 사용한 미디어 앱 5개를 찾고 각 앱에서 재생을 다시 시작하는 데 사용할 수 있는 컨트롤을 제공합니다.

시스템은 SystemUI의 연결을 통해 MediaBrowserService에 연결하려고 합니다. 앱은 이러한 연결을 허용해야 합니다. 허용하지 않으면 재생 재개를 지원할 수 없습니다.

SystemUI의 연결은 패키지 이름 com.android.systemui 및 서명을 사용하여 식별 및 확인할 수 있습니다. SystemUI는 플랫폼 서명으로 서명됩니다. 플랫폼 서명을 확인하는 방법의 예는 UAMP 앱에서 확인할 수 있습니다.

재생 재개를 지원하려면 MediaBrowserService에서 다음 동작을 구현해야 합니다.

  • onGetRoot()는 null이 아닌 루트를 빠르게 반환해야 합니다. 다른 복잡한 로직은 onLoadChildren()에서 처리해야 합니다.

  • onLoadChildren()이 루트 미디어 ID에서 호출되면 결과에는 FLAG_PLAYABLE 하위 요소가 포함되어야 합니다.

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