[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.
This commit is contained in:
Saúl Ibarra Corretgé 2018-02-01 17:02:07 +01:00 committed by Lyubo Marinov
parent 94473e5660
commit b3683068d4
19 changed files with 542 additions and 17 deletions

View File

@ -118,8 +118,8 @@ public class MainActivity extends AppCompatActivity {
} }
@Override @Override
protected void onPause() { protected void onStop() {
super.onPause(); super.onStop();
JitsiMeetView.onHostPause(this); JitsiMeetView.onHostPause(this);
} }
@ -142,6 +142,10 @@ which displays a single `JitsiMeetView`.
See JitsiMeetView.getDefaultURL. See JitsiMeetView.getDefaultURL.
#### getPictureInPictureAvailable()
See JitsiMeetView.getPictureInPictureAvailable.
#### getWelcomePageEnabled() #### getWelcomePageEnabled()
See JitsiMeetView.getWelcomePageEnabled. See JitsiMeetView.getWelcomePageEnabled.
@ -154,6 +158,10 @@ See JitsiMeetView.loadURL.
See JitsiMeetView.setDefaultURL. See JitsiMeetView.setDefaultURL.
#### setPictureInPictureAvailable(Boolean)
See JitsiMeetView.setPictureInPictureAvailable.
#### setWelcomePageEnabled(boolean) #### setWelcomePageEnabled(boolean)
See JitsiMeetView.setWelcomePageEnabled. 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. 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() #### getWelcomePageEnabled()
Returns true if the Welcome page is enabled; otherwise, false. If false, a black 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` Sets the given listener (class implementing the `JitsiMeetViewListener`
interface) on the view. 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) #### setWelcomePageEnabled(boolean)
Sets whether the Welcome page is enabled. See `getWelcomePageEnabled` for more Sets whether the Welcome page is enabled. See `getWelcomePageEnabled` for more
@ -257,7 +278,8 @@ This is a static method.
#### onHostResume(activity) #### 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. This is a static method.
@ -269,6 +291,13 @@ activity's `onNewIntent` method.
This is a static 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
`JitsiMeetViewListener` provides an interface apps can implement to listen to `JitsiMeetViewListener` provides an interface apps can implement to listen to
@ -385,3 +414,15 @@ rules file:
-keep class org.jitsi.meet.sdk.** { *; } -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.

View File

@ -7,10 +7,12 @@
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask" android:launchMode="singleTask"
android:name=".MainActivity" android:name=".MainActivity"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@ -95,7 +95,9 @@ public class MainActivity extends JitsiMeetActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
// As this is the Jitsi Meet app (i.e. not the Jitsi Meet SDK), we do // 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 // SDK at the time of this writing but it is clearer to be explicit
// about what we want anyway. // about what we want anyway.
setWelcomePageEnabled(true); setWelcomePageEnabled(true);

View File

@ -17,6 +17,7 @@
package org.jitsi.meet.sdk; package org.jitsi.meet.sdk;
import android.content.Intent; import android.content.Intent;
import android.content.res.Configuration;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
@ -41,9 +42,7 @@ import java.net.URL;
* hooked to the React Native subsystem via proxy calls through the * hooked to the React Native subsystem via proxy calls through the
* {@code JKConferenceView} static methods. * {@code JKConferenceView} static methods.
*/ */
public class JitsiMeetActivity public class JitsiMeetActivity extends AppCompatActivity {
extends AppCompatActivity {
/** /**
* The request code identifying requests for the permission to draw on top * 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. * of other apps. The value must be 16-bit and is arbitrarily chosen here.
@ -69,6 +68,12 @@ public class JitsiMeetActivity
*/ */
private JitsiMeetView view; 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 * Whether the Welcome page is enabled. The value is used only while
* {@link #view} equals {@code null}. * {@link #view} equals {@code null}.
@ -91,6 +96,15 @@ public class JitsiMeetActivity
return view == null ? defaultURL : view.getDefaultURL(); return view == null ? defaultURL : view.getDefaultURL();
} }
/**
*
* @see JitsiMeetView#getPictureInPictureAvailable()
*/
public Boolean getPictureInPictureAvailable() {
return view == null
? pipAvailable : view.getPictureInPictureAvailable();
}
/** /**
* *
* @see JitsiMeetView#getWelcomePageEnabled() * @see JitsiMeetView#getWelcomePageEnabled()
@ -123,6 +137,7 @@ public class JitsiMeetActivity
// XXX Before calling JitsiMeetView#loadURL, make sure to call whatever // XXX Before calling JitsiMeetView#loadURL, make sure to call whatever
// is documented to need such an order in order to take effect: // is documented to need such an order in order to take effect:
view.setDefaultURL(defaultURL); view.setDefaultURL(defaultURL);
view.setPictureInPictureAvailable(pipAvailable);
view.setWelcomePageEnabled(welcomePageEnabled); view.setWelcomePageEnabled(welcomePageEnabled);
view.loadURL(null); view.loadURL(null);
@ -224,19 +239,24 @@ public class JitsiMeetActivity
} }
@Override @Override
protected void onPause() { protected void onResume() {
super.onPause(); super.onResume();
defaultBackButtonImpl = new DefaultHardwareBackBtnHandlerImpl(this);
JitsiMeetView.onHostResume(this, defaultBackButtonImpl);
}
@Override
public void onStop() {
super.onStop();
JitsiMeetView.onHostPause(this); JitsiMeetView.onHostPause(this);
defaultBackButtonImpl = null; defaultBackButtonImpl = null;
} }
@Override @Override
protected void onResume() { protected void onUserLeaveHint() {
super.onResume(); JitsiMeetView.onUserLeaveHint();
defaultBackButtonImpl = new DefaultHardwareBackBtnHandlerImpl(this);
JitsiMeetView.onHostResume(this, defaultBackButtonImpl);
} }
/** /**
@ -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) * @see JitsiMeetView#setWelcomePageEnabled(boolean)

View File

@ -21,6 +21,7 @@ import android.app.Application;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
@ -30,8 +31,11 @@ import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactRootView; import com.facebook.react.ReactRootView;
import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext; 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.common.LifecycleState;
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.rnimmersive.RNImmersiveModule; import com.rnimmersive.RNImmersiveModule;
import java.net.URL; import java.net.URL;
@ -65,6 +69,7 @@ public class JitsiMeetView extends FrameLayout {
new AppInfoModule(reactContext), new AppInfoModule(reactContext),
new AudioModeModule(reactContext), new AudioModeModule(reactContext),
new ExternalAPIModule(reactContext), new ExternalAPIModule(reactContext),
new PictureInPictureModule(reactContext),
new ProximityModule(reactContext), new ProximityModule(reactContext),
new WiFiStatsModule(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 * 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 * (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; 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. * React Native root view.
*/ */
@ -328,6 +369,17 @@ public class JitsiMeetView extends FrameLayout {
return listener; 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 * Gets whether the Welcome page is enabled. If {@code true}, the Welcome
* page is rendered when this {@code JitsiMeetView} is not at a URL * page is rendered when this {@code JitsiMeetView} is not at a URL
@ -369,12 +421,25 @@ public class JitsiMeetView extends FrameLayout {
if (defaultURL != null) { if (defaultURL != null) {
props.putString("defaultURL", defaultURL.toString()); props.putString("defaultURL", defaultURL.toString());
} }
// externalAPIScope // externalAPIScope
props.putString("externalAPIScope", 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 // url
if (urlObject != null) { if (urlObject != null) {
props.putBundle("url", urlObject); props.putBundle("url", urlObject);
} }
// welcomePageEnabled // welcomePageEnabled
props.putBoolean("welcomePageEnabled", welcomePageEnabled); props.putBoolean("welcomePageEnabled", welcomePageEnabled);
@ -462,6 +527,16 @@ public class JitsiMeetView extends FrameLayout {
this.listener = listener; 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 * Sets whether the Welcome page is enabled. Must be called before
* {@link #loadURL(URL)} for it to take effect. * {@link #loadURL(URL)} for it to take effect.

View File

@ -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"));
}
}

View File

@ -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. 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 #### welcomePageEnabled
Property to get/set whether the Welcome page is enabled. If `NO`, a black empty 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 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 with the conference URL which necessitated the loading of the configuration
file. 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.

View File

@ -25,6 +25,8 @@
@property (copy, nonatomic, nullable) NSURL *defaultURL; @property (copy, nonatomic, nullable) NSURL *defaultURL;
@property (nonatomic) BOOL pipAvailable;
@property (nonatomic) BOOL welcomePageEnabled; @property (nonatomic) BOOL welcomePageEnabled;
+ (BOOL)application:(UIApplication *_Nonnull)application + (BOOL)application:(UIApplication *_Nonnull)application

View File

@ -109,7 +109,11 @@ void registerFatalErrorHandler() {
@end @end
@implementation JitsiMeetView @implementation JitsiMeetView {
NSNumber *_pipAvailable;
}
@dynamic pipAvailable;
static RCTBridgeWrapper *bridgeWrapper; static RCTBridgeWrapper *bridgeWrapper;
@ -265,6 +269,7 @@ static NSMapTable<NSString *, JitsiMeetView *> *views;
} }
props[@"externalAPIScope"] = externalAPIScope; props[@"externalAPIScope"] = externalAPIScope;
props[@"pipAvailable"] = @(self.pipAvailable);
props[@"welcomePageEnabled"] = @(self.welcomePageEnabled); props[@"welcomePageEnabled"] = @(self.welcomePageEnabled);
// XXX If urlObject is nil, then it must appear as undefined in the // XXX If urlObject is nil, then it must appear as undefined in the
@ -315,6 +320,21 @@ static NSMapTable<NSString *, JitsiMeetView *> *views;
[self loadURLObject:urlString ? @{ @"url": urlString } : nil]; [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 #pragma mark Private methods
/** /**

View File

@ -65,4 +65,13 @@
*/ */
- (void)loadConfigError:(NSDictionary *)data; - (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 @end

View File

@ -17,6 +17,7 @@ import '../../mobile/callkit';
import '../../mobile/external-api'; import '../../mobile/external-api';
import '../../mobile/full-screen'; import '../../mobile/full-screen';
import '../../mobile/permissions'; import '../../mobile/permissions';
import '../../mobile/picture-in-picture';
import '../../mobile/proximity'; import '../../mobile/proximity';
import '../../mobile/wake-lock'; import '../../mobile/wake-lock';
@ -36,6 +37,12 @@ export class App extends AbstractApp {
static propTypes = { static propTypes = {
...AbstractApp.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 * Whether the Welcome page is enabled. If {@code true}, the Welcome
* page is rendered when the {@link App} is not at a location (URL) * page is rendered when the {@link App} is not at a location (URL)

View File

@ -14,6 +14,8 @@ import { LOAD_CONFIG_ERROR } from '../../base/config';
import { MiddlewareRegistry } from '../../base/redux'; import { MiddlewareRegistry } from '../../base/redux';
import { toURLString } from '../../base/util'; import { toURLString } from '../../base/util';
import { REQUEST_PIP_MODE } from '../picture-in-picture';
/** /**
* Middleware that captures Redux actions and uses the ExternalAPI module to * Middleware that captures Redux actions and uses the ExternalAPI module to
* turn them into native events so the application knows about them. * turn them into native events so the application knows about them.
@ -62,6 +64,10 @@ MiddlewareRegistry.register(store => next => action => {
}); });
break; break;
} }
case REQUEST_PIP_MODE:
_sendEvent(store, _getSymbolDescription(action.type), /* data */ {});
} }
return result; return result;

View File

@ -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');

View File

@ -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<any>) {
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
};
}

View File

@ -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<void> {
if (pip) {
return pip.enterPictureInPictureMode();
}
return Promise.reject(new Error('PiP not supported'));
}

View File

@ -0,0 +1,6 @@
export * from './actions';
export * from './actionTypes';
export * from './functions';
import './middleware';
import './reducer';

View File

@ -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}`);
});
}
}

