From b3683068d4d80e0c7a54638e5a178afff6112f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C3=BAl=20Ibarra=20Corretg=C3=A9?= Date: Thu, 1 Feb 2018 17:02:07 +0100 Subject: [PATCH] [RN] Add Picture-in-Picture support This only works automatically on Android >= 8. On other platforms / versions, it relies on the SDK user on implementing a "reduced UI" mode and reacting to the "request PIP" delegate method. --- android/README.md | 47 +++++++- android/app/src/main/AndroidManifest.xml | 4 +- .../java/org/jitsi/meet/MainActivity.java | 4 +- .../org/jitsi/meet/sdk/JitsiMeetActivity.java | 52 +++++++-- .../org/jitsi/meet/sdk/JitsiMeetView.java | 75 +++++++++++++ .../meet/sdk/PictureInPictureModule.java | 61 +++++++++++ ios/README.md | 28 +++++ ios/sdk/src/JitsiMeetView.h | 2 + ios/sdk/src/JitsiMeetView.m | 22 +++- ios/sdk/src/JitsiMeetViewDelegate.h | 9 ++ react/features/app/components/App.native.js | 7 ++ .../mobile/external-api/middleware.js | 6 ++ .../mobile/picture-in-picture/actionTypes.js | 22 ++++ .../mobile/picture-in-picture/actions.js | 37 +++++++ .../mobile/picture-in-picture/functions.js | 19 ++++ .../mobile/picture-in-picture/index.js | 6 ++ .../mobile/picture-in-picture/middleware.js | 100 ++++++++++++++++++ .../mobile/picture-in-picture/reducer.js | 15 +++ .../toolbox/components/Toolbox.native.js | 43 +++++++- 19 files changed, 542 insertions(+), 17 deletions(-) create mode 100644 android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java create mode 100644 react/features/mobile/picture-in-picture/actionTypes.js create mode 100644 react/features/mobile/picture-in-picture/actions.js create mode 100644 react/features/mobile/picture-in-picture/functions.js create mode 100644 react/features/mobile/picture-in-picture/index.js create mode 100644 react/features/mobile/picture-in-picture/middleware.js create mode 100644 react/features/mobile/picture-in-picture/reducer.js diff --git a/android/README.md b/android/README.md index f7d392245..166d7b6e3 100644 --- a/android/README.md +++ b/android/README.md @@ -118,8 +118,8 @@ public class MainActivity extends AppCompatActivity { } @Override - protected void onPause() { - super.onPause(); + protected void onStop() { + super.onStop(); JitsiMeetView.onHostPause(this); } @@ -142,6 +142,10 @@ which displays a single `JitsiMeetView`. See JitsiMeetView.getDefaultURL. +#### getPictureInPictureAvailable() + +See JitsiMeetView.getPictureInPictureAvailable. + #### getWelcomePageEnabled() See JitsiMeetView.getWelcomePageEnabled. @@ -154,6 +158,10 @@ See JitsiMeetView.loadURL. See JitsiMeetView.setDefaultURL. +#### setPictureInPictureAvailable(Boolean) + +See JitsiMeetView.setPictureInPictureAvailable. + #### setWelcomePageEnabled(boolean) See JitsiMeetView.setWelcomePageEnabled. @@ -179,6 +187,12 @@ if set to `null`, the default built in JavaScript is used: https://meet.jit.si. Returns the `JitsiMeetViewListener` instance attached to the view. +#### getPictureInPictureAvailable() + +turns true if Picture-in-Picture is available, false otherwise. If the user +doesn't explicitly set it, it will default to true if the platform supports it, +false otherwise. See the Picture-in-Picture section. + #### getWelcomePageEnabled() Returns true if the Welcome page is enabled; otherwise, false. If false, a black @@ -227,6 +241,13 @@ NOTE: Must be called before `loadURL`/`loadURLString` for it to take effect. Sets the given listener (class implementing the `JitsiMeetViewListener` interface) on the view. +#### setPictureInPictureAvailable(Boolean) + +Sets wether Picture-in-Picture is available. When set to `null` if will be +detected at runtime based on platform support. + +NOTE: Must be called before `loadURL`/`loadURLString` for it to take effect. + #### setWelcomePageEnabled(boolean) Sets whether the Welcome page is enabled. See `getWelcomePageEnabled` for more @@ -257,7 +278,8 @@ This is a static method. #### onHostResume(activity) -Helper method which should be called from the activity's `onResume` method. +Helper method which should be called from the activity's `onResume` or `onStop` +method. This is a static method. @@ -269,6 +291,13 @@ activity's `onNewIntent` method. This is a static method. +#### onUserLeaveHint() + +Helper method for integrating automatic Picture-in-Picture. It should be called +from the activity's `onUserLeaveHint` method. + +This is a static method. + #### JitsiMeetViewListener `JitsiMeetViewListener` provides an interface apps can implement to listen to @@ -385,3 +414,15 @@ rules file: -keep class org.jitsi.meet.sdk.** { *; } ``` +## Picture-in-Picture + +The Jitsi Meet app and SDK will enable Android's native Picture-in-Picture mode +iff the platform is supported: for Android >= Oreo. + +If the SDK is integrated in an application which calls +`enterPictureInPictureMode` for the Jitsi Meet activity, the it will self-adjust +by removing some UI elements. + +Alternatively, this can be explicitly disabled with the +`setPctureInPictureAvailable` methods in the Jitsi Meet view or activity. + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fbc29d2b9..526ab3926 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,10 +7,12 @@ android:label="@string/app_name" android:theme="@style/AppTheme"> diff --git a/android/app/src/main/java/org/jitsi/meet/MainActivity.java b/android/app/src/main/java/org/jitsi/meet/MainActivity.java index 7576cb2cd..ae61095e2 100644 --- a/android/app/src/main/java/org/jitsi/meet/MainActivity.java +++ b/android/app/src/main/java/org/jitsi/meet/MainActivity.java @@ -95,7 +95,9 @@ public class MainActivity extends JitsiMeetActivity { @Override protected void onCreate(Bundle savedInstanceState) { // As this is the Jitsi Meet app (i.e. not the Jitsi Meet SDK), we do - // want the Welcome page to be enabled. It defaults to disabled in the + // want to enable some options. + + // The welcome page defaults to disabled in the // SDK at the time of this writing but it is clearer to be explicit // about what we want anyway. setWelcomePageEnabled(true); diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java index c4dcbe3c5..0b8c566f4 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java @@ -17,6 +17,7 @@ package org.jitsi.meet.sdk; import android.content.Intent; +import android.content.res.Configuration; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -41,9 +42,7 @@ import java.net.URL; * hooked to the React Native subsystem via proxy calls through the * {@code JKConferenceView} static methods. */ -public class JitsiMeetActivity - extends AppCompatActivity { - +public class JitsiMeetActivity extends AppCompatActivity { /** * The request code identifying requests for the permission to draw on top * of other apps. The value must be 16-bit and is arbitrarily chosen here. @@ -69,6 +68,12 @@ public class JitsiMeetActivity */ private JitsiMeetView view; + /** + * Whether Picture-in-Picture is available. The value is used only while + * {@link #view} equals {@code null}. + */ + private Boolean pipAvailable; + /** * Whether the Welcome page is enabled. The value is used only while * {@link #view} equals {@code null}. @@ -91,6 +96,15 @@ public class JitsiMeetActivity return view == null ? defaultURL : view.getDefaultURL(); } + /** + * + * @see JitsiMeetView#getPictureInPictureAvailable() + */ + public Boolean getPictureInPictureAvailable() { + return view == null + ? pipAvailable : view.getPictureInPictureAvailable(); + } + /** * * @see JitsiMeetView#getWelcomePageEnabled() @@ -123,6 +137,7 @@ public class JitsiMeetActivity // XXX Before calling JitsiMeetView#loadURL, make sure to call whatever // is documented to need such an order in order to take effect: view.setDefaultURL(defaultURL); + view.setPictureInPictureAvailable(pipAvailable); view.setWelcomePageEnabled(welcomePageEnabled); view.loadURL(null); @@ -224,19 +239,24 @@ public class JitsiMeetActivity } @Override - protected void onPause() { - super.onPause(); + protected void onResume() { + super.onResume(); + + defaultBackButtonImpl = new DefaultHardwareBackBtnHandlerImpl(this); + JitsiMeetView.onHostResume(this, defaultBackButtonImpl); + } + + @Override + public void onStop() { + super.onStop(); JitsiMeetView.onHostPause(this); defaultBackButtonImpl = null; } @Override - protected void onResume() { - super.onResume(); - - defaultBackButtonImpl = new DefaultHardwareBackBtnHandlerImpl(this); - JitsiMeetView.onHostResume(this, defaultBackButtonImpl); + protected void onUserLeaveHint() { + JitsiMeetView.onUserLeaveHint(); } /** @@ -251,6 +271,18 @@ public class JitsiMeetActivity } } + /** + * + * @see JitsiMeetView#setPictureInPictureAvailable(Boolean) + */ + public void setPictureInPictureAvailable(Boolean pipAvailable) { + if (view == null) { + this.pipAvailable = pipAvailable; + } else { + view.setPictureInPictureAvailable(pipAvailable); + } + } + /** * * @see JitsiMeetView#setWelcomePageEnabled(boolean) diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java index 1717ee329..d980019e5 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java @@ -21,6 +21,7 @@ import android.app.Application; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -30,8 +31,11 @@ import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactRootView; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.LifecycleState; import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; +import com.facebook.react.modules.core.DeviceEventManagerModule; import com.rnimmersive.RNImmersiveModule; import java.net.URL; @@ -65,6 +69,7 @@ public class JitsiMeetView extends FrameLayout { new AppInfoModule(reactContext), new AudioModeModule(reactContext), new ExternalAPIModule(reactContext), + new PictureInPictureModule(reactContext), new ProximityModule(reactContext), new WiFiStatsModule(reactContext) ); @@ -243,6 +248,36 @@ public class JitsiMeetView extends FrameLayout { } } + /** + * Activity lifecycle method which should be called from + * {@code Activity.onUserLeaveHint} so we can do the required internal + * processing. + * + * This is currently not mandatory. + */ + public static void onUserLeaveHint() { + sendEvent("onUserLeaveHint", null); + } + + /** + * Helper function to send an event to JavaScript. + * + * @param eventName {@code String} containing the event name. + * @param params {@code WritableMap} optional ancillary data for the event. + */ + private static void sendEvent( + String eventName, @Nullable WritableMap params) { + if (reactInstanceManager != null) { + ReactContext reactContext + = reactInstanceManager.getCurrentReactContext(); + if (reactContext != null) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, params); + } + } + } + /** * The default base {@code URL} used to join a conference when a partial URL * (e.g. a room name only) is specified to {@link #loadURLString(String)} or @@ -264,6 +299,12 @@ public class JitsiMeetView extends FrameLayout { */ private JitsiMeetViewListener listener; + /** + * Whether Picture-in-Picture is available. If {@code null} it will default + * to {@code true} iff the platform supports it. + */ + private Boolean pipAvailable; + /** * React Native root view. */ @@ -328,6 +369,17 @@ public class JitsiMeetView extends FrameLayout { return listener; } + /** + * Gets whether Picture-in-Picture is currently available. It's only + * supported on Android API >= 26 (Oreo), so it should not be enabled on + * older platform versions. + * + * @return {@code true} if PiP is available, {@code false} otherwise. + */ + public Boolean getPictureInPictureAvailable() { + return pipAvailable; + } + /** * Gets whether the Welcome page is enabled. If {@code true}, the Welcome * page is rendered when this {@code JitsiMeetView} is not at a URL @@ -369,12 +421,25 @@ public class JitsiMeetView extends FrameLayout { if (defaultURL != null) { props.putString("defaultURL", defaultURL.toString()); } + // externalAPIScope props.putString("externalAPIScope", externalAPIScope); + + // pipAvailable + boolean pipAvailable_; + if (pipAvailable == null) { + // set it based on platform availability + pipAvailable_ = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } else { + pipAvailable_ = pipAvailable.booleanValue(); + } + props.putBoolean("pipAvailable", pipAvailable_); + // url if (urlObject != null) { props.putBundle("url", urlObject); } + // welcomePageEnabled props.putBoolean("welcomePageEnabled", welcomePageEnabled); @@ -462,6 +527,16 @@ public class JitsiMeetView extends FrameLayout { this.listener = listener; } + /** + * Sets whether Picture-in-Picture is currently available. + * + * @param pipAvailable {@code true} if PiP is available, {@code false} + * otherwise. + */ + public void setPictureInPictureAvailable(Boolean pipAvailable) { + this.pipAvailable = pipAvailable; + } + /** * Sets whether the Welcome page is enabled. Must be called before * {@link #loadURL(URL)} for it to take effect. diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java new file mode 100644 index 000000000..756575823 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/PictureInPictureModule.java @@ -0,0 +1,61 @@ +package org.jitsi.meet.sdk; + +import android.app.Activity; +import android.app.PictureInPictureParams; +import android.os.Build; +import android.util.Log; +import android.util.Rational; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class PictureInPictureModule extends ReactContextBaseJavaModule { + private final static String TAG = "PictureInPicture"; + + public PictureInPictureModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return TAG; + } + + /** + * Enters Picture-in-Picture mode for the current activity. This is only + * supported in Android API >= 26. + * + * @param promise a {@code Promise} which will resolve with a {@code null} + * value in case of success, and an error otherwise. + */ + @ReactMethod + public void enterPictureInPictureMode(Promise promise) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final Activity currentActivity = getCurrentActivity(); + + if (currentActivity == null) { + promise.reject(new Exception("No current Activity!")); + return; + } + + Log.d(TAG, "Entering PiP mode"); + + final PictureInPictureParams.Builder pipParamsBuilder + = new PictureInPictureParams.Builder(); + pipParamsBuilder.setAspectRatio(new Rational(1, 1)).build(); + final boolean r + = currentActivity.enterPictureInPictureMode(pipParamsBuilder.build()); + if (r) { + promise.resolve(null); + } else { + promise.reject(new Exception("Error entering PiP mode")); + } + + return; + } + + promise.reject(new Exception("PiP not supported")); + } +} diff --git a/ios/README.md b/ios/README.md index 5bccb9b25..e8b312ddc 100644 --- a/ios/README.md +++ b/ios/README.md @@ -55,6 +55,13 @@ built in JavaScript is used: https://meet.jit.si. NOTE: Must be set before `loadURL:`/`loadURLString:` for it to take effect. +#### pipAvailable + +Property to get / set wether a Picture-in-Picture mode is available. This must +be implemented by the application at the moment. + +NOTE: Must be set before `loadURL:`/`loadURLString:` for it to take effect. + #### welcomePageEnabled Property to get/set whether the Welcome page is enabled. If `NO`, a black empty @@ -178,3 +185,24 @@ fails. The `data` dictionary contains an "error" key with the error and a "url" key with the conference URL which necessitated the loading of the configuration file. + +#### requestPipMode + +Called when the user requested Picture-in-Picture mode to be entered. At this +point the application should resize the SDK view to a smaller size if it so +desires. + +### Picture-in-Picture + +The Jitsi Meet SDK implements a "reduced UI mode" which will automatically +adjust the UI when presented in a Picture-in-Picture style scenario. Enabling +a native Picture-in-Picture mode on iOS is not currently implemented on the SDK +so applications need to do it themselves. + +When `pipAvailable` is set to `YES` or the `requestPipMode` delegate method is +implemented, the in-call toolbar will show a button to enter PiP mode. It's up +to the application to reduce the size of the SDK view and put it in such mode. + +Once PiP mode has been entered, the SDK will automatically adjust its UI +elements. + diff --git a/ios/sdk/src/JitsiMeetView.h b/ios/sdk/src/JitsiMeetView.h index 254600f1a..e51fa043c 100644 --- a/ios/sdk/src/JitsiMeetView.h +++ b/ios/sdk/src/JitsiMeetView.h @@ -25,6 +25,8 @@ @property (copy, nonatomic, nullable) NSURL *defaultURL; +@property (nonatomic) BOOL pipAvailable; + @property (nonatomic) BOOL welcomePageEnabled; + (BOOL)application:(UIApplication *_Nonnull)application diff --git a/ios/sdk/src/JitsiMeetView.m b/ios/sdk/src/JitsiMeetView.m index 091fb324c..05e283471 100644 --- a/ios/sdk/src/JitsiMeetView.m +++ b/ios/sdk/src/JitsiMeetView.m @@ -109,7 +109,11 @@ void registerFatalErrorHandler() { @end -@implementation JitsiMeetView +@implementation JitsiMeetView { + NSNumber *_pipAvailable; +} + +@dynamic pipAvailable; static RCTBridgeWrapper *bridgeWrapper; @@ -265,6 +269,7 @@ static NSMapTable *views; } props[@"externalAPIScope"] = externalAPIScope; + props[@"pipAvailable"] = @(self.pipAvailable); props[@"welcomePageEnabled"] = @(self.welcomePageEnabled); // XXX If urlObject is nil, then it must appear as undefined in the @@ -315,6 +320,21 @@ static NSMapTable *views; [self loadURLObject:urlString ? @{ @"url": urlString } : nil]; } +#pragma pipAvailable getter / setter + +- (void) setPipAvailable:(BOOL)pipAvailable { + _pipAvailable = [NSNumber numberWithBool:pipAvailable]; +} + +- (BOOL) pipAvailable { + if (_pipAvailable == nil) { + return self.delegate + && [self.delegate respondsToSelector:@selector(requestPipMode:)]; + } + + return [_pipAvailable boolValue]; +} + #pragma mark Private methods /** diff --git a/ios/sdk/src/JitsiMeetViewDelegate.h b/ios/sdk/src/JitsiMeetViewDelegate.h index f0ab68773..f9c188bd3 100644 --- a/ios/sdk/src/JitsiMeetViewDelegate.h +++ b/ios/sdk/src/JitsiMeetViewDelegate.h @@ -65,4 +65,13 @@ */ - (void)loadConfigError:(NSDictionary *)data; +/** + * Called when Picture-in-Picture mode is requested. The app should now resize + * iself to a PiP style and then use the JitsiMeetView.onPipModeChanged to + * notify the JavaScript side about its action. + * + * The `data` dictionary is currently empty. + */ +- (void)requestPipMode:(NSDictionary *)data; + @end diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index cb6ae4559..362a15a7a 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -17,6 +17,7 @@ import '../../mobile/callkit'; import '../../mobile/external-api'; import '../../mobile/full-screen'; import '../../mobile/permissions'; +import '../../mobile/picture-in-picture'; import '../../mobile/proximity'; import '../../mobile/wake-lock'; @@ -36,6 +37,12 @@ export class App extends AbstractApp { static propTypes = { ...AbstractApp.propTypes, + /** + * Whether Picture-in-Picture is available. If available, a button will + * be shown in the {@link Conference} view so the user can enter it. + */ + pipAvailable: PropTypes.bool, + /** * Whether the Welcome page is enabled. If {@code true}, the Welcome * page is rendered when the {@link App} is not at a location (URL) diff --git a/react/features/mobile/external-api/middleware.js b/react/features/mobile/external-api/middleware.js index 559c27603..2c0198bc4 100644 --- a/react/features/mobile/external-api/middleware.js +++ b/react/features/mobile/external-api/middleware.js @@ -14,6 +14,8 @@ import { LOAD_CONFIG_ERROR } from '../../base/config'; import { MiddlewareRegistry } from '../../base/redux'; import { toURLString } from '../../base/util'; +import { REQUEST_PIP_MODE } from '../picture-in-picture'; + /** * Middleware that captures Redux actions and uses the ExternalAPI module to * turn them into native events so the application knows about them. @@ -62,6 +64,10 @@ MiddlewareRegistry.register(store => next => action => { }); break; } + + case REQUEST_PIP_MODE: + _sendEvent(store, _getSymbolDescription(action.type), /* data */ {}); + } return result; diff --git a/react/features/mobile/picture-in-picture/actionTypes.js b/react/features/mobile/picture-in-picture/actionTypes.js new file mode 100644 index 000000000..01dc07458 --- /dev/null +++ b/react/features/mobile/picture-in-picture/actionTypes.js @@ -0,0 +1,22 @@ +/** + * The type of redux action to set the PiP related event listeners. + * + * { + * type: _SET_PIP_MODE_LISTENER, + * listeners: Array|undefined + * } + * + * @protected + */ +export const _SET_PIP_LISTENERS = Symbol('_SET_PIP_LISTENERS'); + +/** + * The type of redux action which signals that the PiP mode is requested. + * + * { + * type: REQUEST_PIP_MODE + * } + * + * @public + */ +export const REQUEST_PIP_MODE = Symbol('REQUEST_PIP_MODE'); diff --git a/react/features/mobile/picture-in-picture/actions.js b/react/features/mobile/picture-in-picture/actions.js new file mode 100644 index 000000000..8740d5562 --- /dev/null +++ b/react/features/mobile/picture-in-picture/actions.js @@ -0,0 +1,37 @@ +// @flow + +import { + _SET_PIP_LISTENERS, + REQUEST_PIP_MODE +} from './actionTypes'; + +/** + * Sets the listeners for the PiP related events. + * + * @param {Array} listeners - Array of listeners to be set. + * @protected + * @returns {{ + * type: _SET_PIP_LISTENERS, + * listeners: Array + * }} + */ +export function _setListeners(listeners: ?Array) { + return { + type: _SET_PIP_LISTENERS, + listeners + }; +} + +/** + * Requests Picture-in-Picture mode. + * + * @public + * @returns {{ + * type: REQUEST_PIP_MODE + * }} + */ +export function requestPipMode() { + return { + type: REQUEST_PIP_MODE + }; +} diff --git a/react/features/mobile/picture-in-picture/functions.js b/react/features/mobile/picture-in-picture/functions.js new file mode 100644 index 000000000..d1f33abd2 --- /dev/null +++ b/react/features/mobile/picture-in-picture/functions.js @@ -0,0 +1,19 @@ +// @flow + +import { NativeModules } from 'react-native'; + +const pip = NativeModules.PictureInPicture; + +/** + * Tells the application to enter the Picture-in-Picture mode, if supported. + * + * @returns {Promise} A promise which is fulfilled when PiP mode was entered, or + * rejected in case there was a problem or it isn't supported. + */ +export function enterPictureInPictureMode(): Promise { + if (pip) { + return pip.enterPictureInPictureMode(); + } + + return Promise.reject(new Error('PiP not supported')); +} diff --git a/react/features/mobile/picture-in-picture/index.js b/react/features/mobile/picture-in-picture/index.js new file mode 100644 index 000000000..969667b3a --- /dev/null +++ b/react/features/mobile/picture-in-picture/index.js @@ -0,0 +1,6 @@ +export * from './actions'; +export * from './actionTypes'; +export * from './functions'; + +import './middleware'; +import './reducer'; diff --git a/react/features/mobile/picture-in-picture/middleware.js b/react/features/mobile/picture-in-picture/middleware.js new file mode 100644 index 000000000..26dd96540 --- /dev/null +++ b/react/features/mobile/picture-in-picture/middleware.js @@ -0,0 +1,100 @@ +// @flow + +import { DeviceEventEmitter } from 'react-native'; + +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../app'; +import { MiddlewareRegistry } from '../../base/redux'; + +import { _setListeners } from './actions'; +import { _SET_PIP_LISTENERS, REQUEST_PIP_MODE } from './actionTypes'; +import { enterPictureInPictureMode } from './functions'; + +/** + * Middleware that handles Picture-in-Picture requests. Currently it enters + * the native PiP mode on Android, when requested. + * + * @param {Store} store - Redux store. + * @returns {Function} + */ +MiddlewareRegistry.register(store => next => action => { + switch (action.type) { + case _SET_PIP_LISTENERS: { + // Remove the current/old listeners. + const { listeners } = store.getState()['features/pip']; + + if (listeners) { + for (const listener of listeners) { + listener.remove(); + } + } + break; + } + + case APP_WILL_MOUNT: + _appWillMount(store); + break; + + case APP_WILL_UNMOUNT: + store.dispatch(_setListeners(undefined)); + break; + + case REQUEST_PIP_MODE: + _enterPictureInPicture(store); + break; + + } + + return next(action); +}); + +/** + * Notifies the feature pip that the action {@link APP_WILL_MOUNT} is being + * dispatched within a specific redux {@code store}. + * + * @param {Store} store - The redux store in which the specified {@code action} + * is being dispatched. + * @param {Dispatch} next - The redux dispatch function to dispatch the + * specified {@code action} to the specified {@code store}. + * @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is + * being dispatched in the specified {@code store}. + * @private + * @returns {*} + */ +function _appWillMount({ dispatch, getState }) { + const context = { + dispatch, + getState + }; + + const listeners = [ + + // Android's onUserLeaveHint activity lifecycle callback + DeviceEventEmitter.addListener('onUserLeaveHint', () => { + _enterPictureInPicture(context); + }) + ]; + + dispatch(_setListeners(listeners)); +} + +/** + * Helper function to enter PiP mode. This is triggered by user request + * (either pressing the button in the toolbox or the home button on Android) + * ans this triggers the PiP mode, iff it's available and we are in a + * conference. + * + * @param {Object} store - Redux store. + * @private + * @returns {void} + */ +function _enterPictureInPicture({ getState }) { + const state = getState(); + const { app } = state['features/app']; + const { conference, joining } = state['features/base/conference']; + + if (app.props.pipAvailable && (conference || joining)) { + enterPictureInPictureMode().catch(e => { + console.warn(`Error entering PiP mode: ${e}`); + }); + } +} diff --git a/react/features/mobile/picture-in-picture/reducer.js b/react/features/mobile/picture-in-picture/reducer.js new file mode 100644 index 000000000..cb0c6f0e4 --- /dev/null +++ b/react/features/mobile/picture-in-picture/reducer.js @@ -0,0 +1,15 @@ +import { ReducerRegistry } from '../../base/redux'; + +import { _SET_PIP_LISTENERS } from './actionTypes'; + +ReducerRegistry.register('features/pip', (state = {}, action) => { + switch (action.type) { + case _SET_PIP_LISTENERS: + return { + ...state, + listeners: action.listeners + }; + } + + return state; +}); diff --git a/react/features/toolbox/components/Toolbox.native.js b/react/features/toolbox/components/Toolbox.native.js index ff0254bc3..20ad0e559 100644 --- a/react/features/toolbox/components/Toolbox.native.js +++ b/react/features/toolbox/components/Toolbox.native.js @@ -23,6 +23,7 @@ import { makeAspectRatioAware } from '../../base/responsive-ui'; import { ColorPalette } from '../../base/styles'; +import { requestPipMode } from '../../mobile/picture-in-picture'; import { beginRoomLockRequest } from '../../room-lock'; import { beginShareRoom } from '../../share-room'; @@ -80,6 +81,11 @@ class Toolbox extends Component { */ _onHangup: PropTypes.func, + /** + * Requests Picture-in-Picture mode. + */ + _onPipRequest: PropTypes.func, + /** * Sets the lock i.e. password protection of the conference/room. */ @@ -101,6 +107,11 @@ class Toolbox extends Component { */ _onToggleCameraFacingMode: PropTypes.func, + /** + * Flag showing whether Picture-in-Picture is available. + */ + _pipAvailable: PropTypes.bool, + /** * Flag showing whether video is muted. */ @@ -296,6 +307,7 @@ class Toolbox extends Component { const underlayColor = 'transparent'; const { _audioOnly: audioOnly, + _pipAvailable: pipAvailable, _videoMuted: videoMuted } = this.props; @@ -305,6 +317,15 @@ class Toolbox extends Component { + { + pipAvailable + && + } { AudioRouteButton &&