文案神器

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

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

media3-ui-compose-material3 库具有很高的灵活性。您可以在大部分界面中使用预构建的组件,但在需要更多控制时,可以将单个组件换成自定义实现。这时,media3-ui-compose 库就可以派上用场。

例如,假设您想使用 Material3 库中的标准 PreviousButton 和 NextButton,但需要完全自定义的 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 中封装 SurfaceView 和 TextureView 的原始 Surface。
Artwork 一个可组合项,用于显示当前媒体文件的艺术作品,例如专辑封面。

自定义 Player 可组合项

使用 media3-ui-compose-material3 库时,Player 可组合项可提供完整的媒体播放体验。您可以通过向其内容槽提供您自己的可组合项来自定义其布局,也可以利用 PlayerDefaults 对象自定义界面的特定部分,例如 TopControls、CenterControls、BottomControls 和 ErrorOverlay。

图 1 显示了 Player 可组合项放置其槽的位置。图 2 显示了绘制层的顺序。

一个视频播放器,其插槽用虚线轮廓标记。从上到下:topControls 沿顶部边缘排列,errorOverlay 位于中心上方,centerControls 位于中间,包含 backSecondary、back、central、forward 和 forwardSecondary 按钮,subtitles 靠近底部,bottomControls 沿底部边缘排列,包含 above、left、progressSlider、right 和 below 槽。ContentFrame 会填充其背后的区域。
图 1. Player 可组合项的槽。 中间和底部的控件显示 PlayerDefaults.CenterControls 和 PlayerDefaults.BottomControls 的槽。topControls、above 和 below 槽默认情况下为空。
六个堆叠层的分解视图。从下到上依次为:1. PlayerSurface;2. shutter;3. artwork;4. subtitles;5. errorOverlay;6. topControls、centerControls 和 bottomControls。第 1 层到第 4 层构成 ContentFrame,所有 6 层构成 Player。
图 2. Player 可组合项的层,按绘制顺序编号。

例如,您可以使用 PlayerDefaults.CenterControls 仅替换中央播放/暂停按钮,同时保持其余中央控件不变。 您还可以为 topControls 等参数传递完全自定义的可组合项,并完全省略 bottomControls 等其他参数,以将其保留为默认值。

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

Player 可组合项还会显示字幕,并且在没有视频可显示时,会显示当前媒体文件的插图。如需自定义其外观,请将您自己的可组合项传递给 subtitles 和 artwork 槽。如需隐藏这些内容,请将空的可组合项传递给 subtitles,并将 null 传递给 artwork。

界面状态容器

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

如需更好地了解如何利用界面状态容器和可组合项的灵活性,请参阅 Compose 如何管理状态。

按钮状态容器

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

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

除了播放和暂停状态之外,PlayPauseButtonState 还会公开 showBuffering、showReplay 和 showRetry。您可以使用这些图标在播放器缓冲时显示缓冲指示器,在播放结束后显示重放图标,以及在播放出错后显示重试图标。showReplay 和 showRetry 优化了 showPlay:只要其中一个是 true,showPlay 也是 true,因此请在 showPlay 之前检查它们。onClick 方法也会处理这些状态,例如在发生错误后再次准备播放器。

如需选择要显示的状态,请将 SHOW_BUFFERING_WHEN_PLAYING、SHOW_BUFFERING_ALWAYS、SHOW_REPLAY_ON_ENDED 和 SHOW_RETRY_ON_ERROR 的组合传递给 rememberPlayPauseButtonState 的 displayMode 参数。默认情况下,displayMode 会组合 SHOW_BUFFERING_ALWAYS、SHOW_REPLAY_ON_ENDED 和 SHOW_RETRY_ON_ERROR,这会显示所有三种状态,甚至在播放暂停时也会显示缓冲状态。

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

视觉输出状态容器

CurrentMediaItemState 提供有关当前播放的 MediaItem 的信息,而 PlaylistState 则公开有关在播放器上设置的 MediaItems 的信息。这些方法有助于在自定义界面中展示元数据信息。

ErrorState 提供有关播放器当前错误状态的信息,可用于显示错误叠加层。

PlaybackState 会公开播放器的核心播放状态(例如 playbackState、isPlaying 和 playerError),而与任何特定界面组件无关。使用它来驱动叠加层或自定义布局,例如在播放器缓冲时显示加载指示器。如需使用预构建的 Material3 指示器,请使用 BufferingIndicator。

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

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

