إضافة ميزة "نافذة ضمن النافذة" إلى تطبيقك باستخدام مشغّل فيديو Compose

ميزة "نافذة ضمن النافذة" (PiP) هي نوع خاص من وضع النوافذ المتعددة يُستخدَم عادةً في تشغيل الفيديو. يتيح للمستخدم مشاهدة مقطع فيديو في نافذة صغيرة مثبّتة في زاوية الشاشة أثناء التنقل بين التطبيقات أو تصفح المحتوى على الشاشة الرئيسية.

تستفيد ميزة "نافذة ضمن النافذة" من واجهات برمجة التطبيقات ذات النوافذ المتعددة المتاحة في الإصدار 7.0 من نظام التشغيل Android لتوفير نافذة تراكب الفيديو المثبتة. لإضافة ميزة "نافذة ضمن النافذة" إلى تطبيقك، عليك تسجيل نشاطك وتبديله إلى وضع "نافذة ضمن النافذة" حسب الحاجة والتأكّد من أنّه تم إخفاء عناصر واجهة المستخدم ومواصلة تشغيل الفيديو عندما يكون النشاط في وضع "نافذة ضمن النافذة".

يوضِّح هذا الدليل كيفية إضافة ميزة "نافذة ضمن النافذة" في نافذة Compose إلى تطبيقك باستخدام فيديو Compose. التنفيذ. يمكنك الاطّلاع على تطبيق Socialite للتعرّف على أفضل الممارسات المتعلّقة بالتسويق على الشبكات الاجتماعية.

إعداد تطبيقك ليتم عرضه في وضع "نافذة ضمن النافذة"

في علامة النشاط لملف AndroidManifest.xml، نفِّذ ما يلي:

  1. إضافة supportsPictureInPicture وضبطه على true للإعلان عن أنّك ستكون باستخدام الميزة "نافذة ضمن النافذة" (PIP) في تطبيقك.
  2. إضافة configChanges وضبطه على orientation|screenLayout|screenSize|smallestScreenSize لتحديد أن يتعامل نشاطك مع تغييرات تهيئة التخطيط. بهذه الطريقة، يمكن أن يكون نشاطك لا تتم إعادة تشغيله عند حدوث تغييرات في التنسيق أثناء عمليات تبديل وضع "نافذة ضمن النافذة".

      <activity
        android:name=".SnippetsActivity"
        android:exported="true"
        android:supportsPictureInPicture="true"
        android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
        android:theme="@style/Theme.Snippets">

في رمز Compose، اتّبِع الخطوات التالية:

  1. ثبِّت هذه الإضافة في Context. ستستخدم هذه الإضافة عدة مرات في جميع أقسام الدليل للوصول إلى النشاط.
    internal fun Context.findActivity(): ComponentActivity {
        var context = this
        while (context is ContextWrapper) {
            if (context is ComponentActivity) return context
            context = context.baseContext
        }
        throw IllegalStateException("Picture in picture should be called in the context of an Activity")
    }

إضافة ميزة "وضع الصورة في الصورة" عند مغادرة التطبيق على الإصدارات الأقدم من Android 12

لإضافة ميزة "نافذة ضمن النافذة" للإصدارات الأقدم من Android 12، استخدِم addOnUserLeaveHintProvider. متابعة الخطوات التالية لإضافة ميزة "نافذة ضمن النافذة" على الأجهزة التي تعمل بالإصدارات الأقدم من نظام Android 12:

  1. أضف بوابة إصدار بحيث يتم الوصول إلى هذا الرمز فقط في الإصدارات O حتى R.
  2. استخدِم DisposableEffect مع Context كمفتاح.
  3. وداخل DisposableEffect، حدِّد السلوك الذي سيتم عنده يتم تشغيل onUserLeaveHintProvider باستخدام دالة lambda. في lambda، اطلب enterPictureInPictureMode() على findActivity() وتمريره PictureInPictureParams.Builder().build()
  4. أضِف addOnUserLeaveHintListener باستخدام findActivity() وأدخِل دالة lambda.
  5. في onDispose، إضافة removeOnUserLeaveHintListener باستخدام findActivity() وأمرر في لامدا.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
    Build.VERSION.SDK_INT < Build.VERSION_CODES.S
) {
    val context = LocalContext.current
    DisposableEffect(context) {
        val onUserLeaveBehavior: () -> Unit = {
            context.findActivity()
                .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
        }
        context.findActivity().addOnUserLeaveHintListener(
            onUserLeaveBehavior
        )
        onDispose {
            context.findActivity().removeOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
        }
    }
} else {
    Log.i("PiP info", "API does not support PiP")
}

