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>
This commit is contained in:
YangJH 2025-01-04 20:37:33 +09:00 committed by GitHub
parent a735a4a581
commit 69a7bc2d26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 739 additions and 76 deletions

View File

@ -42,7 +42,8 @@ enum class EventTypes(val eventName: String) {
EVENT_TEXT_TRACK_DATA_CHANGED("onTextTrackDataChanged"), EVENT_TEXT_TRACK_DATA_CHANGED("onTextTrackDataChanged"),
EVENT_VIDEO_TRACKS("onVideoTracks"), EVENT_VIDEO_TRACKS("onVideoTracks"),
EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent"); EVENT_ON_RECEIVE_AD_EVENT("onReceiveAdEvent"),
EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED("onPictureInPictureStatusChanged");
companion object { companion object {
fun toMap() = fun toMap() =
@ -90,6 +91,7 @@ class VideoEventEmitter {
lateinit var onVideoTracks: (videoTracks: ArrayList<VideoTrack>?) -> Unit lateinit var onVideoTracks: (videoTracks: ArrayList<VideoTrack>?) -> Unit
lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit
lateinit var onReceiveAdEvent: (adEvent: String, adData: Map<String?, String?>?) -> Unit lateinit var onReceiveAdEvent: (adEvent: String, adData: Map<String?, String?>?) -> Unit
lateinit var onPictureInPictureStatusChanged: (isActive: Boolean) -> Unit
fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) { fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) {
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id) val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, view.id)
@ -278,6 +280,11 @@ class VideoEventEmitter {
) )
} }
} }
onPictureInPictureStatusChanged = { isActive ->
event.dispatch(EventTypes.EVENT_PICTURE_IN_PICTURE_STATUS_CHANGED) {
putBoolean("isActive", isActive)
}
}
} }
} }

View File

@ -30,7 +30,8 @@ class ExoPlayerView(private val context: Context) :
FrameLayout(context, null, 0), FrameLayout(context, null, 0),
AdViewProvider { AdViewProvider {
private var surfaceView: View? = null var surfaceView: View? = null
private set
private var shutterView: View private var shutterView: View
private var subtitleLayout: SubtitleView private var subtitleLayout: SubtitleView
private var layout: AspectRatioFrameLayout private var layout: AspectRatioFrameLayout

View File

@ -5,6 +5,7 @@ import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import android.view.WindowManager import android.view.WindowManager
@ -125,6 +126,14 @@ class FullScreenPlayerView(
} }
} }
fun hideWithoutPlayer() {
for (i in 0 until containerView.childCount) {
if (containerView.getChildAt(i) !== exoPlayerView) {
containerView.getChildAt(i).visibility = View.GONE
}
}
}
private fun getFullscreenIconResource(isFullscreen: Boolean): Int = private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
if (isFullscreen) { if (isFullscreen) {
androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit

View File

@ -0,0 +1,207 @@
package com.brentvatne.exoplayer
import android.annotation.SuppressLint
import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.PackageManager
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Process
import android.util.Rational
import androidx.activity.ComponentActivity
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.RequiresApi
import androidx.core.app.AppOpsManagerCompat
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.lifecycle.Lifecycle
import androidx.media3.exoplayer.ExoPlayer
import com.brentvatne.common.toolbox.DebugLog
import com.brentvatne.receiver.PictureInPictureReceiver
import com.facebook.react.uimanager.ThemedReactContext
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")
}
object PictureInPictureUtil {
private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000
private const val TAG = "PictureInPictureUtil"
@JvmStatic
fun addLifecycleEventListener(context: ThemedReactContext, view: ReactExoplayerView): Runnable {
val activity = context.findActivity()
val onPictureInPictureModeChanged: (info: PictureInPictureModeChangedInfo) -> Unit = { info: PictureInPictureModeChangedInfo ->
view.setIsInPictureInPicture(info.isInPictureInPictureMode)
if (!info.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.CREATED) {
// when user click close button of PIP
if (!view.playInBackground) view.setPausedModifier(true)
}
}
val onUserLeaveHintCallback = {
if (view.enterPictureInPictureOnLeave) {
view.enterPictureInPictureMode()
}
}
activity.addOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
activity.addOnUserLeaveHintListener(onUserLeaveHintCallback)
}
// @TODO convert to lambda when ReactExoplayerView migrated
return object : Runnable {
override fun run() {
context.findActivity().removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChanged)
context.findActivity().removeOnUserLeaveHintListener(onUserLeaveHintCallback)
}
}
}
@JvmStatic
fun enterPictureInPictureMode(context: ThemedReactContext, pictureInPictureParams: PictureInPictureParams?) {
if (!isSupportPictureInPicture(context)) return
if (isSupportPictureInPictureAction() && pictureInPictureParams != null) {
try {
context.findActivity().enterPictureInPictureMode(pictureInPictureParams)
} catch (e: IllegalStateException) {
DebugLog.e(TAG, e.toString())
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
@Suppress("DEPRECATION")
context.findActivity().enterPictureInPictureMode()
} catch (e: IllegalStateException) {
DebugLog.e(TAG, e.toString())
}
}
}
@JvmStatic
fun applyPlayingStatus(
context: ThemedReactContext,
pipParamsBuilder: PictureInPictureParams.Builder,
receiver: PictureInPictureReceiver,
isPaused: Boolean
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val actions = getPictureInPictureActions(context, isPaused, receiver)
pipParamsBuilder.setActions(actions)
updatePictureInPictureActions(context, pipParamsBuilder.build())
}
@JvmStatic
fun applyAutoEnterEnabled(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder, autoEnterEnabled: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
pipParamsBuilder.setAutoEnterEnabled(autoEnterEnabled)
updatePictureInPictureActions(context, pipParamsBuilder.build())
}
@JvmStatic
fun applySourceRectHint(context: ThemedReactContext, pipParamsBuilder: PictureInPictureParams.Builder, playerView: ExoPlayerView) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
pipParamsBuilder.setSourceRectHint(calcRectHint(playerView))
updatePictureInPictureActions(context, pipParamsBuilder.build())
}
private fun updatePictureInPictureActions(context: ThemedReactContext, pipParams: PictureInPictureParams) {
if (!isSupportPictureInPictureAction()) return
if (!isSupportPictureInPicture(context)) return
try {
context.findActivity().setPictureInPictureParams(pipParams)
} catch (e: IllegalStateException) {
DebugLog.e(TAG, e.toString())
}
}
@JvmStatic
@RequiresApi(Build.VERSION_CODES.O)
fun getPictureInPictureActions(context: ThemedReactContext, isPaused: Boolean, receiver: PictureInPictureReceiver): ArrayList<RemoteAction> {
val intent = receiver.getPipActionIntent(isPaused)
val resource =
if (isPaused) androidx.media3.ui.R.drawable.exo_icon_play else androidx.media3.ui.R.drawable.exo_icon_pause
val icon = Icon.createWithResource(context, resource)
val title = if (isPaused) "play" else "pause"
return arrayListOf(RemoteAction(icon, title, title, intent))
}
@JvmStatic
@RequiresApi(Build.VERSION_CODES.O)
private fun calcRectHint(playerView: ExoPlayerView): Rect {
val hint = Rect()
playerView.surfaceView?.getGlobalVisibleRect(hint)
val location = IntArray(2)
playerView.surfaceView?.getLocationOnScreen(location)
val height = hint.bottom - hint.top
hint.top = location[1]
hint.bottom = hint.top + height
return hint
}
@JvmStatic
@RequiresApi(Build.VERSION_CODES.O)
fun calcPictureInPictureAspectRatio(player: ExoPlayer): Rational {
var aspectRatio = Rational(player.videoSize.width, player.videoSize.height)
// AspectRatio for the activity in picture-in-picture, must be between 2.39:1 and 1:2.39 (inclusive).
// https://developer.android.com/reference/android/app/PictureInPictureParams.Builder#setAspectRatio(android.util.Rational)
val maximumRatio = Rational(239, 100)
val minimumRatio = Rational(100, 239)
if (aspectRatio.toFloat() > maximumRatio.toFloat()) {
aspectRatio = maximumRatio
} else if (aspectRatio.toFloat() < minimumRatio.toFloat()) {
aspectRatio = minimumRatio
}
return aspectRatio
}
private fun isSupportPictureInPicture(context: ThemedReactContext): Boolean =
checkIsApiSupport() && checkIsSystemSupportPIP(context) && checkIsUserAllowPIP(context)
private fun isSupportPictureInPictureAction(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N)
private fun checkIsApiSupport(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
@RequiresApi(Build.VERSION_CODES.N)
private fun checkIsSystemSupportPIP(context: ThemedReactContext): Boolean {
val activity = context.findActivity() ?: return false
val activityInfo = activity.packageManager.getActivityInfo(activity.componentName, PackageManager.GET_META_DATA)
// detect current activity's android:supportsPictureInPicture value defined within AndroidManifest.xml
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/content/pm/ActivityInfo.java;l=1090-1093;drc=7651f0a4c059a98f32b0ba30cd64500bf135385f
val isActivitySupportPip = activityInfo.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE != 0
// PIP might be disabled on devices that have low RAM.
val isPipAvailable = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
return isActivitySupportPip && isPipAvailable
}
private fun checkIsUserAllowPIP(context: ThemedReactContext): Boolean {
val activity = context.currentActivity ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@SuppressLint("InlinedApi")
val result = AppOpsManagerCompat.noteOpNoThrow(
activity,
AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
Process.myUid(),
activity.packageName
)
AppOpsManager.MODE_ALLOWED == result
} else {
Build.VERSION.SDK_INT < Build.VERSION_CODES.O && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
}
}
}

