スクラブモード

ユーザーがタイムライン上でシークバーのつまみをドラッグしてフレームをプレビューし、再生位置を見つけると、UI は短い間隔で頻繁に Player.seekTo 呼び出しをディスパッチします。Player.seekTo と ExoPlayer.setSeekParameters を使用した標準のシークは、分離されたジャンプ用に設計されています。新しいシークごとに、進行中のシークが中止され、デコーダがリセットされます。連続したドラッグ操作中、この動作により、ユーザーが移動を停止するまでプレビュー フレームがレンダリングされないことがあります。

ExoPlayer.setScrubbingModeEnabled は、インタラクティブなスクラブ用にプレーヤーを構成します。これにより、ユーザーがドラッグすると、動画プレビュー フレームがレスポンシブにレンダリングされます。

ビルド済みの UI コンポーネントを使用する

Media3 のビルド済み UI モジュールには、ExoPlayer インスタンスに直接接続されている場合(ForwardingPlayer でラップされていない、または MediaController を介してアクセスされていない場合)、組み込みのスクラブモードのサポートが含まれています。

  • Jetpack Compose(media3-ui-compose-material3): Player コンポーザブルとスタンドアロンの ProgressSlider の両方で、デフォルトでスクラブモードが使用されます。ユーザーがスライダーのつまみをドラッグすると、ProgressSlider はドラッグの開始時に setScrubbingModeEnabled(true) を呼び出し、ドラッグ中に seekTo の更新をディスパッチし、ドラッグが終了するかコンポーザブルがコンポジションから離れると setScrubbingModeEnabled(false) を呼び出します。詳細については、Material3 Compose をご覧ください。
  • ビュー(media3-ui): PlayerView と PlayerControlView には、スクラブモードの統合が含まれています。これはデフォルトで無効になっており、ユーザーがタイムバーを離したときに 1 回だけシークします。連続スクラブを有効にするには、XML で PlayerView に app:time_bar_scrubbing_enabled="true" を設定するか、コードで setTimeBarScrubbingEnabled(true) を呼び出します。

ProgressSlider または PlayerView を使用する場合でも、ExoPlayer インスタンスで ScrubbingModeParameters をカスタマイズできます。たとえば、スクラバー幅からシーク許容範囲を小数で設定できます。

スクラブモードのオンとオフを切り替えるタイミング

カスタムのシークバーまたはスクラブ コントロールを実装する場合は、ユーザーがインタラクティブなスクラブ操作を行っている間のみ、スクラブモードをオンにします。

  • ユーザーがスクラブバーのつまみをドラッグし始めたら、スクラブモードをオン(setScrubbingModeEnabled(true))にし、ドラッグ位置が変化したら seekTo を呼び出します。
  • ユーザーがジェスチャーを完了またはキャンセルしたら、すぐにスクラブモードをオフ(setScrubbingModeEnabled(false))にします。たとえば、ポインタのリリース時、ジェスチャーのキャンセル時、またはドラッグ中に UI コンポーネントがデタッチまたは破棄された場合にオフにします。
  • 通常の再生中や 1 回のシーク中(早送りボタンや巻き戻しボタンを押す、ドラッグせずにシークバーをタップする、プログラムでシークするなど)は、スクラブモードをオフのままにします。

ドラッグ操作以外でスクラブモードを有効のままにすると、意図しない副作用が生じます。

  • 再生が抑制される: スクラブモードが有効になっている間、ExoPlayer は再生抑制の理由を Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING に設定するため、Player.isPlaying は false を返します。setScrubbingModeEnabled(false) を呼び出すと、playWhenReady が true の場合、通常の再生が自動的に再開されます。
  • 視覚以外のトラックが無効になっている: スクラブ中は、音声トラックとメタデータ トラックがデフォルトで無効になります。
  • 電力とデコーダの使用量の増加: スクラブモードでは動画コーデックの動作率が向上するため、有効のままにするとハードウェアとバッテリーのリソースが追加で消費されます。

次の例では、カスタム スクラバー ジェスチャー コールバックを ExoPlayer に接続しています。