View File

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

View File

@ -23,6 +23,7 @@ import {
makeAspectRatioAware makeAspectRatioAware
} from '../../base/responsive-ui'; } from '../../base/responsive-ui';
import { ColorPalette } from '../../base/styles'; import { ColorPalette } from '../../base/styles';
import { requestPipMode } from '../../mobile/picture-in-picture';
import { beginRoomLockRequest } from '../../room-lock'; import { beginRoomLockRequest } from '../../room-lock';
import { beginShareRoom } from '../../share-room'; import { beginShareRoom } from '../../share-room';
@ -80,6 +81,11 @@ class Toolbox extends Component {
*/ */
_onHangup: PropTypes.func, _onHangup: PropTypes.func,
/**
* Requests Picture-in-Picture mode.
*/
_onPipRequest: PropTypes.func,
/** /**
* Sets the lock i.e. password protection of the conference/room. * Sets the lock i.e. password protection of the conference/room.
*/ */
@ -101,6 +107,11 @@ class Toolbox extends Component {
*/ */
_onToggleCameraFacingMode: PropTypes.func, _onToggleCameraFacingMode: PropTypes.func,
/**
* Flag showing whether Picture-in-Picture is available.
*/
_pipAvailable: PropTypes.bool,
/** /**
* Flag showing whether video is muted. * Flag showing whether video is muted.
*/ */
@ -296,6 +307,7 @@ class Toolbox extends Component {
const underlayColor = 'transparent'; const underlayColor = 'transparent';
const { const {
_audioOnly: audioOnly, _audioOnly: audioOnly,
_pipAvailable: pipAvailable,
_videoMuted: videoMuted _videoMuted: videoMuted
} = this.props; } = this.props;
@ -305,6 +317,15 @@ class Toolbox extends Component {
<View <View
key = 'secondaryToolbar' key = 'secondaryToolbar'
style = { styles.secondaryToolbar }> style = { styles.secondaryToolbar }>
{
pipAvailable
&& <ToolbarButton
iconName = { 'menu-down' }
iconStyle = { iconStyle }
onClick = { this.props._onPipRequest }
style = { style }
underlayColor = { underlayColor } />
}
{ {
AudioRouteButton AudioRouteButton
&& <AudioRouteButton && <AudioRouteButton
@ -391,6 +412,17 @@ function _mapDispatchToProps(dispatch) {
return { return {
...abstractMapDispatchToProps(dispatch), ...abstractMapDispatchToProps(dispatch),
/**
* Requests Picture-in-Picture mode.
*
* @private
* @returns {void}
* @type {Function}
*/
_onPipRequest() {
dispatch(requestPipMode());
},
/** /**
* Sets the lock i.e. password protection of the conference/room. * Sets the lock i.e. password protection of the conference/room.
* *
@ -451,6 +483,7 @@ function _mapDispatchToProps(dispatch) {
function _mapStateToProps(state) { function _mapStateToProps(state) {
const conference = state['features/base/conference']; const conference = state['features/base/conference'];
const { enabled } = state['features/toolbox']; const { enabled } = state['features/toolbox'];
const { app } = state['features/app'];
return { return {
...abstractMapStateToProps(state), ...abstractMapStateToProps(state),
@ -479,7 +512,15 @@ function _mapStateToProps(state) {
* @protected * @protected
* @type {boolean} * @type {boolean}
*/ */
_locked: Boolean(conference.locked) _locked: Boolean(conference.locked),
/**
* The indicator which determines if Picture-in-Picture is available.
*
* @protected
* @type {boolean}
*/
_pipAvailable: Boolean(app && app.props.pipAvailable)
}; };
} }