إضافة ميزة "نافذة ضمن النافذة" (PIP) إلى تطبيق "المغادرة" بعد Android 12

بعد الإصدار Android 12، تتم إضافة PictureInPictureParams.Builder من خلال مفتاح التعديل الذي يتم ضبطه على مشغّل الفيديو في التطبيق.

  1. إنشاء modifier والاتصال بـ onGloballyPositioned من خلالها. التخطيط استخدام الإحداثيات في خطوة لاحقة.
  2. أنشئ متغيّرًا للعنصر PictureInPictureParams.Builder().
  3. يمكنك إضافة عبارة if للتحقّق مما إذا كانت حزمة SDK من إصدار S أو إصدار أحدث. إذا كان الأمر كذلك، أضف setAutoEnterEnabled إلى أداة الإنشاء وضبطه على true للدخول في وضع "نافذة ضمن النافذة" عند التمرير السريع. يقدّم هذا الإجراء رسومًا متحركة أكثر سلاسة من الانتقال إلى enterPictureInPictureMode.
  4. يمكنك استخدام findActivity() للاتصال بـ setPictureInPictureParams(). الاتصال بـ "build()" على builder وتمريره.

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(true)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}
VideoPlayer(pipModifier)

إضافة "نافذة ضمن النافذة" باستخدام زرّ

للدخول إلى وضع "نافذة ضمن النافذة" من خلال النقر على زر، عليك الاتصال enterPictureInPictureMode() في findActivity().

تم تعيين المعلمات من قبل من خلال عمليات الاتصال السابقة PictureInPictureParams.Builder، لذلك لن تحتاج إلى ضبط معلَمات جديدة في منصة الإنشاء. ومع ذلك، إذا كنت تريد تغيير أي معلمات على الزر النقر فوقها، فيمكنك تعيينها هنا.

val context = LocalContext.current
Button(onClick = {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        context.findActivity().enterPictureInPictureMode(
            PictureInPictureParams.Builder().build()
        )
    } else {
        Log.i(PIP_TAG, "API does not support PiP")
    }
}) {
    Text(text = "Enter PiP mode!")
}

التعامل مع واجهة المستخدم في وضع "نافذة ضمن النافذة"

عند الدخول إلى وضع "نافذة ضمن النافذة"، تظهر واجهة المستخدم بالكامل في هذه النافذة، إلا إذا تحديد الشكل الذي يجب أن تبدو عليه واجهة المستخدم داخل وخارج وضع "نافذة ضمن النافذة".

أولاً، يجب معرفة ما إذا كان تطبيقك في وضع "نافذة ضمن النافذة" (PIP) أم لا. يمكنك استخدام OnPictureInPictureModeChangedProvider لتحقيق ذلك. يُعلمك الرمز أدناه بما إذا كان التطبيق في وضع "نافذة ضمن النافذة".

@Composable
fun rememberIsInPipMode(): Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val activity = LocalContext.current.findActivity()
        var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
        DisposableEffect(activity) {
            val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
                pipMode = info.isInPictureInPictureMode
            }
            activity.addOnPictureInPictureModeChangedListener(
                observer
            )
            onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
        }
        return pipMode
    } else {
        return false
    }
}

يمكنك الآن استخدام rememberIsInPipMode() لتبديل عناصر واجهة المستخدم التي تريد عرضها عندما يدخل التطبيق إلى وضع "نافذة ضمن النافذة":

val inPipMode = rememberIsInPipMode()

Column(modifier = modifier) {
    // This text will only show up when the app is not in PiP mode
    if (!inPipMode) {
        Text(
            text = "Picture in Picture",
        )
    }
    VideoPlayer()
}

تأكَّد من دخول التطبيق في وضع "نافذة ضمن النافذة" في الأوقات المناسبة.

يجب ألا يدخل التطبيق في وضع "نافذة ضمن النافذة" (PIP) في الحالات التالية:

  • إذا تم إيقاف الفيديو أو إيقافه مؤقتًا
  • إذا كنت في صفحة مختلفة من التطبيق غير صفحة مشغّل الفيديو

