diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNative.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNative.kt
index c42289b7c4..79839f63ee 100644
--- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNative.kt
+++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNative.kt
@@ -3,14 +3,32 @@ package com.streamvideo.reactnative
import com.streamvideo.reactnative.video.SimulcastVideoEncoderFactoryWrapper
import com.streamvideo.reactnative.video.WrappedVideoDecoderFactoryProxy
import com.oney.WebRTCModule.WebRTCModuleOptions
+import kotlin.properties.Delegates
object StreamVideoReactNative {
+ var pipListeners = ArrayList<(b: Boolean) -> Unit>()
+
+ @JvmField
+ var canAutoEnterPictureInPictureMode = false
+
+ // fires off every time value of the property changes
+ private var isInPictureInPictureMode: Boolean by Delegates.observable(false) { _, _, newValue ->
+ pipListeners.forEach {listener ->
+ listener(newValue)
+ }
+ }
+
@JvmStatic
fun setup() {
val options = WebRTCModuleOptions.getInstance()
options.videoEncoderFactory = SimulcastVideoEncoderFactoryWrapper(null, true, true)
options.videoDecoderFactory = WrappedVideoDecoderFactoryProxy()
}
-}
\ No newline at end of file
+
+ @JvmStatic
+ fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
+ this.isInPictureInPictureMode = isInPictureInPictureMode
+ }
+}
diff --git a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt
index c62baef0e5..2ae2be6668 100644
--- a/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt
+++ b/packages/react-native-sdk/android/src/main/java/com/streamvideo/reactnative/StreamVideoReactNativeModule.kt
@@ -1,11 +1,91 @@
package com.streamvideo.reactnative
-import com.facebook.react.bridge.*
+import android.app.AppOpsManager
+import android.app.PictureInPictureParams
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Process
+import android.util.Rational
+import androidx.annotation.RequiresApi
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
+import com.facebook.react.bridge.Promise;
class StreamVideoReactNativeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String {
- return "StreamVideoReactNative"
+ return NAME;
+ }
+
+ private var isInPictureInPictureMode = false
+
+ override fun initialize() {
+ super.initialize()
+ StreamVideoReactNative.pipListeners.add {isInPictureInPictureMode ->
+ reactApplicationContext.getJSModule(
+ RCTDeviceEventEmitter::class.java
+ ).emit(PIP_CHANGE_EVENT, isInPictureInPictureMode)
+ this.isInPictureInPictureMode = isInPictureInPictureMode
+ }
+ }
+
+ @ReactMethod
+ fun isInPiPMode(promise: Promise) {
+ promise.resolve(isInPictureInPictureMode);
+ }
+
+ @ReactMethod
+ fun addListener(eventName: String?) {
+ }
+
+ @ReactMethod
+ fun removeListeners(count: Int) {
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ @ReactMethod
+ fun enterPipMode(width: Int, height: Int) {
+ if (hasPermission()) {
+ val width1 = if (width > 0) width else 480
+ val height1 = if (height > 0) height else 640
+ val ratio = Rational(width1, height1)
+ val pipBuilder = PictureInPictureParams.Builder()
+ pipBuilder.setAspectRatio(ratio)
+ reactApplicationContext!!.currentActivity!!.enterPictureInPictureMode(pipBuilder.build())
+ }
+ }
+
+ override fun onCatalystInstanceDestroy() {
+ StreamVideoReactNative.pipListeners.clear()
+ super.onCatalystInstanceDestroy()
+ }
+
+ @ReactMethod
+ fun canAutoEnterPipMode(value: Boolean) {
+ StreamVideoReactNative.canAutoEnterPictureInPictureMode = value
+ }
+
+ private fun hasPermission(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && reactApplicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
+ val appOps =
+ reactApplicationContext.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
+ val packageName = reactApplicationContext.packageName
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
+ } else {
+ appOps.checkOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE, Process.myUid(), packageName) == AppOpsManager.MODE_ALLOWED
+ }
+ } else {
+ false
+ }
+ }
+
+ companion object {
+ private const val NAME = "StreamVideoReactNative"
+ private const val PIP_CHANGE_EVENT = NAME + "_PIP_CHANGE_EVENT"
}
}
diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/06-advanced/08-picture-in-picture.mdx b/packages/react-native-sdk/docusaurus/docs/reactnative/06-advanced/08-picture-in-picture.mdx
new file mode 100644
index 0000000000..cea85f4f9d
--- /dev/null
+++ b/packages/react-native-sdk/docusaurus/docs/reactnative/06-advanced/08-picture-in-picture.mdx
@@ -0,0 +1,158 @@
+---
+id: pip
+title: Picture in picture
+description: Tutorial to integrate deep linking for the calls
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import PipVideo from "../assets/06-advanced/08-picture-in-picture/pip-android.mp4";
+
+Picture-in-picture (PiP) mode shrinks the layout in the call into a small window so you can keep watching while using other apps on your mobile device. You can move the small window around your device’s home screen and position it over other apps.
+Through the SDK we provide two ways to enter PiP mode, either manually or automatically. The manual way is using calling a method from the SDK, the automatic way is to make the app enter PiP mode when the home button is pressed.
+
+
+
+
+
+
+
+
+
+
+
+:::note
+PiP is currently only supported in Android by the SDK.
+:::
+
+## Setup
+
+
+
+
+### Changes to AndroidManifest
+Add the following attributes to `AndroidManifest.xml` file in `MainActivity`:
+
+```xml title="AndroidManifest.xml"
+
+ ...
+ android:name=".MainActivity"
+ // highlight-start
+ android:supportsPictureInPicture="true"
+ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
+ // highlight-end
+ ...
+
+```
+
+### Changes to MainActivity
+Add the following imports to `MainActivity.java`:
+
+
+
+```java title="MainActivity.java"
+import android.app.PictureInPictureParams;
+import com.streamvideo.reactnative.StreamVideoReactNative;
+import android.util.Rational;
+import androidx.lifecycle.Lifecycle;
+```
+
+
+
+After that, Add the following function to `MainActivity.java`:
+
+```java title="MainActivity.java"
+ @Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ if (getLifecycle().getCurrentState() == Lifecycle.State.CREATED) {
+ // when user clicks on Close button of PIP
+ finishAndRemoveTask();
+ } else {
+ StreamVideoReactNative.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ }
+ }
+```
+
+#### Optional - Automatically make the app enter PiP mode when the home button is pressed
+
+To make the app to automatically enter PiP on home button press, the following function must be added to `MainActivity.java`:
+
+```java title="MainActivity.java"
+ @Override
+ public void onUserLeaveHint () {
+ if (StreamVideoReactNative.canAutoEnterPictureInPictureMode) {
+ PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+ builder.setAspectRatio(new Rational(480, 640)); // 480 x 640 - Width x Height
+ enterPictureInPictureMode(builder.build());
+ }
+ }
+```
+
+
+
+
+In **app.json**, in the `plugins` field, add the `androidPictureInPicture` property to the `@stream-io/video-react-native-sdk` plugin.
+
+```js title="app.json"
+{
+ "plugins": [
+ [
+ "@stream-io/video-react-native-sdk",
+ {
+ // highlight-start
+ "androidPictureInPicture": {
+ "enableAutomaticEnter": true
+ },
+ // highlight-end
+ }
+ ],
+ ]
+}
+```
+
+If you do not want the app to automatically enter PiP mode on Home button press. You can set the above property to be `"enableAutomaticEnter": false`.
+
+
+
+
+
+## Automatically entering PiP mode
+
+If the setup was done to enter PiP Mode automatically, wherever `CallContent` component is used, the PiP mode will be activated on home button press.
+
+Alternatively, if you do not use the `CallContent` component but still want to enter PiP mode automatically. You can use the `useAutoEnterPiPEffect` hook from the SDK in your component to enable PiP mode in that component as below:
+
+```js
+import { useAutoEnterPiPEffect } from '@stream-io/video-react-native-sdk';
+
+useAutoEnterPiPEffect();
+```
+
+## Entering PiP mode manually
+
+The SDK exposes a method named `enterPiPAndroid`. If this method is invoked, the app will go to PiP mode. You can use the method as shown below:
+
+```js
+import { enterPiPAndroid } from '@stream-io/video-react-native-sdk';
+
+enterPiPAndroid();
+```
+
+## Choosing what to render on PiP mode
+
+In PiP mode, the window is small. So you should only selectively render the important parts of the call. If you use `CallContent` component, this is automatically handled. The `CallContent` component shows only top sorted video on PiP mode.
+
+Alternatively, if you do not use the `CallContent` component, we expose a hook named `useIsInPiPMode` to listen to the state of PiP Mode as shown below:
+
+```js
+import { useIsInPiPMode } from '@stream-io/video-react-native-sdk';
+
+const isInPiPMode = useIsInPiPMode();
+```
+
+You can use the state of the boolean from the hook to selectively render whatever is necessary during PiP mode.
\ No newline at end of file
diff --git a/packages/react-native-sdk/docusaurus/docs/reactnative/assets/06-advanced/08-picture-in-picture/pip-android.mp4 b/packages/react-native-sdk/docusaurus/docs/reactnative/assets/06-advanced/08-picture-in-picture/pip-android.mp4
new file mode 100644
index 0000000000..c5fa37dd58
Binary files /dev/null and b/packages/react-native-sdk/docusaurus/docs/reactnative/assets/06-advanced/08-picture-in-picture/pip-android.mp4 differ
diff --git a/packages/react-native-sdk/expo-config-plugin/__tests__/withAndroidManifest.test.ts b/packages/react-native-sdk/expo-config-plugin/__tests__/withAndroidManifest.test.ts
index 68bd27121b..5ed4ee0ef4 100644
--- a/packages/react-native-sdk/expo-config-plugin/__tests__/withAndroidManifest.test.ts
+++ b/packages/react-native-sdk/expo-config-plugin/__tests__/withAndroidManifest.test.ts
@@ -2,6 +2,7 @@ import withStreamVideoReactNativeSDKManifest from '../src/withAndroidManifest';
import { ExpoConfig } from '@expo/config-types';
import { AndroidConfig } from '@expo/config-plugins';
import { getFixturePath } from '../fixtures';
+import { ConfigProps } from '../src/common/types';
// Define a custom type that extends ExpoConfig
interface CustomExpoConfig extends ExpoConfig {
@@ -29,11 +30,19 @@ const readAndroidManifestAsync =
const getMainApplicationOrThrow =
AndroidConfig.Manifest.getMainApplicationOrThrow;
+const getMainActivityOrThrow = AndroidConfig.Manifest.getMainActivityOrThrow;
+
const sampleManifestPath = getFixturePath('AndroidManifest.xml');
+const props: ConfigProps = {
+ androidPictureInPicture: {
+ enableAutomaticEnter: true,
+ },
+};
+
describe('withStreamVideoReactNativeSDKManifest', () => {
let modifiedConfig: CustomExpoConfig | undefined;
- it('should modify Android Manifest', async () => {
+ it('should modify Android Manifest as per props', async () => {
const manifest = await readAndroidManifestAsync(sampleManifestPath);
// Prepare a mock config
const config: CustomExpoConfig = {
@@ -44,6 +53,7 @@ describe('withStreamVideoReactNativeSDKManifest', () => {
const updatedConfig = withStreamVideoReactNativeSDKManifest(
config,
+ props,
) as CustomExpoConfig;
const mainApp = getMainApplicationOrThrow(updatedConfig.modResults);
@@ -53,9 +63,36 @@ describe('withStreamVideoReactNativeSDKManifest', () => {
(service) =>
service.$['android:name'] === 'app.notifee.core.ForegroundService',
),
- ).toBe(true);
+ ).toBeTruthy();
+
+ const mainActivity = getMainActivityOrThrow(updatedConfig.modResults);
+
+ expect(
+ mainActivity.$['android:supportsPictureInPicture'] === 'true',
+ ).toBeTruthy();
modifiedConfig = updatedConfig;
+
+ const manifest2 = await readAndroidManifestAsync(sampleManifestPath);
+
+ const config2: CustomExpoConfig = {
+ name: 'test-app',
+ slug: 'test-app',
+ modResults: manifest2,
+ };
+
+ const props2: ConfigProps = {};
+
+ const updatedConfig2 = withStreamVideoReactNativeSDKManifest(
+ config2,
+ props2,
+ ) as CustomExpoConfig;
+
+ const mainActivity2 = getMainActivityOrThrow(updatedConfig2.modResults);
+
+ expect(
+ mainActivity2.$['android:supportsPictureInPicture'] === 'true',
+ ).toBeFalsy();
});
it('should not create duplicates', () => {
@@ -63,6 +100,7 @@ describe('withStreamVideoReactNativeSDKManifest', () => {
const updatedConfig = withStreamVideoReactNativeSDKManifest(
modifiedConfig!,
+ props,
) as CustomExpoConfig;
const mainApp = getMainApplicationOrThrow(updatedConfig.modResults);
@@ -87,6 +125,8 @@ describe('withStreamVideoReactNativeSDKManifest', () => {
bla: 'blabla',
},
};
- expect(() => withStreamVideoReactNativeSDKManifest(config)).toThrow();
+ expect(() =>
+ withStreamVideoReactNativeSDKManifest(config, props),
+ ).toThrow();
});
});
diff --git a/packages/react-native-sdk/expo-config-plugin/__tests__/withMainActivity.ts b/packages/react-native-sdk/expo-config-plugin/__tests__/withMainActivity.ts
new file mode 100644
index 0000000000..89c1b9addb
--- /dev/null
+++ b/packages/react-native-sdk/expo-config-plugin/__tests__/withMainActivity.ts
@@ -0,0 +1,113 @@
+import { getFixture } from '../fixtures/index';
+import withMainActivity from '../src/withMainActivity';
+import { ExpoConfig } from '@expo/config-types';
+import { ConfigProps } from '../src/common/types';
+
+// Define a custom type that extends ExpoConfig
+interface CustomExpoConfig extends ExpoConfig {
+ modResults: {
+ language: string;
+ contents: string;
+ };
+}
+
+// the real withMainActivity doesnt return the updated config
+// so we mock it to return the updated config using the callback we pass in the actual implementation
+jest.mock('@expo/config-plugins', () => {
+ const originalModule = jest.requireActual('@expo/config-plugins');
+ return {
+ ...originalModule,
+ withMainActivity: jest.fn((config, callback) => {
+ const updatedConfig: CustomExpoConfig = callback(
+ config as CustomExpoConfig,
+ );
+ return updatedConfig;
+ }),
+ };
+});
+
+const ExpoModulesMainActivity = getFixture('MainActivity.java');
+
+describe('withStreamVideoReactNativeSDKAppDelegate', () => {
+ it('should modify config as per props', () => {
+ // Prepare a mock config
+ const config: CustomExpoConfig = {
+ name: 'test-app',
+ slug: 'test-app',
+ modResults: {
+ language: 'java',
+ contents: ExpoModulesMainActivity,
+ },
+ };
+
+ const props: ConfigProps = {
+ androidPictureInPicture: {
+ enableAutomaticEnter: true,
+ },
+ };
+
+ const updatedConfig = withMainActivity(config, props) as CustomExpoConfig;
+
+ expect(updatedConfig.modResults.contents).toMatch(
+ /StreamVideoReactNative.onPictureInPictureModeChanged/,
+ );
+
+ expect(updatedConfig.modResults.contents).toMatch(
+ /StreamVideoReactNative.canAutoEnterPictureInPictureMode/,
+ );
+
+ const props2: ConfigProps = {
+ androidPictureInPicture: {
+ enableAutomaticEnter: false,
+ },
+ };
+
+ const config2: CustomExpoConfig = {
+ name: 'test-app',
+ slug: 'test-app',
+ modResults: {
+ language: 'java',
+ contents: ExpoModulesMainActivity,
+ },
+ };
+
+ const updatedConfig2 = withMainActivity(
+ config2,
+ props2,
+ ) as CustomExpoConfig;
+
+ expect(updatedConfig2.modResults.contents).not.toMatch(
+ /StreamVideoReactNative.canAutoEnterPictureInPictureMode/,
+ );
+ });
+
+ it('should throw error for malformed manifest and unsupported language', () => {
+ // Prepare a mock config
+ let config: CustomExpoConfig = {
+ name: 'test-app',
+ slug: 'test-app',
+ modResults: {
+ language: 'java',
+ // malformed contents
+ contents: 'blabla',
+ },
+ };
+ const props: ConfigProps = {
+ androidPictureInPicture: {
+ enableAutomaticEnter: true,
+ },
+ };
+ expect(() => withMainActivity(config, props)).toThrow();
+
+ config = {
+ name: 'test-app',
+ slug: 'test-app',
+ modResults: {
+ // unsupported language contents
+ language: 'kt',
+ contents: ExpoModulesMainActivity,
+ },
+ };
+ expect(() => withMainActivity(config, props)).toThrow();
+ });
+});
diff --git a/packages/react-native-sdk/expo-config-plugin/fixtures/MainActivity.java b/packages/react-native-sdk/expo-config-plugin/fixtures/MainActivity.java
new file mode 100644
index 0000000000..4e823f5e40
--- /dev/null
+++ b/packages/react-native-sdk/expo-config-plugin/fixtures/MainActivity.java
@@ -0,0 +1,68 @@
+package io.getstream.expovideosample;
+
+import android.os.Build;
+import android.os.Bundle;
+
+import com.facebook.react.ReactActivity;
+import com.facebook.react.ReactActivityDelegate;
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
+import com.facebook.react.defaults.DefaultReactActivityDelegate;
+
+import expo.modules.ReactActivityDelegateWrapper;
+
+public class MainActivity extends ReactActivity {
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ // Set the theme to AppTheme BEFORE onCreate to support
+ // coloring the background, status bar, and navigation bar.
+ // This is required for expo-splash-screen.
+ setTheme(R.style.AppTheme);
+ super.onCreate(null);
+ }
+
+ /**
+ * Returns the name of the main component registered from JavaScript.
+ * This is used to schedule rendering of the component.
+ */
+ @Override
+ protected String getMainComponentName() {
+ return "main";
+ }
+
+ /**
+ * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link
+ * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React
+ * (aka React 18) with two boolean flags.
+ */
+ @Override
+ protected ReactActivityDelegate createReactActivityDelegate() {
+ return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, new DefaultReactActivityDelegate(
+ this,
+ getMainComponentName(),
+ // If you opted-in for the New Architecture, we enable the Fabric Renderer.
+ DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled
+ // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
+ DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
+ ));
+ }
+
+ /**
+ * Align the back button behavior with Android S
+ * where moving root activities to background instead of finishing activities.
+ * @see onBackPressed
+ */
+ @Override
+ public void invokeDefaultOnBackPressed() {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ if (!moveTaskToBack(false)) {
+ // For non-root activities, use the default implementation to finish them.
+ super.invokeDefaultOnBackPressed();
+ }
+ return;
+ }
+
+ // Use the default back button implementation on Android S
+ // because it's doing more than {@link Activity#moveTaskToBack} in fact.
+ super.invokeDefaultOnBackPressed();
+ }
+}
diff --git a/packages/react-native-sdk/expo-config-plugin/fixtures/index.ts b/packages/react-native-sdk/expo-config-plugin/fixtures/index.ts
index 148c7af996..4bbca7c050 100644
--- a/packages/react-native-sdk/expo-config-plugin/fixtures/index.ts
+++ b/packages/react-native-sdk/expo-config-plugin/fixtures/index.ts
@@ -3,6 +3,7 @@ import fs from 'fs';
type FileName =
| 'AppDelegate.mm'
+ | 'MainActivity.java'
| 'MainApplication.java'
| 'AndroidManifest.xml'
| 'app-build.gradle';
diff --git a/packages/react-native-sdk/expo-config-plugin/src/common/addNewLinesToMainActivity.ts b/packages/react-native-sdk/expo-config-plugin/src/common/addNewLinesToMainActivity.ts
new file mode 100644
index 0000000000..59c100a587
--- /dev/null
+++ b/packages/react-native-sdk/expo-config-plugin/src/common/addNewLinesToMainActivity.ts
@@ -0,0 +1,16 @@
+export default function addNewLinesToMainActivity(
+ content: string,
+ toAdd: string[],
+) {
+ const lines = content.trim().split('\n');
+ let lineIndex = lines.length - 1;
+ if (lines[lineIndex] !== '}') {
+ throw Error('Malformed main activity');
+ }
+ toAdd.unshift('');
+ for (const newLine of toAdd) {
+ lines.splice(lineIndex, 0, newLine);
+ lineIndex++;
+ }
+ return lines.join('\n');
+}
diff --git a/packages/react-native-sdk/expo-config-plugin/src/common/types.ts b/packages/react-native-sdk/expo-config-plugin/src/common/types.ts
index d7c61ea22d..b741cda6f8 100644
--- a/packages/react-native-sdk/expo-config-plugin/src/common/types.ts
+++ b/packages/react-native-sdk/expo-config-plugin/src/common/types.ts
@@ -3,9 +3,14 @@ export type RingingPushNotifications = {
includesCallsInRecentsIos?: boolean;
};
+export type AndroidPictureInPicture = {
+ enableAutomaticEnter: boolean;
+};
+
export type ConfigProps =
| {
ringingPushNotifications?: RingingPushNotifications;
enableNonRingingPushNotifications?: boolean;
+ androidPictureInPicture?: AndroidPictureInPicture;
}
| undefined;
diff --git a/packages/react-native-sdk/expo-config-plugin/src/index.ts b/packages/react-native-sdk/expo-config-plugin/src/index.ts
index 627ad76538..8c71c32354 100644
--- a/packages/react-native-sdk/expo-config-plugin/src/index.ts
+++ b/packages/react-native-sdk/expo-config-plugin/src/index.ts
@@ -5,10 +5,11 @@ import {
} from '@expo/config-plugins';
import withStreamVideoReactNativeSDKAppDelegate from './withStreamVideoReactNativeSDKAppDelegate';
import withPushAppDelegate from './withPushAppDelegate';
-import withStreamVideoReactNativeSDKMainApplication from './withMainApplication';
-import withStreamVideoReactNativeSDKAndroidPermissions from './withAndroidPermissions';
-import withStreamVideoReactNativeSDKManifest from './withAndroidManifest';
-import withStreamVideoReactNativeSDKiOSInfoPList from './withiOSInfoPlist';
+import withMainApplication from './withMainApplication';
+import withAndroidPermissions from './withAndroidPermissions';
+import withAndroidManifest from './withAndroidManifest';
+import withiOSInfoPlist from './withiOSInfoPlist';
+import withMainActivity from './withMainActivity';
import withBuildProperties from './withBuildProperties';
import withAppBuildGradle from './withAppBuildGradle';
import { ConfigProps } from './common/types';
@@ -21,14 +22,17 @@ const withStreamVideoReactNativeSDK: ConfigPlugin = (
props,
) => {
return withPlugins(config, [
+ // ios
() => withPushAppDelegate(config, props),
withStreamVideoReactNativeSDKAppDelegate,
- withStreamVideoReactNativeSDKMainApplication,
- withStreamVideoReactNativeSDKAndroidPermissions,
- withStreamVideoReactNativeSDKManifest,
+ () => withiOSInfoPlist(config, props),
+ // android
+ withMainApplication,
+ withAndroidPermissions,
withAppBuildGradle,
withBuildProperties,
- () => withStreamVideoReactNativeSDKiOSInfoPList(config, props),
+ () => withAndroidManifest(config, props),
+ () => withMainActivity(config, props),
]);
};
diff --git a/packages/react-native-sdk/expo-config-plugin/src/withAndroidManifest.ts b/packages/react-native-sdk/expo-config-plugin/src/withAndroidManifest.ts
index dbca781feb..8301cbf100 100644
--- a/packages/react-native-sdk/expo-config-plugin/src/withAndroidManifest.ts
+++ b/packages/react-native-sdk/expo-config-plugin/src/withAndroidManifest.ts
@@ -3,7 +3,9 @@ import {
ConfigPlugin,
withAndroidManifest,
} from '@expo/config-plugins';
-const { prefixAndroidKeys, getMainApplicationOrThrow } = AndroidConfig.Manifest;
+import { ConfigProps } from './common/types';
+const { prefixAndroidKeys, getMainApplicationOrThrow, getMainActivityOrThrow } =
+ AndroidConfig.Manifest;
// extract the type from array
type Unpacked = T extends Array
@@ -33,11 +35,15 @@ function getNotifeeService() {
} as ManifestService;
}
-const withStreamVideoReactNativeSDKManifest: ConfigPlugin = (configuration) => {
+const withStreamVideoReactNativeSDKManifest: ConfigPlugin = (
+ configuration,
+ props,
+) => {
return withAndroidManifest(configuration, (config) => {
try {
const androidManifest = config.modResults;
const mainApplication = getMainApplicationOrThrow(androidManifest);
+ /* Add the notifeee Service */
let services = mainApplication.service ?? [];
// we filter out the existing notifee service (if any) so that we can override it
services = services.filter(
@@ -46,8 +52,29 @@ const withStreamVideoReactNativeSDKManifest: ConfigPlugin = (configuration) => {
);
services.push(getNotifeeService());
mainApplication.service = services;
+
+ if (props?.androidPictureInPicture) {
+ const mainActivity = getMainActivityOrThrow(androidManifest);
+ ('keyboard|keyboardHidden|orientation|screenSize|uiMode');
+ const currentConfigChangesArray = mainActivity.$[
+ 'android:configChanges'
+ ]
+ ? mainActivity.$['android:configChanges'].split('|')
+ : [];
+ const neededConfigChangesArray =
+ 'screenSize|smallestScreenSize|screenLayout|orientation'.split('|');
+ // Create a Set from the two arrays.
+ const set = new Set([
+ ...currentConfigChangesArray,
+ ...neededConfigChangesArray,
+ ]);
+ const mergedConfigChanges = [...set];
+ mainActivity.$['android:configChanges'] = mergedConfigChanges.join('|');
+ mainActivity.$['android:supportsPictureInPicture'] = 'true';
+ }
config.modResults = androidManifest;
} catch (error: any) {
+ console.log(error);
throw new Error(
'Cannot setup StreamVideoReactNativeSDK because the AndroidManifest is malformed',
);
diff --git a/packages/react-native-sdk/expo-config-plugin/src/withMainActivity.ts b/packages/react-native-sdk/expo-config-plugin/src/withMainActivity.ts
new file mode 100644
index 0000000000..2a8578425a
--- /dev/null
+++ b/packages/react-native-sdk/expo-config-plugin/src/withMainActivity.ts
@@ -0,0 +1,97 @@
+import { ConfigPlugin, withMainActivity } from '@expo/config-plugins';
+import { addImports } from '@expo/config-plugins/build/android/codeMod';
+import { ConfigProps } from './common/types';
+import addNewLinesToMainActivity from './common/addNewLinesToMainActivity';
+
+const withStreamVideoReactNativeSDKMainActivity: ConfigPlugin = (
+ configuration,
+ props,
+) => {
+ return withMainActivity(configuration, (config) => {
+ if (['java'].includes(config.modResults.language)) {
+ try {
+ /*
+ import com.streamvideo.reactnative.StreamVideoReactNative;
+ import android.util.Rational;
+ import androidx.lifecycle.Lifecycle;
+ import android.app.PictureInPictureParams;
+ */
+ config.modResults.contents = addImports(
+ config.modResults.contents,
+ [
+ 'com.streamvideo.reactnative.StreamVideoReactNative',
+ 'android.util.Rational',
+ 'androidx.lifecycle.Lifecycle',
+ 'android.app.PictureInPictureParams',
+ ],
+ config.modResults.language === 'java',
+ );
+ config.modResults.contents = addOnPictureInPictureModeChanged(
+ config.modResults.contents,
+ );
+ if (props?.androidPictureInPicture?.enableAutomaticEnter) {
+ config.modResults.contents = addOnUserLeaveHint(
+ config.modResults.contents,
+ );
+ }
+ } catch (error: any) {
+ throw new Error(
+ "Cannot add StreamVideoReactNativeSDK to the project's MainApplication because it's malformed.",
+ );
+ }
+ } else {
+ throw new Error(
+ 'Cannot setup StreamVideoReactNativeSDK because the MainApplication is not in Java',
+ );
+ }
+ return config;
+ });
+};
+
+function addOnPictureInPictureModeChanged(contents: string) {
+ if (
+ !contents.includes('StreamVideoReactNative.onPictureInPictureModeChanged')
+ ) {
+ const statementToInsert = `
+ @Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ if (getLifecycle().getCurrentState() == Lifecycle.State.CREATED) {
+ // when user clicks on Close button of PIP
+ finishAndRemoveTask();
+ } else {
+ StreamVideoReactNative.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ }
+ }`;
+ contents = addNewLinesToMainActivity(
+ contents,
+ statementToInsert.trim().split('\n'),
+ );
+ }
+ return contents;
+}
+
+function addOnUserLeaveHint(contents: string) {
+ if (
+ !contents.includes(
+ 'StreamVideoReactNative.canAutoEnterPictureInPictureMode',
+ )
+ ) {
+ const statementToInsert = `
+ @Override
+ public void onUserLeaveHint () {
+ if (StreamVideoReactNative.canAutoEnterPictureInPictureMode) {
+ PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+ builder.setAspectRatio(new Rational(480, 640));
+ enterPictureInPictureMode(builder.build());
+ }
+ }`;
+ contents = addNewLinesToMainActivity(
+ contents,
+ statementToInsert.trim().split('\n'),
+ );
+ }
+ return contents;
+}
+
+export default withStreamVideoReactNativeSDKMainActivity;
diff --git a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx
index cfb9a16bb0..0e18b20e26 100644
--- a/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx
+++ b/packages/react-native-sdk/src/components/Call/CallContent/CallContent.tsx
@@ -30,6 +30,7 @@ import {
CallParticipantsListComponentProps,
CallParticipantsListProps,
} from '../CallParticipantsList';
+import { useIsInPiPMode, useAutoEnterPiPEffect } from '../../../hooks';
export type StreamReactionType = StreamReaction & {
icon: string;
@@ -111,19 +112,24 @@ export const CallContent = ({
useLocalParticipant,
} = useCallStateHooks();
+ useAutoEnterPiPEffect();
+
const _remoteParticipants = useRemoteParticipants();
const remoteParticipants = useDebouncedValue(_remoteParticipants, 300); // we debounce the remote participants to avoid unnecessary rerenders that happen when participant tracks are all subscribed simultaneously
const localParticipant = useLocalParticipant();
-
+ const isInPiPMode = useIsInPiPMode();
const hasScreenShare = useHasOngoingScreenShare();
const showSpotlightLayout = hasScreenShare || layout === 'spotlight';
const showFloatingView =
!showSpotlightLayout &&
+ !isInPiPMode &&
remoteParticipants.length > 0 &&
remoteParticipants.length < 3;
const isRemoteParticipantInFloatingView =
- showRemoteParticipantInFloatingView && remoteParticipants.length === 1;
+ showFloatingView &&
+ showRemoteParticipantInFloatingView &&
+ remoteParticipants.length === 1;
/**
* This hook is used to handle IncallManager specs of the application.
@@ -150,8 +156,10 @@ export const CallContent = ({
}, []);
const participantViewProps: ParticipantViewComponentProps = {
- ParticipantLabel,
- ParticipantNetworkQualityIndicator,
+ ParticipantLabel: isInPiPMode ? null : ParticipantLabel,
+ ParticipantNetworkQualityIndicator: isInPiPMode
+ ? null
+ : ParticipantNetworkQualityIndicator,
ParticipantReaction,
ParticipantVideoFallback,
VideoRenderer,
@@ -187,7 +195,7 @@ export const CallContent = ({
// and allows only the top and floating view (its child views) to take up the touches
pointerEvents="box-none"
>
- {CallTopView && (
+ {!isInPiPMode && CallTopView && (
- {CallControls && (
+ {!isInPiPMode && CallControls && (
0 && remoteParticipants.length < 3;
+ !isInPiPMode &&
+ remoteParticipants.length > 0 &&
+ remoteParticipants.length < 3;
- const participants = showFloatingView
+ let participants = showFloatingView
? showLocalParticipant && localParticipant
? [localParticipant]
: remoteParticipants
: allParticipants;
+ if (isInPiPMode) {
+ participants =
+ remoteParticipants.length > 0
+ ? [remoteParticipants[0]]
+ : localParticipant
+ ? [localParticipant]
+ : [];
+ }
+
const participantViewProps: CallParticipantsListComponentProps = {
ParticipantView,
ParticipantLabel,
diff --git a/packages/react-native-sdk/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx b/packages/react-native-sdk/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx
index 905c4a5f1d..40a15e8525 100644
--- a/packages/react-native-sdk/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx
+++ b/packages/react-native-sdk/src/components/Call/CallLayout/CallParticipantsSpotlight.tsx
@@ -18,6 +18,7 @@ import {
} from '../../Participant';
import { useTheme } from '../../../contexts/ThemeContext';
import { CallContentProps } from '../CallContent';
+import { useIsInPiPMode } from '../../../hooks';
/**
* Props for the CallParticipantsSpotlight component.
@@ -62,6 +63,8 @@ export const CallParticipantsSpotlight = ({
const isScreenShareOnSpotlight = hasScreenShare(participantInSpotlight);
const isUserAloneInCall = _allParticipants?.length === 1;
+ const isInPiP = useIsInPiPMode();
+
const participantViewProps: ParticipantViewComponentProps = {
ParticipantLabel,
ParticipantNetworkQualityIndicator,
@@ -117,7 +120,7 @@ export const CallParticipantsSpotlight = ({
{...participantViewProps}
/>
)}
- {!isUserAloneInCall && (
+ {!isInPiP && !isUserAloneInCall && (
{
+ if (Platform.OS !== 'android') {
+ return;
+ }
+
+ NativeModules.StreamVideoReactNative.canAutoEnterPipMode(true);
+
+ return () => {
+ NativeModules.StreamVideoReactNative.canAutoEnterPipMode(false);
+ };
+ }, []);
+}
diff --git a/packages/react-native-sdk/src/hooks/useIsInPiPMode.tsx b/packages/react-native-sdk/src/hooks/useIsInPiPMode.tsx
new file mode 100644
index 0000000000..ba3f0d593a
--- /dev/null
+++ b/packages/react-native-sdk/src/hooks/useIsInPiPMode.tsx
@@ -0,0 +1,29 @@
+import { useEffect, useState } from 'react';
+import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
+
+export function useIsInPiPMode() {
+ const [isInPiPMode, setIsInPiPMode] = useState(false);
+
+ useEffect(() => {
+ if (Platform.OS !== 'android') {
+ return;
+ }
+
+ const eventEmitter = new NativeEventEmitter(
+ NativeModules.StreamVideoReactNative,
+ );
+
+ const subscription = eventEmitter.addListener(
+ 'StreamVideoReactNative_PIP_CHANGE_EVENT',
+ (isPiPEnabled: boolean) => {
+ setIsInPiPMode(isPiPEnabled);
+ },
+ );
+
+ return () => {
+ subscription.remove();
+ };
+ }, []);
+
+ return isInPiPMode;
+}
diff --git a/packages/react-native-sdk/src/providers/MediaStreamManagement.tsx b/packages/react-native-sdk/src/providers/MediaStreamManagement.tsx
index 7d76ab57cc..a8ad1ee9ad 100644
--- a/packages/react-native-sdk/src/providers/MediaStreamManagement.tsx
+++ b/packages/react-native-sdk/src/providers/MediaStreamManagement.tsx
@@ -1,6 +1,7 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings';
import { useAppStateListener } from '../utils/hooks';
+import { NativeModules, Platform } from 'react-native';
export type MediaDevicesInitialState = {
/**
@@ -38,7 +39,19 @@ export const MediaStreamManagement = ({
call?.camera?.resume();
},
() => {
- call?.camera?.disable();
+ if (Platform.OS === 'android') {
+ // in Android, we need to check if we are in PiP mode
+ // in PiP mode, we don't want to disable the camera
+ NativeModules?.StreamVideoReactNative?.isInPiPMode().then(
+ (isInPiP: boolean) => {
+ if (!isInPiP) {
+ call?.camera?.disable();
+ }
+ },
+ );
+ } else {
+ call?.camera?.disable();
+ }
},
);
diff --git a/packages/react-native-sdk/src/utils/enterPiPAndroid.ts b/packages/react-native-sdk/src/utils/enterPiPAndroid.ts
new file mode 100644
index 0000000000..63a5d9cce6
--- /dev/null
+++ b/packages/react-native-sdk/src/utils/enterPiPAndroid.ts
@@ -0,0 +1,11 @@
+import { NativeModules, Platform } from 'react-native';
+
+export function enterPiPAndroid(width?: number, height?: number) {
+ if (Platform.OS !== 'android') {
+ return;
+ }
+ return NativeModules?.StreamVideoReactNative?.enterPipMode(
+ width ? Math.floor(width) : 0,
+ height ? Math.floor(height) : 0,
+ );
+}
diff --git a/packages/react-native-sdk/src/utils/index.ts b/packages/react-native-sdk/src/utils/index.ts
index d1efc7b5a2..516c40d831 100644
--- a/packages/react-native-sdk/src/utils/index.ts
+++ b/packages/react-native-sdk/src/utils/index.ts
@@ -36,4 +36,5 @@ export const getInitialsOfName = (name: string) => {
return initials;
};
+export * from './enterPiPAndroid';
export * from './StreamVideoRN';
diff --git a/sample-apps/react-native/dogfood/android/app/src/main/AndroidManifest.xml b/sample-apps/react-native/dogfood/android/app/src/main/AndroidManifest.xml
index 8721ddbdb9..4ab63678bb 100644
--- a/sample-apps/react-native/dogfood/android/app/src/main/AndroidManifest.xml
+++ b/sample-apps/react-native/dogfood/android/app/src/main/AndroidManifest.xml
@@ -1,4 +1,5 @@
-
+
@@ -32,9 +33,11 @@
android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false" android:theme="@style/AppTheme">
+ android:exported="true"
+ tools:targetApi="n">
@@ -69,4 +72,4 @@
-
\ No newline at end of file
+
diff --git a/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainActivity.java b/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainActivity.java
index 6f1bf11192..fd87103d25 100644
--- a/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainActivity.java
+++ b/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainActivity.java
@@ -1,11 +1,14 @@
package io.getstream.rnvideosample;
+import android.app.PictureInPictureParams;
import android.os.Bundle;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
-import io.wazo.callkeep.RNCallKeepModule;
+import com.streamvideo.reactnative.StreamVideoReactNative;
+import android.util.Rational;
+import androidx.lifecycle.Lifecycle;
public class MainActivity extends ReactActivity {
@@ -40,4 +43,24 @@ protected ReactActivityDelegate createReactActivityDelegate() {
DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled
);
}
+
+ @Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ if (getLifecycle().getCurrentState() == Lifecycle.State.CREATED) {
+ // when user clicks on Close button of PIP
+ finishAndRemoveTask();
+ } else {
+ StreamVideoReactNative.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ }
+ }
+
+ @Override
+ public void onUserLeaveHint () {
+ if (StreamVideoReactNative.canAutoEnterPictureInPictureMode) {
+ PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+ builder.setAspectRatio(new Rational(480, 640));
+ enterPictureInPictureMode(builder.build());
+ }
+ }
}
diff --git a/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainApplication.java b/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainApplication.java
index 7436f1ffc0..f00afd274e 100644
--- a/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainApplication.java
+++ b/sample-apps/react-native/dogfood/android/app/src/main/java/io/getstream/rnvideosample/MainApplication.java
@@ -9,7 +9,6 @@
import com.facebook.react.ReactPackage;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactNativeHost;
-import com.facebook.soloader.SoLoader;
import java.util.List;
public class MainApplication extends Application implements ReactApplication {
diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock
index 4acb7a2aec..52cbb7e75e 100644
--- a/sample-apps/react-native/dogfood/ios/Podfile.lock
+++ b/sample-apps/react-native/dogfood/ios/Podfile.lock
@@ -91,9 +91,9 @@ PODS:
- hermes-engine/Pre-built (= 0.71.11)
- hermes-engine/Pre-built (0.71.11)
- libevent (2.1.12)
- - MMKV (1.3.1):
- - MMKVCore (~> 1.3.1)
- - MMKVCore (1.3.1)
+ - MMKV (1.2.16):
+ - MMKVCore (~> 1.2.16)
+ - MMKVCore (1.2.16)
- OpenSSL-Universal (1.1.1100)
- RCT-Folly (2021.07.22.00):
- boost
@@ -519,7 +519,7 @@ PODS:
- React-Core
- RNVoipPushNotification (3.3.1):
- React-Core
- - SocketRocket (0.6.1)
+ - SocketRocket (0.6.0)
- stream-react-native-webrtc (104.0.1):
- React-Core
- WebRTC-SDK (= 104.5112.17)
@@ -781,8 +781,8 @@ SPEC CHECKSUMS:
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
hermes-engine: 34c863b446d0135b85a6536fa5fd89f48196f848
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
- MMKV: 5a07930c70c70b86cd87761a42c8f3836fb681d7
- MMKVCore: e50135dbd33235b6ab390635991bab437ab873c0
+ MMKV: 784471ce430a2e2d16afef9053d63fab1b357d9e
+ MMKVCore: 9cfef4c48c6c46f66226fc2e4634d78490206a48
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
RCTRequired: f6187ec763637e6a57f5728dd9a3bdabc6d6b4e0
@@ -832,7 +832,7 @@ SPEC CHECKSUMS:
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315
RNVoipPushNotification: 1617f5a07be24066830213ae9252cb790b53886f
- SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
+ SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608
stream-react-native-webrtc: 3e45950d539248d24c1a0a3eafa0f98b5a343ab3
stream-video-react-native: 88fb5445bc4eb134d60b45a052534f3632734629
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
diff --git a/sample-apps/react-native/expo-video-sample/android/app/src/main/AndroidManifest.xml b/sample-apps/react-native/expo-video-sample/android/app/src/main/AndroidManifest.xml
index e3df1a76ec..21104bb2fc 100644
--- a/sample-apps/react-native/expo-video-sample/android/app/src/main/AndroidManifest.xml
+++ b/sample-apps/react-native/expo-video-sample/android/app/src/main/AndroidManifest.xml
@@ -37,7 +37,7 @@
-
+
diff --git a/sample-apps/react-native/expo-video-sample/android/app/src/main/java/io/getstream/expovideosample/MainActivity.java b/sample-apps/react-native/expo-video-sample/android/app/src/main/java/io/getstream/expovideosample/MainActivity.java
index 63c103484f..d1092a846a 100644
--- a/sample-apps/react-native/expo-video-sample/android/app/src/main/java/io/getstream/expovideosample/MainActivity.java
+++ b/sample-apps/react-native/expo-video-sample/android/app/src/main/java/io/getstream/expovideosample/MainActivity.java
@@ -1,4 +1,8 @@
package io.getstream.expovideosample;
+import android.app.PictureInPictureParams;
+import androidx.lifecycle.Lifecycle;
+import android.util.Rational;
+import com.streamvideo.reactnative.StreamVideoReactNative;
import android.os.Build;
import android.os.Bundle;
@@ -62,4 +66,24 @@ public void invokeDefaultOnBackPressed() {
// because it's doing more than {@link Activity#moveTaskToBack} in fact.
super.invokeDefaultOnBackPressed();
}
-}
+
+@Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ if (getLifecycle().getCurrentState() == Lifecycle.State.CREATED) {
+ // when user clicks on Close button of PIP
+ finishAndRemoveTask();
+ } else {
+ StreamVideoReactNative.onPictureInPictureModeChanged(isInPictureInPictureMode);
+ }
+ }
+
+@Override
+ public void onUserLeaveHint () {
+ if (StreamVideoReactNative.canAutoEnterPictureInPictureMode) {
+ PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+ builder.setAspectRatio(new Rational(480, 640));
+ enterPictureInPictureMode(builder.build());
+ }
+ }
+}
\ No newline at end of file
diff --git a/sample-apps/react-native/expo-video-sample/app.json b/sample-apps/react-native/expo-video-sample/app.json
index 9203cea996..f9704d18fd 100644
--- a/sample-apps/react-native/expo-video-sample/app.json
+++ b/sample-apps/react-native/expo-video-sample/app.json
@@ -37,7 +37,10 @@
"disableVideoIos": false,
"includesCallsInRecentsIos": false
},
- "enableNonRingingPushNotifications": true
+ "enableNonRingingPushNotifications": true,
+ "androidPictureInPicture": {
+ "enableAutomaticEnter": true
+ }
}
],
"@config-plugins/react-native-callkeep",
diff --git a/sample-apps/react-native/expo-video-sample/ios/expovideosample.xcodeproj/project.pbxproj b/sample-apps/react-native/expo-video-sample/ios/expovideosample.xcodeproj/project.pbxproj
index 65a12c1d82..c03bb3e360 100644
--- a/sample-apps/react-native/expo-video-sample/ios/expovideosample.xcodeproj/project.pbxproj
+++ b/sample-apps/react-native/expo-video-sample/ios/expovideosample.xcodeproj/project.pbxproj
@@ -379,7 +379,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.expovideosample;
- PRODUCT_NAME = expovideosample;
+ PRODUCT_NAME = "expovideosample";
SWIFT_OBJC_BRIDGING_HEADER = "expovideosample/expovideosample-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -415,7 +415,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = io.getstream.expovideosample;
- PRODUCT_NAME = expovideosample;
+ PRODUCT_NAME = "expovideosample";
SWIFT_OBJC_BRIDGING_HEADER = "expovideosample/expovideosample-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
diff --git a/sample-apps/react-native/expo-video-sample/ios/expovideosample/Info.plist b/sample-apps/react-native/expo-video-sample/ios/expovideosample/Info.plist
index fcb0d96320..e9d56318e6 100644
--- a/sample-apps/react-native/expo-video-sample/ios/expovideosample/Info.plist
+++ b/sample-apps/react-native/expo-video-sample/ios/expovideosample/Info.plist
@@ -56,6 +56,8 @@
NSUserActivityTypes
$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
+ $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
+ $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
UIBackgroundModes