Controlar e anunciar a reprodução usando uma MediaSession

As sessões de mídia oferecem uma maneira universal de interagir com um player de áudio ou vídeo. No Media3, o player padrão é a classe ExoPlayer, que implementa a interface Player. A conexão da sessão de mídia ao player permite que um app anuncie a reprodução de mídia externamente e receba comandos de reprodução de fontes externas.

Os comandos podem ser originados de botões físicos, como o botão de reprodução em um fone de ouvido ou controle remoto de TV. Elas também podem vir de apps clientes que têm um controlador de mídia, como instruir o Google Assistente a "pausar". A sessão de mídia delega esses comandos ao player do app de mídia.

Quando escolher uma sessão de mídia

Ao implementar MediaSession, você permite que os usuários controlem a reprodução:

  • Pelos fones de ouvido. Muitas vezes, há botões ou interações por toque que o usuário pode realizar nos fones de ouvido para reproduzir ou pausar mídia ou ir para a próxima ou a faixa anterior.
  • Falando com o Google Assistente. Um padrão comum é dizer "Ok Google, pause" para pausar qualquer mídia que esteja sendo reproduzida no dispositivo.
  • Com o relógio Wear OS. Isso facilita o acesso aos controles de reprodução mais comuns durante a reprodução no smartphone.
  • Através dos controles de mídia. Esse carrossel mostra os controles de cada sessão de mídia em execução.
  • Na TV. Permite ações com botões físicos de reprodução, controle de reprodução da plataforma e gerenciamento de energia. Por exemplo, se a TV, a soundbar ou o receptor de áudio/vídeo desligar ou se a entrada for trocada, a reprodução será interrompida no app.
  • E qualquer outro processo externo que precise influenciar a reprodução.

Isso é ótimo para muitos casos de uso. Em particular, considere usar MediaSession quando:

  • Você está transmitindo conteúdo de vídeo mais longo, como filmes ou TV ao vivo.
  • Você está transmitindo conteúdo de áudio mais longo, como podcasts ou playlists de música.
  • Você está criando um app para TV.

No entanto, nem todos os casos de uso se encaixam bem com o MediaSession. Talvez seja melhor usar apenas o Player nos seguintes casos:

  • Você está mostrando conteúdo em formato curto, em que não é necessário nenhum controle externo ou reprodução em segundo plano.
  • Não há um único vídeo ativo, como quando o usuário rola uma lista e vários vídeos são exibidos na tela ao mesmo tempo.
  • Você está reproduzindo um vídeo de introdução ou explicação único, que você espera que o usuário assista ativamente sem precisar de controles de reprodução externos.
  • Seu conteúdo é sensível à privacidade e você não quer que processos externos acessem os metadados da mídia (por exemplo, o modo de navegação anônima em um navegador).

Se o caso de uso não se encaixar em nenhum dos listados acima, considere se você aceita que o app continue a reprodução quando o usuário não estiver interagindo ativamente com o conteúdo. Se a resposta for sim, provavelmente você vai querer escolher MediaSession. Se a resposta for "não", use Player.

Criar uma sessão de mídia

Uma sessão de mídia convive com o player que a gerencia. É possível criar uma sessão de mídia com um objeto Context e um Player. Crie e inicialize uma sessão de mídia quando necessário, como o método de ciclo de vida onStart() ou onResume() do Activity ou Fragment, ou o método onCreate() do Service que detém a sessão de mídia e o player associado.

Para criar uma sessão de mídia, inicialize um Player e forneça-o a MediaSession.Builder desta forma:

Kotlin

val player = ExoPlayer.Builder(context).build()
val mediaSession = MediaSession.Builder(context, player).build()

Java

ExoPlayer player = new ExoPlayer.Builder(context).build();
MediaSession mediaSession = new MediaSession.Builder(context, player).build();

Processamento automático de estado

A biblioteca Media3 atualiza automaticamente a sessão de mídia usando o estado do player. Portanto, não é necessário processar manualmente o mapeamento do jogador para a sessão.

Isso é diferente da sessão de mídia da plataforma, em que você precisava criar e manter um PlaybackState de forma independente do próprio player, por exemplo, para indicar erros.

ID exclusivo da sessão

Por padrão, MediaSession.Builder cria uma sessão com uma string vazia como o ID da sessão. Isso é suficiente se um app pretende criar apenas uma instância de sessão, que é o caso mais comum.

