사용자가 타임라인에서 탐색 막대 엄지손가락을 드래그하여 프레임을 미리 보고 재생 위치를 찾으면 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에는 기본적으로 사용 중지된 스크러빙 모드 통합이 포함되어 있으며 사용자가 시간 표시줄을 놓을 때 한 번만 탐색합니다.PlayerView에서app:time_bar_scrubbing_enabled="true"를 설정하여 XML에서 연속 스크러빙을 사용 설정하거나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) }
자바
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(Group of Pictures) 내에서 앞으로 탐색할 때 키프레임으로 다시 재설정하는 것을 건너뛰고, 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스크러버에서 10초 클립, 여기서2dp는 50밀리초에 걸쳐 있음)에서 동일한 분수는 자동으로 탐색을 타겟 프레임에 가깝게 유지합니다.
디스플레이 밀도를 사용하여 타겟 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() }
뷰 기반 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() }
자바
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)으로 인코딩된 버퍼링된 적응형 스트림에 가장 효과적입니다. 캐시되지 않은 지연 시간이 긴 네트워크 연결에서는 버퍼링되지 않은 위치를 자주 탐색하면 네트워크 왕복 시간에 의해 제한됩니다.