Core Compose

La bibliothèque media3-ui-compose fournit les composants de base pour créer une UI multimédia dans Jetpack Compose. Il est conçu pour les développeurs qui ont besoin de plus de personnalisation que ce que propose la bibliothèque media3-ui-compose-material3. Cette page explique comment utiliser les composants principaux et les conteneurs d'état pour créer une UI de lecteur multimédia personnalisée.

Combiner des composants Material 3 et Compose personnalisés

La bibliothèque media3-ui-compose-material3 est conçue pour être flexible. Vous pouvez utiliser les composants prédéfinis pour la majeure partie de votre UI, mais remplacer un seul composant par une implémentation personnalisée lorsque vous avez besoin de plus de contrôle. C'est là que la bibliothèque media3-ui-compose entre en jeu.

Par exemple, imaginez que vous souhaitiez utiliser les éléments standards PreviousButton et NextButton de la bibliothèque Material3, mais que vous ayez besoin d'un PlayPauseButton entièrement personnalisé. Pour ce faire, vous pouvez utiliser PlayPauseButton de la bibliothèque principale media3-ui-compose et le placer à côté des composants prédéfinis.

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)
}

Composants disponibles

La bibliothèque media3-ui-compose fournit un ensemble de composables prédéfinis pour les commandes du lecteur courantes. Voici quelques-uns des composants que vous pouvez utiliser directement dans votre application :

Composant Description
PlayPauseButton Conteneur d'état pour un bouton qui permet de basculer entre lecture et pause.
SeekBackButton Conteneur d'état pour un bouton qui recule d'un incrément défini.
SeekForwardButton Conteneur d'état pour un bouton qui avance d'un incrément défini.
NextButton Conteneur d'état pour un bouton qui permet d'accéder à l'élément multimédia suivant.
PreviousButton Conteneur d'état pour un bouton qui permet d'accéder à l'élément multimédia précédent.
RepeatButton Conteneur d'état pour un bouton qui permet de parcourir les modes de répétition.
ShuffleButton Conteneur d'état pour un bouton qui active ou désactive le mode aléatoire.
MuteButton Conteneur d'état pour un bouton qui coupe et réactive le son du lecteur.
TimeText Conteneur d'état pour un composable qui affiche la progression du joueur.
ContentFrame Une surface permettant d'afficher du contenu multimédia qui gère le format, le redimensionnement et un obturateur
PlayerSurface Surface brute qui encapsule SurfaceView et TextureView dans AndroidView.

Personnaliser le composable Player

Lorsque vous utilisez la bibliothèque media3-ui-compose-material3, le composable Player offre une expérience de lecture multimédia complète. Vous pouvez personnaliser sa mise en page en fournissant vos propres composables à ses emplacements de contenu, ou vous pouvez utiliser l'objet PlayerDefaults pour personnaliser des parties spécifiques de l'UI, telles que TopControls, CenterControls, BottomControls et ErrorOverlay.

La figure 1 montre où le composable Player place ses emplacements. La figure 2 montre l'ordre dans lequel il dessine ses couches.

Lecteur vidéo avec des contours en pointillés qui marquent ses emplacements. De haut en bas : topControls le long du bord supérieur, errorOverlay au-dessus du centre, centerControls au milieu avec les boutons backSecondary, back, central, forward et forwardSecondary, subtitles près du bas, et bottomControls le long du bord inférieur avec les emplacements above, left, progressSlider, right et below. ContentFrame remplit la zone derrière eux.
Figure 1. Les emplacements du composable Player. Les commandes du centre et du bas affichent les emplacements de PlayerDefaults.CenterControls et PlayerDefaults.BottomControls. Les emplacements topControls, above et below sont vides par défaut.
Vue éclatée de six couches empilées. De bas en haut : 1, PlayerSurface ; 2, shutter ; 3, artwork ; 4, subtitles ; 5, errorOverlay ; et 6, topControls, centerControls et bottomControls. Les couches 1 à 4 constituent ContentFrame, et les six couches constituent Player.
Figure 2. Les couches du composable Player, numérotées dans l'ordre dans lequel elles sont dessinées.

Par exemple, vous pouvez utiliser PlayerDefaults.CenterControls pour remplacer uniquement le bouton central de lecture/pause, tout en laissant les autres commandes centrales intactes. Vous pouvez également transmettre un composable entièrement personnalisé pour un paramètre tel que topControls et omettre complètement d'autres paramètres tels que bottomControls pour les laisser par défaut.

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

Conteneurs d'état de l'UI

Si aucun des composants d'échafaudage ne répond à vos besoins, vous pouvez également utiliser directement les objets d'état. Il est généralement conseillé d'utiliser les méthodes remember correspondantes pour préserver l'apparence de votre UI entre les recompositions.

