Controles de mídia

Os controles de mídia no Android estão localizados perto das Configurações rápidas. As sessões de vários apps são organizadas em um carrossel deslizante. O carrossel lista as sessões nesta ordem:

  • streams reproduzidos localmente no smartphone;
  • streams remotos, como aqueles detectados em dispositivos externos ou sessões de transmissão;
  • sessões retomáveis anteriores, na ordem em que foram reproduzidas pela última vez.

A partir do Android 13 (nível 33 da API), para garantir que os usuários possam acessar um conjunto de controles de mídia para apps que reproduzem mídia, os botões de ação nos controles de mídia são derivados do estado Player.

Dessa forma, você pode apresentar um conjunto consistente de controles de mídia e uma experiência de controle de mídia mais refinada em todos os dispositivos.

A Figura 1 mostra um exemplo de como eles são exibidos em um smartphone e em um tablet, respectivamente.

Visualização de controles de mídia em um smartphone e um tablet,
            usando como exemplo uma faixa de som para mostrar como os botões podem ser exibidos.
Figura 1 : controles de mídia em smartphones e tablets

O sistema exibe até cinco botões de ação com base no estado Player, conforme descrito na tabela a seguir. No modo compacto, apenas os três primeiros slots de ação são exibidos. Isso está alinhado à maneira como os controles de mídia são renderizados em outras plataformas Android, como o Auto, o Google Assistente e o Wear OS.

Espaço Critérios Ação
1 playWhenReady é falso ou o estado de reprodução atual é STATE_ENDED. Reproduzir
playWhenReady é verdadeiro e o estado de reprodução atual é STATE_BUFFERING. Ícone de carregamento
playWhenReady é verdadeiro e o estado de reprodução atual é STATE_READY. Pausar
2 O comando do player COMMAND_SEEK_TO_PREVIOUS ou COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM está disponível. Anterior
Nenhum comando do player COMMAND_SEEK_TO_PREVIOUS ou COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM está disponível, e um comando personalizado do layout personalizado que ainda não foi colocado está disponível para preencher o slot. Personalizado
(ainda não compatível com o Media3) Os PlaybackState extras incluem um valor booleano true para a chave EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV. Vazio
3 O comando do player COMMAND_SEEK_TO_NEXT ou COMMAND_SEEK_TO_NEXT_MEDIA_ITEM está disponível. Próxima
Nenhum comando do player COMMAND_SEEK_TO_NEXT ou COMMAND_SEEK_TO_NEXT_MEDIA_ITEM está disponível, e um comando personalizado do layout personalizado que ainda não foi colocado está disponível para preencher o slot. Personalizado
(ainda não compatível com o Media3) Os PlaybackState extras incluem um valor booleano true para a chave EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT. Vazio
4 Um comando personalizado do layout personalizado que ainda não foi colocado está disponível para preencher o slot. Personalizado
5 Um comando personalizado do layout personalizado que ainda não foi colocado está disponível para preencher o slot. Personalizado

Os comandos personalizados são colocados na ordem em que foram adicionados ao layout personalizado.

Personalizar botões de comando

Para personalizar os controles de mídia do sistema com o Jetpack Media3, é possível definir o layout personalizado da sessão e os comandos disponíveis de controladores de acordo com a implementação de um MediaSessionService:

  1. Em onCreate(), crie um MediaSession e defina o layout personalizado dos botões de comando.

  2. Em MediaSession.Callback.onConnect(), autorize os controladores definindo os comandos disponíveis, incluindo comandos personalizados, no ConnectionResult.

  3. Em MediaSession.Callback.onCustomCommand(), responda ao comando personalizado selecionado pelo usuário.

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

Para saber mais sobre como configurar o MediaSession para que clientes como o sistema possam se conectar ao seu app de mídia, consulte Conceder controle a outros clientes.

Com o Jetpack Media3, quando você implementa um MediaSession, o PlaybackState é atualizado automaticamente com o player de mídia. Da mesma forma, quando você implementa um MediaSessionService, a biblioteca publica automaticamente uma notificação MediaStyle para você e a mantém atualizada.

Responder a botões de ação

Quando um usuário toca em um botão de ação nos controles de mídia do sistema, o MediaController do sistema envia um comando de reprodução para o MediaSession. Em seguida, o MediaSession delega esses comandos para o player. Os comandos definidos na interface Player do Media3 são processados automaticamente pela sessão de mídia.

Consulte Adicionar comandos personalizados para saber como responder a um comando personalizado.

Comportamento anterior ao Android 13

Para compatibilidade com versões anteriores, a IU do sistema continua fornecendo um layout alternativo que usa ações de notificação para apps que não são atualizados para o Android 13 ou que não incluem informações PlaybackState. Os botões de ação são derivados da lista Notification.Action anexada à notificação MediaStyle. O sistema mostra até cinco ações na ordem em que foram adicionadas. No modo compacto, até três botões são mostrados, determinados pelos valores transmitidos para setShowActionsInCompactView().

As ações personalizadas são posicionadas na ordem em que foram adicionadas ao PlaybackState.

O exemplo de código abaixo ilustra como adicionar ações à notificação 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();

Suporte à retomada de mídia

A retomada de mídia permite que os usuários reiniciem sessões anteriores do carrossel sem precisar iniciar o app. Quando a reprodução começa, o usuário interage com os controles de mídia da maneira habitual.

O recurso de retomada de reprodução pode ser ativado e desativado usando o app Configurações, nas opções de Som > Mídia. O usuário também pode acessar as Configurações tocando no ícone de engrenagem que aparece depois de deslizar no carrossel aberto.

O Media3 oferece APIs para facilitar o suporte à retomada de mídia. Consulte a documentação Retomada de reprodução com o Media3 para saber como implementar esse recurso.

