وضع التقديم أو الترجيع السريع

عندما يسحب المستخدم شريط التقديم/الترجيع عبر المخطط الزمني لمعاينة اللقطات والعثور على موضع تشغيل، ترسل واجهة المستخدم طلبات 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) عند انتهاء السحب أو عند مغادرة الدالة المركّبة لعملية التركيب. لمزيد من المعلومات، يُرجى الاطّلاع على 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.
  • إيقاف المقاطع غير المرئية: يتم إيقاف المقاطع الصوتية ومقاطع البيانات الوصفية تلقائيًا أثناء البحث السريع.
  • زيادة استهلاك الطاقة واستخدام برنامج فك الترميز: يؤدي وضع "التصفّح السريع" إلى زيادة معدلات تشغيل برامج ترميز الفيديو، ما يستهلك موارد إضافية من الأجهزة والبطارية إذا تم تركه مفعَّلاً.

يربط المثال التالي عمليات استدعاء إيماءات شريط التمرير المخصّصة بـ 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 (مستوى واجهة برمجة التطبيقات 34) والإصدارات الأحدث ليتخطّى أداة فك الترميز إنشاء مخازن مؤقتة للإخراج للإطارات المتوسطة بين الإطار الرئيسي والإطار المستهدف.

تخصيص مَعلمات التنظيف

استخدِم ScrubbingModeParameters.Builder لضبط سلوك التنقل السريع، مثل أنواع المقاطع التي يتم إيقافها أو ما إذا كان يتم تعزيز معدلات تشغيل برامج الترميز. طبِّق الإعدادات باستخدام ExoPlayer.setScrubbingModeParameters أو ExoPlayer.Builder.setScrubbingModeParameters. يجب إنشاء الإصدار من exoPlayer.scrubbingModeParameters.buildUpon() (أو ScrubbingModeParameters.DEFAULT.buildUpon()) لكي تظل التحسينات التلقائية مفعَّلة.

ضبط نسبة مئوية للتسامح في البحث من عرض شريط التمرير

By default, ScrubbingModeParameters leaves fractional seek tolerances unset (null), so seeks during scrubbing use the player's SeekParameters (SeekParameters.EXACT by default). يجب أن يفك البحث الدقيق ترميز كل إطار من الإطار الرئيسي السابق حتى الطابع الزمني المستهدف. عندما يكون هناك العديد من اللقطات منذ اللقطة الرئيسية السابقة، كما هو الحال في أحداث البث التي تتضمّن فواصل زمنية واسعة بين اللقطات الرئيسية أو عدد اللقطات في الثانية عالية، يمكن أن يؤدي عمل فك الترميز هذا إلى زيادة وقت الاستجابة بشكل ملحوظ أثناء السحب.

على شاشات اللمس، لا يمكن لإصبع المستخدم التمييز بشكل موثوق بين 1dp أو 2dp من الحركة على طول شريط التمرير. بالنسبة إلى شريط التمرير الذي يبلغ عرضه widthDp، فإنّ نسبة الخطأ الصغيرة في اللمس، مثل 2dp في أي من الاتجاهين (4dp في المجموع)، تتوافق مع جزء من 2.0 / widthDp من مدة الوسائط. يعتمد مقدار الوقت الذي يمثّله هذا الجزء على مدة الفيديو. ننصحك بتمرير قيمة سماح مستندة إلى dp مقسومة على عرض شريط التمرير في واجهة المستخدم إلى setFractionalSeekTolerance (على سبيل المثال، 2dp أو قيمة مضبوطة بين 1dp و4dp لتحقيق التوازن بين السرعة والدقة):

  • في المحتوى الطويل (على سبيل المثال، فيديو مدته ساعة واحدة على شريط تمرير 400dp، حيث يمتد 2dp إلى 18 ثانية)، يمكن لـ ExoPlayer الانتقال مباشرةً إلى إطار رئيسي قريب ضمن نطاق التسامح مع اللمس بدلاً من فك ترميز الإطارات الوسيطة.
  • في المقاطع القصيرة (على سبيل المثال، مقطع مدته 10 ثوانٍ على 400dp شريط التمرير نفسه، حيث يمتد 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 العادي للمشغّل.

اعتبارات الوسائط والشبكة

يعمل وضع التمرير السريع على تحسين جدولة البحث المحلي وفك ترميز الأجهزة، لذا فهو الأكثر فعالية مع الملفات المحلية أو الوسائط التي تم تنزيلها أو عمليات البث المخزّنة مؤقتًا أو عمليات البث التكيُّفية المخزّنة مؤقتًا والمشفّرة باستخدام إطارات رئيسية متكررة (مجموعات صور قصيرة). في حال الاتصال بشبكة ذات وقت استجابة طويل وبدون تخزين مؤقت، تظل عمليات البحث المتكررة عن المواضع غير المخزّنة مؤقتًا مقيّدة بأوقات ذهاب وعودة الشبكة.