מצב חיפוש לאורך ציר הזמן

כשמשתמש גורר את סמן סרגל הדילוג לאורך ציר הזמן כדי לראות תצוגה מקדימה של פריימים ולמצוא מיקום הפעלה, ממשק המשתמש שולח קריאות 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 כוללות שילוב של מצב חיפוש לאורך ציר הזמן שמושבת כברירת מחדל, ומחפש רק פעם אחת כשהמשתמש משחרר את סרגל הזמן. כדי להפעיל גלילה רציפה ב-XML, מגדירים את app:time_bar_scrubbing_enabled="true" ב-PlayerView, או בקוד, מפעילים את setTimeBarScrubbingEnabled(true).

גם כשמשתמשים ב-ProgressSlider או ב-PlayerView, אפשר להתאים אישית את ScrubbingModeParameters במכונה של ExoPlayer, למשל להגדיר סבילות חלקית לחיפוש מרוחב פס הגלילה.

מתי להפעיל ולכבות את מצב הניקוי

אם אתם מטמיעים סרגל דילוג או פקד הסמן לבחירת המיקום בהתאמה אישית, הפעילו את מצב חיפוש לאורך ציר הזמן רק למשך תנועת חיפוש לאורך ציר הזמן האינטראקטיבית של המשתמש:

  • מפעילים את מצב חיפוש לאורך ציר הזמן (setScrubbingModeEnabled(true)) כשהמשתמש מתחיל לגרור את הסמן לבחירת המיקום, וקוראים ל-seekTo כשמיקום הגרירה משתנה.
  • משביתים את מצב הגלילה (setScrubbingModeEnabled(false)) ברגע שהמשתמש מסיים או מבטל את התנועה. לדוגמה, צריך להשבית אותו כשמשחררים את הסמן, כשמבטלים את התנועה או אם רכיב ממשק המשתמש מנותק או נמחק באמצע הגרירה.
  • מצב חיפוש לאורך ציר הזמן צריך להיות כבוי במהלך הפעלה רגילה ודילוגים בודדים, כמו לחיצה על כפתורי ההרצה קדימה או אחורה, לחיצה על סרגל הדילוג בלי לגרור אותו או דילוג באמצעות קוד.

אם משאירים את מצב חיפוש לאורך ציר הזמן פעיל מחוץ לתנועת הגרירה, עלולות להיות לכך תופעות לוואי לא רצויות:

  • ההפעלה מושהית: כשמצב הגלילה מופעל, 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 כדי למנוע רעשי פופ באודיו ואיפוסים מיותרים של ה-renderer.
  • הגדלת קצב העברת הנתונים של הקודק: מגדילה את קצב הפעולה של MediaCodec בווידאו ומוודאת שתזמון דינמי מופעל, כדי שמאגרי הפלט המפוענחים יעובדו מהר יותר.
  • הפחתת מספר האיפוסים של המפענח: מונע מחיקות מיותרות של MediaCodec כשמחפשים פריים מרכזי בסטרימינג תואם, מדלג על איפוס חזרה לפריים המרכזי כשמחפשים קדימה באותה קבוצת תמונות (GOP), ומשתמש ב-MediaCodec.BUFFER_FLAG_DECODE_ONLY ב-Android בגרסה 14 (רמת API‏ 34) ומעלה כדי שהמפענח ידלג על יצירת מאגרי פלט של פריימים בין פריים מרכזי לפריים היעד.

התאמה אישית של פרמטרים לניקוי נתונים

אפשר להשתמש ב-ScrubbingModeParameters.Builder כדי לשנות את אופן הפעולה של חיפוש לאורך ציר הזמן, למשל אילו סוגי טראקים מושבתים או אם קצבי הפעולה של ה-codec מוגברים. מחילים את ההגדרה באמצעות 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 חלקי הרוחב של סרגל הגרירה בממשק המשתמש אל setFractionalSeekTolerance (לדוגמה, 2dp, או לכוונן בין 1dp ל-4dp כדי לאזן בין מהירות לדיוק):

  • בתוכן ארוך (לדוגמה, סרטון באורך שעה בסרגל גלילה של 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()
}

בממשק משתמש שמבוסס על תצוגה, ממירים את הטולרנס של 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 הרגיל של הנגן.

שיקולים שקשורים למדיה ולרשת

מצב הגלילה משפר את התזמון של החיפוש המקומי ואת פענוח החומרה, ולכן הוא הכי יעיל בקבצים מקומיים, במדיה שהורדה, בשידורים שנשמרו במטמון או בשידורים מותאמים שנשמרו במאגר זמני וקודדו עם פריימים מרכזיים תכופים (GOP קצרים). בחיבורים לרשת עם זמן אחזור גבוה וללא מטמון, חיפושים תכופים של מיקומים לא מאוחסנים במאגר זמני עדיין מוגבלים על ידי זמני הלוך ושוב ברשת.