Когда пользователь перетаскивает ползунок панели перемотки по временной шкале, чтобы посмотреть кадры и найти позицию воспроизведения, интерфейс отправляет частые вызовы 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), когда перетаскивание заканчивается или composable-функция покидает композицию. Подробнее о Material3 Compose… - Просмотры (
media3-ui). ВPlayerViewиPlayerControlViewесть интеграция с режимом перемотки, которая отключена по умолчанию. Перемотка выполняется только один раз, когда пользователь отпускает временную шкалу. Чтобы включить непрерывную прокрутку, задайте атрибутapp:time_bar_scrubbing_enabled="true"дляPlayerViewв XML-файле или вызовите методsetTimeBarScrubbingEnabled(true)в коде.
Даже если вы используете ProgressSlider или PlayerView, вы можете настроить ScrubbingModeParameters в экземпляре ExoPlayer, например задать дробный допуск поиска по ширине ползунка.
Когда включать и отключать режим прокрутки
Если вы используете собственную панель перемотки или ползунок, включайте режим прокрутки только на время интерактивного жеста пользователя:
- Включите режим перемотки (
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) и используетMediaCodec.BUFFER_FLAG_DECODE_ONLYна Android 14 (уровень API 34) и выше, чтобы декодер пропускал создание выходных буферов для промежуточных кадров между ключевым кадром и целевым кадром.
Настройка параметров очистки
Используйте 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 от продолжительности медиаконтента. Сколько времени занимает эта доля, зависит от длины видео. Рекомендуем передавать в setFractionalSeekTolerance допустимое отклонение на основе dp, разделенное на ширину ползунка в интерфейсе (например, 2dp или значение между 1dp и 4dp, чтобы сбалансировать скорость и точность):
- В длинных видео (например, в часовом видео на ползунке
400dp, где2dpохватывает 18 секунд) ExoPlayer может сразу перейти к ближайшему ключевому кадру в пределах допустимого диапазона, не декодируя промежуточные кадры. - В коротких клипах (например, в 10-секундном клипе на том же ползунке
400dp, где2dpохватывает 50 миллисекунд) та же доля автоматически удерживает поиск близко к целевому кадру.
Преобразуйте целевой допуск dp в пиксели, используя плотность экрана, чтобы он соответствовал измеренной ширине ползунка в пикселях, и обновляйте допуск всякий раз, когда ширина ползунка или плотность экрана меняются, например при начальном макете или повороте экрана.
В Jetpack Compose преобразуйте допуск dp в пиксели с помощью LocalDensity, измерьте пользовательский ползунок с помощью Modifier.onSizeChanged и обновите scrubbingModeParameters, когда изменится проигрыватель, ширина или плотность. Если экземпляр ExoPlayer существует дольше, чем composable-функция, очистите допуск при удалении:
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 преобразуйте допуск dp в пиксели с помощью DisplayMetrics.density, примените текущую ширину, если представление ползунка или PlayerView уже размещено, и используйте View.addOnLayoutChangeListener, чтобы обновить scrubbingModeParameters при изменении ширины. Удалите прослушиватель при отсоединении интерфейса и очистите допуск, если экземпляр 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, то, чтобы избежать визуального скачка кадра, когда пользователь убирает палец, оставьте режим перемотки включенным до последнего вызова seekTo в onScrubStop. Если же вам нужно, чтобы воспроизведение начиналось с точной временной метки, вызовите setScrubbingModeEnabled(false) перед последним вызовом seekTo, чтобы при последней перемотке использовалось стандартное значение SeekParameters проигрывателя.
Рекомендации по работе с медиафайлами и сетью
Режим Scrubbing оптимизирует планирование локального поиска и аппаратное декодирование, поэтому он наиболее эффективен при работе с локальными файлами, скачанными медиафайлами, кешированными потоками или буферизованными адаптивными потоками, закодированными с частыми ключевыми кадрами (короткими GOP). При использовании некешированных сетевых подключений с высокой задержкой частые переходы к небуферизованным позициям по-прежнему ограничены временем прохождения сигнала.