Tryb przewijania

Gdy użytkownik przeciąga suwak paska przewijania po osi czasu, aby wyświetlić podgląd klatek i znaleźć pozycję odtwarzania, interfejs wysyła częste wywołania Player.seekTo w krótkich odstępach czasu. Standardowe przewijanie za pomocą Player.seekTo i ExoPlayer.setSeekParameters jest przeznaczone do pojedynczych przeskoków, gdzie każde nowe przewinięcie przerywa bieżące przewijanie i resetuje dekoder. Podczas ciągłego przeciągania może to uniemożliwić renderowanie klatek podglądu, dopóki użytkownik nie przestanie przesuwać.

ExoPlayer.setScrubbingModeEnabled konfiguruje odtwarzacz pod kątem interaktywnego przewijania, dzięki czemu klatki podglądu filmu są renderowane elastycznie, gdy użytkownik przeciąga suwak.

Korzystanie z gotowych komponentów interfejsu

Gotowe moduły interfejsu Media3 obsługują wbudowany tryb przewijania, gdy są połączone bezpośrednio z instancją ExoPlayer (nie są opakowane w ForwardingPlayer ani nie są dostępne przez MediaController):

  • Jetpack Compose (media3-ui-compose-material3): zarówno komponent Player, jak i samodzielny komponent ProgressSlider domyślnie używają trybu przewijania. Gdy użytkownik przeciąga suwak, ProgressSlider wywołuje setScrubbingModeEnabled(true) na początku przeciągania, wysyła aktualizacje seekTo podczas przeciągania i wywołuje setScrubbingModeEnabled(false), gdy przeciąganie się kończy lub gdy element kompozycyjny opuszcza kompozycję. Więcej informacji znajdziesz w artykule Material3 Compose.
  • Widoki (media3-ui): PlayerView i PlayerControlView obejmują integrację trybu przewijania, która jest domyślnie wyłączona. Przewijanie następuje tylko raz, gdy użytkownik zwolni pasek czasu. Włącz ciągłe przewijanie w XML, ustawiając app:time_bar_scrubbing_enabled="true" na PlayerView, lub w kodzie, wywołując setTimeBarScrubbingEnabled(true).

Nawet jeśli używasz ProgressSlider lub PlayerView, możesz dostosować ScrubbingModeParameters w instancji ExoPlayer, np. ustawić ułamkową tolerancję przewijania na podstawie szerokości paska przewijania.

Kiedy włączać i wyłączać tryb czyszczenia

Jeśli implementujesz niestandardowy pasek przewijania lub element sterujący przewijaniem, włącz tryb przewijania tylko na czas interaktywnego gestu przewijania użytkownika:

  • Włącz tryb przewijania (setScrubbingModeEnabled(true)), gdy użytkownik zacznie przeciągać suwak, i wywołaj seekTo, gdy zmieni się pozycja przeciągania.
  • Wyłącz tryb przewijania (setScrubbingModeEnabled(false)), gdy tylko użytkownik zakończy lub anuluje gest. Możesz na przykład wyłączyć go po zwolnieniu wskaźnika, anulowaniu gestu lub odłączeniu albo usunięciu komponentu interfejsu w trakcie przeciągania.
  • Tryb przewijania powinien być wyłączony podczas normalnego odtwarzania i pojedynczych operacji przewijania, takich jak naciśnięcie przycisku przewijania do przodu lub do tyłu, kliknięcie paska przewijania bez przeciągania lub przewijanie programowe.

Pozostawienie aktywnego trybu przewijania poza gestem przeciągania ma niezamierzone skutki uboczne:

  • Odtwarzanie jest wstrzymane: gdy tryb przewijania jest włączony, ExoPlayer ustawia przyczynę wstrzymania odtwarzania na Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING, więc Player.isPlaying zwraca false. Gdy wywołasz funkcję setScrubbingModeEnabled(false), normalne odtwarzanie zostanie automatycznie wznowione, jeśli playWhenReady to true.
  • Ścieżki niewizualne są wyłączone: ścieżki audio i metadanych są domyślnie wyłączone podczas przewijania.
  • Zwiększone zużycie energii i dekodera: tryb przewijania zwiększa szybkość działania kodeka wideo, co powoduje dodatkowe zużycie zasobów sprzętowych i baterii, jeśli jest włączony.

W tym przykładzie wywołania zwrotne niestandardowego gestu suwaka są połączone z 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);
}

Jak tryb przewijania optymalizuje wyszukiwanie

Dzięki ScrubbingModeParameters.DEFAULT ExoPlayer stosuje kilka optymalizacji, gdy tryb przewijania jest aktywny:

  • Łączy szybkie przewijanie: zamiast przerywać aktywne przewijanie przy każdym ruchu wskaźnika, ExoPlayer kończy dekodowanie i renderowanie bieżącej klatki docelowej, zachowując w kolejce tylko ostatnią pozycję przewijania. Zastąpione wyszukiwania pośrednie są odrzucane i zgłaszane do AnalyticsListener.onDroppedSeeksWhileScrubbing.
  • Wyłącza ścieżki audio i metadanych: tymczasowo wyłącza ścieżki C.TRACK_TYPE_AUDIO i C.TRACK_TYPE_METADATA, aby uniknąć trzasków i niepotrzebnych resetów renderera.
  • Zwiększa przepustowość kodeka: zwiększa szybkość działania MediaCodec wideo i zapewnia włączenie dynamicznego planowania, dzięki czemu bufory wyjściowe dekodowania są przetwarzane szybciej.
  • Zmniejsza liczbę resetowań dekodera: unika niepotrzebnych operacji MediaCodec podczas przechodzenia do klatki kluczowej w zgodnych strumieniach, pomija resetowanie do klatki kluczowej podczas przechodzenia do przodu w tej samej grupie obrazów (GOP) i używa MediaCodec.BUFFER_FLAG_DECODE_ONLY na Androidzie 14 (poziom interfejsu API 34) i nowszych, dzięki czemu dekoder pomija generowanie buforów wyjściowych dla klatek pośrednich między klatką kluczową a klatką docelową.