Se um app quiser gerenciar várias instâncias de sessão ao mesmo tempo, ele precisa garantir que o ID de cada sessão seja exclusivo. O ID da sessão pode ser definido ao criar a sessão com MediaSession.Builder.setId(String id).

Se você notar que um IllegalStateException está travando seu app com a mensagem de erro IllegalStateException: Session ID must be unique. ID=, é provável que uma sessão tenha sido criada inesperadamente antes que uma instância anteriormente criada com o mesmo ID tenha sido liberada. Para evitar que as sessões sejam vazadas por um erro de programação, esses casos são detectados e notificados com uma exceção.

Conceder controle a outros clientes

A sessão de mídia é a chave para controlar a reprodução. Ele permite encaminhar comandos de fontes externas para o player que reproduz a mídia. Essas fontes podem ser botões físicos, como o botão de reprodução em um fone de ouvido ou controle remoto da TV, ou comandos indiretos, como o comando "pausar" para o Google Assistente. Da mesma forma, você pode conceder acesso ao sistema Android para facilitar os controles de notificação e bloqueio da tela ou a um relógio Wear OS para controlar a reprodução pelo mostrador do relógio. Clientes externos podem usar um controle de mídia para emitir comandos de reprodução para o app de mídia. Eles são recebidos pela sessão de mídia, que delega comandos ao player de mídia.

Um diagrama que demonstra a interação entre uma MediaSession e uma MediaController.
Figura 1: o media controller facilita a transmissão de comandos de fontes externas para a sessão de mídia.

Quando um controlador está prestes a se conectar à sua sessão de mídia, o método onConnect() é chamado. Use o ControllerInfo fornecido para decidir se vai aceitar ou rejeitar a solicitação. Confira um exemplo de aceitação de uma solicitação de conexão na seção Declarar comandos disponíveis.

Depois de se conectar, um controlador pode enviar comandos de reprodução para a sessão. A sessão então delega esses comandos ao jogador. Os comandos de reprodução e playlist definidos na interface Player são processados automaticamente pela sessão.

Outros métodos de callback permitem processar, por exemplo, solicitações de comandos de reprodução personalizados e modificar a playlist. Esses callbacks também incluem um objeto ControllerInfo para que você possa modificar como responde a cada solicitação em cada controlador.

Modificar a playlist

Uma sessão de mídia pode modificar diretamente a playlist do player, conforme explicado no guia do ExoPlayer para playlists. Os controles também podem modificar a playlist se COMMAND_SET_MEDIA_ITEM ou COMMAND_CHANGE_MEDIA_ITEMS estiver disponível para o controle.

Ao adicionar novos itens à playlist, o player normalmente exige instâncias MediaItem com um URI definido para que eles possam ser reproduzidos. Por padrão, os itens recém-adicionados são encaminhados automaticamente para métodos do player, como player.addMediaItem, se tiverem um URI definido.

Se você quiser personalizar as instâncias de MediaItem adicionadas ao player, é possível substituir onAddMediaItems(). Essa etapa é necessária quando você quer oferecer suporte a controladores que solicitam mídia sem um URI definido. Em vez disso, o MediaItem normalmente tem um ou mais dos seguintes campos definidos para descrever a mídia solicitada:

  • MediaItem.id: um ID genérico que identifica a mídia.
  • MediaItem.RequestMetadata.mediaUri: um URI de solicitação que pode usar um esquema personalizado e não é necessariamente reproduzido diretamente pelo player.
  • MediaItem.RequestMetadata.searchQuery: uma consulta de pesquisa textual, por exemplo, do Google Assistente.
  • MediaItem.MediaMetadata: metadados estruturados, como "title" ou "artist".

Para mais opções de personalização para playlists completamente novas, você pode substituir onSetMediaItems(), que permite definir o item inicial e a posição na playlist. Por exemplo, é possível expandir um único item solicitado para uma playlist inteira e instruir o reprodutor a começar no índice do item solicitado originalmente. Uma implementação de exemplo de onSetMediaItems() com esse recurso pode ser encontrada no app de demonstração de sessão.

Gerenciar layout e comandos personalizados

As seções a seguir descrevem como anunciar um layout personalizado de botões de comando personalizados para apps cliente e autorizar controladores a enviar os comandos personalizados.

Definir o layout personalizado da sessão

Para indicar aos apps clientes quais controles de reprodução você quer mostrar ao usuário, defina o layout personalizado da sessão ao criar o MediaSession no método onCreate() do serviço.

Kotlin

