Skip to content

Commit 69a7bc2

Browse files
YangJonghunjonghunfreeboub
authored
feat: implement enterPictureInPictureOnLeave prop for both platform (Android, iOS) (#3385)
* docs: enable Android PIP * chore: change comments * feat(android): implement Android PictureInPicture * refactor: minor refactor code and apply lint * fix: rewrite pip action intent code for Android14 * fix: remove redundant codes * feat: add isInPictureInPicture flag for lifecycle handling - activity provide helper method for same purpose, but this flag makes code simple * feat: add pipFullscreenPlayerView for makes PIP include video only * fix: add manifest value checker for prevent crash * docs: add pictureInPicture prop's Android guide * fix: sync controller visibility * refactor: refining variable name * fix: check multi window mode when host pause - some OS version call onPause on multi-window mode * fix: handling when onStop is called while in multi-window mode * refactor: enhance PIP util codes * fix: fix FullscreenPlayerView constructor * refactor: add enterPictureInPictureOnLeave prop and pip methods - remove pictureInPicture boolean prop - add enterPictureInPictureOnLeave boolean prop - add enterPictureInPicture method - add exitPictureInPicture method * fix: fix lint error * fix: prevent audio play in background without playInBackground prop * fix: fix onDetachedFromWindow * docs: update docs for pip * fix(android): sync pip controller with external controller state - for media session * fix(ios): fix pip active fn variable reference * refactor(ios): refactor code * refactor(android): refactor codes * fix(android): fix lint error * refactor(android): refactor android pip logics * fix(android): fix flickering issue when stop picture in picture * fix(android): fix import * fix(android): fix picture in picture with fullscreen mode * fix(ios): fix syntax error * fix(android): fix Fragment managed code * refactor(android): remove redundant override lifecycle * �fix(js): add PIP type definition for codegen * fix(android): fix syntax * chore(android): fix lint error * fix(ios): fix enter background handler * refactor(ios): remove redundant code * fix(ios): fix applicationDidEnterBackground for PIP * fix(android): fix onPictureInPictureStatusChanged * fix(ios): fix RCTPictureInPicture * refactor(android): Ignore exception for some device ignore pip checker - some device ignore PIP availability check, so we need to handle exception to prevent crash * fix(android): add hideWithoutPlayer fn into Kotlin ver * refactor(android): remove redundant code * fix(android): fix pip ratio to be calculated with correct ratio value * fix(android): fix crash issue when unmounting in PIP mode * fix(android): fix lint error * Update android/src/main/java/com/brentvatne/react/VideoManagerModule.kt * fix(android): fix lint error * fix(ios): fix lint error * fix(ios): fix lint error * feat(expo): add android picture in picture config within expo plugin * fix: Replace Fragment with androidx.activity - remove code that uses Fragment, which is a tricky implementation * fix: fix lint error * fix(android): disable auto enter when player released * fix(android): fix event handler to check based on Activity it's bound to --------- Co-authored-by: jonghun <jonghun@toss.im> Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
1 parent a735a4a commit 69a7bc2

28 files changed

+738
-75
lines changed

android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt

+8-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ enum class EventTypes(val eventName: String) {
4242

4343
EVENT_TEXT_TRACK_DATA_CHANGED("onTextTrackDataChanged"),
4444
EVENT_VIDEO_TRACKS("onVideoTracks"),
45-
EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent");
45+
EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent"),
46+
EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED("onPictureInPictureStatusChanged");
4647

4748
companion object {
4849
fun toMap() =
@@ -90,6 +91,7 @@ class VideoEventEmitter {
9091
lateinit var onVideoTracks: (videoTracks: ArrayList<VideoTrack>?) -> Unit
9192
lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit
9293
lateinit var onReceiveAdEvent: (adEvent: String, adData: Map<String?, String?>?) -> Unit
94+
lateinit var onPictureInPictureStatusChanged: (isActive: Boolean) -> Unit
9395

9496
fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) {
9597
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
@@ -278,6 +280,11 @@ class VideoEventEmitter {
278280
)
279281
}
280282
}
283+
onPictureInPictureStatusChanged = { isActive ->
284+
event.dispatch(EventTypes.EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED) {
285+
putBoolean("isActive", isActive)
286+
}
287+
}
281288
}
282289
}
283290

android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ class ExoPlayerView(private val context: Context) :
3030
FrameLayout(context, null, 0),
3131
AdViewProvider {
3232

33-
private var surfaceView: View? = null
33+
var surfaceView: View? = null
34+
private set
3435
private var shutterView: View
3536
private var subtitleLayout: SubtitleView
3637
private var layout: AspectRatioFrameLayout

android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.app.Dialog
55
import android.content.Context
66
import android.os.Handler
77
import android.os.Looper
8+
import android.view.View
89
import android.view.ViewGroup
910
import android.view.Window
1011
import android.view.WindowManager
@@ -125,6 +126,14 @@ class FullScreenPlayerView(
125126
}
126127
}
127128