Como usar as APIs de mídia legadas

Esta seção explica como fazer a integração com os controles de mídia do sistema usando as APIs MediaCompat legadas.

O sistema recupera as seguintes informações do MediaMetadata do MediaSession e as exibe quando estão disponíveis:

  • METADATA_KEY_ALBUM_ART_URI
  • METADATA_KEY_TITLE
  • METADATA_KEY_DISPLAY_TITLE
  • METADATA_KEY_ARTIST
  • METADATA_KEY_DURATION: se a duração não for definida, a barra de busca não mostrará o progresso.

Para garantir uma notificação de controle de mídia válida e precisa, defina o valor dos metadados METADATA_KEY_TITLE ou METADATA_KEY_DISPLAY_TITLE para o título da mídia que está sendo reproduzida.

O player de mídia mostra o tempo decorrido da mídia em reprodução no momento com uma barra de busca mapeada para o MediaSession PlaybackState.

O player de mídia mostra o progresso da mídia em reprodução no momento com uma barra de busca mapeada para o PlaybackState do MediaSession. A barra de busca permite que os usuários mudem a posição e mostra o tempo decorrido do item de mídia. Para que a barra de busca seja ativada, é necessário implementar PlaybackState.Builder#setActions e incluir ACTION_SEEK_TO.

Espaço Ação Critérios
1 Reproduzir O estado atual do PlaybackState é um dos seguintes:
  • STATE_NONE
  • STATE_STOPPED
  • STATE_PAUSED
  • STATE_ERROR
Ícone de carregamento O estado atual do PlaybackState é um dos seguintes:
  • STATE_CONNECTING
  • STATE_BUFFERING
Pausar O estado atual do PlaybackState não está listado acima.
2 Anterior As ações PlaybackState incluem ACTION_SKIP_TO_PREVIOUS.
Personalizado As ações PlaybackState não incluem ACTION_SKIP_TO_PREVIOUS e as ações personalizadas PlaybackState incluem uma ação personalizada que ainda não foi posicionada.
Vazio Os PlaybackState extras incluem um valor booleano true para a chave SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV.
3 Próxima As ações PlaybackState incluem ACTION_SKIP_TO_NEXT.
Personalizado As ações PlaybackState não incluem ACTION_SKIP_TO_NEXT e as ações personalizadas PlaybackState incluem uma ação personalizada que ainda não foi posicionada.
Vazio Os PlaybackState extras incluem um valor booleano true para a chave SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT.
4 Personalizado As ações personalizadas PlaybackStateincluem uma ação personalizada que ainda não foi posicionada.
5 Personalizado As ações personalizadas PlaybackStateincluem uma ação personalizada que ainda não foi posicionada.

Adicionar ações padrão

Os exemplos de código abaixo ilustram como adicionar ações padrão e personalizadas do PlaybackState.

Para reproduzir, pausar, voltar e avançar, defina essas ações no PlaybackState para a sessão de mídia.

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

Se você não quiser botões nos slots anteriores ou seguintes, não adicione ACTION_SKIP_TO_PREVIOUS ou ACTION_SKIP_TO_NEXT. Em vez disso, adicione extras à sessão:

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

Adicionar ações personalizadas

Para outras ações que você quer mostrar nos controles de mídia, crie um PlaybackStateCompat.CustomAction e adicione ao PlaybackState. Essas ações são mostradas na ordem em que foram adicionadas.

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

Como responder a ações de PlaybackState

Quando um usuário toca em um botão, o SystemUI usa MediaController.TransportControls para enviar um comando de volta ao MediaSession. É necessário registrar um callback que possa responder corretamente a esses eventos.

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

Retomada de mídia

Para que o app do player apareça na área de configurações rápidas, você precisa criar uma notificação MediaStyle com um token MediaSession válido.

Para mostrar o título da notificação do MediaStyle, use NotificationBuilder.setContentTitle().

Para exibir o ícone de marca do player de mídia, use NotificationBuilder.setSmallIcon().

Para compatibilidade com a retomada de reprodução, os apps precisam implementar um MediaBrowserService e um MediaSession. O MediaSession precisa implementar o callback onPlay().

Implementação de MediaBrowserService

Depois que o dispositivo for inicializado, o sistema procurará os cinco apps de mídia mais recentes e fornecerá controles que podem ser usados para reiniciar a reprodução de cada app.

O sistema tentará entrar em contato com o MediaBrowserService com uma conexão da SystemUI. Seu app precisa permitir essas conexões. Caso contrário, não será possível retomar a reprodução.

As conexões da SystemUI podem ser identificadas e verificadas usando o nome do pacote com.android.systemui e a assinatura. A SystemUI tem a assinatura da plataforma. Um exemplo de como verificar a assinatura da plataforma pode ser encontrado no app UAMP.

Para compatibilidade com a retomada de reprodução, seu MediaBrowserService precisa implementar estes comportamentos:

  • onGetRoot() precisa retornar uma raiz não nula rapidamente. Outra lógica complexa precisa ser tratada em onLoadChildren()

  • Quando onLoadChildren() é chamado no ID de mídia raiz, o resultado precisa conter um filho FLAG_PLAYABLE.

  • MediaBrowserService precisa retornar o item de mídia reproduzido mais recentemente quando receber uma consulta EXTRA_RECENT. O valor retornado precisa ser um item de mídia real em vez de uma função genérica.

  • MediaBrowserService precisa fornecer uma MediaDescription apropriada com um título e uma legenda não vazios. Ele também precisa definir um URI de ícone ou um bitmap de ícone.

Os exemplos de código a seguir ilustram como implementar 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);
}