Kotlin

fun onScrubStart(exoPlayer: ExoPlayer, positionMs: Long) {
  exoPlayer.setScrubbingModeEnabled(true)
  exoPlayer.seekTo(positionMs)
}

fun onScrubMove(exoPlayer: ExoPlayer, positionMs: Long) {
  exoPlayer.seekTo(positionMs)
}

fun onScrubStop(exoPlayer: ExoPlayer, positionMs: Long, canceled: Boolean) {
  if (!canceled) {
    exoPlayer.seekTo(positionMs)
  }
  exoPlayer.setScrubbingModeEnabled(false)
}

Java

public static void onScrubStart(ExoPlayer exoPlayer, long positionMs) {
  exoPlayer.setScrubbingModeEnabled(true);
  exoPlayer.seekTo(positionMs);
}

public static void onScrubMove(ExoPlayer exoPlayer, long positionMs) {
  exoPlayer.seekTo(positionMs);
}

public static void onScrubStop(ExoPlayer exoPlayer, long positionMs, boolean canceled) {
  if (!canceled) {
    exoPlayer.seekTo(positionMs);
  }
  exoPlayer.setScrubbingModeEnabled(false);
}

スクラブモードでシークを最適化する方法

ScrubbingModeParameters.DEFAULT を使用すると、スクラブモードが有効なときに ExoPlayer がいくつかの最適化を適用します。

  • 高速シークを統合する: ExoPlayer は、ポインタが移動するたびにアクティブなシークをプリエンプトするのではなく、キューに格納された最新のシーク位置のみを保持しながら、現在のターゲット フレームのデコードとレンダリングを完了します。置き換えられた中間シークは破棄され、AnalyticsListener.onDroppedSeeksWhileScrubbing に報告されます。
  • 音声トラックとメタデータ トラックを無効にする: 音声のポップノイズや不要なレンダラの再設定を回避するため、C.TRACK_TYPE_AUDIO トラックと C.TRACK_TYPE_METADATA トラックを一時的に無効にします。
  • コーデックのスループットを向上させる: 動画の MediaCodec 動作レートを向上させ、動的スケジューリングを有効にして、デコードされた出力バッファがより高速に処理されるようにします。
  • デコーダのリセットを削減: 互換性のあるストリームでキーフレームにシークする際に不要な MediaCodec フラッシュを回避し、同じグループ オブ ピクチャ(GOP)内で前方にシークする際にキーフレームへのリセットをスキップします。また、Android 14(API レベル 34)以降では MediaCodec.BUFFER_FLAG_DECODE_ONLY を使用して、デコーダがキーフレームとターゲット フレームの間の中間フレームの出力バッファの生成をスキップするようにします。

スクラブ パラメータをカスタマイズする

ScrubbingModeParameters.Builder を使用して、無効にするトラックタイプや、コーデックの動作レートを上げるかどうかなど、スクラブ動作を調整します。ExoPlayer.setScrubbingModeParameters または ExoPlayer.Builder.setScrubbingModeParameters で構成を適用します。exoPlayer.scrubbingModeParameters.buildUpon()(または ScrubbingModeParameters.DEFAULT.buildUpon())からビルドして、デフォルトの最適化が有効なままになるようにします。

スクラバーの幅からシークの許容範囲を分数で設定します。

デフォルトでは、ScrubbingModeParameters は部分的なシーク許容値を未設定(null)のままにするため、スクラブ中のシークではプレーヤーの SeekParameters(デフォルトでは SeekParameters.EXACT)が使用されます。正確なシークでは、前のキーフレームからターゲット タイムスタンプまでのすべてのフレームをデコードする必要があります。キーフレーム間隔が広いストリームやフレームレートが高いストリームなど、前のキーフレームから多くのフレームがある場合、このデコード作業により、ドラッグ中に顕著なレイテンシが発生することがあります。

