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:
@@ -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<VideoTrack>?) -> Unit
|
||||
lateinit var onTextTrackDataChanged: (textTrackData: String) -> Unit
|
||||
lateinit var onReceiveAdEvent: (adEvent: String, adData: Map<String?, String?>?) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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.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<Integer> rootViewChildrenOriginalVisibility = new ArrayList<Integer>();
|
||||
|
||||
/*
|
||||
* 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<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) {
|
||||
this.muted = muted;
|
||||
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_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))
|
||||
|
@@ -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) {
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user