Core Compose

A biblioteca media3-ui-compose fornece os componentes básicos para criar uma interface de mídia no Jetpack Compose. Ela foi projetada para desenvolvedores que precisam de mais personalização do que a oferecida pela biblioteca media3-ui-compose-material3. Nesta página, explicamos como usar os componentes principais e os detentores de estado para criar uma interface personalizada de player de mídia.

Como misturar componentes do Material 3 e personalizados do Compose

A biblioteca media3-ui-compose-material3 foi projetada para ser flexível. Você pode usar os componentes pré-criados para a maior parte da interface, mas trocar um único componente por uma implementação personalizada quando precisar de mais controle. É aí que entra a biblioteca media3-ui-compose.

Por exemplo, imagine que você queira usar os elementos padrão PreviousButton e NextButton da biblioteca Material3, mas precise de um PlayPauseButton totalmente personalizado. Para isso, use PlayPauseButton da biblioteca principal media3-ui-compose e coloque-o ao lado dos componentes pré-criados.

Row {
  // Use prebuilt component from the Media3 UI Compose Material3 library
  PreviousButton(player)
  // Use the scaffold component from Media3 UI Compose library
  PlayPauseButton(player) {
    // `this` is PlayPauseButtonState
    FilledTonalButton(
      onClick = {
        Log.d("PlayPauseButton", "Clicking on play-pause button")
        this.onClick()
      },
      enabled = this.isEnabled,
    ) {
      Icon(
        imageVector = if (showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
        contentDescription = if (showPlay) "Play" else "Pause",
      )
    }
  }
  // Use prebuilt component from the Media3 UI Compose Material3 library
  NextButton(player)
}

Componentes disponíveis

A biblioteca media3-ui-compose oferece um conjunto de elementos de composição pré-criados para controles comuns do player. Confira alguns dos componentes que podem ser usados diretamente no seu app:

Componente Descrição
PlayPauseButton Um contêiner de estado para um botão que alterna entre reproduzir e pausar.
SeekBackButton Um contêiner de estado para um botão que retrocede em um incremento definido.
SeekForwardButton Um contêiner de estado para um botão que avança em um incremento definido.
NextButton Um contêiner de estado para um botão que busca o próximo item de mídia.
PreviousButton Um contêiner de estado para um botão que busca o item de mídia anterior.
RepeatButton Um contêiner de estado para um botão que alterna entre os modos de repetição.
ShuffleButton Um contêiner de estado para um botão que ativa/desativa o modo de ordem aleatória.
MuteButton Um contêiner de estado para um botão que ativa e desativa o som do player.
TimeText Um contêiner de estado para um elemento combinável que mostra o progresso do jogador.
ContentFrame Uma superfície para conteúdo de mídia que lida com o gerenciamento de proporção, redimensionamento, um obturador, arte e um espaço para legendas.
PlayerSurface Superfície bruta que envolve SurfaceView e TextureView em AndroidView.
Artwork Um elemento de composição que mostra a arte do item de mídia atual, como a capa de um álbum.

Personalizar o elemento combinável Player

Ao usar a biblioteca media3-ui-compose-material3, o elemento combinável Player oferece uma experiência completa de reprodução de mídia. É possível personalizar o layout fornecendo seus próprios elementos combináveis para os slots de conteúdo ou usar o objeto PlayerDefaults para personalizar partes específicas da interface, como TopControls, CenterControls, BottomControls e ErrorOverlay.

A Figura 1 mostra onde o elemento combinável Player coloca os slots. A Figura 2 mostra a ordem em que as camadas são desenhadas.

Um player de vídeo com contornos tracejados que marcam os espaços. De cima para
      baixo: topControls na borda superior, errorOverlay acima do centro,
      centerControls no meio com os botões backSecondary, back, central,
      forward e forwardSecondary, legendas perto da parte de baixo e
      bottomControls na borda inferior com os slots above, left,
      progressSlider, right e below. O ContentFrame preenche a área
      atrás deles.
Figura 1. Os slots do elemento combinável Player. Os controles central e inferior mostram os slots de PlayerDefaults.CenterControls e PlayerDefaults.BottomControls. Os slots topControls, above e below ficam vazios por padrão.
Uma vista explodida de seis camadas empilhadas. De baixo para cima: 1,
      PlayerSurface; 2, shutter; 3, artwork; 4, subtitles; 5, errorOverlay; e
      6, topControls, centerControls e bottomControls. As camadas de 1 a 4 compõem o
      ContentFrame, e as seis compõem o Player.
Figura 2. As camadas do elemento combinável Player, numeradas na ordem em que são desenhadas.

Por exemplo, é possível usar PlayerDefaults.CenterControls para substituir apenas o botão central de reproduzir/pausar, deixando o restante dos controles centrais intactos. Também é possível transmitir um elemento combinável totalmente personalizado para um parâmetro como topControls e omitir completamente outros, como bottomControls, para deixá-los no padrão.

@Composable
fun CustomPlayerSlots(player: Player, showControls: Boolean, modifier: Modifier = Modifier) {
  Player(
    player = player,
    modifier = modifier,
    showControls = showControls,
    topControls = {
      // Fully custom top controls
      Text("My custom title")
    },
    centerControls = {
      // Use default CenterControls but override the central button
      PlayerDefaults.CenterControls(
        player = player,
        central = {
          // A custom play/pause button
          Material3PlayPauseButton(player, modifier = Modifier.size(64.dp))
        },
      )
    },
    // bottomControls are left as default
  )
}

O elemento combinável Player também mostra legendas e, quando não há vídeo para mostrar, a arte do item de mídia atual. Para personalizar a aparência, transmita seus próprios elementos combináveis para os slots subtitles e artwork. Para ocultá-los, transmita um elemento combinável vazio para subtitles e null para artwork.

Detentores de estado da interface

Se nenhum dos componentes de scaffolding atender às suas necessidades, use os objetos de estado diretamente. Em geral, é recomendável usar os métodos remember correspondentes para preservar a aparência da interface entre as recomposições.

Para entender melhor como usar a flexibilidade dos detentores de estado da interface em comparação com os elementos combináveis, leia sobre como o Compose gerencia o estado.

Detentores de estado do botão

Para alguns estados de interface, a biblioteca pressupõe que eles provavelmente serão consumidos por elementos combináveis semelhantes a botões.

Estado remember*State Tipo
PlayPauseButtonState rememberPlayPauseButtonState Alternância dupla ou múltipla
PreviousButtonState rememberPreviousButtonState Constante
NextButtonState rememberNextButtonState Constante
RepeatButtonState rememberRepeatButtonState 3-Alternar
ShuffleButtonState rememberShuffleButtonState 2-Alternar
PlaybackSpeedState rememberPlaybackSpeedState Menu ou N-Toggle

Além dos estados de reprodução e pausa, o PlayPauseButtonState expõe showBuffering, showReplay e showRetry. Use-os para mostrar um indicador de armazenamento em buffer enquanto o player estiver armazenando em buffer, um ícone de repetição após o término da reprodução e um ícone de nova tentativa após um erro de reprodução. showReplay e showRetry refinam showPlay: sempre que um deles é true, showPlay também é true. Portanto, verifique-os antes de showPlay. O método onClick também processa esses estados, por exemplo, preparando o player novamente após um erro.

Para escolher quais desses estados mostrar, transmita uma combinação de SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS, SHOW_REPLAY_ON_ENDED e SHOW_RETRY_ON_ERROR para o parâmetro displayMode de rememberPlayPauseButtonState. Por padrão, displayMode combina SHOW_BUFFERING_ALWAYS, SHOW_REPLAY_ON_ENDED e SHOW_RETRY_ON_ERROR, que mostra os três estados, e o estado de armazenamento em buffer aparece mesmo quando a reprodução está pausada.

Exemplo de uso de PlayPauseButtonState:

val state = rememberPlayPauseButtonState(player)

IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
  Icon(
    imageVector = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
    contentDescription =
      if (state.showPlay) stringResource(R.string.playpause_button_play)
      else stringResource(R.string.playpause_button_pause),
  )
}

