Mode scrubbing

Saat pengguna menarik thumb bilah geser di sepanjang linimasa untuk melihat pratinjau frame dan menemukan posisi pemutaran, UI akan sering mengirimkan panggilan Player.seekTo dalam waktu singkat. Pencarian standar dengan Player.seekTo dan ExoPlayer.setSeekParameters dirancang untuk lompatan terisolasi, di mana setiap pencarian baru membatalkan pencarian yang sedang berlangsung dan mereset dekoder. Selama gestur tarik berkelanjutan, perilaku tersebut dapat mencegah frame pratinjau dirender hingga pengguna berhenti bergerak.

ExoPlayer.setScrubbingModeEnabled mengonfigurasi pemutar untuk scrubbing interaktif sehingga frame pratinjau video dirender secara responsif saat pengguna menariknya.

Menggunakan komponen UI bawaan

Modul UI bawaan Media3 mencakup dukungan mode scrubbing bawaan saat terhubung langsung ke instance ExoPlayer (tidak digabungkan dalam ForwardingPlayer atau diakses melalui MediaController):

  • Jetpack Compose (media3-ui-compose-material3): Composable Player dan ProgressSlider mandiri menggunakan mode scrubbing secara default. Saat pengguna menarik thumb penggeser, ProgressSlider akan memanggil setScrubbingModeEnabled(true) di awal penarikan, mengirimkan update seekTo selama penarikan, dan memanggil setScrubbingModeEnabled(false) saat penarikan berakhir atau composable keluar dari komposisi. Untuk informasi selengkapnya, lihat Compose Material3.
  • Tampilan (media3-ui): PlayerView dan PlayerControlView menyertakan integrasi mode scrubbing yang dinonaktifkan secara default, hanya mencari sekali saat pengguna melepaskan panel waktu. Aktifkan scrubbing berkelanjutan di XML dengan menetapkan app:time_bar_scrubbing_enabled="true" di PlayerView, atau dalam kode dengan memanggil setTimeBarScrubbingEnabled(true).

Meskipun menggunakan ProgressSlider atau PlayerView, Anda dapat menyesuaikan ScrubbingModeParameters pada instance ExoPlayer, seperti menetapkan toleransi pencarian pecahan dari lebar scrubber.

Kapan harus mengaktifkan dan menonaktifkan mode scrubbing

Jika Anda menerapkan kontrol scrubber atau bilah geser kustom, aktifkan mode scrubbing hanya selama durasi gestur scrubbing pengguna interaktif:

  • Aktifkan mode scrubbing (setScrubbingModeEnabled(true)) saat pengguna mulai menarik thumb scrubber, dan panggil seekTo saat posisi tarik berubah.
  • Nonaktifkan mode scrubbing (setScrubbingModeEnabled(false)) segera setelah pengguna menyelesaikan atau membatalkan gestur. Misalnya, nonaktifkan saat pointer dilepaskan, saat gestur dibatalkan, atau jika komponen UI Anda dilepaskan atau dibuang di tengah proses tarik.
  • Nonaktifkan mode scrubbing selama pemutaran normal dan pencarian tunggal, seperti menekan tombol maju cepat atau mundur, mengetuk bilah geser tanpa menyeret, atau mencari secara terprogram.

Membiarkan mode scrubbing aktif di luar gestur tarik memiliki efek samping yang tidak diinginkan:

  • Pemutaran dihentikan: Saat mode scrubbing diaktifkan, ExoPlayer menetapkan alasan penghentian pemutaran ke Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBING, sehingga Player.isPlaying menampilkan false. Saat Anda memanggil setScrubbingModeEnabled(false), pemutaran normal akan otomatis dilanjutkan jika playWhenReady adalah true.
  • Trek non-visual dinonaktifkan: Trek audio dan metadata dinonaktifkan secara default saat scrubbing.
  • Peningkatan penggunaan daya dan decoder: Mode scrubbing meningkatkan kecepatan operasi codec video, yang menggunakan resource hardware dan baterai tambahan jika dibiarkan aktif.

Contoh berikut menghubungkan callback gestur scrubber kustom ke 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);
}

Cara mode scrubbing mengoptimalkan pencarian

Dengan ScrubbingModeParameters.DEFAULT, ExoPlayer menerapkan beberapa pengoptimalan saat mode scrubbing aktif:

  • Menggabungkan pencarian cepat: Daripada mendahului pencarian aktif pada setiap gerakan pointer, ExoPlayer menyelesaikan decoding dan rendering frame target saat ini sambil hanya menahan posisi pencarian terbaru dalam antrean. Pencarian perantara yang digantikan akan dihapus dan dilaporkan ke AnalyticsListener.onDroppedSeeksWhileScrubbing.
  • Menonaktifkan trek audio dan metadata: Menonaktifkan sementara trek C.TRACK_TYPE_AUDIO dan C.TRACK_TYPE_METADATA untuk menghindari letupan audio dan reset perender yang tidak perlu.
  • Meningkatkan throughput codec: Meningkatkan kecepatan operasi MediaCodec video dan memastikan penjadwalan dinamis diaktifkan sehingga buffering output yang didekode diproses lebih cepat.
  • Mengurangi reset decoder: Menghindari flush MediaCodec yang tidak perlu saat mencari keyframe dalam streaming yang kompatibel, melewati reset kembali ke keyframe saat mencari ke depan dalam grup gambar (GOP) yang sama, dan menggunakan MediaCodec.BUFFER_FLAG_DECODE_ONLY di Android 14 (API level 34) dan yang lebih tinggi sehingga decoder melewati pembuatan buffering output untuk frame perantara antara keyframe dan frame target.

