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:
parent
a735a4a581
commit
69a7bc2d26
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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) {
|
||||||
|
@ -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))
|
||||||
|
@ -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) {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
||||||
|
|
||||||
|
@ -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']} />
|
||||||
|
@ -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']} />
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 |
|
@ -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()
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
@ -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
|
||||||
|
31
src/expo-plugins/withAndroidPictureInPicture.ts
Normal file
31
src/expo-plugins/withAndroidPictureInPicture.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user