Detentores de estado de saída visual

CurrentMediaItemState fornece informações sobre o MediaItem que está sendo reproduzido, enquanto PlaylistState expõe informações sobre o MediaItems definido no player. Eles são úteis para exibir metadados de exibição na sua interface personalizada.

ErrorState fornece informações sobre o estado de erro atual do player, que podem ser usadas para mostrar uma sobreposição de erro.

PlaybackState expõe o estado de reprodução principal do player, como playbackState, isPlaying e playerError, independentemente de qualquer componente específico da interface. Use-o para gerar sobreposições ou layouts personalizados, por exemplo, para mostrar um indicador de carregamento enquanto o player está em buffer. Para um indicador pré-criado do Material 3, use BufferingIndicator.

PresentationState contém informações sobre quando a saída de vídeo em um PlayerSurface pode ser mostrada ou precisa ser coberta por um elemento da interface de marcador de posição. O elemento combinável ContentFrame combina o processamento da proporção com o cuidado de mostrar o obturador em uma superfície que ainda não está pronta.

@Composable
fun ContentFrame(
  player: Player?,
  modifier: Modifier = Modifier,
  surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
  contentScale: ContentScale = ContentScale.Fit,
  keepContentOnReset: Boolean = false,
  shutter: @Composable () -> Unit = { Box(Modifier.fillMaxSize().background(Color.Black)) },
) {
  val presentationState = rememberPresentationState(player, keepContentOnReset)
  val scaledModifier =
    modifier.resizeWithContentScale(contentScale, presentationState.videoAspectRatio)

  // Always leave PlayerSurface to be part of the Compose tree because it will be initialized in
  // the process. If this composable is guarded by some condition, it might never become visible
  // because the Player won't emit the relevant event, e.g. the first frame being ready.
  PlayerSurface(player, scaledModifier, surfaceType)

  if (presentationState.coverSurface) {
    // Cover the surface that is being prepared with a shutter
    shutter()
  }
}