للتحكّم في وقت دخول التطبيق في وضع "نافذة ضمن النافذة"، أضِف متغيّرًا يتتبّع الحالة. في مشغّل الفيديو باستخدام mutableStateOf.

تبديل الحالة استنادًا إلى ما إذا كان الفيديو يتم تشغيله

لتبديل الحالة حسب ما إذا كان مشغّل الفيديو قيد التشغيل، عليك إضافة مستمع على مشغّل الفيديو. بدِّل حالة متغيّر الحالة استنادًا إلى ما إذا كان اللاعب يلعب أم لا:

player.addListener(object : Player.Listener {
    override fun onIsPlayingChanged(isPlaying: Boolean) {
        shouldEnterPipMode = isPlaying
    }
})

يمكنك تبديل الحالة بناءً على ما إذا تم تحرير اللاعب.

عند إصدار المُشغِّل، اضبط متغيّر الحالة على false:

fun releasePlayer() {
    shouldEnterPipMode = false
}

استخدام الحالة لتحديد ما إذا كان سيتم تفعيل وضع "نافذة ضمن النافذة" (قبل Android 12)

  1. بما أنّ إضافة "نافذة ضمن النافذة (PIP) لمرحلة ما قبل 12 عامًا" تستخدم DisposableEffect، عليك إنشاء متغيّر جديد من قِبل rememberUpdatedState مع ضبط newValue على أنّه متغير الحالة. سيضمن ذلك استخدام الإصدار المُحدَّث ضمن DisposableEffect
  2. في دالة lambda التي تحدّد السلوك عند استخدام OnUserLeaveHintListener أضف عبارة if بمتغير الحالة حول استدعاء الدالة إلى enterPictureInPictureMode():

    val currentShouldEnterPipMode by rememberUpdatedState(newValue = shouldEnterPipMode)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
        Build.VERSION.SDK_INT < Build.VERSION_CODES.S
    ) {
        val context = LocalContext.current
        DisposableEffect(context) {
            val onUserLeaveBehavior: () -> Unit = {
                if (currentShouldEnterPipMode) {
                    context.findActivity()
                        .enterPictureInPictureMode(PictureInPictureParams.Builder().build())
                }
            }
            context.findActivity().addOnUserLeaveHintListener(
                onUserLeaveBehavior
            )
            onDispose {
                context.findActivity().removeOnUserLeaveHintListener(
                    onUserLeaveBehavior
                )
            }
        }
    } else {
        Log.i("PiP info", "API does not support PiP")
    }

استخدام الحالة لتحديد ما إذا تم تفعيل وضع "نافذة ضمن النافذة" (بعد إصدار Android 12)

أدخِل متغيّر الحالة إلى setAutoEnterEnabled كي يدخل تطبيقك فقط. وضع "نافذة ضمن النافذة" في الوقت المناسب:

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()

    // Add autoEnterEnabled for versions S and up
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

استخدام setSourceRectHint لتنفيذ صورة متحركة سلسة

تنشئ واجهة برمجة التطبيقات setSourceRectHint صورة متحركة أكثر سلاسة للدخول في وضع "نافذة ضمن النافذة". الحالي. وفي الإصدار 12 من نظام التشغيل Android والإصدارات الأحدث، يمكن أيضًا إنشاء صور متحركة أكثر سلاسة للخروج من وضع "نافذة ضمن النافذة". أضف واجهة برمجة التطبيقات هذه إلى أداة إنشاء "نافذة ضمن النافذة" للإشارة إلى منطقة النشاط بعد الانتقال إلى وضع "نافذة ضمن النافذة".

  1. لا تُضِف setSourceRectHint() إلى builder إلا إذا كانت الحالة تحدّد أنّه يجب أن يدخل التطبيق في وضع "صورة في صورة". لتجنُّب احتساب sourceRect عند استخدام التطبيق لا يحتاج إلى إدخال "نافذة ضمن النافذة".
  2. لضبط قيمة sourceRect، استخدِم layoutCoordinates المُقدّمة. من الدالة onGloballyPositioned على مفتاح التعديل.
  3. اتصل بالرقم setSourceRectHint() على builder وأدخِل المتغيّر sourceRect .