タッチ スクリーンでは、ユーザーの指でスクラブバーに沿って 1dp または 2dp の動きを正確に区別することはできません。幅 widthDp のスクラバーの場合、どちらの方向でも 2dp(合計 4dp)などの小さなタッチ許容範囲は、メディア再生時間の 2.0 / widthDp の割合に相当します。この割合が占めるメディア時間は、動画の長さによって異なります。dp ベースの許容値を UI スクラバーの幅で割った値を setFractionalSeekTolerance に渡すことをおすすめします(たとえば、2dp、または速度と精度のバランスを取るために 1dp と 4dp の間で調整された値)。

  • 長いコンテンツ(たとえば、400dp スクラバー上の 1 時間の動画で、2dp が 18 秒にわたる場合)では、ExoPlayer は中間フレームをデコードする代わりに、そのタッチ許容範囲内の近くのキーフレームに直接スナップできます。
  • 短いクリップ(たとえば、同じ 400dp スクラバー上の 10 秒のクリップで、2dp が 50 ミリ秒にわたる場合)では、同じ割合でシークが自動的にターゲット フレームの近くに維持されます。

ディスプレイ密度を使用して、ターゲットの dp 許容値をピクセルに変換し、スクラバーの測定されたピクセル幅と一致するようにします。また、初期レイアウトや画面の回転など、スクラバーの幅やディスプレイ密度が変化するたびに許容値を更新します。

Jetpack Compose では、LocalDensity を使用して dp 許容値をピクセルに変換し、Modifier.onSizeChanged を使用してカスタム スライダーを測定し、プレーヤー、幅、密度が変更されたときに scrubbingModeParameters を更新します。ExoPlayer インスタンスがコンポーザブルよりも長く存続する場合は、破棄時に許容値をクリアします。

private val SCRUBBING_TOLERANCE = 2.dp

@Composable
fun CustomScrubbingSlider(exoPlayer: ExoPlayer?, modifier: Modifier = Modifier) {
  var sliderWidthPx by remember { mutableIntStateOf(0) }
  val tolerancePx = with(LocalDensity.current) { SCRUBBING_TOLERANCE.toPx() }
  LaunchedEffect(exoPlayer, sliderWidthPx, tolerancePx) {
    if (exoPlayer != null && sliderWidthPx > 0) {
      val tolerance = (tolerancePx.toDouble() / sliderWidthPx).coerceIn(0.0, 1.0)
      exoPlayer.setFractionalSeekTolerance(tolerance)
    }
  }
  DisposableEffect(exoPlayer) { onDispose { exoPlayer?.setFractionalSeekTolerance(null) } }
  CustomSlider(
    player = exoPlayer,
    modifier = modifier.onSizeChanged { size -> sliderWidthPx = size.width },
  )
}

private fun ExoPlayer.setFractionalSeekTolerance(tolerance: Double?) {
  scrubbingModeParameters =
    scrubbingModeParameters
      .buildUpon()
      .setFractionalSeekTolerance(
        /* toleranceBefore= */ tolerance,
        /* toleranceAfter= */ tolerance,
      )
      .build()
}

ビューベースの UI では、DisplayMetrics.density を使用して dp の許容範囲をピクセルに変換し、スクラバービューまたは PlayerView がすでにレイアウトされている場合は現在の幅を適用し、幅が変更されたときに View.addOnLayoutChangeListener を使用して scrubbingModeParameters を更新します。UI をデタッチするときにリスナーを削除し、ExoPlayer インスタンスがビューよりも長く存続する場合は許容値をクリアします。

Kotlin

private const val SCRUBBING_TOLERANCE_DP = 2.0

fun configureViewFractionalSeekTolerance(
  exoPlayer: ExoPlayer,
  scrubberView: View,
): View.OnLayoutChangeListener {
  updateFractionalSeekTolerance(exoPlayer, scrubberView, scrubberView.width)
  val listener = View.OnLayoutChangeListener { v, left, _, right, _, oldLeft, _, oldRight, _ ->
    val widthPx = right - left
    if (widthPx != oldRight - oldLeft) {
      updateFractionalSeekTolerance(exoPlayer, v, widthPx)
    }
  }
  scrubberView.addOnLayoutChangeListener(listener)
  return listener
}