Aqui, podemos usar presentationState.videoAspectRatio para dimensionar a Surface para a proporção escolhida (consulte Escala de conteúdo para mais tipos) e presentationState.coverSurface para saber quando não é o momento certo para mostrar a Surface. Nesse caso, é possível posicionar um obturador opaco sobre a superfície, que vai desaparecer quando ela estiver pronta. O ContentFrame permite personalizar o obturador como uma lambda final, mas, por padrão, ele será um @Composable Box preto que preenche o tamanho do contêiner pai.

O PresentationState também expõe showArtwork, que é true quando o item de mídia atual é reproduzido sem uma faixa de vídeo, como conteúdo somente de áudio. Nesse caso, ContentFrame mostra o elemento combinável Artwork, que exibe a arte do MediaMetadata do item. Para personalizar ou ocultar a arte, transmita seu próprio elemento combinável ou null para o slot artwork de ContentFrame.

Por padrão, o Artwork só carrega dados de arte incorporados nos metadados ou de um URI content, file ou android.resource. Para carregar artes de URIs remotos ou de outras fontes, use uma das seguintes opções:

  • Transmita um BitmapLoader, como DataSourceBitmapLoader, para Artwork.
  • Use a sobrecarga Artwork que usa um painter para carregar imagens com a biblioteca de sua escolha.

Ao contrário do elemento combinável Player, que renderiza legendas por padrão, ContentFrame deixa o slot subtitles vazio. Para renderizar legendas em um ContentFrame ou personalizar a renderização de legendas em Player, observe as indicações atuais do player com CuesState e renderize-as no slot subtitles. O ContentFrame define esse slot dentro dos limites do vídeo dimensionado e o desenha acima do obturador e da arte, para que as legendas permaneçam visíveis quando não houver vídeo.

Detentores de estado de seleção de faixas

TrackSelectionState ajuda você a criar um menu para um único tipo de faixa, como um menu de idioma de áudio ou de legendas. Crie-o com rememberTrackSelectionState, transmitindo o tipo de faixa e uma combinação de filtros, como TRACK_FILTER_LANGUAGE ou TRACK_FILTER_RESOLUTION, que determinam quais propriedades de faixa distinguem as opções. Defina hasOffOption ou hasAutoOption como true para adicionar uma opção que desativa o tipo de faixa ou uma que permite que o player escolha as faixas automaticamente.

O estado expõe o selectionOptions e o selectedOption disponíveis. Cada TrackSelectionOption indica se o dispositivo é compatível com o recurso e se ele está selecionado. Transmitir uma opção para selectOption atualiza os parâmetros de seleção de faixa do player de acordo. Antes de mostrar o menu, verifique canSetTrackSelectionParameters e se há opções suficientes para escolher. Por exemplo, o menu de legendas a seguir oferece uma opção para cada idioma de legenda e uma opção para desativar as legendas:

