Introdução à interface baseada no Compose

Adicionar a dependência

A biblioteca Media3 inclui um módulo de interface baseado no Jetpack Compose. Para usar, adicione a seguinte dependência:

Kotlin

implementation("androidx.media3:media3-ui-compose:1.7.1")

Groovy

implementation "androidx.media3:media3-ui-compose:1.7.1"

Recomendamos que você desenvolva seu app com o Compose em primeiro lugar ou migre do uso de Views.

App de demonstração totalmente no Compose

Embora a biblioteca media3-ui-compose não inclua elementos combináveis prontos para uso (como botões, indicadores, imagens ou caixas de diálogo), você pode encontrar um app de demonstração totalmente escrito em Compose que evita soluções de interoperabilidade, como o encapsulamento de PlayerView em AndroidView. O app de demonstração usa as classes de suporte de estado da interface do módulo media3-ui-compose e a biblioteca Compose Material3.

Detentores de estado da interface

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

Detentores de estado de botão

Para alguns estados da interface, presumimos 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:

@Composable
fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
  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),
    )
  }
}

Observe como state não tem informações de temas, como ícones para tocar ou pausar. A única responsabilidade dele é transformar o Player em estado da interface.

Em seguida, combine os botões no layout de sua preferência:

Row(
  modifier = modifier.fillMaxWidth(),
  horizontalArrangement = Arrangement.SpaceEvenly,
  verticalAlignment = Alignment.CenterVertically,
) {
  PreviousButton(player)
  PlayPauseButton(player)
  NextButton(player)
}

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.

val presentationState = rememberPresentationState(player)
val scaledModifier = Modifier.resize(ContentScale.Fit, presentationState.videoSizeDp)

Box(modifier) {
  // 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 = player,
    surfaceType = SURFACE_TYPE_SURFACE_VIEW,
    modifier = scaledModifier,
  )

  if (presentationState.coverSurface) {
    // Cover the surface that is being prepared with a shutter
    Box(Modifier.background(Color.Black))
  }

Aqui, podemos usar presentationState.videoSizeDp para dimensionar a superfície até a proporção desejada (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.

Onde ficam 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, 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 garantir a menor latência possível entre um Player.Event e o consumo dele no lado da interface, evitando mostrar elementos que estejam 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 típica de detentor de estado da UI 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. Aceitar 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 manterá 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.