View File

@ -10,6 +10,8 @@ import static androidx.media3.common.C.TIME_END_OF_SOURCE;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.ActivityManager; import android.app.ActivityManager;
import android.app.PictureInPictureParams;
import android.app.RemoteAction;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
@ -24,6 +26,7 @@ import android.os.Looper;
import android.os.Message; import android.os.Message;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.CaptioningManager; import android.view.accessibility.CaptioningManager;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageButton; import android.widget.ImageButton;
@ -122,6 +125,7 @@ import com.brentvatne.react.R;
import com.brentvatne.react.ReactNativeVideoManager; import com.brentvatne.react.ReactNativeVideoManager;
import com.brentvatne.receiver.AudioBecomingNoisyReceiver; import com.brentvatne.receiver.AudioBecomingNoisyReceiver;
import com.brentvatne.receiver.BecomingNoisyListener; import com.brentvatne.receiver.BecomingNoisyListener;
import com.brentvatne.receiver.PictureInPictureReceiver;
import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.UiThreadUtil;
@ -202,6 +206,8 @@ public class ReactExoplayerView extends FrameLayout implements
private boolean isPaused; private boolean isPaused;
private boolean isBuffering; private boolean isBuffering;
private boolean muted = false; private boolean muted = false;
public boolean enterPictureInPictureOnLeave = false;
private PictureInPictureParams.Builder pictureInPictureParamsBuilder;
private boolean hasAudioFocus = false; private boolean hasAudioFocus = false;
private float rate = 1f; private float rate = 1f;
private AudioOutput audioOutput = AudioOutput.SPEAKER; private AudioOutput audioOutput = AudioOutput.SPEAKER;
@ -212,8 +218,10 @@ public class ReactExoplayerView extends FrameLayout implements
private boolean selectTrackWhenReady = false; private boolean selectTrackWhenReady = false;
private final Handler mainHandler; private final Handler mainHandler;
private Runnable mainRunnable; private Runnable mainRunnable;
private Runnable pipListenerUnsubscribe;
private boolean useCache = false; private boolean useCache = false;
private ControlsConfig controlsConfig = new ControlsConfig(); private ControlsConfig controlsConfig = new ControlsConfig();
private ArrayList<Integer> rootViewChildrenOriginalVisibility = new ArrayList<Integer>();
/* /*
* When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK
@ -237,7 +245,7 @@ public class ReactExoplayerView extends FrameLayout implements
private boolean disableDisconnectError; private boolean disableDisconnectError;
private boolean preventsDisplaySleepDuringVideoPlayback = true; private boolean preventsDisplaySleepDuringVideoPlayback = true;
private float mProgressUpdateInterval = 250.0f; private float mProgressUpdateInterval = 250.0f;
private boolean playInBackground = false; protected boolean playInBackground = false;
private boolean mReportBandwidth = false; private boolean mReportBandwidth = false;
private boolean controls; private boolean controls;
@ -248,6 +256,7 @@ public class ReactExoplayerView extends FrameLayout implements
private final ThemedReactContext themedReactContext; private final ThemedReactContext themedReactContext;
private final AudioManager audioManager; private final AudioManager audioManager;
private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver;
private final PictureInPictureReceiver pictureInPictureReceiver;
private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener;
// store last progress event values to avoid sending unnecessary messages // store last progress event values to avoid sending unnecessary messages
@ -314,13 +323,19 @@ public class ReactExoplayerView extends FrameLayout implements
this.eventEmitter = new VideoEventEmitter(); this.eventEmitter = new VideoEventEmitter();
this.config = config; this.config = config;
this.bandwidthMeter = config.getBandwidthMeter(); this.bandwidthMeter = config.getBandwidthMeter();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pictureInPictureParamsBuilder == null) {
this.pictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
}
mainHandler = new Handler(); mainHandler = new Handler();
createViews(); createViews();
audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
themedReactContext.addLifecycleEventListener(this); themedReactContext.addLifecycleEventListener(this);
pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(context, this);
audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext);
audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext); audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext);
pictureInPictureReceiver = new PictureInPictureReceiver(this, themedReactContext);
} }
private boolean isPlayingAd() { private boolean isPlayingAd() {
@ -336,12 +351,21 @@ public class ReactExoplayerView extends FrameLayout implements
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT); LayoutParams.MATCH_PARENT);
exoPlayerView = new ExoPlayerView(getContext()); exoPlayerView = new ExoPlayerView(getContext());
exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) ->
PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)
);
exoPlayerView.setLayoutParams(layoutParams); exoPlayerView.setLayoutParams(layoutParams);
addView(exoPlayerView, 0, layoutParams); addView(exoPlayerView, 0, layoutParams);
exoPlayerView.setFocusable(this.focusable); exoPlayerView.setFocusable(this.focusable);
} }
@Override
protected void onDetachedFromWindow() {
cleanupPlaybackService();
super.onDetachedFromWindow();
}
// LifecycleEventListener implementation // LifecycleEventListener implementation
@Override @Override
public void onHostResume() { public void onHostResume() {
@ -354,7 +378,10 @@ public class ReactExoplayerView extends FrameLayout implements
@Override @Override
public void onHostPause() { public void onHostPause() {
isInBackground = true; isInBackground = true;
if (playInBackground) { Activity activity = themedReactContext.getCurrentActivity();
boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode();
boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode();
if (playInBackground || isInPictureInPicture || isInMultiWindowMode) {
return; return;
} }
setPlayWhenReady(false); setPlayWhenReady(false);
@ -365,12 +392,6 @@ public class ReactExoplayerView extends FrameLayout implements
cleanUpResources(); cleanUpResources();
} }
@Override
protected void onDetachedFromWindow() {
cleanupPlaybackService();
super.onDetachedFromWindow();
}
public void cleanUpResources() { public void cleanUpResources() {
stopPlayback(); stopPlayback();
themedReactContext.removeLifecycleEventListener(this); themedReactContext.removeLifecycleEventListener(this);
@ -850,6 +871,7 @@ public class ReactExoplayerView extends FrameLayout implements
exoPlayerView.setPlayer(player); exoPlayerView.setPlayer(player);
audioBecomingNoisyReceiver.setListener(self); audioBecomingNoisyReceiver.setListener(self);
pictureInPictureReceiver.setListener();
bandwidthMeter.addEventListener(new Handler(), self); bandwidthMeter.addEventListener(new Handler(), self);
setPlayWhenReady(!isPaused); setPlayWhenReady(!isPaused);
playerNeedsSource = true; playerNeedsSource = true;
@ -1274,6 +1296,10 @@ public class ReactExoplayerView extends FrameLayout implements
updateResumePosition(); updateResumePosition();
player.release(); player.release();
player.removeListener(this); player.removeListener(this);
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, false);
if (pipListenerUnsubscribe != null) {
new Handler().post(pipListenerUnsubscribe);
}
trackSelector = null; trackSelector = null;
ReactNativeVideoManager.Companion.getInstance().onInstanceRemoved(instanceId, player); ReactNativeVideoManager.Companion.getInstance().onInstanceRemoved(instanceId, player);
@ -1286,6 +1312,7 @@ public class ReactExoplayerView extends FrameLayout implements
} }
progressHandler.removeMessages(SHOW_PROGRESS); progressHandler.removeMessages(SHOW_PROGRESS);
audioBecomingNoisyReceiver.removeListener(); audioBecomingNoisyReceiver.removeListener();
pictureInPictureReceiver.removeListener();
bandwidthMeter.removeEventListener(this); bandwidthMeter.removeEventListener(this);
if (mainHandler != null && mainRunnable != null) { if (mainHandler != null && mainRunnable != null) {
@ -1804,7 +1831,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (isPlaying && isSeeking) { if (isPlaying && isSeeking) {
eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition);
} }
PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying);
eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking);
if (isPlaying) { if (isPlaying) {
@ -2218,6 +2245,84 @@ public class ReactExoplayerView extends FrameLayout implements
} }
} }
public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) {
this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && enterPictureInPictureOnLeave;
PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave);
}
protected void setIsInPictureInPicture(boolean isInPictureInPicture) {
eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture);
if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) {
if (isInPictureInPicture) fullScreenPlayerView.hideWithoutPlayer();
return;
}
Activity currentActivity = themedReactContext.getCurrentActivity();
if (currentActivity == null) return;
View decorView = currentActivity.getWindow().getDecorView();
ViewGroup rootView = decorView.findViewById(android.R.id.content);
LayoutParams layoutParams = new LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
if (isInPictureInPicture) {
ViewGroup parent = (ViewGroup)exoPlayerView.getParent();
if (parent != null) {
parent.removeView(exoPlayerView);
}
for (int i = 0; i < rootView.getChildCount(); i++) {
if (rootView.getChildAt(i) != exoPlayerView) {
rootViewChildrenOriginalVisibility.add(rootView.getChildAt(i).getVisibility());
rootView.getChildAt(i).setVisibility(View.GONE);
}
}
rootView.addView(exoPlayerView, layoutParams);
} else {
rootView.removeView(exoPlayerView);
if (!rootViewChildrenOriginalVisibility.isEmpty()) {
for (int i = 0; i < rootView.getChildCount(); i++) {
rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i));
}
addView(exoPlayerView, 0, layoutParams);
}
}
}
public void enterPictureInPictureMode() {
PictureInPictureParams _pipParams = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ArrayList<RemoteAction> actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, isPaused, pictureInPictureReceiver);
pictureInPictureParamsBuilder.setActions(actions);
_pipParams = pictureInPictureParamsBuilder
.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player))
.build();
}
PictureInPictureUtil.enterPictureInPictureMode(themedReactContext, _pipParams);
}
public void exitPictureInPictureMode() {
Activity currentActivity = themedReactContext.getCurrentActivity();
if (currentActivity == null) return;
View decorView = currentActivity.getWindow().getDecorView();
ViewGroup rootView = decorView.findViewById(android.R.id.content);
if (!rootViewChildrenOriginalVisibility.isEmpty()) {
if (exoPlayerView.getParent().equals(rootView)) rootView.removeView(exoPlayerView);
for (int i = 0; i < rootView.getChildCount(); i++) {
rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i));
}
rootViewChildrenOriginalVisibility.clear();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && currentActivity.isInPictureInPictureMode()) {
currentActivity.moveTaskToBack(false);
}
}
public void setMutedModifier(boolean muted) { public void setMutedModifier(boolean muted) {
this.muted = muted; this.muted = muted;
if (player != null) { if (player != null) {

View File

@ -32,6 +32,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type" private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type"
private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value" private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value"
private const val PROP_PAUSED = "paused" private const val PROP_PAUSED = "paused"
private const val PROP_ENTER_PICTURE_IN_PICTURE_ON_LEAVE = "enterPictureInPictureOnLeave"
private const val PROP_MUTED = "muted" private const val PROP_MUTED = "muted"
private const val PROP_AUDIO_OUTPUT = "audioOutput" private const val PROP_AUDIO_OUTPUT = "audioOutput"
private const val PROP_VOLUME = "volume" private const val PROP_VOLUME = "volume"
@ -69,6 +70,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
override fun onDropViewInstance(view: ReactExoplayerView) { override fun onDropViewInstance(view: ReactExoplayerView) {
view.cleanUpResources() view.cleanUpResources()
view.exitPictureInPictureMode()
ReactNativeVideoManager.getInstance().unregisterView(this) ReactNativeVideoManager.getInstance().unregisterView(this)
} }
@ -154,6 +156,11 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
videoView.setMutedModifier(muted) videoView.setMutedModifier(muted)
} }
@ReactProp(name = PROP_ENTER_PICTURE_IN_PICTURE_ON_LEAVE, defaultBoolean = false)
fun setEnterPictureInPictureOnLeave(videoView: ReactExoplayerView, enterPictureInPictureOnLeave: Boolean) {
videoView.setEnterPictureInPictureOnLeave(enterPictureInPictureOnLeave)
}
@ReactProp(name = PROP_AUDIO_OUTPUT) @ReactProp(name = PROP_AUDIO_OUTPUT)
fun setAudioOutput(videoView: ReactExoplayerView, audioOutput: String) { fun setAudioOutput(videoView: ReactExoplayerView, audioOutput: String) {
videoView.setAudioOutput(AudioOutput.get(audioOutput)) videoView.setAudioOutput(AudioOutput.get(audioOutput))

View File

@ -65,6 +65,20 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
} }
} }
@ReactMethod
fun enterPictureInPictureCmd(reactTag: Int) {
performOnPlayerView(reactTag) {
it?.enterPictureInPictureMode()
}
}
@ReactMethod
fun exitPictureInPictureCmd(reactTag: Int) {
performOnPlayerView(reactTag) {
it?.exitPictureInPictureMode()
}
}
@ReactMethod @ReactMethod
fun setSourceCmd(reactTag: Int, source: ReadableMap?) { fun setSourceCmd(reactTag: Int, source: ReadableMap?) {
performOnPlayerView(reactTag) { performOnPlayerView(reactTag) {

View File

@ -0,0 +1,72 @@
package com.brentvatne.receiver
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.core.content.ContextCompat
import com.brentvatne.exoplayer.ReactExoplayerView
import com.facebook.react.uimanager.ThemedReactContext
class PictureInPictureReceiver(private val view: ReactExoplayerView, private val context: ThemedReactContext) : BroadcastReceiver() {
companion object {
const val ACTION_MEDIA_CONTROL = "rnv_media_control"
const val EXTRA_CONTROL_TYPE = "rnv_control_type"
// The request code for play action PendingIntent.
const val REQUEST_PLAY = 1
// The request code for pause action PendingIntent.
const val REQUEST_PAUSE = 2
// The intent extra value for play action.
const val CONTROL_TYPE_PLAY = 1
// The intent extra value for pause action.
const val CONTROL_TYPE_PAUSE = 2
}
override fun onReceive(context: Context?, intent: Intent?) {
intent ?: return
if (intent.action == ACTION_MEDIA_CONTROL) {
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_TYPE_PLAY -> view.setPausedModifier(false)
CONTROL_TYPE_PAUSE -> view.setPausedModifier(true)
}
}
}
fun setListener() {
ContextCompat.registerReceiver(context, this, IntentFilter(ACTION_MEDIA_CONTROL), ContextCompat.RECEIVER_NOT_EXPORTED)
}
fun removeListener() {
try {
context.unregisterReceiver(this)
} catch (e: Exception) {
// ignore if already unregistered
}
}
fun getPipActionIntent(isPaused: Boolean): PendingIntent {
val requestCode = if (isPaused) REQUEST_PLAY else REQUEST_PAUSE
val controlType = if (isPaused) CONTROL_TYPE_PLAY else CONTROL_TYPE_PAUSE
val flag =
if (Build.VERSION.SDK_INT >=
Build.VERSION_CODES.M
) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(
EXTRA_CONTROL_TYPE,
controlType
)
intent.setPackage(context.packageName)
return PendingIntent.getBroadcast(context, requestCode, intent, flag)
}
}

View File

@ -317,7 +317,7 @@ Example:
### `onPictureInPictureStatusChanged` ### `onPictureInPictureStatusChanged`
<PlatformsList types={['iOS']} /> <PlatformsList types={['iOS', 'Android']} />
Callback function that is called when picture in picture becomes active or inactive. Callback function that is called when picture in picture becomes active or inactive.

View File

@ -78,6 +78,56 @@ Future:
- Will support more formats in the future through options - Will support more formats in the future through options
- Will support custom directory and file name through options - Will support custom directory and file name through options
### `enterPictureInPicture`
<PlatformsList types={['Android', 'iOS']} />
`enterPictureInPicture()`
To use this feature on Android with Expo, you must set 'enableAndroidPictureInPicture' true within expo plugin config (app.json)
```json
"plugins": [
[
"react-native-video",
{
"enableAndroidPictureInPicture": true,
}
]
]
```
To use this feature on Android with Bare React Native, you must:
- [Declare PiP support](https://developer.android.com/develop/ui/views/picture-in-picture#declaring) in your AndroidManifest.xml
- setting `android:supportsPictureInPicture` to `true`
```xml
<activity
android:name=".MainActivity"
...
android:supportsPictureInPicture="true">
```
NOTE: Foreground picture in picture is not supported on Android due to limitations of react native (Single Activity App). So, If you call `enterPictureInPicture`, application will switch to background on Android.
NOTE: Video ads cannot start when you are using the PIP on iOS (more info available at [Google IMA SDK Docs](https://developers.google.com/interactive-media-ads/docs/sdks/ios/client-side/picture_in_picture?hl=en#starting_ads)). If you are using custom controls, you must hide your PIP button when you receive the `STARTED` event from `onReceiveAdEvent` and show it again when you receive the `ALL_ADS_COMPLETED` event.
### `exitPictureInPicture`
<PlatformsList types={['Android', 'iOS']} />
`exitPictureInPicture()`
Exits the active picture in picture; if it is not active, the function call is ignored.
### `restoreUserInterfaceForPictureInPictureStopCompleted`
<PlatformsList types={['iOS']} />
`restoreUserInterfaceForPictureInPictureStopCompleted(restored)`
This function corresponds to the completion handler in Apple's [restoreUserInterfaceForPictureInPictureStop](https://developer.apple.com/documentation/avkit/avpictureinpicturecontrollerdelegate/1614703-pictureinpicturecontroller?language=objc). IMPORTANT: This function must be called after `onRestoreUserInterfaceForPictureInPictureStop` is called.
### `seek` ### `seek`
<PlatformsList types={['All']} /> <PlatformsList types={['All']} />

View File

@ -252,6 +252,42 @@ To setup DRM please follow [this guide](/component/drm)
> ⚠️ DRM is not supported on visionOS yet > ⚠️ DRM is not supported on visionOS yet
### `enterPictureInPictureOnLeave`
<PlatformsList types={['iOS', 'Android']} />
Determine whether to play media as a picture in picture when the user goes to the background.
- **false (default)** - Don't not play as picture in picture
- **true** - Play the media as picture in picture
To use this feature on Android with Expo, you must set 'enableAndroidPictureInPicture' true within expo plugin config (app.json)
```json
"plugins": [
[
"react-native-video",
{
"enableAndroidPictureInPicture": true,
}
]
]
```
To use this feature on Android with Bare React Native, you must:
- [Declare PiP support](https://developer.android.com/develop/ui/views/picture-in-picture#declaring) in your AndroidManifest.xml
- setting `android:supportsPictureInPicture` to `true`
```xml
<activity
android:name=".MainActivity"
...
android:supportsPictureInPicture="true">
```
NOTE: Video ads cannot start when you are using the PIP on iOS (more info available at [Google IMA SDK Docs](https://developers.google.com/interactive-media-ads/docs/sdks/ios/client-side/picture_in_picture?hl=en#starting_ads)). If you are using custom controls, you must hide your PIP button when you receive the `STARTED` event from `onReceiveAdEvent` and show it again when you receive the `ALL_ADS_COMPLETED` event.
### `filter` ### `filter`
<PlatformsList types={['iOS', 'visionOS']} /> <PlatformsList types={['iOS', 'visionOS']} />
@ -425,17 +461,6 @@ Controls whether the media is paused
- **false (default)** - Don't pause the media - **false (default)** - Don't pause the media
- **true** - Pause the media - **true** - Pause the media
### `pictureInPicture`
<PlatformsList types={['iOS']} />
Determine whether the media should played as picture in picture.
- **false (default)** - Don't not play as picture in picture
- **true** - Play the media as picture in picture
NOTE: Video ads cannot start when you are using the PIP on iOS (more info available at [Google IMA SDK Docs](https://developers.google.com/interactive-media-ads/docs/sdks/ios/client-side/picture_in_picture?hl=en#starting_ads)). If you are using custom controls, you must hide your PIP button when you receive the `STARTED` event from `onReceiveAdEvent` and show it again when you receive the `ALL_ADS_COMPLETED` event.
### `playInBackground` ### `playInBackground`
<PlatformsList types={['Android', 'iOS', 'visionOS']} /> <PlatformsList types={['Android', 'iOS', 'visionOS']} />

View File

@ -13,7 +13,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap
- Subtitles (embeded or side loaded) - Subtitles (embeded or side loaded)
- DRM support - DRM support
- Client side Ads insertion (via google IMA) - Client side Ads insertion (via google IMA)
- Pip (ios) - Pip
- Embedded playback controls - Embedded playback controls
- And much more - And much more

View File

@ -38,3 +38,4 @@ It's useful when you are using `expo` managed workflow (expo prebuild) as it wil
| enableADSExtension | boolean | false | Add required changes to use ads extension for video player | | enableADSExtension | boolean | false | Add required changes to use ads extension for video player |
| enableCacheExtension | boolean | false | Add required changes to use cache extension for video player on iOS | | enableCacheExtension | boolean | false | Add required changes to use cache extension for video player on iOS |
| androidExtensions | object | {} | You can enable/disable extensions as per your requirement - this allow to reduce library size on android | | androidExtensions | object | {} | You can enable/disable extensions as per your requirement - this allow to reduce library size on android |
| enableAndroidPictureInPicture | boolean | false | Apply configs to be able to use Picture-in-picture on android |

View File

@ -4,16 +4,16 @@
class RCTIMAAdsManager: NSObject, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMALinkOpenerDelegate { class RCTIMAAdsManager: NSObject, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMALinkOpenerDelegate {
private weak var _video: RCTVideo? private weak var _video: RCTVideo?
private var _pipEnabled: () -> Bool private var _isPictureInPictureActive: () -> Bool
/* Entry point for the SDK. Used to make ad requests. */ /* Entry point for the SDK. Used to make ad requests. */
private var adsLoader: IMAAdsLoader! private var adsLoader: IMAAdsLoader!
/* Main point of interaction with the SDK. Created by the SDK as the result of an ad request. */ /* Main point of interaction with the SDK. Created by the SDK as the result of an ad request. */
private var adsManager: IMAAdsManager! private var adsManager: IMAAdsManager!
init(video: RCTVideo!, pipEnabled: @escaping () -> Bool) { init(video: RCTVideo!, isPictureInPictureActive: @escaping () -> Bool) {
_video = video _video = video
_pipEnabled = pipEnabled _isPictureInPictureActive = isPictureInPictureActive
super.init() super.init()
} }
@ -103,7 +103,7 @@
} }
// Play each ad once it has been loaded // Play each ad once it has been loaded
if event.type == IMAAdEventType.LOADED { if event.type == IMAAdEventType.LOADED {
if _pipEnabled() { if _isPictureInPictureActive() {
return return
} }
adsManager.start() adsManager.start()

View File

@ -11,7 +11,9 @@ import React
private var _onPictureInPictureExit: (() -> Void)? private var _onPictureInPictureExit: (() -> Void)?
private var _onRestoreUserInterfaceForPictureInPictureStop: (() -> Void)? private var _onRestoreUserInterfaceForPictureInPictureStop: (() -> Void)?
private var _restoreUserInterfaceForPIPStopCompletionHandler: ((Bool) -> Void)? private var _restoreUserInterfaceForPIPStopCompletionHandler: ((Bool) -> Void)?
private var _isActive = false private var _isPictureInPictureActive: Bool {
return _pipController?.isPictureInPictureActive ?? false
}
init( init(
_ onPictureInPictureEnter: (() -> Void)? = nil, _ onPictureInPictureEnter: (() -> Void)? = nil,
@ -67,20 +69,22 @@ import React
_pipController = nil _pipController = nil
} }
func setPictureInPicture(_ isActive: Bool) { func enterPictureInPicture() {
if _isActive == isActive {
return
}
_isActive = isActive
guard let _pipController else { return } guard let _pipController else { return }
if !_isPictureInPictureActive {
_pipController.startPictureInPicture()
}
}
if _isActive && !_pipController.isPictureInPictureActive { func exitPictureInPicture() {
DispatchQueue.main.async { guard let _pipController else { return }
_pipController.startPictureInPicture() if _isPictureInPictureActive {
} let state = UIApplication.shared.applicationState
} else if !_isActive && _pipController.isPictureInPictureActive { if state == .background || state == .inactive {
DispatchQueue.main.async { deinitPipController()
_onPictureInPictureExit?()
_onRestoreUserInterfaceForPictureInPictureStop?()
} else {
_pipController.stopPictureInPicture() _pipController.stopPictureInPicture()
} }
} }

View File

@ -63,10 +63,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
private var _showNotificationControls = false private var _showNotificationControls = false
// Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed // Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed
private var _lastBitrate = -2.0 private var _lastBitrate = -2.0
private var _pictureInPictureEnabled = false { private var _enterPictureInPictureOnLeave = false {
didSet { didSet {
#if os(iOS) #if os(iOS)
if _pictureInPictureEnabled { if isPictureInPictureActive() { return }
if _enterPictureInPictureOnLeave {
initPictureinPicture() initPictureinPicture()
_playerViewController?.allowsPictureInPicturePlayback = true _playerViewController?.allowsPictureInPicturePlayback = true
} else { } else {
@ -166,11 +167,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
onRestoreUserInterfaceForPictureInPictureStop?([:]) onRestoreUserInterfaceForPictureInPictureStop?([:])
} }
func isPipEnabled() -> Bool { func isPictureInPictureActive() -> Bool {
return _pictureInPictureEnabled
}
func isPipActive() -> Bool {
#if os(iOS) #if os(iOS)
return _pip?._pipController?.isPictureInPictureActive == true return _pip?._pipController?.isPictureInPictureActive == true
#else #else
@ -180,15 +177,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
func initPictureinPicture() { func initPictureinPicture() {
#if os(iOS) #if os(iOS)
_pip = RCTPictureInPicture({ [weak self] in if _pip == nil {
self?._onPictureInPictureEnter() _pip = RCTPictureInPicture({ [weak self] in
}, { [weak self] in self?._onPictureInPictureEnter()
self?._onPictureInPictureExit() }, { [weak self] in
}, { [weak self] in self?._onPictureInPictureExit()
self?.onRestoreUserInterfaceForPictureInPictureStop?([:]) }, { [weak self] in
}) self?.onRestoreUserInterfaceForPictureInPictureStop?([:])
})
}
if _playerLayer != nil && !_controls { if _playerLayer != nil && !_controls && _pip?._pipController == nil {
_pip?.setupPipController(_playerLayer) _pip?.setupPipController(_playerLayer)
} }
#else #else
@ -200,17 +199,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
ReactNativeVideoManager.shared.registerView(newInstance: self) ReactNativeVideoManager.shared.registerView(newInstance: self)
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
_imaAdsManager = RCTIMAAdsManager(video: self, pipEnabled: isPipEnabled) _imaAdsManager = RCTIMAAdsManager(video: self, isPictureInPictureActive: isPictureInPictureActive)
#endif #endif
_eventDispatcher = eventDispatcher _eventDispatcher = eventDispatcher
#if os(iOS) #if os(iOS)
if _pictureInPictureEnabled { if _enterPictureInPictureOnLeave {
initPictureinPicture() initPictureinPicture()
_playerViewController?.allowsPictureInPicturePlayback = true
} else {
_playerViewController?.allowsPictureInPicturePlayback = false
} }
#endif #endif
@ -242,6 +238,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(screenWillLock),
name: UIApplication.protectedDataWillBecomeUnavailableNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(screenDidUnlock),
name: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil
)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(audioRouteChanged(notification:)), selector: #selector(audioRouteChanged(notification:)),
@ -257,7 +267,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder) super.init(coder: aDecoder)
#if USE_GOOGLE_IMA #if USE_GOOGLE_IMA
_imaAdsManager = RCTIMAAdsManager(video: self, pipEnabled: isPipEnabled) _imaAdsManager = RCTIMAAdsManager(video: self, isPictureInPictureActive: isPictureInPictureActive)
#endif #endif
} }
@ -313,8 +323,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc @objc
func applicationDidEnterBackground(notification _: NSNotification!) { func applicationDidEnterBackground(notification _: NSNotification!) {
if !_paused && isPictureInPictureActive() {
_player?.play()
_player?.rate = _rate
}
let isExternalPlaybackActive = getIsExternalPlaybackActive() let isExternalPlaybackActive = getIsExternalPlaybackActive()
if !_playInBackground || isExternalPlaybackActive || isPipActive() { return } if !_playInBackground || isExternalPlaybackActive || isPictureInPictureActive() { return }
// Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html // Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html
_playerLayer?.player = nil _playerLayer?.player = nil
_playerViewController?.player = nil _playerViewController?.player = nil
@ -327,6 +341,24 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_playerViewController?.player = _player _playerViewController?.player = _player
} }
@objc
func screenWillLock() {
let isActiveBackgroundPip = isPictureInPictureActive() && UIApplication.shared.applicationState != .active
if _playInBackground || !_isPlaying || !isActiveBackgroundPip { return }
_player?.pause()
_player?.rate = 0.0
}
@objc
func screenDidUnlock() {
let isActiveBackgroundPip = isPictureInPictureActive() && UIApplication.shared.applicationState != .active
if _paused || !isActiveBackgroundPip { return }
_player?.play()
_player?.rate = _rate
}
// MARK: - Audio events // MARK: - Audio events
@objc @objc
@ -710,19 +742,16 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
@objc @objc
func setPictureInPicture(_ pictureInPicture: Bool) { func setEnterPictureInPictureOnLeave(_ enterPictureInPictureOnLeave: Bool) {
#if os(iOS) #if os(iOS)
let audioSession = AVAudioSession.sharedInstance() let audioSession = AVAudioSession.sharedInstance()
do { do {
try audioSession.setCategory(.playback) try audioSession.setCategory(.playback)
try audioSession.setActive(true, options: []) try audioSession.setActive(true, options: [])
} catch {} } catch {}
if pictureInPicture { if _enterPictureInPictureOnLeave != enterPictureInPictureOnLeave {
_pictureInPictureEnabled = true _enterPictureInPictureOnLeave = enterPictureInPictureOnLeave
} else {
_pictureInPictureEnabled = false
} }
_pip?.setPictureInPicture(pictureInPicture)
#endif #endif
} }
@ -1093,8 +1122,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
viewController.view.frame = self.bounds viewController.view.frame = self.bounds
viewController.player = player viewController.player = player
if #available(tvOS 14.0, *) { if #available(tvOS 14.0, *) {
viewController.allowsPictureInPicturePlayback = _pictureInPictureEnabled viewController.allowsPictureInPicturePlayback = _enterPictureInPictureOnLeave
} }
#if os(iOS)
viewController.allowsPictureInPicturePlayback = _enterPictureInPictureOnLeave
#endif
return viewController return viewController
} }
@ -1114,7 +1146,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
self.layer.needsDisplayOnBoundsChange = true self.layer.needsDisplayOnBoundsChange = true
#if os(iOS) #if os(iOS)
if _pictureInPictureEnabled { if _enterPictureInPictureOnLeave {
_pip?.setupPipController(_playerLayer) _pip?.setupPipController(_playerLayer)
} }
#endif #endif
@ -1685,6 +1717,31 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
} }
} }
@objc
func enterPictureInPicture() {
if _pip?._pipController == nil {
initPictureinPicture()
_playerViewController?.allowsPictureInPicturePlayback = true
}
_pip?.enterPictureInPicture()
}
@objc
func exitPictureInPicture() {
guard isPictureInPictureActive() else { return }
_pip?.exitPictureInPicture()
#if os(iOS)
if _enterPictureInPictureOnLeave {
initPictureinPicture()
_playerViewController?.allowsPictureInPicturePlayback = true
} else {
_pip?.deinitPipController()
_playerViewController?.allowsPictureInPicturePlayback = false
}
#endif
}
// Workaround for #3418 - https://github.com/TheWidlarzGroup/react-native-video/issues/3418#issuecomment-2043508862 // Workaround for #3418 - https://github.com/TheWidlarzGroup/react-native-video/issues/3418#issuecomment-2043508862
@objc @objc
func setOnClick(_: Any) {} func setOnClick(_: Any) {}

