เมื่อผู้ใช้ลากแถบเลื่อนบนไทม์ไลน์เพื่อดูตัวอย่างเฟรมและค้นหาตำแหน่งการเล่น UI จะส่งการเรียก Player.seekTo บ่อยครั้งติดต่อกันอย่างรวดเร็ว การกรอแบบมาตรฐานด้วย Player.seekTo และ ExoPlayer.setSeekParameters ออกแบบมาสำหรับการข้ามแบบแยกต่างหาก โดยการกรอใหม่แต่ละครั้งจะยกเลิกการกรอที่กำลังดำเนินการอยู่และรีเซ็ตตัวถอดรหัส
ในระหว่างการลากอย่างต่อเนื่อง ลักษณะการทำงานดังกล่าวอาจทำให้เฟรมตัวอย่างไม่แสดงผลจนกว่าผู้ใช้จะหยุดเลื่อน
ExoPlayer.setScrubbingModeEnabled จะกำหนดค่าเพลเยอร์สำหรับการสครับแบบอินเทอร์แอกทีฟ เพื่อให้เฟรมตัวอย่างวิดีโอแสดงผลอย่างรวดเร็วเมื่อผู้ใช้ลาก
ใช้คอมโพเนนต์ UI ที่สร้างไว้ล่วงหน้า
โมดูล UI ที่สร้างไว้ล่วงหน้าของ Media3 มีการรองรับโหมดการสครับในตัวเมื่อเชื่อมต่อโดยตรงกับอินสแตนซ์ ExoPlayer (ไม่ได้รวมอยู่ใน ForwardingPlayer หรือเข้าถึงผ่าน MediaController)
- Jetpack Compose (
media3-ui-compose-material3): ทั้งPlayerComposable และProgressSliderแบบสแตนด์อโลนจะใช้โหมดการสครับโดยค่าเริ่มต้น เมื่อผู้ใช้ลากแถบเลื่อนProgressSliderจะเรียกใช้setScrubbingModeEnabled(true)เมื่อเริ่มลาก ส่งการอัปเดตseekToระหว่างการลาก และเรียกใช้setScrubbingModeEnabled(false)เมื่อการลากสิ้นสุดลงหรือ Composable ออกจาก Composition ดูข้อมูลเพิ่มเติมได้ที่ Material3 Compose - Views (
media3-ui):PlayerViewและPlayerControlViewมีการผสานรวมโหมดการสครับซึ่งปิดใช้อยู่โดยค่าเริ่มต้น โดยจะกรอเพียงครั้งเดียวเมื่อผู้ใช้ปล่อยแถบเวลา เปิดใช้การลากแถบเวลาแบบต่อเนื่องใน XML โดยตั้งค่าapp:time_bar_scrubbing_enabled="true"เป็นPlayerViewหรือในโค้ดโดยเรียกใช้setTimeBarScrubbingEnabled(true)
แม้ว่าจะใช้ ProgressSlider หรือ PlayerView คุณก็ปรับแต่ง ScrubbingModeParameters ในอินสแตนซ์ ExoPlayer ได้ เช่น การตั้งค่าความคลาดเคลื่อนในการกรอแบบเศษส่วนจากความกว้างของแถบเลื่อน
กรณีที่ควรเปิดและปิดโหมดสครับบิ้ง
หากคุณใช้แถบเลื่อนหรือตัวควบคุมการสครับที่กำหนดเอง ให้เปิดโหมดการสครับเฉพาะในช่วงที่มีท่าทางการสครับของผู้ใช้แบบอินเทอร์แอกทีฟเท่านั้น โดยทำดังนี้
- เปิดโหมดสครับ (
setScrubbingModeEnabled(true)) เมื่อผู้ใช้เริ่มลากแถบเลื่อน และเรียกใช้seekToเมื่อตำแหน่งการลากเปลี่ยนไป - ปิดโหมดการสครับ (
setScrubbingModeEnabled(false)) ทันทีที่ผู้ใช้ทำท่าทางสัมผัสเสร็จหรือยกเลิก เช่น ปิดเมื่อปล่อยตัวชี้ เมื่อยกเลิกท่าทางสัมผัส หรือหากคอมโพเนนต์ UI ถูกแยกออกหรือทิ้งระหว่างการลาก - ปิดโหมดการลากแถบเวลาในระหว่างการเล่นปกติและการกรอวิดีโอแบบครั้งเดียว เช่น การกดปุ่มกรอไปข้างหน้าหรือกรอกลับ การแตะแถบเลื่อนโดยไม่ลาก หรือการกรอวิดีโอแบบเป็นโปรแกรม
การปล่อยให้โหมดสครับทำงานอยู่นอกท่าทางการลากอาจทำให้เกิดผลข้างเคียงที่ไม่พึงประสงค์ ดังนี้
- การเล่นถูกระงับ: ขณะเปิดใช้โหมดสครับ ExoPlayer จะตั้งค่าเหตุผลในการระงับการเล่นเป็น
Player.PLAYBACK_SUPPRESSION_REASON_SCRUBBINGดังนั้นPlayer.isPlayingจะแสดงผลเป็นfalseเมื่อคุณเรียกใช้setScrubbingModeEnabled(false)การเล่นปกติจะกลับมาทำงานอีกครั้งโดยอัตโนมัติหากplayWhenReadyเป็นtrue - แทร็กที่ไม่ใช่ภาพถูกปิดใช้: แทร็กเสียงและข้อมูลเมตาจะถูกปิดใช้โดยค่าเริ่มต้นขณะสครับ
- การใช้พลังงานและตัวถอดรหัสเพิ่มขึ้น: โหมดสครับจะเพิ่มอัตราการทำงานของตัวแปลงรหัสวิดีโอ ซึ่งจะใช้ทรัพยากรฮาร์ดแวร์และแบตเตอรี่เพิ่มเติมหากเปิดใช้ไว้
ตัวอย่างต่อไปนี้จะเชื่อมต่อ Callback ท่าทางสัมผัสของแถบเลื่อนที่กำหนดเองกับ 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 ของระยะเวลาของสื่อ โดยระยะเวลาของสื่อที่เศษส่วนนั้นครอบคลุมจะขึ้นอยู่กับความยาวของวิดีโอ เราขอแนะนำให้ส่งค่าความคลาดเคลื่อนที่อิงตาม dp หารด้วยความกว้างของแถบเลื่อน UI ไปยัง setFractionalSeekTolerance (เช่น 2dp หรือปรับระหว่าง 1dp และ 4dp เพื่อให้ความเร็วและความแม่นยำสมดุลกัน) ดังนี้
- ในเนื้อหาที่มีความยาว (เช่น วิดีโอความยาว 1 ชั่วโมงบนแถบเลื่อน
400dpโดยที่2dpมีระยะเวลา 18 วินาที) ExoPlayer จะสแนปไปยังคีย์เฟรมที่อยู่ใกล้เคียงภายในค่าความคลาดเคลื่อนของการแตะนั้นได้โดยตรงแทนที่จะถอดรหัสเฟรมกลาง - ในคลิปสั้น (เช่น คลิป 10 วินาทีใน
400dpScrubber เดียวกัน โดยที่2dpมีระยะเวลา 50 มิลลิวินาที) เศษส่วนเดียวกัน จะทำให้การกรอวิดีโออยู่ใกล้กับเฟรมเป้าหมายโดยอัตโนมัติ
แปลงค่าความคลาดเคลื่อนของเป้าหมาย dp เป็นพิกเซลโดยใช้ความหนาแน่นของการแสดงผลเพื่อให้ตรงกับความกว้างพิกเซลที่วัดได้ของแถบเลื่อน และอัปเดตค่าความคลาดเคลื่อนเมื่อความกว้างหรือความหนาแน่นของการแสดงผลของแถบเลื่อนเปลี่ยนแปลง เช่น ในเลย์เอาต์เริ่มต้นหรือการหมุนหน้าจอ
ใน Jetpack Compose ให้แปลงค่าเผื่อ dp เป็นพิกเซลด้วย LocalDensity,
วัดแถบเลื่อนที่กำหนดเองด้วย 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 ที่อิงตามมุมมอง ให้แปลงค่าความคลาดเคลื่อน dp เป็นพิกเซลโดยใช้ DisplayMetrics.density ใช้ความกว้างปัจจุบันหากมุมมองแถบเลื่อนหรือ PlayerView มีการจัดวางอยู่แล้ว และใช้ View.addOnLayoutChangeListener เพื่ออัปเดต scrubbingModeParameters เมื่อความกว้างเปลี่ยนแปลง นำ Listener ออกเมื่อแยก 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() }
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 มาตรฐานของเพลเยอร์
ข้อควรพิจารณาเกี่ยวกับสื่อและเครือข่าย
โหมดสครับจะเพิ่มประสิทธิภาพการกำหนดเวลาการค้นหาในเครื่องและการถอดรหัสฮาร์ดแวร์ จึงมีประสิทธิภาพสูงสุดกับไฟล์ในเครื่อง สื่อที่ดาวน์โหลด สตรีมที่แคชไว้ หรือสตรีมแบบปรับอัตโนมัติที่บัฟเฟอร์ซึ่งเข้ารหัสด้วยคีย์เฟรมบ่อยๆ (GOP แบบสั้น) ในการเชื่อมต่อเครือข่ายที่มีเวลาในการตอบสนองสูงและไม่มีการแคช การกรอไปยังตำแหน่งที่ไม่มีการบัฟเฟอร์บ่อยครั้งจะยังคงถูกจำกัดด้วย Round-Trip Time (RTT) ของเครือข่าย