val context = LocalContext.current

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()
    if (shouldEnterPipMode) {
        val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
        builder.setSourceRectHint(sourceRect)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

استخدِم setAspectRatio لضبط نسبة العرض إلى الارتفاع الخاصة بنافذة "نافذة ضمن النافذة".

لضبط نسبة العرض إلى الارتفاع للنافذة ضمن النافذة، يمكنك اختيار صورة محدّدة نسبة العرض إلى الارتفاع أو استخدام عرض وارتفاع حجم فيديو المشغّل. إذا كنت باستخدام مشغل Media3، تحقق من أن المشغّل ليس فارغًا ومن أن قيمة لا يساوي حجم الفيديو VideoSize.UNKNOWN قبل ضبط السمة النسبة المئوية.

val context = LocalContext.current

val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
    val builder = PictureInPictureParams.Builder()
    if (shouldEnterPipMode && player != null && player.videoSize != VideoSize.UNKNOWN) {
        val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
        builder.setSourceRectHint(sourceRect)
        builder.setAspectRatio(
            Rational(player.videoSize.width, player.videoSize.height)
        )
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        builder.setAutoEnterEnabled(shouldEnterPipMode)
    }
    context.findActivity().setPictureInPictureParams(builder.build())
}

VideoPlayer(pipModifier)

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

إضافة إجراءات عن بُعد

إذا أردت إضافة عناصر تحكّم (تشغيل أو إيقاف مؤقت أو غير ذلك) إلى نافذة "نافذة ضمن النافذة"، أنشِئ RemoteAction لكل عنصر تحكّم تريد إضافته.

  1. إضافة ثوابت لعناصر التحكم في البث:
    // Constant for broadcast receiver
    const val ACTION_BROADCAST_CONTROL = "broadcast_control"
    
    // Intent extras for broadcast controls from Picture-in-Picture mode.
    const val EXTRA_CONTROL_TYPE = "control_type"
    const val EXTRA_CONTROL_PLAY = 1
    const val EXTRA_CONTROL_PAUSE = 2
  2. أنشئ قائمة RemoteActions لعناصر التحكّم في نافذة "وضع الصورة في الصورة".
  3. بعد ذلك، يمكنك إضافة BroadcastReceiver وإلغاء onReceive() لضبط إجراءات كل زر. استخدِم DisposableEffect لتسجيل المستقبل والإجراءات عن بُعد. عندما يتم التخلص من المشغّل، يجب إلغاء تسجيل المستلم.
    @RequiresApi(Build.VERSION_CODES.O)
    @Composable
    fun PlayerBroadcastReceiver(player: Player?) {
        val isInPipMode = rememberIsInPipMode()
        if (!isInPipMode || player == null) {
            // Broadcast receiver is only used if app is in PiP mode and player is non null
            return
        }
        val context = LocalContext.current
    
        DisposableEffect(player) {
            val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
                override fun onReceive(context: Context?, intent: Intent?) {
                    if ((intent == null) || (intent.action != ACTION_BROADCAST_CONTROL)) {
                        return
                    }
    
                    when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
                        EXTRA_CONTROL_PAUSE -> player.pause()
                        EXTRA_CONTROL_PLAY -> player.play()
                    }
                }
            }
            ContextCompat.registerReceiver(
                context,
                broadcastReceiver,
                IntentFilter(ACTION_BROADCAST_CONTROL),
                ContextCompat.RECEIVER_NOT_EXPORTED
            )
            onDispose {
                context.unregisterReceiver(broadcastReceiver)
            }
        }
    }
  4. مرِّر قائمة بالإجراءات عن بُعد إلى PictureInPictureParams.Builder:
    val context = LocalContext.current
    
    val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
        val builder = PictureInPictureParams.Builder()
        builder.setActions(
            listOfRemoteActions()
        )
    
        if (shouldEnterPipMode && player != null && player.videoSize != VideoSize.UNKNOWN) {
            val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
            builder.setSourceRectHint(sourceRect)
            builder.setAspectRatio(
                Rational(player.videoSize.width, player.videoSize.height)
            )
        }
    
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            builder.setAutoEnterEnabled(shouldEnterPipMode)
        }
        context.findActivity().setPictureInPictureParams(builder.build())
    }
    VideoPlayer(modifier = pipModifier)

الخطوات التالية

تعرَّفت في هذا الدليل على أفضل الممارسات لإضافة "نافذة ضمن النافذة" في Compose. قبل الإصدار 12 وما بعد Android 12.