override fun onCreate() {
  super.onCreate()

  val likeButton = CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build()
  val favoriteButton = CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(SessionCommand(SAVE_TO_FAVORITES, Bundle()))
    .build()

  session =
    MediaSession.Builder(this, player)
      .setCallback(CustomMediaSessionCallback())
      .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
      .build()
}

Java

@Override
public void onCreate() {
  super.onCreate();

  CommandButton likeButton = new CommandButton.Builder()
    .setDisplayName("Like")
    .setIconResId(R.drawable.like_icon)
    .setSessionCommand(new SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
    .build();
  CommandButton favoriteButton = new CommandButton.Builder()
    .setDisplayName("Save to favorites")
    .setIconResId(R.drawable.favorite_icon)
    .setSessionCommand(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
    .build();

  Player player = new ExoPlayer.Builder(this).build();
  mediaSession =
      new MediaSession.Builder(this, player)
          .setCallback(new CustomMediaSessionCallback())
          .setCustomLayout(ImmutableList.of(likeButton, favoriteButton))
          .build();
}

Declarar comandos personalizados e o player disponível

Os aplicativos de mídia podem definir comandos personalizados que, por exemplo, podem ser usados em um layout personalizado. Por exemplo, você pode implementar botões que permitem que o usuário salve um item de mídia em uma lista de itens favoritos. O MediaController envia comandos personalizados, e o MediaSession.Callback os recebe.

É possível definir quais comandos de sessão personalizados estão disponíveis para um MediaController quando ele se conecta à sua sessão de mídia. Para fazer isso, substitua MediaSession.Callback.onConnect(). Configure e retorne o conjunto de comandos disponíveis ao aceitar uma solicitação de conexão de um MediaController no método de callback onConnect:

Kotlin

private class CustomMediaSessionCallback: MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  override fun onConnect(
    session: MediaSession,
    controller: MediaSession.ControllerInfo
  ): MediaSession.ConnectionResult {
    val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
        .add(SessionCommand(SAVE_TO_FAVORITES, Bundle.EMPTY))
        .build()
    return AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build()
  }
}

Java

class CustomMediaSessionCallback implements MediaSession.Callback {
  // Configure commands available to the controller in onConnect()
  @Override
  public ConnectionResult onConnect(
    MediaSession session,
    ControllerInfo controller) {
    SessionCommands sessionCommands =
        ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
            .add(new SessionCommand(SAVE_TO_FAVORITES, new Bundle()))
            .build();
    return new AcceptedResultBuilder(session)
        .setAvailableSessionCommands(sessionCommands)
        .build();
  }
}

Para receber solicitações de comando personalizadas de um MediaController, substitua o método onCustomCommand() no Callback.

Kotlin

private class CustomMediaSessionCallback: MediaSession.Callback {
  ...
  override fun onCustomCommand(
    session: MediaSession,
    controller: MediaSession.ControllerInfo,
    customCommand: SessionCommand,
    args: Bundle
  ): ListenableFuture<SessionResult> {
    if (customCommand.customAction == SAVE_TO_FAVORITES) {
      // Do custom logic here
      saveToFavorites(session.player.currentMediaItem)
      return Futures.immediateFuture(
        SessionResult(SessionResult.RESULT_SUCCESS)
      )
    }
    ...
  }
}

Java

class CustomMediaSessionCallback implements MediaSession.Callback {
  ...
  @Override
  public ListenableFuture<SessionResult> onCustomCommand(
    MediaSession session, 
    ControllerInfo controller,
    SessionCommand customCommand,
    Bundle args
  ) {
    if(customCommand.customAction.equals(SAVE_TO_FAVORITES)) {
      // Do custom logic here
      saveToFavorites(session.getPlayer().getCurrentMediaItem());
      return Futures.immediateFuture(
        new SessionResult(SessionResult.RESULT_SUCCESS)
      );
    }
    ...
  }
}

É possível acompanhar qual controlador de mídia está fazendo uma solicitação usando a propriedade packageName do objeto MediaSession.ControllerInfo que é transmitida para os métodos Callback. Isso permite personalizar o comportamento do app em resposta a um determinado comando, se ele for originado pelo sistema, pelo próprio app ou por outros apps clientes.

Atualizar o layout personalizado após uma interação do usuário

Depois de processar um comando personalizado ou qualquer outra interação com o player, é recomendável atualizar o layout exibido na interface do controle. Um exemplo típico é um botão de alternância que muda o ícone depois de acionar a ação associada a ele. Para atualizar o layout, use MediaSession.setCustomLayout:

Kotlin

val removeFromFavoritesButton = CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(SessionCommand(REMOVE_FROM_FAVORITES, Bundle()))
  .build()
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton))