Pour mieux comprendre comment utiliser la flexibilité des conteneurs d'état de l'UI par rapport aux composables, découvrez comment Compose gère l'état.

Conteneurs d'état des boutons

Pour certains états d'interface utilisateur, la bibliothèque suppose qu'ils seront probablement utilisés par des composables de type bouton.

État remember*State Type
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle
PreviousButtonState rememberPreviousButtonState Constante
NextButtonState rememberNextButtonState Constante
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState Menu ou N-Toggle

Exemple d'utilisation 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),
  )
}

Conteneurs d'état de sortie visuelle

CurrentMediaItemState fournit des informations sur l'élément MediaItem en cours de lecture, tandis que PlaylistState expose des informations sur l'élément MediaItems défini sur le lecteur. Elles sont utiles pour afficher des informations de métadonnées dans votre UI personnalisée.

ErrorState fournit des informations sur l'état d'erreur actuel du lecteur, qui peuvent être utilisées pour afficher une superposition d'erreur.

PresentationState contient des informations indiquant quand la sortie vidéo d'un PlayerSurface peut être affichée ou doit être couverte par un élément d'interface utilisateur d'espace réservé. Le composable ContentFrame combine la gestion du format et l'affichage de l'obturateur sur une surface qui n'est pas encore prête.

@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()
  }
}

Ici, nous pouvons utiliser à la fois presentationState.videoAspectRatio pour adapter la surface au format choisi (voir Échelle du contenu pour d'autres types) et presentationState.coverSurface pour savoir quand le moment n'est pas opportun pour afficher la surface. Dans ce cas, vous pouvez placer un obturateur opaque au-dessus de la surface, qui disparaîtra lorsque la surface sera prête. ContentFrame vous permet de personnaliser l'obturateur en tant que lambda de fin, mais par défaut, il s'agit d'un @Composable Box noir remplissant la taille du conteneur parent.

Où se trouvent les flux ?

De nombreux développeurs Android savent utiliser les objets Kotlin Flow pour collecter des données d'UI en constante évolution. Par exemple, vous pouvez rechercher un flux Player.isPlaying que vous pouvez collect en tenant compte du cycle de vie. ou quelque chose comme Player.eventsFlow pour vous fournir un Flow<Player.Events> que vous pouvez filter comme vous le souhaitez.

Toutefois, l'utilisation de flux pour l'état de l'UI Player présente certains inconvénients. L'un des principaux problèmes est la nature asynchrone du transfert de données. Nous voulons obtenir une latence aussi faible que possible entre un Player.Event et sa consommation côté UI, en évitant d'afficher des éléments d'interface utilisateur qui ne sont pas synchronisés avec le Player.

Autres points :

  • Un flux contenant tous les Player.Events ne respecterait pas le principe de responsabilité unique, car chaque consommateur devrait filtrer les événements pertinents.
  • Si vous créez un flux pour chaque Player.Event, vous devrez les combiner (avec combine) pour chaque élément d'interface utilisateur. Il existe un mappage de plusieurs à plusieurs entre un Player.Event et une modification d'élément d'interface utilisateur. L'utilisation de combine peut entraîner des états potentiellement non valides de l'UI.

Créer des états d'interface utilisateur personnalisés

Vous pouvez ajouter des états d'UI personnalisés si ceux existants ne répondent pas à vos besoins. Consultez le code source de l'état existant pour copier le modèle. Une classe de conteneur d'état d'UI standard effectue les opérations suivantes :

  1. Accepte un objet Player.
  2. S'abonne à Player à l'aide de coroutines. Pour en savoir plus, consultez Player.listen.
  3. Répond à des Player.Events spécifiques en mettant à jour son état interne.
  4. Accepte les commandes de logique métier qui seront transformées en une mise à jour Player appropriée.
  5. Il peut être créé à plusieurs endroits de l'arborescence de l'UI et conserve toujours une vue cohérente de l'état du lecteur.
  6. Expose les champs Compose State qui peuvent être utilisés par un composable pour répondre de manière dynamique aux modifications.
  7. Fourni avec une fonction remember*State pour mémoriser l'instance entre les compositions.

Que se passe-t-il en arrière-plan ?

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)
    }
  }
}

Pour réagir à vos propres Player.Events, vous pouvez les intercepter à l'aide de Player.listen, qui est un suspend fun vous permettant d'accéder au monde des coroutines et d'écouter indéfiniment les Player.Events. L'implémentation de Media3 de différents états d'UI permet au développeur final de ne pas se soucier de l'apprentissage de Player.Events.