逐帧拖动模式

当用户在时间轴上拖动进度条滑块以预览帧并查找播放位置时,界面会快速连续发出频繁的 Player.seekTo 调用。使用 Player.seekTo 和 ExoPlayer.setSeekParameters 的标准跳转专为孤立跳转而设计,其中每个新跳转都会中止任何正在进行的跳转并重置解码器。在连续拖动手势期间,该行为可能会阻止预览帧渲染,直到用户停止移动。

ExoPlayer.setScrubbingModeEnabled 会配置播放器以进行互动式拖动,以便在用户拖动时快速渲染视频预览帧。

使用预构建的界面组件

Media3 的预构建界面模块在直接连接到 ExoPlayer 实例(未封装在 ForwardingPlayer 中或通过 MediaController 访问)时,包含内置的拖动模式支持:

  • Jetpack Compose (media3-ui-compose-material3):Player 可组合项和独立 ProgressSlider 默认使用拖动模式。当用户拖动滑块滑块时,ProgressSlider 会在拖动开始时调用 setScrubbingModeEnabled(true),在拖动期间分派 seekTo 更新,并在拖动结束或可组合项离开组合时调用 setScrubbingModeEnabled(false)。如需了解详情,请参阅 Material3 Compose。
  • 视图 (media3-ui):PlayerView 和 PlayerControlView 包含拖动模式集成,该集成默认处于停用状态,仅在用户松开时间栏时跳转一次。如需在 XML 中启用连续拖动,请在 PlayerView 上设置 app:time_bar_scrubbing_enabled="true";如需在代码中启用连续拖动,请调用 setTimeBarScrubbingEnabled(true)。

即使您使用 ProgressSlider 或 PlayerView,也可以在 ExoPlayer 实例上自定义 ScrubbingModeParameters,例如根据进度条宽度设置分数跳转容差。

何时开启和关闭拖动模式

如果您实现自定义进度条或拖动条控件,请仅在用户执行交互式拖动条手势期间开启拖动模式:

  • 当用户开始拖动进度条滑块时,开启拖动模式 (setScrubbingModeEnabled(true)),并在拖动位置发生变化时调用 seekTo。
  • 用户完成或取消手势后,立即关闭拖动模式 (setScrubbingModeEnabled(false))。例如,在指针释放、手势取消或界面组件在拖动过程中分离或处置时,将其关闭。
  • 在正常播放和单次跳转期间,请关闭拖动模式,例如按下快进或快退按钮、点按进度条而不拖动,或以编程方式跳转。

在拖动手势之外保持拖动模式处于活跃状态会产生意外的副作用:

  • 播放被禁止:启用拖动模式后,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 的容差除以界面拖动条的宽度,然后将结果传递给 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()
}

在基于视图的界面中,使用 DisplayMetrics.density 将 dp 容差转换为像素;如果拖动条视图或 PlayerView 已布局,则应用当前宽度;并在 scrubbingModeParameters 的宽度发生变化时使用 View.addOnLayoutChangeListener 更新它。在分离界面时移除监听器,如果 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)编码的缓冲自适应串流。在未缓存的高延迟网络连接中,频繁跳转到未缓冲的位置仍会受到网络往返时间的限制。