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. É possível usar os componentes pré-criados para a maior parte da interface, mas trocar um único componente por uma implementação personalizada quando você 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 combináveis 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 por 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 exibir conteúdo de mídia que lida com o gerenciamento de proporção, o redimensionamento e um obturador.
PlayerSurface Superfície bruta que envolve SurfaceView e TextureView em AndroidView.

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 de composição 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 de cima, 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 de baixo 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
  )
}

Detentores de estado da IU

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 2-Alternar
PreviousButtonState rememberPreviousButtonState Constante
NextButtonState rememberNextButtonState Constante
RepeatButtonState rememberRepeatButtonState 3-Alternar
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

CurrentMediaItemState fornece informações sobre o MediaItem em reprodução, 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.

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.

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. Ter que usar 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 de interface típica faz o seguinte:

  1. Recebe 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 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.