View File

@ -23,7 +23,7 @@ RCT_EXPORT_VIEW_PROPERTY(playInBackground, BOOL);
RCT_EXPORT_VIEW_PROPERTY(preventsDisplaySleepDuringVideoPlayback, BOOL); RCT_EXPORT_VIEW_PROPERTY(preventsDisplaySleepDuringVideoPlayback, BOOL);
RCT_EXPORT_VIEW_PROPERTY(preferredForwardBufferDuration, float); RCT_EXPORT_VIEW_PROPERTY(preferredForwardBufferDuration, float);
RCT_EXPORT_VIEW_PROPERTY(playWhenInactive, BOOL); RCT_EXPORT_VIEW_PROPERTY(playWhenInactive, BOOL);
RCT_EXPORT_VIEW_PROPERTY(pictureInPicture, BOOL); RCT_EXPORT_VIEW_PROPERTY(enterPictureInPictureOnLeave, BOOL);
RCT_EXPORT_VIEW_PROPERTY(ignoreSilentSwitch, NSString); RCT_EXPORT_VIEW_PROPERTY(ignoreSilentSwitch, NSString);
RCT_EXPORT_VIEW_PROPERTY(mixWithOthers, NSString); RCT_EXPORT_VIEW_PROPERTY(mixWithOthers, NSString);
RCT_EXPORT_VIEW_PROPERTY(rate, float); RCT_EXPORT_VIEW_PROPERTY(rate, float);
@ -73,6 +73,8 @@ RCT_EXTERN_METHOD(setLicenseResultErrorCmd : (nonnull NSNumber*)reactTag error :
RCT_EXTERN_METHOD(setPlayerPauseStateCmd : (nonnull NSNumber*)reactTag paused : (nonnull BOOL)paused) RCT_EXTERN_METHOD(setPlayerPauseStateCmd : (nonnull NSNumber*)reactTag paused : (nonnull BOOL)paused)
RCT_EXTERN_METHOD(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume) RCT_EXTERN_METHOD(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume)
RCT_EXTERN_METHOD(setFullScreenCmd : (nonnull NSNumber*)reactTag fullscreen : (nonnull BOOL)fullScreen) RCT_EXTERN_METHOD(setFullScreenCmd : (nonnull NSNumber*)reactTag fullscreen : (nonnull BOOL)fullScreen)
RCT_EXTERN_METHOD(enterPictureInPictureCmd : (nonnull NSNumber*)reactTag)
RCT_EXTERN_METHOD(exitPictureInPictureCmd : (nonnull NSNumber*)reactTag)
RCT_EXTERN_METHOD(setSourceCmd : (nonnull NSNumber*)reactTag source : (NSDictionary*)source) RCT_EXTERN_METHOD(setSourceCmd : (nonnull NSNumber*)reactTag source : (NSDictionary*)source)
RCT_EXTERN_METHOD(save RCT_EXTERN_METHOD(save

View File

@ -72,6 +72,20 @@ class RCTVideoManager: RCTViewManager {
}) })
} }
@objc(enterPictureInPictureCmd:)
func enterPictureInPictureCmd(_ reactTag: NSNumber) {
performOnVideoView(withReactTag: reactTag, callback: { videoView in
videoView?.enterPictureInPicture()
})
}
@objc(exitPictureInPictureCmd:)
func exitPictureInPictureCmd(_ reactTag: NSNumber) {
performOnVideoView(withReactTag: reactTag, callback: { videoView in
videoView?.exitPictureInPicture()
})
}
@objc(setSourceCmd:source:) @objc(setSourceCmd:source:)
func setSourceCmd(_ reactTag: NSNumber, source: NSDictionary) { func setSourceCmd(_ reactTag: NSNumber, source: NSDictionary) {
performOnVideoView(withReactTag: reactTag, callback: { videoView in performOnVideoView(withReactTag: reactTag, callback: { videoView in

View File

@ -409,6 +409,40 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
[setFullScreen], [setFullScreen],
); );
const enterPictureInPicture = useCallback(async () => {
if (!nativeRef.current) {
console.warn('Video Component is not mounted');
return;
}
const _enterPictureInPicture = () => {
NativeVideoManager.enterPictureInPictureCmd(getReactTag(nativeRef));
};
Platform.select({
ios: _enterPictureInPicture,
android: _enterPictureInPicture,
default: () => {},
})();
}, []);
const exitPictureInPicture = useCallback(async () => {
if (!nativeRef.current) {
console.warn('Video Component is not mounted');
return;
}
const _exitPictureInPicture = () => {
NativeVideoManager.exitPictureInPictureCmd(getReactTag(nativeRef));
};
Platform.select({
ios: _exitPictureInPicture,
android: _exitPictureInPicture,
default: () => {},
})();
}, []);
const save = useCallback((options: object) => { const save = useCallback((options: object) => {
// VideoManager.save can be null on android & windows // VideoManager.save can be null on android & windows
if (Platform.OS !== 'ios') { if (Platform.OS !== 'ios') {
@ -657,6 +691,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume, setVolume,
getCurrentPosition, getCurrentPosition,
setFullScreen, setFullScreen,
enterPictureInPicture,
exitPictureInPicture,
setSource, setSource,
}), }),
[ [
@ -670,6 +706,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume, setVolume,
getCurrentPosition, getCurrentPosition,
setFullScreen, setFullScreen,
enterPictureInPicture,
exitPictureInPicture,
setSource, setSource,
], ],
); );

View File

@ -193,6 +193,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
dismissFullscreenPlayer, dismissFullscreenPlayer,
setFullScreen, setFullScreen,
save: unsupported, save: unsupported,
enterPictureInPicture: unsupported,
exitPictureInPicture: unsupported,
restoreUserInterfaceForPictureInPictureStopCompleted: unsupported, restoreUserInterfaceForPictureInPictureStopCompleted: unsupported,
nativeHtmlVideoRef: nativeRef, nativeHtmlVideoRef: nativeRef,
}), }),

