文案神器

media3-ui-compose 库为在 Jetpack Compose 中构建媒体界面提供了基础组件。它专为需要比 media3-ui-compose-material3 库提供的更多自定义功能的开发者而设计。本页介绍了如何使用核心组件和状态持有者创建自定义媒体播放器界面。

混合使用 Material3 和自定义 Compose 组件

media3-ui-compose-material3 库在设计上具有灵活性。您可以将预构建的组件用于大多数界面,但如果需要更多控制权,可以将单个组件替换为自定义实现。此时,media3-ui-compose 库便可发挥作用。

例如,假设您想使用 Material3 库中的标准 PreviousButtonNextButton,但需要完全自定义的 PlayPauseButton。为此,您可以使用核心 media3-ui-compose 库中的 PlayPauseButton,并将其与预构建的组件放在一起。

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

可用组件

media3-ui-compose 库提供了一组预构建的可组合项,用于实现常见的播放器控件。以下是一些可以直接在应用中使用的组件:

组件 说明
PlayPauseButton 一种用于在播放和暂停之间切换的按钮的状态容器。
SeekBackButton 用于按定义的增量向后搜索的按钮的状态容器。
SeekForwardButton 一种状态容器,用于按定义的增量向前搜索的按钮。
NextButton 用于查找下一个媒体项的按钮的状态容器。
PreviousButton 用于跳转到上一个媒体项的按钮的状态容器。
RepeatButton 用于循环切换重复模式的按钮的状态容器。
ShuffleButton 用于切换随机播放模式的按钮的状态容器。
MuteButton 用于存放播放器静音和取消静音按钮的状态容器。
TimeText 用于显示玩家进度的可组合项的状态容器。
ContentFrame 用于显示媒体内容的 Surface,可处理宽高比管理、调整大小和快门
PlayerSurface AndroidView 中封装 SurfaceViewTextureView 的原始界面。

界面状态容器

如果没有任何脚手架组件符合您的需求,您也可以直接使用状态对象。一般建议使用相应的 remember 方法,以在重组之间保持界面外观。

如需更好地了解如何灵活使用界面状态容器与可组合项,请阅读有关 Compose 如何管理状态的文章。

按钮状态容器

对于某些界面状态,该库会假设它们很可能会被类似按钮的可组合项使用。

状态 remember*State 类型
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle
PreviousButtonState rememberPreviousButtonState 常量
NextButtonState rememberNextButtonState 常量
RepeatButtonState rememberRepeatButtonState 3-切换
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState 菜单或 N-Toggle

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

可视化输出状态容器

PresentationState 用于保存 PlayerSurface 中视频输出何时可以显示或何时应被占位界面元素覆盖的信息。ContentFrame 可组合项将宽高比处理与在尚未就绪的 Surface 上显示快门相结合。

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

在此示例中,我们可以使用 presentationState.videoSizeDp 将 Surface 缩放到所选宽高比(如需了解更多类型,请参阅 ContentScale 文档),并使用 presentationState.coverSurface 来了解何时不适合显示 Surface。在这种情况下,您可以在 Surface 上放置一个不透明的快门,当 Surface 准备就绪时,该快门会消失。ContentFrame 可让您将快门自定义为尾随 lambda,但默认情况下,它将是填充父容器大小的黑色 @Composable Box

Flows 在哪里?

许多 Android 开发者都熟悉如何使用 Kotlin Flow 对象来收集不断变化的界面数据。例如,您可能正在寻找可以以生命周期感知方式进行 collectPlayer.isPlaying 流。或者,您可以使用 Player.eventsFlow 之类的工具,为您提供可按您希望的方式进行 filterFlow<Player.Events>

不过,使用 Flow 处理 Player 界面状态存在一些缺点。主要问题之一是数据传输的异步性。我们希望尽可能缩短 Player.Event 与界面端使用之间的延迟时间,避免显示与 Player 不同步的界面元素。

其他要点包括:

  • 如果一个流程包含所有 Player.Events,则不符合单一责任原则,每个消费者都必须过滤掉相关事件。
  • 为每个 Player.Event 创建一个 flow 需要您将它们(使用 combine)组合在一起,以用于每个界面元素。Player.Event 与界面元素更改之间存在多对多映射。必须使用 combine 可能会导致界面处于潜在的非法状态。

创建自定义界面状态

如果现有界面状态无法满足您的需求,您可以添加自定义界面状态。 查看现有状态的源代码,以复制该模式。典型的界面状态容器类会执行以下操作:

  1. 接受 Player
  2. 使用协程订阅 Player。如需了解详情,请参阅 Player.listen
  3. 通过更新其内部状态来响应特定的 Player.Events
  4. 接受将转换为相应 Player 更新的业务逻辑命令。
  5. 可以在界面树中的多个位置创建,并且始终保持播放器状态的一致视图。
  6. 公开可由可组合项使用的 Compose State 字段,以动态响应更改。
  7. 附带一个 remember*State 函数,用于在组合之间记住实例。

幕后会发生什么:

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

如需对自己的 Player.Events 做出反应,您可以使用 Player.listen 捕获它们,Player.listen 是一个 suspend fun,可让您进入协程世界并无限期地监听 Player.Events。Media3 对各种界面状态的实现有助于最终开发者不必担心学习 Player.Events