Modo de limpieza

Cuando un usuario arrastra el control deslizante de una barra de búsqueda por una línea de tiempo para obtener una vista previa de los fotogramas y encontrar una posición de reproducción, la IU envía llamadas Player.seekTo frecuentes en una sucesión corta. La búsqueda estándar con Player.seekTo y ExoPlayer.setSeekParameters está diseñada para saltos aislados, en los que cada búsqueda nueva anula cualquier búsqueda en curso y restablece el decodificador. Durante un gesto de arrastre continuo, ese comportamiento puede impedir que se rendericen los fotogramas de vista previa hasta que el usuario deje de moverse.

ExoPlayer.setScrubbingModeEnabled configura el reproductor para el desplazamiento interactivo, de modo que los fotogramas de vista previa del video se rendericen de forma responsiva a medida que el usuario arrastra el control.

Usa componentes de la IU prediseñados

Los módulos de IU precompilados de Media3 incluyen compatibilidad integrada con el modo de arrastre cuando se conectan directamente a una instancia de ExoPlayer (no unida en un ForwardingPlayer ni a la que se accede a través de un MediaController):

  • Jetpack Compose (media3-ui-compose-material3): Tanto el elemento componible Player como el elemento ProgressSlider independiente usan el modo de limpieza de forma predeterminada. Cuando un usuario arrastra el control deslizante, ProgressSlider llama a setScrubbingModeEnabled(true) al comienzo del arrastre, envía actualizaciones de seekTo durante el arrastre y llama a setScrubbingModeEnabled(false) cuando finaliza el arrastre o el elemento componible sale de la composición. Para obtener más información, consulta Material3 Compose.
  • Vistas (media3-ui): PlayerView y PlayerControlView incluyen la integración del modo de arrastre que está inhabilitado de forma predeterminada, que busca solo una vez cuando el usuario suelta la barra de tiempo. Para habilitar el arrastre continuo en XML, configura app:time_bar_scrubbing_enabled="true" en PlayerView o, en el código, llama a setTimeBarScrubbingEnabled(true).

Incluso cuando usas ProgressSlider o PlayerView, puedes personalizar ScrubbingModeParameters en la instancia de ExoPlayer, como establecer una tolerancia de búsqueda fraccionaria desde el ancho del depurador.

Cuándo activar y desactivar el modo de arrastre

Si implementas una barra deslizante de búsqueda o un control de limpieza personalizados, activa el modo de limpieza solo durante el gesto de limpieza interactivo del usuario:

  • Activa el modo de limpieza (setScrubbingModeEnabled(true)) cuando el usuario comience a arrastrar el control deslizante y llama a seekTo a medida que cambie la posición de arrastre.
  • Desactiva el modo de arrastre (setScrubbingModeEnabled(false)) en cuanto el usuario finalice o cancele el gesto. Por ejemplo, desactívalo cuando se suelte el puntero, cuando se cancele el gesto o si el componente de la IU se desconecta o se elimina durante el arrastre.
  • Mantén desactivado el modo de arrastre durante la reproducción normal y las búsquedas individuales, como presionar los botones de avance rápido o retroceso, presionar una barra deslizante de búsqueda sin arrastrar o realizar búsquedas de manera programática.

Dejar activo el modo de limpieza fuera de un gesto de arrastre tiene efectos secundarios no deseados:

  • La reproducción se suprime: Mientras el modo de limpieza está habilitado, ExoPlayer establece el motivo de la supresión de la reproducción en Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING, por lo que Player.isPlaying devuelve false. Cuando llamas a setScrubbingModeEnabled(false), la reproducción normal se reanuda automáticamente si playWhenReady es true.
  • Las pistas no visuales están inhabilitadas: Las pistas de audio y metadatos están inhabilitadas de forma predeterminada durante el barrido.
  • Aumento del uso de energía y del decodificador: El modo de limpieza aumenta las tasas de funcionamiento del códec de video, lo que consume recursos adicionales de hardware y batería si se deja habilitado.

En el siguiente ejemplo, se conectan las devoluciones de llamada de gestos de limpieza personalizados a 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);
}

Cómo el modo de arrastre optimiza la búsqueda

Con ScrubbingModeParameters.DEFAULT, ExoPlayer aplica varias optimizaciones mientras el modo de arrastre está activo:

  • Une las búsquedas rápidas: En lugar de interrumpir una búsqueda activa en cada movimiento del puntero, ExoPlayer termina de decodificar y renderizar el fotograma de destino actual mientras mantiene solo la posición de búsqueda más reciente en cola. Las búsquedas intermedias reemplazadas se descartan y se informan a AnalyticsListener.onDroppedSeeksWhileScrubbing.
  • Inhabilita las pistas de audio y metadatos: Inhabilita temporalmente las pistas C.TRACK_TYPE_AUDIO y C.TRACK_TYPE_METADATA para evitar ruidos de audio y restablecimientos innecesarios del renderizador.
  • Aumenta la capacidad de procesamiento del códec: Aumenta la tasa de funcionamiento de MediaCodec del video y garantiza que la programación dinámica esté habilitada para que los búferes de salida decodificados se procesen más rápido.
  • Reduce los restablecimientos del decodificador: Evita vaciados innecesarios de MediaCodec cuando se busca un fotograma clave en transmisiones compatibles, omite el restablecimiento al fotograma clave cuando se busca hacia adelante dentro del mismo grupo de imágenes (GOP) y usa MediaCodec.BUFFER_FLAG_DECODE_ONLY en Android 14 (nivel de API 34) y versiones posteriores para que el decodificador omita la producción de búferes de salida para fotogramas intermedios entre un fotograma clave y el fotograma de destino.