Menyesuaikan parameter scrubbing

Gunakan ScrubbingModeParameters.Builder untuk menyesuaikan perilaku scrubbing, seperti jenis trek yang dinonaktifkan atau apakah kecepatan operasi codec ditingkatkan. Terapkan konfigurasi dengan ExoPlayer.setScrubbingModeParameters atau ExoPlayer.Builder.setScrubbingModeParameters. Bangun dari exoPlayer.scrubbingModeParameters.buildUpon() (atau ScrubbingModeParameters.DEFAULT.buildUpon()) agar pengoptimalan default tetap diaktifkan.

Menetapkan toleransi pencarian pecahan dari lebar scrubber

Secara default, ScrubbingModeParameters membiarkan toleransi pencarian pecahan tidak ditetapkan (null), sehingga pencarian selama scrubbing menggunakan SeekParameters pemutar (SeekParameters.EXACT secara default). Penelusuran yang tepat harus mendekode setiap frame dari keyframe sebelumnya hingga stempel waktu target. Jika ada banyak frame sejak keyframe sebelumnya, seperti dalam streaming dengan interval keyframe yang lebar atau kecepatan frame yang tinggi, pekerjaan decoding ini dapat menambahkan latensi yang terlihat saat menarik.

Di layar sentuh, jari pengguna tidak dapat membedakan gerakan 1dp atau 2dp dengan andal di sepanjang panel scrubber. Untuk scrubber dengan lebar widthDp, toleransi sentuh kecil seperti 2dp di kedua arah (total 4dp) sesuai dengan pecahan 2.0 / widthDp dari durasi media. Durasi waktu media yang dicakup oleh pecahan tersebut bergantung pada durasi video. Sebaiknya teruskan toleransi berbasis dp yang dibagi dengan lebar scrubber UI Anda ke setFractionalSeekTolerance (misalnya, 2dp, atau disesuaikan antara 1dp dan 4dp untuk menyeimbangkan kecepatan dan presisi):

  • Pada konten panjang (misalnya, video berdurasi 1 jam pada scrubber 400dp, dengan 2dp berdurasi 18 detik), ExoPlayer dapat langsung beralih ke keyframe terdekat dalam toleransi sentuh tersebut, bukan mendekode frame perantara.
  • Pada klip pendek (misalnya, klip 10 detik pada scrubber 400dp yang sama, dengan 2dp berdurasi 50 milidetik), pecahan yang sama secara otomatis membuat pencarian tetap dekat dengan frame target.

Konversikan toleransi dp target Anda ke piksel menggunakan kepadatan tampilan agar sesuai dengan lebar piksel scrubber yang diukur, dan perbarui toleransi setiap kali lebar scrubber atau kepadatan tampilan berubah, seperti pada tata letak awal atau rotasi layar.

Di Jetpack Compose, konversikan toleransi dp ke piksel dengan LocalDensity, ukur penggeser kustom Anda dengan Modifier.onSizeChanged, dan perbarui scrubbingModeParameters saat pemutar, lebar, atau kepadatan berubah. Jika instance ExoPlayer bertahan lebih lama dari composable, hapus toleransi saat pembuangan:

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

Dalam UI berbasis View, konversikan toleransi dp ke piksel menggunakan DisplayMetrics.density, terapkan lebar saat ini jika tampilan scrubber atau PlayerView sudah ditata letak, dan gunakan View.addOnLayoutChangeListener untuk memperbarui scrubbingModeParameters saat lebarnya berubah. Hapus pemroses saat melepaskan UI, dan hapus toleransi jika instance ExoPlayer bertahan lebih lama dari tampilan:

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

Saat Anda menggunakan toleransi berbasis dp, mempertahankan mode scrubbing tetap aktif melalui panggilan seekTo terakhir di onScrubStop akan menghindari lompatan frame visual saat pengguna mengangkat jari. Jika aplikasi Anda memerlukan posisi rilis akhir untuk mendarat pada stempel waktu yang tepat, panggil setScrubbingModeEnabled(false) sebelum panggilan seekTo terakhir Anda sehingga pencarian akhir menggunakan SeekParameters standar pemutar.

Pertimbangan media dan jaringan

Mode scrubbing mengoptimalkan penjadwalan pencarian lokal dan decoding hardware, sehingga paling efektif dengan file lokal, media yang didownload, streaming yang di-cache, atau streaming adaptif yang di-buffer yang dienkode dengan keyframe yang sering (GOP pendek). Melalui koneksi jaringan tanpa cache dan latensi tinggi, pencarian yang sering ke posisi yang tidak di-buffer tetap terikat oleh waktu round-trip jaringan.