feat: implement enterPictureInPictureOnLeave prop for both platform (Android, iOS) (#3385)

* docs: enable Android PIP

* chore: change comments

* feat(android): implement Android PictureInPicture

* refactor: minor refactor code and apply lint

* fix: rewrite pip action intent code for Android14

* fix: remove redundant codes

* feat: add isInPictureInPicture flag for lifecycle handling

- activity provide helper method for same purpose, but this flag makes code simple

* feat: add pipFullscreenPlayerView for makes PIP include video only

* fix: add manifest value checker for prevent crash

* docs: add pictureInPicture prop's Android guide

* fix: sync controller visibility

* refactor: refining variable name

* fix: check multi window mode when host pause

- some OS version call onPause on multi-window mode

* fix: handling when onStop is called while in multi-window mode

* refactor:  enhance PIP util codes

* fix: fix FullscreenPlayerView constructor

* refactor: add enterPictureInPictureOnLeave prop and pip methods

- remove pictureInPicture boolean prop
- add enterPictureInPictureOnLeave boolean prop
- add enterPictureInPicture method
- add exitPictureInPicture method

* fix: fix lint error

* fix: prevent audio play in background without playInBackground prop

* fix: fix onDetachedFromWindow

* docs: update docs for pip

* fix(android): sync pip controller with external controller state

- for media session

* fix(ios): fix pip active fn variable reference

* refactor(ios): refactor code

* refactor(android): refactor codes

* fix(android): fix lint error

* refactor(android): refactor android pip logics

* fix(android): fix flickering issue when stop picture in picture

* fix(android): fix import

* fix(android): fix picture in picture with fullscreen mode

* fix(ios): fix syntax error

* fix(android): fix Fragment managed code

* refactor(android): remove redundant override lifecycle

* fix(js): add PIP type definition for codegen

* fix(android): fix syntax

* chore(android): fix lint error

* fix(ios): fix enter background handler

* refactor(ios): remove redundant code

* fix(ios): fix applicationDidEnterBackground for PIP

* fix(android): fix onPictureInPictureStatusChanged

* fix(ios): fix RCTPictureInPicture

* refactor(android): Ignore exception for some device ignore pip checker

- some device ignore PIP availability check, so we need to handle exception to prevent crash

* fix(android): add hideWithoutPlayer fn into Kotlin ver

* refactor(android): remove redundant code

* fix(android): fix pip ratio to be calculated with correct ratio value

* fix(android): fix crash issue when unmounting in PIP mode

* fix(android): fix lint error

* Update android/src/main/java/com/brentvatne/react/VideoManagerModule.kt

* fix(android): fix lint error

* fix(ios): fix lint error

* fix(ios): fix lint error

* feat(expo): add android picture in picture config within expo plugin

* fix: Replace Fragment with androidx.activity

- remove code that uses Fragment, which is a tricky implementation

* fix: fix lint error

* fix(android): disable auto enter when player released

* fix(android): fix event handler to check based on Activity it's bound to

---------

Co-authored-by: jonghun <jonghun@toss.im>
Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
This commit is contained in:
YangJH
2025-01-04 20:37:33 +09:00
committed by GitHub
parent a735a4a581
commit 69a7bc2d26
28 changed files with 739 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ export interface VideoManagerType {
setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>;
setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => 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>;
getCurrentPosition: (reactTag: Int32) => Promise<Int32>;
}

View File

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

View File

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

View File

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

View File

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