文章マジック

media3-ui-compose ライブラリは、Jetpack Compose でメディア UI を構築するための基盤となるコンポーネントを提供します。これは、media3-ui-compose-material3 ライブラリで提供される以上のカスタマイズが必要なデベロッパー向けに設計されています。このページでは、コア コンポーネントと状態ホルダーを使用してカスタム メディア プレーヤー UI を作成する方法について説明します。

Material3 とカスタム Compose コンポーネントの組み合わせ

media3-ui-compose-material3 ライブラリは柔軟性を重視して設計されています。UI のほとんどに事前構築済みのコンポーネントを使用できますが、より細かく制御する必要がある場合は、1 つのコンポーネントをカスタム実装に置き換えることができます。ここで 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、中央に backSecondary、back、central、forward、forwardSecondary ボタンを含む centerControls、下部付近に字幕、下端に沿って above、left、progressSlider、right、below スロットを含む bottomControls があります。ContentFrame は、それらの背後の領域を埋めます。
図 1. Player コンポーザブルのスロット。 中央と下部のコントロールには、PlayerDefaults.CenterControls と PlayerDefaults.BottomControls のスロットが表示されます。topControls、above、below スロットはデフォルトで空です。
6 つのレイヤが積み重なった分解図。下から順に、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 に渡します。

UI 状態ホルダー

どのスキャフォールディング コンポーネントもニーズに合わない場合は、状態オブジェクトを直接使用することもできます。一般的には、対応する remember メソッドを使用して、再コンポーズ間で UI の外観を維持することをおすすめします。

コンポーザブルと比較して UI 状態ホルダーの柔軟性をどのように活用できるかを理解するには、Compose が状態を管理する方法をご覧ください。

ボタンの状態ホルダー

一部の UI 状態については、ライブラリは、ボタンのようなコンポーザブルによって使用される可能性が高いと想定しています。

状態 remember*State タイプ
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle または N-Toggle
PreviousButtonState rememberPreviousButtonState 定数
NextButtonState rememberNextButtonState 定数
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState メニューまたは N-Toggle

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 を組み合わせたもので、3 つの状態すべてが表示され、再生が一時停止されている間もバッファリング状態が表示されます。

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 は、特定の UI コンポーネントとは独立して、playbackState、isPlaying、playerError などのプレーヤーのコア再生状態を公開します。これを使用して、オーバーレイやカスタム レイアウトを操作します。たとえば、プレーヤーがバッファリングしている間に読み込みインジケーターを表示します。事前ビルドされた Material3 インジケーターの場合は、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 を使用すると、シャッターを末尾のラムダとしてカスタマイズできますが、デフォルトでは、親コンテナのサイズを埋める黒い @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 と、選択できるオプションが十分にあるかどうかを確認します。たとえば、次の字幕メニューでは、字幕言語ごとに 1 つのオプションと、字幕をオフにするオプションが用意されています。

@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 データを収集することに慣れています。たとえば、ライフサイクルを認識する方法で collect できる Player.isPlaying フローを探しているとします。または、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 状態を追加できます。既存の状態のソースコードをチェックアウトして、パターンをコピーします。一般的な UI 状態ホルダークラスは、次の処理を行います。

  1. Player オブジェクトを受け取ります。
  2. コルーチンを使用して Player に登録します。詳しくは、Player.listen をご覧ください。
  3. 特定の Player.Events に応答して内部状態を更新します。
  4. ビジネス ロジック コマンドを受け入れ、適切な Player 更新に変換します。
  5. UI ツリーの複数の場所で作成でき、プレーヤーの状態の一貫したビューを常に維持します。
  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 を使用してキャッチします。これは、コルーチンの世界に入り、Player.Events を無期限にリッスンできる suspend fun です。Media3 でさまざまな UI 状態を実装することで、エンド デベロッパーは Player.Events について学習する必要がなくなります。