הוספת התכונה 'תמונה בתוך תמונה' (PiP) לאפליקציה באמצעות נגן וידאו של Compose

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

התכונה 'תמונה בתוך תמונה' משתמשת בממשקי ה-API עם ריבוי חלונות שזמינים ב-Android 7.0 כדי לספק חלון שכבת-על של סרטון מוצמד. כדי להוסיף את 'תמונה בתוך תמונה' לאפליקציה, צריך לרשום את להעביר את הפעילות למצב 'תמונה בתוך תמונה' לפי הצורך ולוודא רכיבים בממשק המשתמש. מוסתרות והפעלת הסרטונים נמשכת כשהפעילות במצב 'תמונה בתוך תמונה'.

במדריך הזה מוסבר איך להוסיף את התכונה PiP ב-Compose לאפליקציה באמצעות הטמעת וידאו ב-Compose. כדאי להיכנס לאפליקציית Socialite כדי לראות את התמונות הכי טובות בפועל.

הגדרת האפליקציה ל'תמונה בתוך תמונה'

בתג הפעילות של הקובץ 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">

בקוד הכתיבה, מבצעים את הפעולות הבאות:

  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")
    }

הוספת 'תמונה בתוך תמונה' באפליקציית Waze ל-Android 12 ואילך

כדי להוסיף את התכונה 'תמונה בתוך תמונה' למכשירים עם מערכת Android 12 טרום-Android, משתמשים ב-addOnUserLeaveHintProvider. הוספה למעקב כדי להוסיף 'תמונה בתוך תמונה' למכשירים עם Android 12, צריך לבצע את השלבים הבאים:

  1. מוסיפים שער גרסאות כדי שניתן יהיה לגשת לקוד הזה רק בגרסאות O עד R.
  2. משתמשים ב-DisposableEffect עם Context בתור המפתח.
  3. בתוך DisposableEffect, מגדירים את ההתנהגות כאשר onUserLeaveHintProvider מופעל באמצעות lambda. ב-lambda, קוראים enterPictureInPictureMode() ב-findActivity() והעברה PictureInPictureParams.Builder().build().
  4. מוסיפים addOnUserLeaveHintListener באמצעות findActivity() ומעבירים במבדה.
  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")
}

הוספת 'תמונה בתוך תמונה' באפליקציית Waze ל-Android 12

אחרי Android 12, אנחנו מוסיפים את PictureInPictureParams.Builder באמצעות מקש צירוף שמועבר לנגן הווידאו של האפליקציה.

  1. צריך ליצור modifier ולהתקשר אליו onGloballyPositioned. בשלב מאוחר יותר נשתמש בקואורדינטות של הפריסה.
  2. יוצרים משתנה בשביל PictureInPictureParams.Builder().
  3. צריך להוסיף הצהרת if כדי לבדוק אם גרסת ה-SDK היא S ואילך. אם כן, עליך להוסיף setAutoEnterEnabled ל-builder ומגדירים אותו לערך 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, כך שאין צורך להגדיר פרמטרים חדשים ב-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, כל ממשק המשתמש של האפליקציה נכנס לחלון ה-PIP, אלא אם מציינים איך ממשק המשתמש אמור להיראות במצב 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.

החלפת מצב אם הסרטון מופעל

כדי להפעיל או להשבית את מצב ההפעלה של נגן הווידאו, צריך להוסיף את ה-listener נגן הווידאו. החלפת המצב של משתנה המצב בהתאם למצב של הנגן מופעל או לא:

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 כדי להטמיע אנימציה חלקה

ה-API של setSourceRectHint יוצר אנימציה חלקה יותר בכניסה ל'תמונה בתוך תמונה' במצב 'סינון תוכן'. ב-Android מגרסה 12 ואילך, התכונה גם יוצרת אנימציה חלקה יותר ליציאה ממצב 'תמונה בתוך תמונה'. צריך להוסיף את ה-API הזה ל-builder של 'תמונה בתוך תמונה' כדי לציין את אזור הפעילות. לאחר המעבר ל'תמונה בתוך תמונה'.

  1. מוסיפים את setSourceRectHint() ל-builder רק אם המצב מגדיר שהאפליקציה צריכה לעבור למצב PiP. כך לא צריך לחשב את sourceRect כשהאפליקציה לא צריכה לעבור למצב PiP.
  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 כדי להגדיר את יחס הגובה-רוחב של חלון PiP

כדי להגדיר את יחס הגובה-רוחב של חלון 'תמונה בתוך תמונה', אפשר לבחור תמונה ספציפית יחס גובה-רוחב או להשתמש ברוחב ובגובה של גודל הסרטון בנגן. אם אתם באמצעות נגן 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)

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

הוספת פעולות מרחוק

אם רוצים להוסיף פקדים (הפעלה, השהיה וכו') לחלון 'תמונה בתוך תמונה', צריך ליצור 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)

השלבים הבאים

במדריך הזה למדתם את השיטות המומלצות להוספת 'תמונה בתוך תמונה' בניסוח אוטומטי לפני Android 12 ואחרי Android 12.