129+
fun hideWithoutPlayer() {
130+
for (i in 0 until containerView.childCount) {
131+
if (containerView.getChildAt(i) !== exoPlayerView) {
132+
containerView.getChildAt(i).visibility = View.GONE
133+
}
134+
}
135+
}
136+
128137
private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
129138
if (isFullscreen) {
130139
androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package com.brentvatne.exoplayer
2+
3+
import android.annotation.SuppressLint
4+
import android.app.AppOpsManager
5+
import android.app.PictureInPictureParams
6+
import android.app.RemoteAction
7+
import android.content.Context
8+
import android.content.ContextWrapper
9+
import android.content.pm.PackageManager
10+
import android.graphics.Rect
11+
import android.graphics.drawable.Icon
12+
import android.os.Build
13+
import android.os.Process
14+
import android.util.Rational
15+
import androidx.activity.ComponentActivity
16+
import androidx.annotation.ChecksSdkIntAtLeast
17+
import androidx.annotation.RequiresApi
18+
import androidx.core.app.AppOpsManagerCompat
19+
import androidx.core.app.PictureInPictureModeChangedInfo
20+
import androidx.lifecycle.Lifecycle
21+
import androidx.media3.exoplayer.ExoPlayer
22+
import com.brentvatne.common.toolbox.DebugLog
23+
import com.brentvatne.receiver.PictureInPictureReceiver
24+
import com.facebook.react.uimanager.ThemedReactContext
25+
26+
internal fun Context.findActivity(): ComponentActivity {
27+
var context = this
28+
while (context is ContextWrapper) {
29+
if (context is ComponentActivity) return context
30+
context = context.baseContext
31+
}
32+
throw IllegalStateException("Picture in picture should be called in the context of an Activity")
33+
}
34+
35+
object PictureInPictureUtil {
36+
private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000
37+
private const val TAG = "PictureInPictureUtil"
38+
39+
@JvmStatic
40+
fun addLifecycleEventListener(context: ThemedReactContext, view: ReactExoplayerView): Runnable {
41+
val activity = context.findActivity()
42+
43+
val onPictureInPictureModeChanged: (info: PictureInPictureModeChangedInfo) -> Unit = { info: PictureInPictureModeChangedInfo ->
44+
view.setIsInPictureInPicture(info.isInPictureInPictureMode)
45+
if (!info.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.CREATED) {
46+
// when user click close button of PIP
47+
if (!view.playInBackground) view.setPausedModifier(true)
48+
}
49+
}
50+
51+
val onUserLeaveHintCallback = {
52+
if (view.enterPictureInPictureOnLeave) {
53+
view.enterPictureInPictureMode()
54+
}
55+
}
56+
57+
activity.addOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
58+
59+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
60+
activity.addOnUserLeaveHintListener(onUserLeaveHintCallback)
61+
}
62+
63+
// @TODO convert to lambda when ReactExoplayerView migrated
64+
return object : Runnable {
65+
override fun run() {
66+
context.findActivity().removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
67+
context.findActivity().removeOnUserLeaveHintListener(onUserLeaveHintCallback)
68+
}
69+
}
70+
}
71+
72+
@JvmStatic
73+
fun enterPictureInPictureMode(context: ThemedReactContext, pictureInPictureParams: PictureInPictureParams?) {
74+
if (!isSupportPictureInPicture(context)) return
75+
if (isSupportPictureInPictureAction() && pictureInPictureParams != null) {
76+
try {
77+
context.findActivity().enterPictureInPictureMode(pictureInPictureParams)
78+
} catch (e: IllegalStateException) {
79+
DebugLog.e(TAG, e.toString())
80+
}
81+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
82+
try {
83+
@Suppress("DEPRECATION")
84+
context.findActivity().enterPictureInPictureMode()
85+
} catch (e: IllegalStateException) {
86+
DebugLog.e(TAG, e.toString())
87+
}
88+
}
89+
}
90+
91+
@JvmStatic
92+
fun applyPlayingStatus(
93+
context: ThemedReactContext,
94+
pipParamsBuilder: PictureInPictureParams.Builder,
95+
receiver: PictureInPictureReceiver,
96+
isPaused: Boolean
97+
) {
98+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
99+
val actions = getPictureInPictureActions(context, isPaused, receiver)
100+
pipParamsBuilder.setActions(actions)
101+
updatePictureInPictureActions(context, pipParamsBuilder.build())
102+
}
103+
104+
@JvmStatic
105+
fun applyAutoEnterEnabled(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder, autoEnterEnabled: Boolean) {
106+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
107+
pipParamsBuilder.setAutoEnterEnabled(autoEnterEnabled)
108+
updatePictureInPictureActions(context, pipParamsBuilder.build())
109+
}
110+
111+
@JvmStatic
112+
fun applySourceRectHint(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder, playerView: ExoPlayerView) {
113+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
114+
pipParamsBuilder.setSourceRectHint(calcRectHint(playerView))
115+
updatePictureInPictureActions(context, pipParamsBuilder.build())
116+
}
117+
118+
private fun updatePictureInPictureActions(context: ThemedReactContext, pipParams: PictureInPictureParams) {
119+
if (!isSupportPictureInPictureAction()) return
120+
if (!isSupportPictureInPicture(context)) return
121+
try {
122+
context.findActivity().setPictureInPictureParams(pipParams)
123+
} catch (e: IllegalStateException) {
124+
DebugLog.e(TAG, e.toString())
125+
}
126+
}
127+
128+
@JvmStatic
129+
@RequiresApi(Build.VERSION_CODES.O)
130+
fun getPictureInPictureActions(context: ThemedReactContext, isPaused: Boolean, receiver: PictureInPictureReceiver): ArrayList<RemoteAction> {
131+
val intent = receiver.getPipActionIntent(isPaused)
132+
val resource =
133+
if (isPaused) androidx.media3.ui.R.drawable.exo_icon_play else androidx.media3.ui.R.drawable.exo_icon_pause
134+
val icon = Icon.createWithResource(context, resource)
135+
val title = if (isPaused) "play" else "pause"
136+
return arrayListOf(RemoteAction(icon, title, title, intent))
137+
}
138+
139+
@JvmStatic
140+
@RequiresApi(Build.VERSION_CODES.O)
141+
private fun calcRectHint(playerView: ExoPlayerView): Rect {
142+
val hint = Rect()
143+
playerView.surfaceView?.getGlobalVisibleRect(hint)
144+
val location = IntArray(2)
145+
playerView.surfaceView?.getLocationOnScreen(location)
146+
147+
val height = hint.bottom - hint.top
148+
hint.top = location[1]
149+
hint.bottom = hint.top + height
150+
return hint
151+
}
152+
153+
@JvmStatic
154+
@RequiresApi(Build.VERSION_CODES.O)
155+
fun calcPictureInPictureAspectRatio(player: ExoPlayer): Rational {
156+
var aspectRatio = Rational(player.videoSize.width, player.videoSize.height)
157+
// AspectRatio for the activity in picture-in-picture, must be between 2.39:1 and 1:2.39 (inclusive).
158+
// https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational)
159+
val maximumRatio = Rational(239, 100)
160+
val minimumRatio = Rational(100, 239)
161+
if (aspectRatio.toFloat() > maximumRatio.toFloat()) {
162+
aspectRatio = maximumRatio
163+
} else if (aspectRatio.toFloat() < minimumRatio.toFloat()) {
164+
aspectRatio = minimumRatio
165+
}
166+
return aspectRatio
167+
}
168+
169+
private fun isSupportPictureInPicture(context: ThemedReactContext): Boolean =
170+
checkIsApiSupport() && checkIsSystemSupportPIP(context) && checkIsUserAllowPIP(context)
171+
172+
private fun isSupportPictureInPictureAction(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
173+
174+
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
175+
private fun checkIsApiSupport(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
176+
177+
@RequiresApi(Build.VERSION_CODES.N)
178+
private fun checkIsSystemSupportPIP(context: ThemedReactContext): Boolean {
179+
val activity = context.findActivity() ?: return false
180+
181+
val activityInfo = activity.packageManager.getActivityInfo(activity.componentName, PackageManager.GET_META_DATA)
182+
// detect current activity's android:supportsPictureInPicture value defined within AndroidManifest.xml
183+
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/content/pm/ActivityInfo.java;l=1090-1093;drc=7651f0a4c059a98f32b0ba30cd64500bf135385f
184+
val isActivitySupportPip = activityInfo.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE != 0
185+
186+
// PIP might be disabled on devices that have low RAM.
187+
val isPipAvailable = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
188+
189+
return isActivitySupportPip && isPipAvailable
190+
}
191+
192+
private fun checkIsUserAllowPIP(context: ThemedReactContext): Boolean {
193+
val activity = context.currentActivity ?: return false
194+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
195+
@SuppressLint("InlinedApi")
196+
val result = AppOpsManagerCompat.noteOpNoThrow(
197+
activity,
198+
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
199+
Process.myUid(),
200+
activity.packageName
201+
)
202+
AppOpsManager.MODE_ALLOWED == result
203+
} else {
204+
Build.VERSION.SDK_INT < Build.VERSION_CODES.O && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)