-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> Co-authored-by: Olivier Bouillet <[email protected]>
- Loading branch information
1 parent
a735a4a
commit 69a7bc2
Showing
28 changed files
with
738 additions
and
75 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
android/src/main/java/com/brentvatne/exoplayer/PictureInPictureUtil.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} |
Oops, something went wrong.