fun clearViewFractionalSeekTolerance(
  exoPlayer: ExoPlayer,
  scrubberView: View,
  listener: View.OnLayoutChangeListener,
) {
  scrubberView.removeOnLayoutChangeListener(listener)
  setFractionalSeekTolerance(exoPlayer, tolerance = null)
}

private fun updateFractionalSeekTolerance(
  exoPlayer: ExoPlayer,
  scrubberView: View,
  widthPx: Int,
) {
  val density = scrubberView.resources.displayMetrics.density
  if (widthPx > 0 && density > 0f) {
    val tolerancePx = SCRUBBING_TOLERANCE_DP * density
    setFractionalSeekTolerance(exoPlayer, (tolerancePx / widthPx).coerceIn(0.0, 1.0))
  }
}

private fun setFractionalSeekTolerance(exoPlayer: ExoPlayer, tolerance: Double?) {
  exoPlayer.scrubbingModeParameters =
    exoPlayer.scrubbingModeParameters
      .buildUpon()
      .setFractionalSeekTolerance(
        /* toleranceBefore= */ tolerance,
        /* toleranceAfter= */ tolerance,
      )
      .build()
}

Java

private static final double SCRUBBING_TOLERANCE_DP = 2.0;

public static View.OnLayoutChangeListener configureViewFractionalSeekTolerance(
    ExoPlayer exoPlayer, View scrubberView) {
  updateFractionalSeekTolerance(exoPlayer, scrubberView, scrubberView.getWidth());
  View.OnLayoutChangeListener listener =
      (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
        int widthPx = right - left;
        if (widthPx != oldRight - oldLeft) {
          updateFractionalSeekTolerance(exoPlayer, v, widthPx);
        }
      };
  scrubberView.addOnLayoutChangeListener(listener);
  return listener;
}

public static void clearViewFractionalSeekTolerance(
    ExoPlayer exoPlayer, View scrubberView, View.OnLayoutChangeListener listener) {
  scrubberView.removeOnLayoutChangeListener(listener);
  setFractionalSeekTolerance(exoPlayer, /* tolerance= */ null);
}

private static void updateFractionalSeekTolerance(
    ExoPlayer exoPlayer, View scrubberView, int widthPx) {
  float density = scrubberView.getResources().getDisplayMetrics().density;
  if (widthPx > 0 && density > 0f) {
    double tolerancePx = SCRUBBING_TOLERANCE_DP * density;
    setFractionalSeekTolerance(exoPlayer, Math.min(1.0, tolerancePx / widthPx));
  }
}

private static void setFractionalSeekTolerance(ExoPlayer exoPlayer, @Nullable Double tolerance) {
  exoPlayer.setScrubbingModeParameters(
      exoPlayer
          .getScrubbingModeParameters()
          .buildUpon()
          .setFractionalSeekTolerance(
              /* toleranceBefore= */ tolerance, /* toleranceAfter= */ tolerance)
          .build());
}

dp ベースの許容範囲を使用する場合、onScrubStop の最後の seekTo 呼び出しまでスクラブモードを有効にしておくと、ユーザーが指を離したときにフレームが視覚的にジャンプするのを回避できます。代わりに、アプリで最終リリース位置を正確なタイムスタンプに合わせる必要がある場合は、最後の seekTo 呼び出しの前に setScrubbingModeEnabled(false) を呼び出して、最後のシークでプレーヤーの標準の SeekParameters が使用されるようにします。

メディアとネットワークに関する考慮事項

スクラブモードは、ローカル シーク スケジューリングとハードウェア デコードを最適化するため、ローカル ファイル、ダウンロードされたメディア、キャッシュに保存されたストリーム、頻繁なキーフレーム(短い GOP)でエンコードされたバッファリングされたアダプティブ ストリームで最も効果的です。キャッシュに保存されていない高レイテンシのネットワーク接続では、バッファされていない位置への頻繁なシークは、ネットワークのラウンドトリップ時間によって制限されます。