PresentationState 还会公开 showArtwork,当当前媒体文件在没有视频轨道的情况下播放(例如仅音频内容)时,该值为 true。在这种情况下,ContentFrame 会显示 Artwork 可组合项,该可组合项会显示该项的 MediaMetadata 中的图片。如需自定义或隐藏封面图片,请将您自己的可组合项或 null 传递给 ContentFrame 的 artwork 槽。

默认情况下,Artwork 仅加载嵌入在元数据中的封面图片数据,或从 content、file 或 android.resource URI 加载封面图片数据。如需从远程 URI 或其他来源加载图片,请使用以下任一选项:

  • 将 BitmapLoader(例如 DataSourceBitmapLoader)传递给 Artwork。
  • 使用接受 painter 的 Artwork 重载,通过您选择的库加载图片。

与默认呈现字幕的 Player 可组合项不同,ContentFrame 会将其 subtitles 槽留空。如需在 ContentFrame 中呈现字幕,或在 Player 中自定义字幕呈现,请使用 CuesState 观察播放器的当前字幕,并在 subtitles 槽中呈现它们。ContentFrame 会在缩放后的视频边界内布置此槽,并将其绘制在快门和图片上方,以便在没有视频时字幕仍保持可见。

轨道选择状态容器

TrackSelectionState 可帮助您为单个轨道类型(例如音频语言或字幕菜单)构建菜单。使用 rememberTrackSelectionState 创建它,并传递轨道类型和过滤条件组合(例如 TRACK_FILTER_LANGUAGE 或 TRACK_FILTER_RESOLUTION),以确定哪些轨道属性可区分选项。将 hasOffOption 或 hasAutoOption 设置为 true,以添加用于停用轨道类型的选项或让播放器自动选择轨道的选项。

该状态会公开可用的 selectionOptions 和 selectedOption。 每个 TrackSelectionOption 都表示设备是否支持该功能以及是否已选择该功能。向 selectOption 传递选项会相应地更新播放器的轨道选择参数。在显示菜单之前,请检查 canSetTrackSelectionParameters 以及是否有足够的选项可供选择。例如,以下字幕菜单为每种字幕语言提供了一个选项,并提供了一个关闭字幕的选项:

@Composable
fun SubtitleMenu(player: Player, modifier: Modifier = Modifier) {
  val state =
    rememberTrackSelectionState(
      player,
      trackType = C.TRACK_TYPE_TEXT,
      trackFilters = TRACK_FILTER_LANGUAGE,
      hasOffOption = true,
    )
  // Hide the menu if the player can't change tracks or has no subtitles to choose from
  if (!state.canSetTrackSelectionParameters || state.selectionOptions.size < 2) return

  Column(modifier.selectableGroup()) {
    for (option in state.selectionOptions) {
      Row(
        Modifier.fillMaxWidth()
          .heightIn(min = 48.dp)
          .selectable(
            selected = option.isSelected,
            enabled = option.isSupported,
            role = Role.RadioButton,
            onClick = { state.selectOption(option) },
          )
          .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
      ) {
        RadioButton(selected = option.isSelected, onClick = null, enabled = option.isSupported)
        Text(
          text =
            if (option.onOffState == TRACK_SELECTION_OFF) "Off"
            else option.language?.let { Locale.forLanguageTag(it).displayName } ?: "Unknown",
          color =
            if (option.isSupported) Color.Unspecified
            else LocalContentColor.current.copy(alpha = 0.38f),
        )
      }
    }
  }
}

如果您需要直接访问玩家的 Tracks 和 TrackSelectionParameters,请改用 TrackSelectionParametersState。如需详细了解播放器如何选择轨道,请参阅轨道选择。

Flows 在哪里?

许多 Android 开发者都熟悉如何使用 Kotlin Flow 对象来收集不断变化的界面数据。例如,您可能正在寻找可以以生命周期感知方式collect的 Player.isPlaying 流。或者使用 Player.eventsFlow 之类的工具,为您提供一个可以按您希望的方式filter的Flow<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. 可以在界面树中的多个位置创建,并且始终保持 Player 状态的一致视图。
  6. 公开可由可组合项使用的 Compose State 字段,以动态响应更改。
  7. 附带一个 remember*State 函数,用于在组合之间记住实例。

幕后故事:

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

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