From 69a7bc2d265f2cf4985f8d81054c46f47ee3bae2 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 4 Jan 2025 20:37:33 +0900 Subject: [PATCH] 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 Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com> --- .../common/react/VideoEventEmitter.kt | 9 +- .../com/brentvatne/exoplayer/ExoPlayerView.kt | 3 +- .../exoplayer/FullScreenPlayerView.kt | 9 + .../exoplayer/PictureInPictureUtil.kt | 207 ++++++++++++++++++ .../exoplayer/ReactExoplayerView.java | 123 ++++++++++- .../exoplayer/ReactExoplayerViewManager.kt | 7 + .../brentvatne/react/VideoManagerModule.kt | 14 ++ .../receiver/PictureInPictureReceiver.kt | 72 ++++++ docs/pages/component/events.mdx | 2 +- docs/pages/component/methods.mdx | 50 +++++ docs/pages/component/props.mdx | 47 +++- docs/pages/index.md | 2 +- docs/pages/other/expo.md | 3 +- ios/Video/Features/RCTIMAAdsManager.swift | 8 +- ios/Video/Features/RCTPictureInPicture.swift | 30 +-- ios/Video/RCTVideo.swift | 117 +++++++--- ios/Video/RCTVideoManager.m | 4 +- ios/Video/RCTVideoManager.swift | 14 ++ src/Video.tsx | 38 ++++ src/Video.web.tsx | 2 + src/expo-plugins/@types.ts | 5 + .../withAndroidPictureInPicture.ts | 31 +++ src/expo-plugins/withRNVideo.ts | 8 + src/specs/NativeVideoManager.ts | 2 + src/specs/VideoNativeComponent.ts | 2 +- src/types/events.ts | 2 +- src/types/video-ref.ts | 2 + src/types/video.ts | 2 +- 28 files changed, 739 insertions(+), 76 deletions(-) create mode 100644 android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt create mode 100644 android/src/main/java/com/brentvatne/receiver/PictureInPictureReceiver.kt create mode 100644 src/expo-plugins/withAndroidPictureInPicture.ts diff --git a/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt b/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt index a7be7058..02b29226 100644 --- a/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt +++ b/android/src/main/java/com/brentvatne/common/react/VideoEventEmitter.kt @@ -42,7 +42,8 @@ enum class EventTypes(val eventName: String) { EVENT_TEXT_TRACK_DATA_CHANGED("onTextTrackDataChanged"), 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 { fun toMap() = @@ -90,6 +91,7 @@ class VideoEventEmitter { lateinit var onVideoTracks: (videoTracks: ArrayList?) -> Unit lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit lateinit var onReceiveAdEvent: (adEvent: String, adData: Map?) -> Unit + lateinit var onPictureInPictureStatusChanged: (isActive: Boolean) -> Unit fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) { 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) + } + } } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt index 462354c2..2e1812a1 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt @@ -30,7 +30,8 @@ class ExoPlayerView(private val context: Context) : FrameLayout(context, null, 0), AdViewProvider { - private var surfaceView: View? = null + var surfaceView: View? = null + private set private var shutterView: View private var subtitleLayout: SubtitleView private var layout: AspectRatioFrameLayout diff --git a/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt b/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt index 9eff62dd..a0f56675 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt @@ -5,6 +5,7 @@ import android.app.Dialog import android.content.Context import android.os.Handler import android.os.Looper +import android.view.View import android.view.ViewGroup import android.view.Window 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 = if (isFullscreen) { androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit diff --git a/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt b/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt new file mode 100644 index 00000000..0dfbbc1a --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt @@ -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 { + 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 + } + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 935f8844..63ab2aee 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -10,6 +10,8 @@ import static androidx.media3.common.C.TIME_END_OF_SOURCE; import android.annotation.SuppressLint; import android.app.Activity; import android.app.ActivityManager; +import android.app.PictureInPictureParams; +import android.app.RemoteAction; import android.app.AlertDialog; import android.content.ComponentName; import android.content.Context; @@ -24,6 +26,7 @@ import android.os.Looper; import android.os.Message; import android.text.TextUtils; import android.view.View; +import android.view.ViewGroup; import android.view.accessibility.CaptioningManager; import android.widget.FrameLayout; import android.widget.ImageButton; @@ -122,6 +125,7 @@ import com.brentvatne.react.R; import com.brentvatne.react.ReactNativeVideoManager; import com.brentvatne.receiver.AudioBecomingNoisyReceiver; import com.brentvatne.receiver.BecomingNoisyListener; +import com.brentvatne.receiver.PictureInPictureReceiver; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.UiThreadUtil; @@ -202,6 +206,8 @@ public class ReactExoplayerView extends FrameLayout implements private boolean isPaused; private boolean isBuffering; private boolean muted = false; + public boolean enterPictureInPictureOnLeave = false; + private PictureInPictureParams.Builder pictureInPictureParamsBuilder; private boolean hasAudioFocus = false; private float rate = 1f; private AudioOutput audioOutput = AudioOutput.SPEAKER; @@ -212,8 +218,10 @@ public class ReactExoplayerView extends FrameLayout implements private boolean selectTrackWhenReady = false; private final Handler mainHandler; private Runnable mainRunnable; + private Runnable pipListenerUnsubscribe; private boolean useCache = false; private ControlsConfig controlsConfig = new ControlsConfig(); + private ArrayList rootViewChildrenOriginalVisibility = new ArrayList(); /* * 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 preventsDisplaySleepDuringVideoPlayback = true; private float mProgressUpdateInterval = 250.0f; - private boolean playInBackground = false; + protected boolean playInBackground = false; private boolean mReportBandwidth = false; private boolean controls; @@ -248,6 +256,7 @@ public class ReactExoplayerView extends FrameLayout implements private final ThemedReactContext themedReactContext; private final AudioManager audioManager; private final AudioBecomingNoisyReceiver audioBecomingNoisyReceiver; + private final PictureInPictureReceiver pictureInPictureReceiver; private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener; // 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.config = config; this.bandwidthMeter = config.getBandwidthMeter(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pictureInPictureParamsBuilder == null) { + this.pictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); + } mainHandler = new Handler(); + createViews(); audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); themedReactContext.addLifecycleEventListener(this); + pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(context, this); audioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver(themedReactContext); audioFocusChangeListener = new OnAudioFocusChangedListener(this, themedReactContext); + pictureInPictureReceiver = new PictureInPictureReceiver(this, themedReactContext); } private boolean isPlayingAd() { @@ -336,12 +351,21 @@ public class ReactExoplayerView extends FrameLayout implements LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); 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); addView(exoPlayerView, 0, layoutParams); exoPlayerView.setFocusable(this.focusable); } + @Override + protected void onDetachedFromWindow() { + cleanupPlaybackService(); + super.onDetachedFromWindow(); + } + // LifecycleEventListener implementation @Override public void onHostResume() { @@ -354,7 +378,10 @@ public class ReactExoplayerView extends FrameLayout implements @Override public void onHostPause() { 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; } setPlayWhenReady(false); @@ -365,12 +392,6 @@ public class ReactExoplayerView extends FrameLayout implements cleanUpResources(); } - @Override - protected void onDetachedFromWindow() { - cleanupPlaybackService(); - super.onDetachedFromWindow(); - } - public void cleanUpResources() { stopPlayback(); themedReactContext.removeLifecycleEventListener(this); @@ -850,6 +871,7 @@ public class ReactExoplayerView extends FrameLayout implements exoPlayerView.setPlayer(player); audioBecomingNoisyReceiver.setListener(self); + pictureInPictureReceiver.setListener(); bandwidthMeter.addEventListener(new Handler(), self); setPlayWhenReady(!isPaused); playerNeedsSource = true; @@ -1274,6 +1296,10 @@ public class ReactExoplayerView extends FrameLayout implements updateResumePosition(); player.release(); player.removeListener(this); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, false); + if (pipListenerUnsubscribe != null) { + new Handler().post(pipListenerUnsubscribe); + } trackSelector = null; ReactNativeVideoManager.Companion.getInstance().onInstanceRemoved(instanceId, player); @@ -1286,6 +1312,7 @@ public class ReactExoplayerView extends FrameLayout implements } progressHandler.removeMessages(SHOW_PROGRESS); audioBecomingNoisyReceiver.removeListener(); + pictureInPictureReceiver.removeListener(); bandwidthMeter.removeEventListener(this); if (mainHandler != null && mainRunnable != null) { @@ -1804,7 +1831,7 @@ public class ReactExoplayerView extends FrameLayout implements if (isPlaying && isSeeking) { eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); } - + PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying); eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); 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 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) { this.muted = muted; if (player != null) { diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt index 2ac8dd16..d05bb48e 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerViewManager.kt @@ -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_VALUE = "value" 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_AUDIO_OUTPUT = "audioOutput" private const val PROP_VOLUME = "volume" @@ -69,6 +70,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View override fun onDropViewInstance(view: ReactExoplayerView) { view.cleanUpResources() + view.exitPictureInPictureMode() ReactNativeVideoManager.getInstance().unregisterView(this) } @@ -154,6 +156,11 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View 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) fun setAudioOutput(videoView: ReactExoplayerView, audioOutput: String) { videoView.setAudioOutput(AudioOutput.get(audioOutput)) diff --git a/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt b/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt index d3968276..15031e79 100644 --- a/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt +++ b/android/src/main/java/com/brentvatne/react/VideoManagerModule.kt @@ -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 fun setSourceCmd(reactTag: Int, source: ReadableMap?) { performOnPlayerView(reactTag) { diff --git a/android/src/main/java/com/brentvatne/receiver/PictureInPictureReceiver.kt b/android/src/main/java/com/brentvatne/receiver/PictureInPictureReceiver.kt new file mode 100644 index 00000000..bc34ccb0 --- /dev/null +++ b/android/src/main/java/com/brentvatne/receiver/PictureInPictureReceiver.kt @@ -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) + } +} diff --git a/docs/pages/component/events.mdx b/docs/pages/component/events.mdx index 6e0c1498..47f90204 100644 --- a/docs/pages/component/events.mdx +++ b/docs/pages/component/events.mdx @@ -317,7 +317,7 @@ Example: ### `onPictureInPictureStatusChanged` - + Callback function that is called when picture in picture becomes active or inactive. diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index b3d89a04..5c1f39a2 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -78,6 +78,56 @@ Future: - Will support more formats in the future through options - Will support custom directory and file name through options +### `enterPictureInPicture` + + + +`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 + +``` + +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` + + + +`exitPictureInPicture()` + +Exits the active picture in picture; if it is not active, the function call is ignored. + +### `restoreUserInterfaceForPictureInPictureStopCompleted` + + + +`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` diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index 9854ed76..2f418840 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -252,6 +252,42 @@ To setup DRM please follow [this guide](/component/drm) > ⚠️ DRM is not supported on visionOS yet +### `enterPictureInPictureOnLeave` + + + +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 + +``` + +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` @@ -425,17 +461,6 @@ Controls whether the media is paused - **false (default)** - Don't pause the media - **true** - Pause the media -### `pictureInPicture` - - - -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` diff --git a/docs/pages/index.md b/docs/pages/index.md index 33f20810..5894dad6 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -13,7 +13,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap - Subtitles (embeded or side loaded) - DRM support - Client side Ads insertion (via google IMA) -- Pip (ios) +- Pip - Embedded playback controls - And much more diff --git a/docs/pages/other/expo.md b/docs/pages/other/expo.md index fdb41a9e..f211d466 100644 --- a/docs/pages/other/expo.md +++ b/docs/pages/other/expo.md @@ -37,4 +37,5 @@ It's useful when you are using `expo` managed workflow (expo prebuild) as it wil | enableBackgroundAudio | boolean | false | Add required changes to play video in background on iOS | | 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 | -| androidExtensions | object | {} | You can enable/disable extensions as per your requirement - this allow to reduce library size on android | \ No newline at end of file +| 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 | \ No newline at end of file diff --git a/ios/Video/Features/RCTIMAAdsManager.swift b/ios/Video/Features/RCTIMAAdsManager.swift index a6548f3f..845c373a 100644 --- a/ios/Video/Features/RCTIMAAdsManager.swift +++ b/ios/Video/Features/RCTIMAAdsManager.swift @@ -4,16 +4,16 @@ class RCTIMAAdsManager: NSObject, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMALinkOpenerDelegate { private weak var _video: RCTVideo? - private var _pipEnabled: () -> Bool + private var _isPictureInPictureActive: () -> Bool /* Entry point for the SDK. Used to make ad requests. */ private var adsLoader: IMAAdsLoader! /* Main point of interaction with the SDK. Created by the SDK as the result of an ad request. */ private var adsManager: IMAAdsManager! - init(video: RCTVideo!, pipEnabled: @escaping () -> Bool) { + init(video: RCTVideo!, isPictureInPictureActive: @escaping () -> Bool) { _video = video - _pipEnabled = pipEnabled + _isPictureInPictureActive = isPictureInPictureActive super.init() } @@ -103,7 +103,7 @@ } // Play each ad once it has been loaded if event.type == IMAAdEventType.LOADED { - if _pipEnabled() { + if _isPictureInPictureActive() { return } adsManager.start() diff --git a/ios/Video/Features/RCTPictureInPicture.swift b/ios/Video/Features/RCTPictureInPicture.swift index ab24cd87..67a7446f 100644 --- a/ios/Video/Features/RCTPictureInPicture.swift +++ b/ios/Video/Features/RCTPictureInPicture.swift @@ -11,7 +11,9 @@ import React private var _onPictureInPictureExit: (() -> Void)? private var _onRestoreUserInterfaceForPictureInPictureStop: (() -> Void)? private var _restoreUserInterfaceForPIPStopCompletionHandler: ((Bool) -> Void)? - private var _isActive = false + private var _isPictureInPictureActive: Bool { + return _pipController?.isPictureInPictureActive ?? false + } init( _ onPictureInPictureEnter: (() -> Void)? = nil, @@ -67,20 +69,22 @@ import React _pipController = nil } - func setPictureInPicture(_ isActive: Bool) { - if _isActive == isActive { - return - } - _isActive = isActive - + func enterPictureInPicture() { guard let _pipController else { return } + if !_isPictureInPictureActive { + _pipController.startPictureInPicture() + } + } - if _isActive && !_pipController.isPictureInPictureActive { - DispatchQueue.main.async { - _pipController.startPictureInPicture() - } - } else if !_isActive && _pipController.isPictureInPictureActive { - DispatchQueue.main.async { + func exitPictureInPicture() { + guard let _pipController else { return } + if _isPictureInPictureActive { + let state = UIApplication.shared.applicationState + if state == .background || state == .inactive { + deinitPipController() + _onPictureInPictureExit?() + _onRestoreUserInterfaceForPictureInPictureStop?() + } else { _pipController.stopPictureInPicture() } } diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 3ce69c8c..7915e82d 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -63,10 +63,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _showNotificationControls = false // 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 _pictureInPictureEnabled = false { + private var _enterPictureInPictureOnLeave = false { didSet { #if os(iOS) - if _pictureInPictureEnabled { + if isPictureInPictureActive() { return } + if _enterPictureInPictureOnLeave { initPictureinPicture() _playerViewController?.allowsPictureInPicturePlayback = true } else { @@ -166,11 +167,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH onRestoreUserInterfaceForPictureInPictureStop?([:]) } - func isPipEnabled() -> Bool { - return _pictureInPictureEnabled - } - - func isPipActive() -> Bool { + func isPictureInPictureActive() -> Bool { #if os(iOS) return _pip?._pipController?.isPictureInPictureActive == true #else @@ -180,15 +177,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH func initPictureinPicture() { #if os(iOS) - _pip = RCTPictureInPicture({ [weak self] in - self?._onPictureInPictureEnter() - }, { [weak self] in - self?._onPictureInPictureExit() - }, { [weak self] in - self?.onRestoreUserInterfaceForPictureInPictureStop?([:]) - }) + if _pip == nil { + _pip = RCTPictureInPicture({ [weak self] in + self?._onPictureInPictureEnter() + }, { [weak self] in + self?._onPictureInPictureExit() + }, { [weak self] in + self?.onRestoreUserInterfaceForPictureInPictureStop?([:]) + }) + } - if _playerLayer != nil && !_controls { + if _playerLayer != nil && !_controls && _pip?._pipController == nil { _pip?.setupPipController(_playerLayer) } #else @@ -200,17 +199,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) ReactNativeVideoManager.shared.registerView(newInstance: self) #if USE_GOOGLE_IMA - _imaAdsManager = RCTIMAAdsManager(video: self, pipEnabled: isPipEnabled) + _imaAdsManager = RCTIMAAdsManager(video: self, isPictureInPictureActive: isPictureInPictureActive) #endif _eventDispatcher = eventDispatcher #if os(iOS) - if _pictureInPictureEnabled { + if _enterPictureInPictureOnLeave { initPictureinPicture() - _playerViewController?.allowsPictureInPicturePlayback = true - } else { - _playerViewController?.allowsPictureInPicturePlayback = false } #endif @@ -242,6 +238,20 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH 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( self, selector: #selector(audioRouteChanged(notification:)), @@ -257,7 +267,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) #if USE_GOOGLE_IMA - _imaAdsManager = RCTIMAAdsManager(video: self, pipEnabled: isPipEnabled) + _imaAdsManager = RCTIMAAdsManager(video: self, isPictureInPictureActive: isPictureInPictureActive) #endif } @@ -313,8 +323,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH @objc func applicationDidEnterBackground(notification _: NSNotification!) { + if !_paused && isPictureInPictureActive() { + _player?.play() + _player?.rate = _rate + } 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 _playerLayer?.player = nil _playerViewController?.player = nil @@ -327,6 +341,24 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH _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 @objc @@ -710,19 +742,16 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } @objc - func setPictureInPicture(_ pictureInPicture: Bool) { + func setEnterPictureInPictureOnLeave(_ enterPictureInPictureOnLeave: Bool) { #if os(iOS) let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playback) try audioSession.setActive(true, options: []) } catch {} - if pictureInPicture { - _pictureInPictureEnabled = true - } else { - _pictureInPictureEnabled = false + if _enterPictureInPictureOnLeave != enterPictureInPictureOnLeave { + _enterPictureInPictureOnLeave = enterPictureInPictureOnLeave } - _pip?.setPictureInPicture(pictureInPicture) #endif } @@ -1093,8 +1122,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH viewController.view.frame = self.bounds viewController.player = player if #available(tvOS 14.0, *) { - viewController.allowsPictureInPicturePlayback = _pictureInPictureEnabled + viewController.allowsPictureInPicturePlayback = _enterPictureInPictureOnLeave } + #if os(iOS) + viewController.allowsPictureInPicturePlayback = _enterPictureInPictureOnLeave + #endif return viewController } @@ -1114,7 +1146,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } self.layer.needsDisplayOnBoundsChange = true #if os(iOS) - if _pictureInPictureEnabled { + if _enterPictureInPictureOnLeave { _pip?.setupPipController(_playerLayer) } #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 @objc func setOnClick(_: Any) {} diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 2a3b14e8..330aef85 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -23,7 +23,7 @@ RCT_EXPORT_VIEW_PROPERTY(playInBackground, BOOL); RCT_EXPORT_VIEW_PROPERTY(preventsDisplaySleepDuringVideoPlayback, BOOL); RCT_EXPORT_VIEW_PROPERTY(preferredForwardBufferDuration, float); 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(mixWithOthers, NSString); 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(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume) 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(save diff --git a/ios/Video/RCTVideoManager.swift b/ios/Video/RCTVideoManager.swift index dbe83ba0..b408e966 100644 --- a/ios/Video/RCTVideoManager.swift +++ b/ios/Video/RCTVideoManager.swift @@ -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:) func setSourceCmd(_ reactTag: NSNumber, source: NSDictionary) { performOnVideoView(withReactTag: reactTag, callback: { videoView in diff --git a/src/Video.tsx b/src/Video.tsx index dd7d19bf..09de1742 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -409,6 +409,40 @@ const Video = forwardRef( [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) => { // VideoManager.save can be null on android & windows if (Platform.OS !== 'ios') { @@ -657,6 +691,8 @@ const Video = forwardRef( setVolume, getCurrentPosition, setFullScreen, + enterPictureInPicture, + exitPictureInPicture, setSource, }), [ @@ -670,6 +706,8 @@ const Video = forwardRef( setVolume, getCurrentPosition, setFullScreen, + enterPictureInPicture, + exitPictureInPicture, setSource, ], ); diff --git a/src/Video.web.tsx b/src/Video.web.tsx index a8b5a218..920e4065 100644 --- a/src/Video.web.tsx +++ b/src/Video.web.tsx @@ -193,6 +193,8 @@ const Video = forwardRef( dismissFullscreenPlayer, setFullScreen, save: unsupported, + enterPictureInPicture: unsupported, + exitPictureInPicture: unsupported, restoreUserInterfaceForPictureInPictureStopCompleted: unsupported, nativeHtmlVideoRef: nativeRef, }), diff --git a/src/expo-plugins/@types.ts b/src/expo-plugins/@types.ts index 6e941dfb..6f81ee96 100644 --- a/src/expo-plugins/@types.ts +++ b/src/expo-plugins/@types.ts @@ -5,6 +5,11 @@ export type ConfigProps = { * @default false */ enableNotificationControls?: boolean; + /** + * Apply configs to be able to use Picture-in-picture on Android. + * @default false + */ + enableAndroidPictureInPicture?: boolean; /** * Whether to enable background audio feature. * @default false diff --git a/src/expo-plugins/withAndroidPictureInPicture.ts b/src/expo-plugins/withAndroidPictureInPicture.ts new file mode 100644 index 00000000..4f04a6d6 --- /dev/null +++ b/src/expo-plugins/withAndroidPictureInPicture.ts @@ -0,0 +1,31 @@ +import { + AndroidConfig, + withAndroidManifest, + type ConfigPlugin, +} from '@expo/config-plugins'; + +export const withAndroidPictureInPicture: ConfigPlugin = ( + 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 element - skipping adding Picture-In-Picture related config.', + ); + return _config; + } + + mainActivity.$['android:supportsPictureInPicture'] = 'true'; + + return _config; + }); +}; diff --git a/src/expo-plugins/withRNVideo.ts b/src/expo-plugins/withRNVideo.ts index 6cd545f1..5861d9fd 100644 --- a/src/expo-plugins/withRNVideo.ts +++ b/src/expo-plugins/withRNVideo.ts @@ -2,6 +2,7 @@ import {type ConfigPlugin, createRunOncePlugin} from '@expo/config-plugins'; import type {ConfigProps} from './@types'; import {withNotificationControls} from './withNotificationControls'; import {withAndroidExtensions} from './withAndroidExtensions'; +import {withAndroidPictureInPicture} from './withAndroidPictureInPicture'; import {withAds} from './withAds'; import {withBackgroundAudio} from './withBackgroundAudio'; import {withPermissions} from '@expo/config-plugins/build/android/Permissions'; @@ -21,6 +22,13 @@ const withRNVideo: ConfigPlugin = (config, props = {}) => { ); } + if (props.enableAndroidPictureInPicture) { + config = withAndroidPictureInPicture( + config, + props.enableAndroidPictureInPicture, + ); + } + if (props.androidExtensions != null) { config = withAndroidExtensions(config, props.androidExtensions); } diff --git a/src/specs/NativeVideoManager.ts b/src/specs/NativeVideoManager.ts index a7dde175..64644d81 100644 --- a/src/specs/NativeVideoManager.ts +++ b/src/specs/NativeVideoManager.ts @@ -23,6 +23,8 @@ export interface VideoManagerType { setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise; setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => Promise; setVolumeCmd: (reactTag: Int32, volume: number) => Promise; + enterPictureInPictureCmd: (reactTag: number) => Promise; + exitPictureInPictureCmd: (reactTag: number) => Promise; save: (reactTag: Int32, option: UnsafeObject) => Promise; getCurrentPosition: (reactTag: Int32) => Promise; } diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index fdd9c7e5..69412bc8 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -349,7 +349,7 @@ export interface VideoNativeProps extends ViewProps { preventsDisplaySleepDuringVideoPlayback?: boolean; preferredForwardBufferDuration?: Float; //ios, 0 playWhenInactive?: boolean; // ios, false - pictureInPicture?: boolean; // ios, false + enterPictureInPictureOnLeave?: boolean; // default false ignoreSilentSwitch?: WithDefault; // ios, 'inherit' mixWithOthers?: WithDefault; // ios, 'inherit' rate?: Float; diff --git a/src/types/events.ts b/src/types/events.ts index f8f405e8..b99aa7ab 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -252,7 +252,7 @@ export interface ReactVideoEvents { onLoadStart?: (e: OnLoadStartData) => void; //All onPictureInPictureStatusChanged?: ( e: OnPictureInPictureStatusChangedData, - ) => void; //iOS + ) => void; //Android, iOS onPlaybackRateChange?: (e: OnPlaybackRateChangeData) => void; //All onVolumeChange?: (e: OnVolumeChangeData) => void; //Android, iOS onProgress?: (e: OnProgressData) => void; //All diff --git a/src/types/video-ref.ts b/src/types/video-ref.ts index 69bbe422..f17eff73 100644 --- a/src/types/video-ref.ts +++ b/src/types/video-ref.ts @@ -19,5 +19,7 @@ export interface VideoRef { getCurrentPosition: () => Promise; setFullScreen: (fullScreen: boolean) => void; setSource: (source?: ReactVideoSource) => void; + enterPictureInPicture: () => void; + exitPictureInPicture: () => void; nativeHtmlVideoRef?: RefObject; // web only } diff --git a/src/types/video.ts b/src/types/video.ts index 5dab4ec9..d14fe7e2 100644 --- a/src/types/video.ts +++ b/src/types/video.ts @@ -315,7 +315,7 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps { mixWithOthers?: EnumValues; // iOS muted?: boolean; paused?: boolean; - pictureInPicture?: boolean; // iOS + enterPictureInPictureOnLeave?: boolean; playInBackground?: boolean; playWhenInactive?: boolean; // iOS poster?: string | ReactVideoPoster; // string is deprecated