拖曳模式

當使用者在時間軸上拖曳跳轉滑桿的滑桿,預覽影格並尋找播放位置時,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 包含拖曳模式整合功能,預設為停用,只有在使用者放開時間列時才會跳轉一次。如要啟用連續拖曳功能,請在 XML 中設定 PlayerView 的 app:time_bar_scrubbing_enabled="true",或在程式碼中呼叫 setTimeBarScrubbingEnabled(true)。

即使使用 ProgressSlider 或 PlayerView,您也可以自訂 ExoPlayer 執行個體上的 ScrubbingModeParameters,例如設定拖曳條寬度的部分跳轉容許值。

開啟和關閉清除模式的時機

如果您導入自訂跳轉滑桿或拖曳控制項,請只在使用者拖曳手勢互動期間開啟拖曳模式:

  • 當使用者開始拖曳拖曳桿時,開啟拖曳模式 (setScrubbingModeEnabled(true)),並在拖曳位置變更時呼叫 seekTo。
  • 使用者完成或取消手勢後,請立即關閉拖曳模式 (setScrubbingModeEnabled(false))。舉例來說,在指標放開、手勢取消,或 UI 元件在拖曳過程中卸載或處置時,關閉該功能。
  • 在正常播放和單次跳轉期間,請關閉拖曳模式,例如按下快轉或倒轉按鈕、輕觸跳轉滑桿而不拖曳,或透過程式輔助跳轉。

在拖曳手勢以外的情況下啟用拖曳模式,會產生非預期的副作用:

  • 播放功能受到抑制:啟用拖曳模式時,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 拖曳列上,2dp 跨越 50 毫秒的 10 秒短片),相同分數會自動讓跳轉位置接近目標影格。

使用顯示密度將目標 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()
}

在以 View 為基礎的 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) 編碼的緩衝自適應串流。在未快取、高延遲的網路連線上,頻繁跳轉至未緩衝的位置仍會受到網路封包往返時間的限制。