Personaliza los parámetros de limpieza

Usa ScrubbingModeParameters.Builder para ajustar el comportamiento de arrastre, como qué tipos de pistas están desactivados o si se aumentan las tasas de funcionamiento de los códecs. Aplica la configuración con ExoPlayer.setScrubbingModeParameters o ExoPlayer.Builder.setScrubbingModeParameters. Compila desde exoPlayer.scrubbingModeParameters.buildUpon() (o ScrubbingModeParameters.DEFAULT.buildUpon()) para que las optimizaciones predeterminadas permanezcan habilitadas.

Establece una tolerancia de búsqueda fraccionaria a partir del ancho de la barra de desplazamiento

De forma predeterminada, ScrubbingModeParameters deja sin establecer las tolerancias de búsqueda fraccionaria (null), por lo que las búsquedas durante el arrastre usan el SeekParameters del reproductor (SeekParameters.EXACT de forma predeterminada). Una búsqueda exacta debe decodificar cada fotograma desde el fotograma clave anterior hasta la marca de tiempo de destino. Cuando hay muchos fotogramas desde el fotograma clave anterior, como en las transmisiones con intervalos de fotogramas clave amplios o velocidades de fotogramas altas, este trabajo de decodificación puede agregar una latencia notable mientras se arrastra.

En las pantallas táctiles, el dedo de un usuario no puede distinguir de manera confiable 1dp o 2dp de movimiento a lo largo de una barra de desplazamiento. Para un control de limpieza de ancho widthDp, una tolerancia táctil pequeña, como 2dp en cualquier dirección (4dp en total), corresponde a una fracción de 2.0 / widthDp de la duración del contenido multimedia. La cantidad de tiempo de contenido multimedia que abarca esa fracción depende de la duración del video. Te recomendamos que pases una tolerancia basada en dp dividida por el ancho del control de limpieza de la IU a setFractionalSeekTolerance (por ejemplo, 2dp o ajustada entre 1dp y 4dp para equilibrar la velocidad y la precisión):

  • En el contenido largo (por ejemplo, un video de 1 hora en un control deslizante de 400dp, en el que 2dp abarca 18 segundos), ExoPlayer puede ajustarse directamente a un fotograma clave cercano dentro de esa tolerancia táctil en lugar de decodificar fotogramas intermedios.
  • En los clips cortos (por ejemplo, un clip de 10 segundos en el mismo depurador de 400dp, en el que 2dp abarca 50 milisegundos), la misma fracción mantiene automáticamente las búsquedas cerca del fotograma objetivo.

Convierte tu tolerancia de dp objetivo en píxeles con la densidad de la pantalla para que coincida con el ancho de píxeles medido del control de limpieza y actualiza la tolerancia cada vez que cambie el ancho del control de limpieza o la densidad de la pantalla, como en el diseño inicial o la rotación de la pantalla.

En Jetpack Compose, convierte la tolerancia de dp en píxeles con LocalDensity, mide el control deslizante personalizado con Modifier.onSizeChanged y actualiza scrubbingModeParameters cuando cambien el reproductor, el ancho o la densidad. Si la ExoPlayer instancia sobrevive al elemento componible, borra la tolerancia en la eliminación:

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

En una IU basada en vistas, convierte la tolerancia de dp en píxeles con DisplayMetrics.density, aplica el ancho actual si tu vista de barra de desplazamiento o PlayerView ya están diseñadas y usa View.addOnLayoutChangeListener para actualizar scrubbingModeParameters cuando cambie su ancho. Quita el objeto de escucha cuando desconectes la IU y borra la tolerancia si la instancia de ExoPlayer sobrevive a la vista:

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

Cuando usas una tolerancia basada en dp, mantener habilitado el modo de arrastre durante la llamada final a seekTo en onScrubStop evita un salto visual de fotogramas cuando el usuario levanta el dedo. Si, en cambio, tu app requiere que la posición de liberación final se ubique en una marca de tiempo exacta, llama a setScrubbingModeEnabled(false) antes de la llamada final a seekTo para que la búsqueda final use el SeekParameters estándar del reproductor.

Consideraciones sobre los medios y la red

El modo de limpieza optimiza la programación de búsqueda local y la decodificación de hardware, por lo que es más eficaz con archivos locales, contenido multimedia descargado, transmisiones almacenadas en caché o transmisiones adaptables almacenadas en búfer codificadas con fotogramas clave frecuentes (GOP cortos). En las conexiones de red de alta latencia sin almacenamiento en caché, las búsquedas frecuentes de posiciones sin almacenamiento en búfer siguen estando limitadas por los tiempos de ida y vuelta de la red.