Compose 核心

media3-ui-compose 程式庫提供基礎元件,可用於在 Jetpack Compose 中建構媒體 UI。如果開發人員需要比 media3-ui-compose-material3 程式庫提供的更多自訂功能,則可使用此 API。本頁說明如何使用核心元件和狀態容器,建立自訂媒體播放器 UI。

混合 Material 3 和自訂 Compose 元件

media3-ui-compose-material3 程式庫的設計具有彈性。您可以在大部分 UI 中使用預先建構的元件,但如果需要更多控制權,可以將單一元件換成自訂實作。這時 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 媒體內容介面,可處理顯示比例管理、調整大小、快門、圖片和字幕版位。
PlayerSurface 在 AndroidView 中包裝 SurfaceView 和 TextureView 的原始介面。
Artwork 可組合函式,用於顯示目前媒體項目的圖片,例如專輯封面。

自訂 Player 可組合函式

使用 media3-ui-compose-material3 程式庫時,Player 可組合項可提供完整的媒體播放體驗。您可以為內容版位提供自己的可組合函式,藉此自訂版面配置,也可以利用 PlayerDefaults 物件自訂 UI 的特定部分,例如 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、快門;3、圖片;4、字幕;5、errorOverlay;6、topControls、centerControls 和 bottomControls。第 1 到 4 層構成 ContentFrame,而所有六層則構成 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。

UI 狀態容器

如果鷹架元件都不符合需求,也可以直接使用狀態物件。一般建議使用對應的 remember 方法,在重組之間保留 UI 外觀。

如要進一步瞭解如何靈活運用 UI 狀態容器和可組合函式,請參閱 Compose 管理狀態的方式。

按鈕狀態容器

對於某些 UI 狀態,程式庫會假設這些狀態最有可能由類似按鈕的可組合項使用。

狀態 remember*State 類型
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle 或 N-Toggle
PreviousButtonState rememberPreviousButtonState 常數
NextButtonState rememberNextButtonState 常數
RepeatButtonState rememberRepeatButtonState 3-切換
ShuffleButtonState rememberShuffleButtonState 2 個切換鈕
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 相關資訊。這些方法可用於在自訂 UI 中顯示中繼資料資訊。

ErrorState 提供播放器目前的錯誤狀態資訊,可用於顯示錯誤疊加層。

PlaybackState 會公開播放器的核心播放狀態,例如 playbackState、isPlaying 和 playerError,不受任何特定 UI 元件影響。您可以使用這個狀態來驅動疊加層或自訂版面配置,例如在播放器緩衝時顯示載入指標。如要使用預建的 Material 3 指標,請使用 BufferingIndicator。

PresentationState 包含 PlayerSurface 中影片輸出內容何時可顯示,或應由預留位置 UI 元素覆蓋的資訊。ContentFrame 可組合項結合了顯示比例處理功能,以及在尚未準備就緒的介面上顯示快門。

@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。在這種情況下,您可以在介面上方放置不透明的快門,當介面準備就緒時,快門就會消失。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 物件收集不斷變化的 UI 資料。舉例來說,您可能正在尋找 Player.isPlaying 流程,以便透過生命週期感知方式 collect。或類似 Player.eventsFlow 的內容,為您提供可依所需方式 filter 的 Flow<Player.Events>。

不過,使用流程處理 Player UI 狀態有一些缺點。其中一項主要考量是資料移轉的非同步性質。我們希望盡可能縮短 Player.Event 與 UI 端消耗之間的延遲時間,避免顯示與 Player 不同步的 UI 元素。

其他重點包括:

  • 如果流程包含所有 Player.Events,就不符合單一責任原則,每個消費者都必須篩選出相關事件。
  • 為每個 Player.Event 建立流程時,您必須為每個 UI 元素合併這些流程 (使用 combine)。Player.Event 和 UI 元素變更之間存在多對多對應關係。如果必須使用 combine,UI 可能會進入非法狀態。

建立自訂 UI 狀態

如果現有狀態不符合需求,可以新增自訂 UI 狀態。查看現有狀態的原始碼,複製模式。一般 UI 狀態容器類別會執行下列操作:

  1. 接受 Player 物件。
  2. 使用協同程式訂閱 Player。詳情請參閱 Player.listen。
  3. 透過更新內部狀態,回應特定 Player.Events。
  4. 接受商業邏輯指令,並轉換為適當的 Player 更新。
  5. 可在 UI 樹狀結構的多個位置建立,且一律會維持 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 實作各種 UI 狀態,可協助終端開發人員不必擔心學習 Player.Events。