View File

@ -5,6 +5,11 @@ export type ConfigProps = {
* @default false * @default false
*/ */
enableNotificationControls?: boolean; enableNotificationControls?: boolean;
/**
* Apply configs to be able to use Picture-in-picture on Android.
* @default false
*/
enableAndroidPictureInPicture?: boolean;
/** /**
* Whether to enable background audio feature. * Whether to enable background audio feature.
* @default false * @default false

View File

@ -0,0 +1,31 @@
import {
AndroidConfig,
withAndroidManifest,
type ConfigPlugin,
} from '@expo/config-plugins';
export const withAndroidPictureInPicture: ConfigPlugin<boolean> = (
config,
enableAndroidPictureInPicture,
) => {
return withAndroidManifest(config, (_config) => {
if (!enableAndroidPictureInPicture) {
return _config;
}
const mainActivity = AndroidConfig.Manifest.getMainActivity(
_config.modResults,
);
if (!mainActivity) {
console.warn(
'AndroidManifest.xml is missing an <activity android:name=".MainActivity" /> element - skipping adding Picture-In-Picture related config.',
);
return _config;
}
mainActivity.$['android:supportsPictureInPicture'] = 'true';
return _config;
});
};

View File

@ -2,6 +2,7 @@ import {type ConfigPlugin, createRunOncePlugin} from '@expo/config-plugins';
import type {ConfigProps} from './@types'; import type {ConfigProps} from './@types';
import {withNotificationControls} from './withNotificationControls'; import {withNotificationControls} from './withNotificationControls';
import {withAndroidExtensions} from './withAndroidExtensions'; import {withAndroidExtensions} from './withAndroidExtensions';
import {withAndroidPictureInPicture} from './withAndroidPictureInPicture';
import {withAds} from './withAds'; import {withAds} from './withAds';
import {withBackgroundAudio} from './withBackgroundAudio'; import {withBackgroundAudio} from './withBackgroundAudio';
import {withPermissions} from '@expo/config-plugins/build/android/Permissions'; import {withPermissions} from '@expo/config-plugins/build/android/Permissions';
@ -21,6 +22,13 @@ const withRNVideo: ConfigPlugin<ConfigProps> = (config, props = {}) => {
); );
} }
if (props.enableAndroidPictureInPicture) {
config = withAndroidPictureInPicture(
config,
props.enableAndroidPictureInPicture,
);
}
if (props.androidExtensions != null) { if (props.androidExtensions != null) {
config = withAndroidExtensions(config, props.androidExtensions); config = withAndroidExtensions(config, props.androidExtensions);
} }

View File

@ -23,6 +23,8 @@ export interface VideoManagerType {
setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>; setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>;
setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => Promise<void>; setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => Promise<void>;
setVolumeCmd: (reactTag: Int32, volume: number) => Promise<void>; setVolumeCmd: (reactTag: Int32, volume: number) => Promise<void>;
enterPictureInPictureCmd: (reactTag: number) => Promise<void>;
exitPictureInPictureCmd: (reactTag: number) => Promise<void>;
save: (reactTag: Int32, option: UnsafeObject) => Promise<VideoSaveData>; save: (reactTag: Int32, option: UnsafeObject) => Promise<VideoSaveData>;
getCurrentPosition: (reactTag: Int32) => Promise<Int32>; getCurrentPosition: (reactTag: Int32) => Promise<Int32>;
} }

View File

@ -349,7 +349,7 @@ export interface VideoNativeProps extends ViewProps {
preventsDisplaySleepDuringVideoPlayback?: boolean; preventsDisplaySleepDuringVideoPlayback?: boolean;
preferredForwardBufferDuration?: Float; //ios, 0 preferredForwardBufferDuration?: Float; //ios, 0
playWhenInactive?: boolean; // ios, false playWhenInactive?: boolean; // ios, false
pictureInPicture?: boolean; // ios, false enterPictureInPictureOnLeave?: boolean; // default false
ignoreSilentSwitch?: WithDefault<string, 'inherit'>; // ios, 'inherit' ignoreSilentSwitch?: WithDefault<string, 'inherit'>; // ios, 'inherit'
mixWithOthers?: WithDefault<string, 'inherit'>; // ios, 'inherit' mixWithOthers?: WithDefault<string, 'inherit'>; // ios, 'inherit'
rate?: Float; rate?: Float;

View File

@ -252,7 +252,7 @@ export interface ReactVideoEvents {
onLoadStart?: (e: OnLoadStartData) => void; //All onLoadStart?: (e: OnLoadStartData) => void; //All
onPictureInPictureStatusChanged?: ( onPictureInPictureStatusChanged?: (
e: OnPictureInPictureStatusChangedData, e: OnPictureInPictureStatusChangedData,
) => void; //iOS ) => void; //Android, iOS
onPlaybackRateChange?: (e: OnPlaybackRateChangeData) => void; //All onPlaybackRateChange?: (e: OnPlaybackRateChangeData) => void; //All
onVolumeChange?: (e: OnVolumeChangeData) => void; //Android, iOS onVolumeChange?: (e: OnVolumeChangeData) => void; //Android, iOS
onProgress?: (e: OnProgressData) => void; //All onProgress?: (e: OnProgressData) => void; //All

View File

@ -19,5 +19,7 @@ export interface VideoRef {
getCurrentPosition: () => Promise<number>; getCurrentPosition: () => Promise<number>;
setFullScreen: (fullScreen: boolean) => void; setFullScreen: (fullScreen: boolean) => void;
setSource: (source?: ReactVideoSource) => void; setSource: (source?: ReactVideoSource) => void;
enterPictureInPicture: () => void;
exitPictureInPicture: () => void;
nativeHtmlVideoRef?: RefObject<HTMLVideoElement>; // web only nativeHtmlVideoRef?: RefObject<HTMLVideoElement>; // web only
} }

View File

@ -315,7 +315,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
mixWithOthers?: EnumValues<MixWithOthersType>; // iOS mixWithOthers?: EnumValues<MixWithOthersType>; // iOS
muted?: boolean; muted?: boolean;
paused?: boolean; paused?: boolean;
pictureInPicture?: boolean; // iOS enterPictureInPictureOnLeave?: boolean;
playInBackground?: boolean; playInBackground?: boolean;
playWhenInactive?: boolean; // iOS playWhenInactive?: boolean; // iOS
poster?: string | ReactVideoPoster; // string is deprecated poster?: string | ReactVideoPoster; // string is deprecated