@Composable
fun SubtitleMenu(player: Player, modifier: Modifier = Modifier) {
  val state =
    rememberTrackSelectionState(
      player,
      trackType = C.TRACK_TYPE_TEXT,
      trackFilters = TRACK_FILTER_LANGUAGE,
      hasOffOption = true,
    )
  // Hide the menu if the player can't change tracks or has no subtitles to choose from
  if (!state.canSetTrackSelectionParameters || state.selectionOptions.size < 2) return

  Column(modifier.selectableGroup()) {
    for (option in state.selectionOptions) {
      Row(
        Modifier.fillMaxWidth()
          .heightIn(min = 48.dp)
          .selectable(
            selected = option.isSelected,
            enabled = option.isSupported,
            role = Role.RadioButton,
            onClick = { state.selectOption(option) },
          )
          .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
      ) {
        RadioButton(selected = option.isSelected, onClick = null, enabled = option.isSupported)
        Text(
          text =
            if (option.onOffState == TRACK_SELECTION_OFF) "Off"
            else option.language?.let { Locale.forLanguageTag(it).displayName } ?: "Unknown",
          color =
            if (option.isSupported) Color.Unspecified
            else LocalContentColor.current.copy(alpha = 0.38f),
        )
      }
    }
  }
}

Se você precisar de acesso direto ao Tracks e ao TrackSelectionParameters do player, use TrackSelectionParametersState. Para mais informações sobre como o player seleciona faixas, consulte Seleção de faixas.

Onde estão os Flows?

Muitos desenvolvedores Android estão familiarizados com o uso de objetos Flow do Kotlin para coletar dados de interface em constante mudança. Por exemplo, talvez você esteja procurando um fluxo Player.isPlaying que possa ser collect de acordo com o ciclo de vida. Ou algo como Player.eventsFlow para fornecer um Flow<Player.Events> que você pode filter como quiser.

No entanto, o uso de fluxos para o estado da interface do Player tem algumas desvantagens. Uma das principais preocupações é a natureza assíncrona da transferência de dados. Queremos alcançar a menor latência possível entre um Player.Event e o consumo dele no lado da interface, evitando mostrar elementos da interface que não estejam sincronizados com o Player.

Outros pontos incluem:

  • Um fluxo com todos os Player.Events não seguiria um princípio de responsabilidade única, e cada consumidor teria que filtrar os eventos relevantes.
  • Criar um fluxo para cada Player.Event exige que você os combine (com combine) para cada elemento da interface. Há um mapeamento de muitos para muitos entre um Player.Event e uma mudança de elemento da interface. O uso de combine pode levar a IU a estados potencialmente ilegais.

Criar estados de interface personalizados

É possível adicionar estados de interface personalizados se os atuais não atenderem às suas necessidades. Confira o código-fonte do estado atual para copiar o padrão. Uma classe detentora de estado da interface típica faz o seguinte:

  1. Usa um objeto Player.
  2. Inscreve-se no Player usando corrotinas. Veja Player.listen para mais detalhes.
  3. Responde a um Player.Events específico atualizando o estado interno.
  4. Aceita comandos de lógica de negócios que serão transformados em uma atualização Player adequada.
  5. Pode ser criado em vários lugares na árvore da interface e sempre vai manter uma visualização consistente do estado do Player.
  6. Expõe os campos State do Compose que podem ser consumidos por um elemento de composição para responder dinamicamente às mudanças.
  7. Vem com uma função remember*State para lembrar a instância entre composições.

O que acontece nos bastidores:

class SomeButtonState(private val player: Player) {
  var isEnabled by mutableStateOf(player.isCommandAvailable(COMMAND_ACTION_A))
    private set

  var someFieldValue by mutableStateOf(someFieldDefault)
    private set

  fun onClick() {
    player.actionA()
  }

  suspend fun observe(): Nothing = player.listen { events ->
    if (events.containsAny(EVENT_B_CHANGED, EVENT_C_CHANGED, EVENT_AVAILABLE_COMMANDS_CHANGED)) {
      someFieldValue = this.someField
      isEnabled = this.isCommandAvailable(COMMAND_ACTION_A)
    }
  }
}

Para reagir aos seus próprios Player.Events, você pode capturá-los usando Player.listen, que é um suspend fun que permite que você entre no mundo das corrotinas e detecte Player.Events indefinidamente. A implementação de vários estados de interface pela Media3 ajuda o desenvolvedor final a não se preocupar em aprender sobre Player.Events.