[RN] Add Picture-in-Picture support (Coding style: naming, consistency)

This commit is contained in:
Lyubo Marinov
2018-02-23 11:21:26 -06:00
parent b3683068d4
commit b8de5bbfc3
23 changed files with 481 additions and 378 deletions
+4 -3
View File
@@ -38,10 +38,11 @@ export class App extends AbstractApp {
...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.
* Whether Picture-in-Picture is enabled. If {@code true}, a toolbar
* button is rendered in the {@link Conference} view to afford entering
* Picture-in-Picture.
*/
pipAvailable: PropTypes.bool,
pictureInPictureEnabled: PropTypes.bool,
/**
* Whether the Welcome page is enabled. If {@code true}, the Welcome
@@ -13,8 +13,7 @@ import {
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';
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture';
/**
* Middleware that captures Redux actions and uses the ExternalAPI module to
@@ -55,6 +54,10 @@ MiddlewareRegistry.register(store => next => action => {
_sendConferenceEvent(store, action);
break;
case ENTER_PICTURE_IN_PICTURE:
_sendEvent(store, _getSymbolDescription(action.type), /* data */ {});
break;
case LOAD_CONFIG_ERROR: {
const { error, locationURL, type } = action;
@@ -64,10 +67,6 @@ MiddlewareRegistry.register(store => next => action => {
});
break;
}
case REQUEST_PIP_MODE:
_sendEvent(store, _getSymbolDescription(action.type), /* data */ {});
}
return result;
@@ -1,22 +1,24 @@
/**
* The type of redux action to set the PiP related event listeners.
* The type of redux action to enter (or rather initiate entering)
* picture-in-picture.
*
* {
* 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
* type: ENTER_PICTURE_IN_PICTURE
* }
*
* @public
*/
export const REQUEST_PIP_MODE = Symbol('REQUEST_PIP_MODE');
export const ENTER_PICTURE_IN_PICTURE = Symbol('ENTER_PICTURE_IN_PICTURE');
/**
* The type of redux action to set the {@code EventEmitter} subscriptions
* utilized by the feature picture-in-picture.
*
* {
* type: _SET_EMITTER_SUBSCRIPTIONS,
* emitterSubscriptions: Array|undefined
* }
*
* @protected
*/
export const _SET_EMITTER_SUBSCRIPTIONS = Symbol('_SET_EMITTER_SUBSCRIPTIONS');
@@ -1,37 +1,60 @@
// @flow
import { NativeModules } from 'react-native';
import {
_SET_PIP_LISTENERS,
REQUEST_PIP_MODE
ENTER_PICTURE_IN_PICTURE,
_SET_EMITTER_SUBSCRIPTIONS
} from './actionTypes';
/**
* Sets the listeners for the PiP related events.
* Enters (or rather initiates entering) picture-in-picture.
* 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 {Array} listeners - Array of listeners to be set.
* @protected
* @returns {{
* type: _SET_PIP_LISTENERS,
* listeners: Array
* }}
* @public
* @returns {Function}
*/
export function _setListeners(listeners: ?Array<any>) {
return {
type: _SET_PIP_LISTENERS,
listeners
export function enterPictureInPicture() {
return (dispatch: Dispatch, getState: Function) => {
const state = getState();
const { app } = state['features/app'];
const { conference, joining } = state['features/base/conference'];
if (app
&& app.props.pictureInPictureEnabled
&& (conference || joining)) {
const { PictureInPicture } = NativeModules;
const p
= PictureInPicture
? PictureInPicture.enterPictureInPicture()
: Promise.reject(
new Error('Picture-in-Picture not supported'));
p.then(
() => dispatch({ type: ENTER_PICTURE_IN_PICTURE }),
e => console.warn(`Error entering PiP mode: ${e}`));
}
};
}
/**
* Requests Picture-in-Picture mode.
* Sets the {@code EventEmitter} subscriptions utilized by the feature
* picture-in-picture.
*
* @public
* @param {Array<Object>} emitterSubscriptions - The {@code EventEmitter}
* subscriptions to be set.
* @protected
* @returns {{
* type: REQUEST_PIP_MODE
* type: _SET_EMITTER_SUBSCRIPTIONS,
* emitterSubscriptions: Array<Object>
* }}
*/
export function requestPipMode() {
export function _setEmitterSubscriptions(emitterSubscriptions: ?Array<Object>) {
return {
type: REQUEST_PIP_MODE
type: _SET_EMITTER_SUBSCRIPTIONS,
emitterSubscriptions
};
}
@@ -0,0 +1,112 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { ToolbarButton } from '../../../toolbox';
import { enterPictureInPicture } from '../actions';
/**
* The type of {@link EnterPictureInPictureToobarButton}'s React
* {@code Component} props.
*/
type Props = {
/**
* Enters (or rather initiates entering) picture-in-picture.
*
* @protected
*/
_onEnterPictureInPicture: Function,
/**
* The indicator which determines whether Picture-in-Picture is enabled.
*
* @protected
*/
_pictureInPictureEnabled: boolean
};
/**
* Implements a {@link ToolbarButton} to enter Picture-in-Picture.
*/
class EnterPictureInPictureToolbarButton extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
_onEnterPictureInPicture,
_pictureInPictureEnabled,
...props
} = this.props;
if (!_pictureInPictureEnabled) {
return null;
}
return (
<ToolbarButton
iconName = { 'menu-down' }
onClick = { _onEnterPictureInPicture }
{ ...props } />
);
}
}
/**
* Maps redux actions to {@link EnterPictureInPictureToolbarButton}'s React
* {@code Component} props.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* }}
* @private
*/
function _mapDispatchToProps(dispatch) {
return {
/**
* Requests Picture-in-Picture mode.
*
* @private
* @returns {void}
* @type {Function}
*/
_onEnterPictureInPicture() {
dispatch(enterPictureInPicture());
}
};
}
/**
* Maps (parts of) the redux state to
* {@link EnterPictureInPictureToolbarButton}'s React {@code Component} props.
*
* @param {Object} state - The redux store/state.
* @private
* @returns {{
* }}
*/
function _mapStateToProps(state) {
const { app } = state['features/app'];
return {
/**
* The indicator which determines whether Picture-in-Picture is enabled.
*
* @protected
* @type {boolean}
*/
_pictureInPictureEnabled:
Boolean(app && app.props.pictureInPictureEnabled)
};
}
export default connect(_mapStateToProps, _mapDispatchToProps)(
EnterPictureInPictureToolbarButton);
@@ -0,0 +1,2 @@
export { default as EnterPictureInPictureToolbarButton }
from './EnterPictureInPictureToolbarButton';
@@ -1,19 +0,0 @@
// @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'));
}
@@ -1,6 +1,6 @@
export * from './actions';
export * from './actionTypes';
export * from './functions';
export * from './components';
import './middleware';
import './reducer';
@@ -5,9 +5,8 @@ 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';
import { enterPictureInPicture, _setEmitterSubscriptions } from './actions';
import { _SET_EMITTER_SUBSCRIPTIONS } from './actionTypes';
/**
* Middleware that handles Picture-in-Picture requests. Currently it enters
@@ -18,30 +17,28 @@ import { enterPictureInPictureMode } from './functions';
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case _SET_PIP_LISTENERS: {
// Remove the current/old listeners.
const { listeners } = store.getState()['features/pip'];
case APP_WILL_MOUNT:
return _appWillMount(store, next, action);
if (listeners) {
for (const listener of listeners) {
listener.remove();
case APP_WILL_UNMOUNT:
store.dispatch(_setEmitterSubscriptions(undefined));
break;
case _SET_EMITTER_SUBSCRIPTIONS: {
// Remove the current/old EventEmitter subscriptions.
const { emitterSubscriptions } = store.getState()['features/pip'];
if (emitterSubscriptions) {
for (const emitterSubscription of emitterSubscriptions) {
// XXX We may be removing an EventEmitter subscription which is
// in both the old and new Array of EventEmitter subscriptions!
// Thankfully, we don't have such a practical use case at the
// time of this writing.
emitterSubscription.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);
@@ -58,43 +55,16 @@ MiddlewareRegistry.register(store => next => action => {
* @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is
* being dispatched in the specified {@code store}.
* @private
* @returns {*}
* @returns {*} The value returned by {@code next(action)}.
*/
function _appWillMount({ dispatch, getState }) {
const context = {
dispatch,
getState
};
const listeners = [
function _appWillMount({ dispatch }, next, action) {
dispatch(_setEmitterSubscriptions([
// Android's onUserLeaveHint activity lifecycle callback
DeviceEventEmitter.addListener('onUserLeaveHint', () => {
_enterPictureInPicture(context);
})
];
DeviceEventEmitter.addListener(
'onUserLeaveHint',
() => dispatch(enterPictureInPicture()))
]));
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}`);
});
}
return next(action);
}
@@ -1,13 +1,15 @@
// @flow
import { ReducerRegistry } from '../../base/redux';
import { _SET_PIP_LISTENERS } from './actionTypes';
import { _SET_EMITTER_SUBSCRIPTIONS } from './actionTypes';
ReducerRegistry.register('features/pip', (state = {}, action) => {
switch (action.type) {
case _SET_PIP_LISTENERS:
case _SET_EMITTER_SUBSCRIPTIONS:
return {
...state,
listeners: action.listeners
emitterSubscriptions: action.emitterSubscriptions
};
}
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
// @flow
import React, { Component } from 'react';
import { View } from 'react-native';
import { connect } from 'react-redux';
@@ -23,7 +24,9 @@ import {
makeAspectRatioAware
} from '../../base/responsive-ui';
import { ColorPalette } from '../../base/styles';
import { requestPipMode } from '../../mobile/picture-in-picture';
import {
EnterPictureInPictureToolbarButton
} from '../../mobile/picture-in-picture';
import { beginRoomLockRequest } from '../../room-lock';
import { beginShareRoom } from '../../share-room';
@@ -46,92 +49,82 @@ import ToolbarButton from './ToolbarButton';
*/
const _SHARE_ROOM_TOOLBAR_BUTTON = true;
/**
* The type of {@link Toolbox}'s React {@code Component} props.
*/
type Props = {
/**
* Flag showing that audio is muted.
*/
_audioMuted: boolean,
/**
* Flag showing whether the audio-only mode is in use.
*/
_audioOnly: boolean,
/**
* The indicator which determines whether the toolbox is enabled.
*/
_enabled: boolean,
/**
* Flag showing whether room is locked.
*/
_locked: boolean,
/**
* Handler for hangup.
*/
_onHangup: Function,
/**
* Sets the lock i.e. password protection of the conference/room.
*/
_onRoomLock: Function,
/**
* Begins the UI procedure to share the conference/room URL.
*/
_onShareRoom: Function,
/**
* Toggles the audio-only flag of the conference.
*/
_onToggleAudioOnly: Function,
/**
* Switches between the front/user-facing and back/environment-facing
* cameras.
*/
_onToggleCameraFacingMode: Function,
/**
* Flag showing whether video is muted.
*/
_videoMuted: boolean,
/**
* Flag showing whether toolbar is visible.
*/
_visible: boolean,
dispatch: Function
};
/**
* Implements the conference toolbox on React Native.
*/
class Toolbox extends Component {
/**
* Toolbox component's property types.
*
* @static
*/
static propTypes = {
/**
* Flag showing that audio is muted.
*/
_audioMuted: PropTypes.bool,
/**
* Flag showing whether the audio-only mode is in use.
*/
_audioOnly: PropTypes.bool,
/**
* The indicator which determines whether the toolbox is enabled.
*/
_enabled: PropTypes.bool,
/**
* Flag showing whether room is locked.
*/
_locked: PropTypes.bool,
/**
* Handler for hangup.
*/
_onHangup: PropTypes.func,
/**
* Requests Picture-in-Picture mode.
*/
_onPipRequest: PropTypes.func,
/**
* Sets the lock i.e. password protection of the conference/room.
*/
_onRoomLock: PropTypes.func,
/**
* Begins the UI procedure to share the conference/room URL.
*/
_onShareRoom: PropTypes.func,
/**
* Toggles the audio-only flag of the conference.
*/
_onToggleAudioOnly: PropTypes.func,
/**
* Switches between the front/user-facing and back/environment-facing
* cameras.
*/
_onToggleCameraFacingMode: PropTypes.func,
/**
* Flag showing whether Picture-in-Picture is available.
*/
_pipAvailable: PropTypes.bool,
/**
* Flag showing whether video is muted.
*/
_videoMuted: PropTypes.bool,
/**
* Flag showing whether toolbar is visible.
*/
_visible: PropTypes.bool,
dispatch: PropTypes.func
};
class Toolbox extends Component<Props> {
/**
* Initializes a new {@code Toolbox} instance.
*
* @param {Object} props - The read-only React {@code Component} props with
* @param {Props} props - The read-only React {@code Component} props with
* which the new instance is to be initialized.
*/
constructor(props) {
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once per instance.
@@ -183,22 +176,26 @@ class Toolbox extends Component {
let style;
if (this.props[`_${mediaType}Muted`]) {
iconName = this[`${mediaType}MutedIcon`];
iconName = `${mediaType}MutedIcon`;
iconStyle = styles.whitePrimaryToolbarButtonIcon;
style = styles.whitePrimaryToolbarButton;
} else {
iconName = this[`${mediaType}Icon`];
iconName = `${mediaType}Icon`;
iconStyle = styles.primaryToolbarButtonIcon;
style = styles.primaryToolbarButton;
}
return {
iconName,
// $FlowExpectedError
iconName: this[iconName],
iconStyle,
style
};
}
_onToggleAudio: () => void;
/**
* Dispatches an action to toggle the mute state of the audio/microphone.
*
@@ -226,6 +223,8 @@ class Toolbox extends Component {
/* ensureTrack */ true));
}
_onToggleVideo: () => void;
/**
* Dispatches an action to toggle the mute state of the video/camera.
*
@@ -307,7 +306,6 @@ class Toolbox extends Component {
const underlayColor = 'transparent';
const {
_audioOnly: audioOnly,
_pipAvailable: pipAvailable,
_videoMuted: videoMuted
} = this.props;
@@ -317,15 +315,6 @@ class Toolbox extends Component {
<View
key = 'secondaryToolbar'
style = { styles.secondaryToolbar }>
{
pipAvailable
&& <ToolbarButton
iconName = { 'menu-down' }
iconStyle = { iconStyle }
onClick = { this.props._onPipRequest }
style = { style }
underlayColor = { underlayColor } />
}
{
AudioRouteButton
&& <AudioRouteButton
@@ -364,6 +353,10 @@ class Toolbox extends Component {
style = { style }
underlayColor = { underlayColor } />
}
<EnterPictureInPictureToolbarButton
iconStyle = { iconStyle }
style = { style }
underlayColor = { underlayColor } />
</View>
);
@@ -390,6 +383,7 @@ class Toolbox extends Component {
* TODO As soon as we have common font sets for web and native, this will no
* longer be required.
*/
// $FlowExpectedError
Object.assign(Toolbox.prototype, {
audioIcon: 'microphone',
audioMutedIcon: 'mic-disabled',
@@ -398,31 +392,20 @@ Object.assign(Toolbox.prototype, {
});
/**
* Maps actions to React component props.
* Maps redux actions to {@link Toolbox}'s React {@code Component} props.
*
* @param {Function} dispatch - Redux action dispatcher.
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @private
* @returns {{
* _onRoomLock: Function,
* _onToggleAudioOnly: Function,
* _onToggleCameraFacingMode: Function,
* }}
* @private
*/
function _mapDispatchToProps(dispatch) {
return {
...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.
*
@@ -471,19 +454,20 @@ function _mapDispatchToProps(dispatch) {
}
/**
* Maps part of Redux store to React component props.
* Maps (parts of) the redux state to {@link Toolbox}'s React {@code Component}
* props.
*
* @param {Object} state - Redux store.
* @param {Object} state - The redux store/state.
* @private
* @returns {{
* _audioOnly: boolean,
* _enabled: boolean,
* _locked: boolean
* }}
* @private
*/
function _mapStateToProps(state) {
const conference = state['features/base/conference'];
const { enabled } = state['features/toolbox'];
const { app } = state['features/app'];
return {
...abstractMapStateToProps(state),
@@ -512,15 +496,7 @@ function _mapStateToProps(state) {
* @protected
* @type {boolean}
*/
_locked: Boolean(conference.locked),
/**
* The indicator which determines if Picture-in-Picture is available.
*
* @protected
* @type {boolean}
*/
_pipAvailable: Boolean(app && app.props.pipAvailable)
_locked: Boolean(conference.locked)
};
}
+4 -4
View File
@@ -1,21 +1,21 @@
/* @flow */
import type { Dispatch } from 'redux';
// @flow
import { appNavigate } from '../app';
import { MEDIA_TYPE } from '../base/media';
import { isLocalTrackMuted } from '../base/tracks';
import type { Dispatch } from 'redux';
/**
* Maps redux actions to {@link Toolbox} (React {@code Component}) props.
*
* @param {Function} dispatch - The redux {@code dispatch} function.
* @private
* @returns {{
* _onHangup: Function,
* _onToggleAudio: Function,
* _onToggleVideo: Function
* }}
* @private
*/
export function abstractMapDispatchToProps(dispatch: Dispatch<*>): Object {
return {
+5 -1
View File
@@ -7,7 +7,11 @@ import getDefaultButtons from './defaultToolbarButtons';
declare var interfaceConfig: Object;
export { abstractMapStateToProps, getButton } from './functions.native';
export {
abstractMapDispatchToProps,
abstractMapStateToProps,
getButton
} from './functions.native';
/**
* Returns an object which contains the default buttons for the primary and