Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-native): add picture-in-picture support for Android #1133

Merged
merged 17 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

@JvmStatic
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
this.isInPictureInPictureMode = isInPictureInPictureMode
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,84 @@
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 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) {
}

@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())
}
}

@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"
}
}
Original file line number Diff line number Diff line change
@@ -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.

<!-- vale off -->

<div style={{ display: "flex", justifyContent: "center" }}>
<div style={{ width: "270px" }}>
<video muted controls style={{ maxWidth: "100%" }}>
<source src={PipVideo} type="video/mp4"/>
Alas, your browser doesn't support HTML5 video. That's OK! You can still
<a href={PipVideo}> download the video</a> and watch it with a video player.
</video>
</div>
</div>

<!-- vale on -->

:::note
PiP is currently only supported in Android by the SDK.
:::

## Setup

<Tabs>
<TabItem value="rn" label="React Native" default>

### Changes to AndroidManifest
Add the following attributes to `AndroidManifest.xml` file in `MainActivity`:

```xml title="AndroidManifest.xml"
<activity>
...
android:name=".MainActivity"
// highlight-start
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
// highlight-end
...
</activity>
```

### Changes to MainActivity
Add the following imports to `MainActivity.java`:

<!-- vale off -->

```java title="MainActivity.java"
import android.app.PictureInPictureParams;
import com.streamvideo.reactnative.StreamVideoReactNative;
import android.util.Rational;
import androidx.lifecycle.Lifecycle;
```

<!-- vale on -->

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());
}
}
```
</TabItem>

<TabItem value="expo" label="Expo">

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`.

</TabItem>
</Tabs>


## 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.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand All @@ -44,6 +53,7 @@ describe('withStreamVideoReactNativeSDKManifest', () => {

const updatedConfig = withStreamVideoReactNativeSDKManifest(
config,
props,
) as CustomExpoConfig;

const mainApp = getMainApplicationOrThrow(updatedConfig.modResults);
Expand All @@ -53,16 +63,44 @@ 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', () => {
expect(modifiedConfig?.modResults).toBeDefined();

const updatedConfig = withStreamVideoReactNativeSDKManifest(
modifiedConfig!,
props,
) as CustomExpoConfig;

const mainApp = getMainApplicationOrThrow(updatedConfig.modResults);
Expand All @@ -87,6 +125,8 @@ describe('withStreamVideoReactNativeSDKManifest', () => {
bla: 'blabla',
},
};
expect(() => withStreamVideoReactNativeSDKManifest(config)).toThrow();
expect(() =>
withStreamVideoReactNativeSDKManifest(config, props),
).toThrow();
});
});
Loading
Loading