Core Compose

A biblioteca media3-ui-compose fornece os componentes básicos para criar uma interface de mídia no Jetpack Compose. Ele foi criado 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 titulares de estado para criar uma interface personalizada de player de mídia.

Como misturar componentes personalizados do Material3 e do Compose

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

Por exemplo, imagine que você quer usar os PreviousButton e NextButton padrão da biblioteca Material3, mas precisa de um PlayPauseButton completamente personalizado. Para isso, use PlayPauseButton da biblioteca principal media3-ui-compose e coloque 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 fornece um conjunto de elementos combináveis pré-criados para controles comuns do player. Confira alguns 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 busca para trás 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 passa por modos de repetição.
ShuffleButton Um contêiner de estado para um botão que ativa/desativa o modo aleatório.
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 mostrar conteúdo de mídia que lida com gerenciamento de proporção, redimensionamento e um obturador.
PlayerSurface Superfície bruta que une SurfaceView e TextureView em AndroidView.

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 recomposições.

Para entender melhor como usar a flexibilidade dos detentores de estado da UI em vez de Composables, leia sobre como o Compose gerencia o estado.

Detentores de estado de botão

Para alguns estados da 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 2. Alternar
PreviousButtonState rememberPreviousButtonState Constante
NextButtonState rememberNextButtonState Constante
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2. Alternar
PlaybackSpeedState rememberPlaybackSpeedState Menu ou N-Toggle

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

PresentationState contém informações sobre quando a saída de vídeo em um PlayerSurface pode ser mostrada ou deve ser coberta por um elemento de 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.videoSizeDp)

  // Always leave PlayerSurface to be part of the Compose tree because it will be initialised 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.videoSizeDp para dimensionar a superfície de acordo com a proporção escolhida (consulte os documentos do ContentScale para mais tipos) e presentationState.coverSurface para saber quando não é o momento certo de mostrar a superfície. Nesse caso, posicione um obturador opaco em cima da superfície, que vai desaparecer quando ela estiver pronta. ContentFrame permite personalizar o obturador como uma lambda final, mas, por padrão, ele será um @Composable Box preto preenchendo o tamanho do contêiner pai.

Onde ficam os Flows?

Muitos desenvolvedores Android estão acostumados a usar objetos Flow do Kotlin para coletar dados de interface em constante mudança. Por exemplo, você pode procurar um fluxo Player.isPlaying que pode ser collect de maneira compatível com o ciclo de vida. Ou algo como Player.eventsFlow para oferecer um Flow<Player.Events> que você pode filter do jeito que quiser.

No entanto, usar fluxos para o estado da interface 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 que estão dessincronizados com o Player.

Outros pontos incluem:

  • Um fluxo com todos os Player.Events não obedeceria a um único princípio de responsabilidade, e cada consumidor teria que filtrar os eventos relevantes.
  • Para criar um fluxo para cada Player.Event, é necessário combiná-los (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. Ter que usar combine pode levar a interface a estados potencialmente ilegais.

Criar estados de UI personalizados

Você pode 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 de UI típica faz o seguinte:

  1. Recebe um Player.
  2. Inscreve-se no Player usando corrotinas. Consulte 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 mantém uma visualização consistente do estado do player.
  6. Expõe campos State do Compose que podem ser consumidos por um elemento combinável para responder dinamicamente a 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(Player.COMMAND_ACTION_A))
    private set

  var someField by mutableStateOf(someFieldDefault)
    private set

  fun onClick() {
    player.actionA()
  }

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

Para reagir aos seus próprios Player.Events, capture-os usando Player.listen, que é um suspend fun que permite entrar no mundo das corrotinas e detectar Player.Events indefinidamente. A implementação do Media3 de vários estados da interface ajuda o desenvolvedor final a não se preocupar em aprender sobre Player.Events.