Java

CommandButton removeFromFavoritesButton = new CommandButton.Builder()
  .setDisplayName("Remove from favorites")
  .setIconResId(R.drawable.favorite_remove_icon)
  .setSessionCommand(new SessionCommand(REMOVE_FROM_FAVORITES, new Bundle()))
  .build();
mediaSession.setCustomLayout(ImmutableList.of(likeButton, removeFromFavoritesButton));

Personalizar o comportamento do comando de reprodução

Para personalizar o comportamento de um comando definido na interface Player, como play() ou seekToNext(), envolva o Player em um ForwardingSimpleBasePlayer antes de transmiti-lo para MediaSession.

Kotlin

val player = (logic to build a Player instance)

val forwardingPlayer = object : ForwardingSimpleBasePlayer(player) {
  // Customizations
}

val mediaSession = MediaSession.Builder(context, forwardingPlayer).build()

Java

ExoPlayer player = (logic to build a Player instance)

ForwardingSimpleBasePlayer forwardingPlayer =
    new ForwardingSimpleBasePlayer(player) {
      // Customizations
    };

MediaSession mediaSession =
  new MediaSession.Builder(context, forwardingPlayer).build();

Para mais informações sobre ForwardingSimpleBasePlayer, consulte o guia do ExoPlayer sobre Personalização.

Identificar o controlador solicitante de um comando do jogador

Quando uma chamada para um método Player é originada por um MediaController, é possível identificar a origem com MediaSession.controllerForCurrentRequest e adquirir o ControllerInfo para a solicitação atual:

Kotlin

class CallerAwarePlayer(player: Player) :
  ForwardingSimpleBasePlayer(player) {

  override fun handleSeek(
    mediaItemIndex: Int,
    positionMs: Long,
    seekCommand: Int,
  ): ListenableFuture<*> {
    Log.d(
      "caller",
      "seek operation from package ${session.controllerForCurrentRequest?.packageName}",
    )
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand)
  }
}

Java

public class CallerAwarePlayer extends ForwardingSimpleBasePlayer {
  public CallerAwarePlayer(Player player) {
    super(player);
  }

  @Override
  protected ListenableFuture<?> handleSeek(
        int mediaItemIndex, long positionMs, int seekCommand) {
    Log.d(
        "caller",
        "seek operation from package: "
            + session.getControllerForCurrentRequest().getPackageName());
    return super.handleSeek(mediaItemIndex, positionMs, seekCommand);
  }
}

Responder a botões de mídia

Os botões de mídia são botões de hardware encontrados em dispositivos Android e outros dispositivos periféricos, como o botão de reprodução/pausa em um fone de ouvido Bluetooth. O Media3 processa os eventos do botão de mídia para você quando eles chegam à sessão e chama o método Player apropriado no player da sessão.

Um app pode substituir o comportamento padrão substituindo MediaSession.Callback.onMediaButtonEvent(Intent). Nesse caso, o app pode/precisa processar todas as especificidades da API por conta própria.

Tratamento e geração de relatórios de erros

Há dois tipos de erros que uma sessão emite e informa aos controladores. Erros fatais relatam uma falha técnica de reprodução do player de sessão que interrompe a reprodução. Erros fatais são informados ao controlador automaticamente quando ocorrem. Erros não fatais são erros não técnicos ou de política que não interrompem a reprodução e são enviados manualmente pelo aplicativo aos controladores.

Erros fatais de reprodução

Um erro fatal de reprodução é informado à sessão pelo player e, em seguida, informado aos controladores para chamar por Player.Listener.onPlayerError(PlaybackException) e Player.Listener.onPlayerErrorChanged(@Nullable PlaybackException).

Nesse caso, o estado de reprodução é transferido para STATE_IDLE, e MediaController.getPlaybackError() retorna o PlaybackException que causou a transição. Um controlador pode inspecionar o PlayerException.errorCode para receber informações sobre o motivo do erro.

Para interoperabilidade, um erro fatal é replicado na sessão da plataforma fazendo a transição do estado para STATE_ERROR e definindo o código e a mensagem de erro de acordo com o PlaybackException.

Personalização de erros fatais

Para fornecer informações localizadas e significativas ao usuário, o código de erro, a mensagem de erro e os extras de erro de um erro fatal de reprodução podem ser personalizados usando um ForwardingPlayer ao criar a sessão:

