ユーザーがタイムライン上でシークバーのつまみをドラッグしてフレームをプレビューし、再生位置を見つけると、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)でエンコードされたバッファリングされたアダプティブ ストリームで最も効果的です。キャッシュに保存されていない高レイテンシのネットワーク接続では、バッファされていない位置への頻繁なシークは、ネットワークのラウンドトリップ時間によって制限されます。