โหมดสครับ

เมื่อผู้ใช้ลากแถบเลื่อนบนไทม์ไลน์เพื่อดูตัวอย่างเฟรมและค้นหาตำแหน่งการเล่น UI จะส่งการเรียก Player.seekTo บ่อยครั้งติดต่อกันอย่างรวดเร็ว การกรอแบบมาตรฐานด้วย Player.seekTo และ ExoPlayer.setSeekParameters ออกแบบมาสำหรับการข้ามแบบแยกต่างหาก โดยการกรอใหม่แต่ละครั้งจะยกเลิกการกรอที่กำลังดำเนินการอยู่และรีเซ็ตตัวถอดรหัส ในระหว่างการลากอย่างต่อเนื่อง ลักษณะการทำงานดังกล่าวอาจทำให้เฟรมตัวอย่างไม่แสดงผลจนกว่าผู้ใช้จะหยุดเลื่อน

ExoPlayer.setScrubbingModeEnabled จะกำหนดค่าเพลเยอร์สำหรับการสครับแบบอินเทอร์แอกทีฟ เพื่อให้เฟรมตัวอย่างวิดีโอแสดงผลอย่างรวดเร็วเมื่อผู้ใช้ลาก

ใช้คอมโพเนนต์ UI ที่สร้างไว้ล่วงหน้า

โมดูล UI ที่สร้างไว้ล่วงหน้าของ Media3 มีการรองรับโหมดการสครับในตัวเมื่อเชื่อมต่อโดยตรงกับอินสแตนซ์ ExoPlayer (ไม่ได้รวมอยู่ใน ForwardingPlayer หรือเข้าถึงผ่าน MediaController)

  • Jetpack Compose (media3-ui-compose-material3): ทั้ง Player Composable และ 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 วินาทีใน400dp Scrubber เดียวกัน โดยที่ 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) ของเครือข่าย