Kotlin

val forwardingPlayer = ErrorForwardingPlayer(player)
val session = MediaSession.Builder(context, forwardingPlayer).build()

Java

Player forwardingPlayer = new ErrorForwardingPlayer(player);
MediaSession session =
    new MediaSession.Builder(context, forwardingPlayer).build();

O player de encaminhamento pode usar ForwardingSimpleBasePlayer para interceptar o erro e personalizar o código, a mensagem ou os extras de erro. Da mesma forma, você também pode gerar novos erros que não existem no player original:

Kotlin

class ErrorForwardingPlayer (private val context: Context, player: Player) :
    ForwardingSimpleBasePlayer(player) {

  override fun getState(): State {
    var state = super.getState()
    if (state.playerError != null) {
      state =
        state.buildUpon()
          .setPlayerError(customizePlaybackException(state.playerError!!))
          .build()
    }
    return state
  }

  fun customizePlaybackException(error: PlaybackException): PlaybackException {
    val buttonLabel: String
    val errorMessage: String
    when (error.errorCode) {
      PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
        buttonLabel = context.getString(R.string.err_button_label_restart_stream)
        errorMessage = context.getString(R.string.err_msg_behind_live_window)
      }
      else -> {
        buttonLabel = context.getString(R.string.err_button_label_ok)
        errorMessage = context.getString(R.string.err_message_default)
      }
    }
    val extras = Bundle()
    extras.putString("button_label", buttonLabel)
    return PlaybackException(errorMessage, error.cause, error.errorCode, extras)
  }
}

Java

class ErrorForwardingPlayer extends ForwardingSimpleBasePlayer {

  private final Context context;

  public ErrorForwardingPlayer(Context context, Player player) {
    super(player);
    this.context = context;
  }

  @Override
  protected State getState() {
    State state = super.getState();
    if (state.playerError != null) {
      state =
          state.buildUpon()
              .setPlayerError(customizePlaybackException(state.playerError))
              .build();
    }
    return state;
  }

  private PlaybackException customizePlaybackException(PlaybackException error) {
    String buttonLabel;
    String errorMessage;
    switch (error.errorCode) {
      case PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW:
        buttonLabel = context.getString(R.string.err_button_label_restart_stream);
        errorMessage = context.getString(R.string.err_msg_behind_live_window);
        break;
      default:
        buttonLabel = context.getString(R.string.err_button_label_ok);
        errorMessage = context.getString(R.string.err_message_default);
        break;
    }
    Bundle extras = new Bundle();
    extras.putString("button_label", buttonLabel);
    return new PlaybackException(errorMessage, error.getCause(), error.errorCode, extras);
  }
}

Erros não fatais

Erros não fatais que não têm origem em uma exceção técnica podem ser enviados por um app para todos ou para um controlador específico:

Kotlin

val sessionError = SessionError(
  SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
  context.getString(R.string.error_message_authentication_expired),
)

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError)

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
mediaSession.mediaNotificationControllerInfo?.let {
  mediaSession.sendError(it, sessionError)
}

Java

SessionError sessionError = new SessionError(
    SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED,
    context.getString(R.string.error_message_authentication_expired));

// Option 1: Sending a nonfatal error to all controllers.
mediaSession.sendError(sessionError);

// Option 2: Sending a nonfatal error to the media notification controller only
// to set the error code and error message in the playback state of the platform
// media session.
ControllerInfo mediaNotificationControllerInfo =
    mediaSession.getMediaNotificationControllerInfo();
if (mediaNotificationControllerInfo != null) {
  mediaSession.sendError(mediaNotificationControllerInfo, sessionError);
}

Quando um erro não fatal é enviado ao controlador de notificação de mídia, o código de erro e a mensagem de erro são replicados para a sessão de mídia da plataforma, enquanto PlaybackState.state não é alterado para STATE_ERROR.

Receber erros não fatais

Um MediaController recebe um erro não fatal ao implementar MediaController.Listener.onError:

Kotlin

val future = MediaController.Builder(context, sessionToken)
  .setListener(object : MediaController.Listener {
    override fun onError(controller: MediaController, sessionError: SessionError) {
      // Handle nonfatal error.
    }
  })
  .buildAsync()

Java

MediaController.Builder future =
    new MediaController.Builder(context, sessionToken)
        .setListener(
            new MediaController.Listener() {
              @Override
              public void onError(MediaController controller, SessionError sessionError) {
                // Handle nonfatal error.
              }
            });