Dostosowywanie parametrów przewijania

Użyj ScrubbingModeParameters.Builder, aby dostosować zachowanie podczas przewijania, np. które typy ścieżek są wyłączone lub czy szybkość działania kodeka jest zwiększona. Zastosuj konfigurację za pomocą polecenia ExoPlayer.setScrubbingModeParameters lub ExoPlayer.Builder.setScrubbingModeParameters. Kompiluj z exoPlayer.scrubbingModeParameters.buildUpon() (lub ScrubbingModeParameters.DEFAULT.buildUpon()), aby domyślne optymalizacje pozostały włączone.

Ustawianie ułamkowej tolerancji przewijania na podstawie szerokości paska przewijania

Domyślnie ScrubbingModeParameters pozostawia tolerancje przewijania ułamkowego bez ustawienia (null), więc przewijanie podczas przesuwania wykorzystuje SeekParameters odtwarzacza (domyślnie SeekParameters.EXACT). Dokładne przewijanie musi zdekodować każdą klatkę od poprzedniej klatki kluczowej do docelowej sygnatury czasowej. Jeśli od poprzedniej klatki kluczowej upłynęło wiele klatek, np. w strumieniach z szerokimi odstępami między klatkami kluczowymi lub wysoką liczbą klatek, dekodowanie może powodować zauważalne opóźnienie podczas przeciągania.

Na ekranach dotykowych palec użytkownika nie jest w stanie precyzyjnie rozróżnić ruchu o 1dp lub 2dp wzdłuż paska przewijania. W przypadku suwaka o szerokości widthDp niewielka tolerancja dotyku, np. 2dp w każdym kierunku (łącznie 4dp), odpowiada ułamkowi 2.0 / widthDp czasu trwania multimediów. To, ile czasu odtwarzania obejmuje ten ułamek, zależy od długości filmu. Zalecamy przekazanie do metody setFractionalSeekTolerance tolerancji opartej na dp podzielonej przez szerokość suwaka interfejsu (np. 2dp lub dostosowanej w zakresie od 1dp do 4dp, aby zachować równowagę między szybkością a precyzją):

  • W przypadku długich treści (np. godzinnego filmu na pasku przewijania o długości 400dp, gdzie 2dp odpowiada 18 sekundom) ExoPlayer może przeskoczyć bezpośrednio do pobliskiej klatki kluczowej w ramach tej tolerancji dotyku zamiast dekodować klatki pośrednie.
  • W przypadku krótkich klipów (np. 10-sekundowego klipu na tym samym pasku przewijania 400dp, gdzie 2dp obejmuje 50 milisekund) ten sam ułamek automatycznie utrzymuje przewijanie blisko klatki docelowej.

Przekonwertuj docelową tolerancję dp na piksele, używając gęstości wyświetlania, aby odpowiadała zmierzonej szerokości suwaka w pikselach. Aktualizuj tolerancję za każdym razem, gdy zmienia się szerokość suwaka lub gęstość wyświetlania, np. przy początkowym układzie lub obracaniu ekranu.

W Jetpack Compose przekonwertuj tolerancję dp na piksele za pomocą LocalDensity, zmierz niestandardowy suwak za pomocą Modifier.onSizeChanged i zaktualizuj scrubbingModeParameters, gdy zmieni się odtwarzacz, szerokość lub gęstość. Jeśli instancja ExoPlayer działa dłużej niż funkcja kompozycyjna, wyczyść tolerancję po usunięciu:

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()
}

W interfejsie opartym na widoku przekonwertuj tolerancję dp na piksele za pomocą DisplayMetrics.density, zastosuj bieżącą szerokość, jeśli widok suwaka lub PlayerView jest już ułożony, i użyj View.addOnLayoutChangeListener, aby zaktualizować scrubbingModeParameters, gdy zmieni się jego szerokość. Usuń detektor podczas odłączania interfejsu i wyczyść tolerancję, jeśli instancja ExoPlayer działa dłużej niż widok:

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());
}

Jeśli używasz tolerancji opartej na dp, pozostawienie trybu przewijania włączonego podczas ostatniego wywołania seekTo w onScrubStop pozwala uniknąć wizualnego przeskoku klatki, gdy użytkownik podniesie palec. Jeśli aplikacja wymaga, aby ostateczna pozycja odtwarzania była zgodna z dokładną sygnaturą czasową, wywołaj setScrubbingModeEnabled(false) przed ostatnim wywołaniem seekTo, aby ostateczne przewijanie korzystało ze standardowego SeekParameters odtwarzacza.

Kwestie związane z multimediami i siecią

Tryb przewijania optymalizuje lokalne planowanie wyszukiwania i dekodowanie sprzętowe, dzięki czemu jest najbardziej skuteczny w przypadku plików lokalnych, pobranych multimediów, strumieni zapisanych w pamięci podręcznej lub buforowanych strumieni adaptacyjnych zakodowanych z częstymi klatkami kluczowymi (krótkie GOP). W przypadku niebuforowanych połączeń sieciowych o dużym opóźnieniu częste wyszukiwanie niebuforowanych pozycji jest ograniczone przez czas błądzenia sieci.