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

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

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

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

ضبط إعدادات تطبيقك في وضع "نافذة ضمن النافذة" (PIP)

في علامة النشاط لملف AndroidManifest.xml، قم بما يلي:

  1. يُرجى إضافة السمة supportsPictureInPicture وضبطها على true للإشارة إلى أنّك ستستخدم ميزة "نافذة ضمن النافذة" في تطبيقك.
  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")
    }

إضافة ميزة "نافذة ضمن النافذة" (PiP) عند مغادرة التطبيق للأجهزة التي تعمل بالإصدارات السابقة من Android 12

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

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

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_TAG, "API does not support PiP")
}

إضافة ميزة "نافذة ضمن النافذة" في تطبيق المغادرة للإصدار 12 من نظام التشغيل Android

بعد الإصدار 12 من نظام التشغيل Android، تتم إضافة 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)

إضافة "نافذة ضمن النافذة" (PIP) من خلال زرّ

للدخول إلى وضع "نافذة ضمن النافذة" (PiP) بنقرة زر، يُرجى الاتصال بالرقم 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()
}

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

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

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

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

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

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

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

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

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

fun releasePlayer() {
    shouldEnterPipMode = false
}

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

  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_TAG, "API does not support PiP")
    }

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

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

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 والإصدارات الأحدث، يتم أيضًا إنشاء صورة متحركة أكثر سلاسة للخروج من وضع "نافذة ضمن النافذة". أضِف واجهة برمجة التطبيقات هذه إلى منصة إنشاء "نافذة ضمن النافذة" (PiP) للإشارة إلى منطقة النشاط التي تظهر بعد الانتقال إلى "نافذة ضمن النافذة".

  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" لضبط نسبة العرض إلى الارتفاع لنافذة "نافذة ضمن النافذة".

لضبط نسبة العرض إلى الارتفاع في نافذة "نافذة ضمن النافذة"، يمكنك اختيار نسبة عرض إلى ارتفاع محدّدة أو استخدام عرض المتغيّر 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)
        builder.setAspectRatio(
            Rational(sourceRect.width(), sourceRect.height())
        )
    }

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

VideoPlayer(pipModifier)

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

إذا أردت إضافة عناصر التحكّم (التشغيل والإيقاف المؤقت وغير ذلك) إلى نافذة "نافذة ضمن النافذة"، يمكنك إنشاء 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) {
            val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
            builder.setSourceRectHint(sourceRect)
            builder.setAspectRatio(
                Rational(sourceRect.width(), sourceRect.height())
            )
        }
    
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            builder.setAutoEnterEnabled(shouldEnterPipMode)
        }
        context.findActivity().setPictureInPictureParams(builder.build())
    }
    VideoPlayer(modifier = pipModifier)

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

تعلّمنا في هذا الدليل أفضل الممارسات الخاصة بإضافة ميزة "نافذة ضمن النافذة" في Compose ما قبل الإصدار 12 من نظام التشغيل Android وما بعده.