[RN] Implement Recording on mobile

This commit is contained in:
Bettenbuk Zoltan 2018-06-14 11:15:36 +02:00 committed by Paweł Domas
parent 4ac367d403
commit 7164cd49e4
20 changed files with 769 additions and 539 deletions

View File

@ -1906,31 +1906,6 @@ export default {
}
});
/* eslint-enable max-params */
room.on(
JitsiConferenceEvents.RECORDER_STATE_CHANGED,
recorderSession => {
if (!recorderSession) {
logger.error(
'Received invalid recorder status update',
recorderSession);
return;
}
// These errors fire when the local participant has requested a
// recording but the request itself failed, hence the missing
// session ID because the recorder never started.
if (recorderSession.getError()) {
this._showRecordingErrorNotification(recorderSession);
return;
}
logger.error(
'Received a recorder status update with no ID or error');
});
room.on(JitsiConferenceEvents.KICKED, () => {
APP.UI.hideStats();
APP.UI.notifyKicked();
@ -2728,57 +2703,5 @@ export default {
if (score === -1 || (score >= 1 && score <= 5)) {
APP.store.dispatch(submitFeedback(score, message, room));
}
},
/**
* Shows a notification about an error in the recording session. A
* default notification will display if no error is specified in the passed
* in recording session.
*
* @param {Object} recorderSession - The recorder session model from the
* lib.
* @private
* @returns {void}
*/
_showRecordingErrorNotification(recorderSession) {
const isStreamMode
= recorderSession.getMode()
=== JitsiMeetJS.constants.recording.mode.STREAM;
switch (recorderSession.getError()) {
case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE:
APP.UI.messageHandler.showError({
descriptionKey: 'recording.unavailable',
descriptionArguments: {
serviceName: isStreamMode
? 'Live Streaming service'
: 'Recording service'
},
titleKey: isStreamMode
? 'liveStreaming.unavailableTitle'
: 'recording.unavailableTitle'
});
break;
case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT:
APP.UI.messageHandler.showError({
descriptionKey: isStreamMode
? 'liveStreaming.busy'
: 'recording.busy',
titleKey: isStreamMode
? 'liveStreaming.busyTitle'
: 'recording.busyTitle'
});
break;
default:
APP.UI.messageHandler.showError({
descriptionKey: isStreamMode
? 'liveStreaming.error'
: 'recording.error',
titleKey: isStreamMode
? 'liveStreaming.failedToStart'
: 'recording.failedToStart'
});
break;
}
}
};

View File

@ -1,3 +1,15 @@
// @flow
/**
* The type of Redux action which clears all the data of every sessions.
*
* {
* type: CLEAR_RECORDING_SESSIONS
* }
* @public
*/
export const CLEAR_RECORDING_SESSIONS = Symbol('CLEAR_RECORDING_SESSIONS');
/**
* The type of Redux action which updates the current known state of a recording
* session.
@ -9,3 +21,17 @@
* @public
*/
export const RECORDING_SESSION_UPDATED = Symbol('RECORDING_SESSION_UPDATED');
/**
* The type of Redux action which sets the pending recording notification UID to
* use it for when hiding the notification is necessary, or unsets it when
* undefined (or no param) is passed.
*
* {
* type: SET_PENDING_RECORDING_NOTIFICATION_UID,
* uid: ?number
* }
* @public
*/
export const SET_PENDING_RECORDING_NOTIFICATION_UID
= Symbol('SET_PENDING_RECORDING_NOTIFICATION_UID');

View File

@ -1,4 +1,109 @@
import { RECORDING_SESSION_UPDATED } from './actionTypes';
// @flow
import {
hideNotification,
showErrorNotification,
showNotification
} from '../notifications';
import {
CLEAR_RECORDING_SESSIONS,
RECORDING_SESSION_UPDATED,
SET_PENDING_RECORDING_NOTIFICATION_UID
} from './actionTypes';
/**
* Clears the data of every recording sessions.
*
* @returns {{
* type: CLEAR_RECORDING_SESSIONS
* }}
*/
export function clearRecordingSessions() {
return {
type: CLEAR_RECORDING_SESSIONS
};
}
/**
* Signals that the pending recording notification should be removed from the
* screen.
*
* @returns {Function}
*/
export function hidePendingRecordingNotification() {
return (dispatch: Function, getState: Function) => {
const { pendingNotificationUid } = getState()['features/recording'];
if (pendingNotificationUid) {
dispatch(hideNotification(pendingNotificationUid));
dispatch(setPendingRecordingNotificationUid());
}
};
}
/**
* Sets UID of the the pending recording notification to use it when hinding
* the notification is necessary, or unsets it when
* undefined (or no param) is passed.
*
* @param {?number} uid - The UID of the notification.
* redux.
* @returns {{
* type: SET_PENDING_RECORDING_NOTIFICATION_UID,
* uid: number
* }}
*/
export function setPendingRecordingNotificationUid(uid: ?number) {
return {
type: SET_PENDING_RECORDING_NOTIFICATION_UID,
uid
};
}
/**
* Signals that the pending recording notification should be shown on the
* screen.
*
* @returns {Function}
*/
export function showPendingRecordingNotification() {
return (dispatch: Function) => {
const showNotificationAction = showNotification({
descriptionKey: 'recording.pending',
isDismissAllowed: false,
titleKey: 'dialog.recording'
});
dispatch(showNotificationAction);
dispatch(setPendingRecordingNotificationUid(
showNotificationAction.uid));
};
}
/**
* Signals that the recording error notification should be shown.
*
* @param {Object} props - The Props needed to render the notification.
* @returns {showErrorNotification}
*/
export function showRecordingError(props: Object) {
return showErrorNotification(props);
}
/**
* Signals that the stopped recording notification should be shown on the
* screen for a given period.
*
* @returns {showNotification}
*/
export function showStoppedRecordingNotification() {
return showNotification({
descriptionKey: 'recording.off',
titleKey: 'dialog.recording'
}, 2500);
}
/**
* Updates the known state for a given recording session.
@ -10,7 +115,7 @@ import { RECORDING_SESSION_UPDATED } from './actionTypes';
* sessionData: Object
* }}
*/
export function updateRecordingSessionData(session) {
export function updateRecordingSessionData(session: Object) {
return {
type: RECORDING_SESSION_UPDATED,
sessionData: {

View File

@ -34,8 +34,45 @@ export type Props = {
/**
* Abstract class for the {@code RecordingLabel} component.
*/
export default class AbstractRecordingLabel<P: Props, S: *>
extends Component<P, S> {
export default class AbstractRecordingLabel<P: Props>
extends Component<P> {
/**
* Implements React {@code Component}'s render.
*
* @inheritdoc
*/
render() {
return this.props._visible ? this._renderLabel() : null;
}
_getLabelKey: () => ?string
/**
* Returns the label key that this indicator should render.
*
* @protected
* @returns {?string}
*/
_getLabelKey() {
switch (this.props.mode) {
case JitsiRecordingConstants.mode.STREAM:
return 'recording.live';
case JitsiRecordingConstants.mode.FILE:
return 'recording.rec';
default:
// Invalid mode is passed to the component.
return undefined;
}
}
/**
* Renders the platform specific label component.
*
* @protected
* @returns {React$Element}
*/
_renderLabel: () => React$Element<*>
}
@ -50,7 +87,7 @@ export default class AbstractRecordingLabel<P: Props, S: *>
* _visible: boolean
* }}
*/
export function _abstractMapStateToProps(state: Object, ownProps: Props) {
export function _mapStateToProps(state: Object, ownProps: Props) {
const { mode } = ownProps;
const _recordingSessions = state['features/recording'].sessionDatas;
const _visible

View File

@ -0,0 +1,103 @@
// @flow
import React, { Component } from 'react';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
export type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
}
/**
* Abstract class for {@code StartRecordingDialog} components.
*/
export default class AbstractStartRecordingDialog<P: Props>
extends Component<P> {
/**
* Initializes a new {@code StartRecordingDialog} instance.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Dialog
okTitleKey = 'dialog.confirm'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this._renderDialogContent() }
</Dialog>
);
}
_onSubmit: () => boolean;
/**
* Starts a file recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
sendAnalytics(
createRecordingDialogEvent('start', 'confirm.button')
);
this.props._conference.startRecording({
mode: JitsiRecordingConstants.mode.FILE
});
return true;
}
/**
* Renders the platform specific dialog content.
*
* @protected
* @returns {React$Component}
*/
_renderDialogContent: () => React$Component<*>
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code StartRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference
* }}
*/
export function _mapStateToProps(state: Object) {
return {
_conference: state['features/base/conference'].conference
};
}

View File

@ -0,0 +1,120 @@
// @flow
import React, { Component } from 'react';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { getActiveSession } from '../../functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractStopRecordingDialog}.
*/
export type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The redux representation of the recording session to be stopped.
*/
_fileRecordingSession: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* Abstract React Component for getting confirmation to stop a file recording
* session in progress.
*
* @extends Component
*/
export default class AbstractStopRecordingDialog<P: Props>
extends Component<P> {
/**
* Initializes a new {@code AbstrStopRecordingDialog} instance.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Dialog
okTitleKey = 'dialog.confirm'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this._renderDialogContent() }
</Dialog>
);
}
_onSubmit: () => boolean;
/**
* Stops the recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
const { _fileRecordingSession } = this.props;
if (_fileRecordingSession) {
this.props._conference.stopRecording(_fileRecordingSession.id);
}
return true;
}
/**
* Renders the platform specific dialog content.
*
* @protected
* @returns {React$Component}
*/
_renderDialogContent: () => React$Component<*>
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code StopRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference,
* _fileRecordingSession: Object
* }}
*/
export function _mapStateToProps(state: Object) {
return {
_conference: state['features/base/conference'].conference,
_fileRecordingSession:
getActiveSession(state, JitsiRecordingConstants.mode.FILE)
};
}

View File

@ -0,0 +1,108 @@
// @flow
import { connect } from 'react-redux';
import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import {
isLocalParticipantModerator
} from '../../../base/participants';
import {
AbstractButton,
type AbstractButtonProps
} from '../../../base/toolbox';
import { getActiveSession } from '../../functions';
import StartRecordingDialog from './StartRecordingDialog';
import StopRecordingDialog from './StopRecordingDialog';
/**
* The type of the React {@code Component} props of {@link RecordButton}.
*/
type Props = AbstractButtonProps & {
/**
* The current conference object.
*/
_conference: Object,
/**
* True if there is a running active recording, false otherwise.
*/
_isRecordingRunning: boolean,
/**
* The redux {@code dispatch} function.
*/
dispatch: Function,
/**
* The i18n translate function.
*/
t: Function
};
/**
* An implementation of a button for starting and stopping recording.
*/
class RecordButton extends AbstractButton<Props, *> {
accessibilityLabel = 'Recording';
iconName = 'recEnable';
label = 'dialog.startRecording';
toggledIconName = 'recDisable';
toggledLabel = 'dialog.stopRecording';
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_handleClick() {
const { _isRecordingRunning, dispatch } = this.props;
dispatch(openDialog(
_isRecordingRunning ? StopRecordingDialog : StartRecordingDialog
));
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isToggled() {
return this.props._isRecordingRunning;
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code RecordButton} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: Object,
* _isRecordingRunning: boolean,
* visible: boolean
* }}
*/
function _mapStateToProps(state): Object {
const isModerator = isLocalParticipantModerator(state);
const { fileRecordingsEnabled } = state['features/base/config'];
return {
_conference: state['features/base/conference'].conference,
_isRecordingRunning:
Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE)),
visible: isModerator && fileRecordingsEnabled
};
}
export default translate(connect(_mapStateToProps)(RecordButton));

View File

@ -0,0 +1,40 @@
// @flow
import React from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import styles from '../styles';
import AbstractStartRecordingDialog, {
type Props,
_mapStateToProps
} from './AbstractStartRecordingDialog';
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialog extends AbstractStartRecordingDialog<Props> {
/**
* Renders the platform specific dialog content.
*
* @inheritdoc
*/
_renderDialogContent() {
const { t } = this.props;
return (
<View style = { styles.messageContainer }>
<Text>
{ t('recording.startRecordingBody') }
</Text>
</View>
);
}
}
export default translate(connect(_mapStateToProps)(StartRecordingDialog));

View File

@ -1,103 +1,33 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
/**
* The type of the React {@code Component} props of
* {@link StartRecordingDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
import AbstractStartRecordingDialog, {
type Props,
_mapStateToProps
} from './AbstractStartRecordingDialog';
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialog extends Component<Props> {
class StartRecordingDialog extends AbstractStartRecordingDialog<Props> {
/**
* Initializes a new {@code StartRecordingDialog} instance.
* Renders the platform specific dialog content.
*
* @param {Props} props - The read-only properties with which the new
* instance is to be initialized.
* @protected
* @returns {React$Component}
*/
constructor(props: Props) {
super(props);
_renderDialogContent() {
const { t } = this.props;
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Dialog
okTitleKey = 'dialog.confirm'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this.props.t('recording.startRecordingBody') }
</Dialog>
t('recording.startRecordingBody')
);
}
_onSubmit: () => boolean;
/**
* Starts a file recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('start', 'confirm.button'));
this.props._conference.startRecording({
mode: JitsiRecordingConstants.mode.FILE
});
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code StartRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference
};
}
export default translate(connect(_mapStateToProps)(StartRecordingDialog));

View File

@ -0,0 +1,42 @@
// @flow
import React from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import styles from '../styles';
import AbstractStopRecordingDialog, {
type Props,
_mapStateToProps
} from './AbstractStopRecordingDialog';
/**
* React Component for getting confirmation to stop a file recording session in
* progress.
*
* @extends Component
*/
class StopRecordingDialog extends AbstractStopRecordingDialog<Props> {
/**
* Renders the platform specific dialog content.
*
* @inheritdoc
*/
_renderDialogContent() {
const { t } = this.props;
return (
<View style = { styles.messageContainer }>
<Text>
{ t('dialog.stopRecordingWarning') }
</Text>
</View>
);
}
}
export default translate(connect(_mapStateToProps)(StopRecordingDialog));

View File

@ -1,35 +1,13 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
/**
* The type of the React {@code Component} props of {@link StopRecordingDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The redux representation of the recording session to be stopped.
*/
session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
import AbstractStopRecordingDialog, {
type Props,
_mapStateToProps
} from './AbstractStopRecordingDialog';
/**
* React Component for getting confirmation to stop a file recording session in
@ -37,73 +15,21 @@ type Props = {
*
* @extends Component
*/
class StopRecordingDialog extends Component<Props> {
/**
* Initializes a new {@code StopRecordingDialog} instance.
*
* @param {Props} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
class StopRecordingDialog extends AbstractStopRecordingDialog<Props> {
/**
* Implements React's {@link Component#render()}.
* Renders the platform specific dialog content.
*
* @inheritdoc
* @returns {ReactElement}
* @protected
* @returns {React$Component}
*/
render() {
_renderDialogContent() {
const { t } = this.props;
return (
<Dialog
okTitleKey = 'dialog.stopRecording'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this.props.t('dialog.stopRecordingWarning') }
</Dialog>
t('dialog.stopRecordingWarning')
);
}
_onSubmit: () => boolean;
/**
* Stops the recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
const { session } = this.props;
if (session) {
this.props._conference.stopRecording(session.id);
}
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code StopRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference
};
}
export default translate(connect(_mapStateToProps)(StopRecordingDialog));

View File

@ -1,2 +1,3 @@
export { default as RecordButton } from './RecordButton';
export { default as StartRecordingDialog } from './StartRecordingDialog';
export { default as StopRecordingDialog } from './StopRecordingDialog';

View File

@ -6,52 +6,34 @@ import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { CircularLabel } from '../../base/label';
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import { combineStyles } from '../../base/styles';
import AbstractRecordingLabel, {
type Props as AbstractProps,
_abstractMapStateToProps
type Props,
_mapStateToProps
} from './AbstractRecordingLabel';
import styles from './styles';
type Props = AbstractProps & {
/**
* Style of the component passed as props.
*/
style: ?Object
};
/**
* Implements a React {@link Component} which displays the current state of
* conference recording.
*
* @extends {Component}
*/
class RecordingLabel extends AbstractRecordingLabel<Props, *> {
class RecordingLabel extends AbstractRecordingLabel<Props> {
/**
* Implements React {@code Component}'s render.
* Renders the platform specific label component.
*
* @inheritdoc
*/
render() {
const { _visible, mode, style, t } = this.props;
if (!_visible) {
return null;
}
let labelKey;
_renderLabel() {
let indicatorStyle;
switch (mode) {
switch (this.props.mode) {
case JitsiRecordingConstants.mode.STREAM:
labelKey = 'recording.live';
indicatorStyle = styles.indicatorLive;
break;
case JitsiRecordingConstants.mode.FILE:
labelKey = 'recording.rec';
indicatorStyle = styles.indicatorRecording;
break;
default:
@ -61,31 +43,12 @@ class RecordingLabel extends AbstractRecordingLabel<Props, *> {
return (
<CircularLabel
label = { t(labelKey) }
style = {
combineStyles(indicatorStyle, style)
} />
label = { this.props.t(this._getLabelKey()) }
style = { indicatorStyle } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code RecordingLabel}'s props.
*
* NOTE: This component has no props other than the abstract ones but keeping
* the coding style the same for consistency reasons.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The component's own props.
* @private
* @returns {{
* }}
*/
function _mapStateToProps(state: Object, ownProps: Object) {
return {
..._abstractMapStateToProps(state, ownProps)
};
_getLabelKey: () => ?string
}
export default translate(connect(_mapStateToProps)(RecordingLabel));

View File

@ -5,243 +5,35 @@ import { connect } from 'react-redux';
import { CircularLabel } from '../../base/label';
import { translate } from '../../base/i18n';
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import AbstractRecordingLabel, {
type Props as AbstractProps,
_abstractMapStateToProps
type Props,
_mapStateToProps
} from './AbstractRecordingLabel';
/**
* The translation keys to use when displaying messages. The values are set
* lazily to work around circular dependency issues with lib-jitsi-meet causing
* undefined imports.
*
* @private
* @type {Object}
*/
let TRANSLATION_KEYS_BY_MODE = null;
/**
* Lazily initializes TRANSLATION_KEYS_BY_MODE with translation keys to be used
* by the {@code RecordingLabel} for messaging recording session state.
*
* @private
* @returns {Object}
*/
function _getTranslationKeysByMode() {
if (!TRANSLATION_KEYS_BY_MODE) {
const {
error: errorConstants,
mode: modeConstants,
status: statusConstants
} = JitsiRecordingConstants;
TRANSLATION_KEYS_BY_MODE = {
[modeConstants.FILE]: {
status: {
[statusConstants.PENDING]: 'recording.pending',
[statusConstants.OFF]: 'recording.off'
},
errors: {
[errorConstants.BUSY]: 'recording.failedToStart',
[errorConstants.ERROR]: 'recording.error'
}
},
[modeConstants.STREAM]: {
status: {
[statusConstants.PENDING]: 'liveStreaming.pending',
[statusConstants.OFF]: 'liveStreaming.off'
},
errors: {
[errorConstants.BUSY]: 'liveStreaming.busy',
[errorConstants.ERROR]: 'liveStreaming.error'
}
}
};
}
return TRANSLATION_KEYS_BY_MODE;
}
/**
* The type of the React {@code Component} props of {@link RecordingLabel}.
*/
type Props = AbstractProps & {
/**
* The redux representation of a recording session.
*/
session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* The type of the React {@code Component} state of {@link RecordingLabel}.
*/
type State = {
/**
* Whether or not the {@link RecordingLabel} should be invisible.
*/
hidden: boolean
};
/**
* Implements a React {@link Component} which displays the current state of
* conference recording.
*
* @extends {Component}
*/
class RecordingLabel extends AbstractRecordingLabel<Props, State> {
_autohideTimeout: TimeoutID;
state = {
hidden: false
};
static defaultProps = {
session: {}
};
class RecordingLabel extends AbstractRecordingLabel<Props> {
/**
* Sets a timeout to automatically hide the {@link RecordingLabel} if the
* recording session started as failed.
* Renders the platform specific label component.
*
* @inheritdoc
*/
componentDidMount() {
if (this.props.session.status === JitsiRecordingConstants.status.OFF) {
this._setHideTimeout();
}
}
/**
* Sets a timeout to automatically hide {the @link RecordingLabel} if it has
* transitioned to off.
*
* @inheritdoc
*/
componentWillReceiveProps(nextProps) {
const { status } = this.props.session;
const nextStatus = nextProps.session.status;
if (status !== JitsiRecordingConstants.status.OFF
&& nextStatus === JitsiRecordingConstants.status.OFF) {
this._setHideTimeout();
}
}
/**
* Clears the timeout for automatically hiding the {@link RecordingLabel}.
*
* @inheritdoc
*/
componentWillUnmount() {
this._clearAutoHideTimeout();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
if (this.state.hidden) {
return null;
}
const {
error: errorConstants,
mode: modeConstants,
status: statusConstants
} = JitsiRecordingConstants;
const { session } = this.props;
const allTranslationKeys = _getTranslationKeysByMode();
const translationKeys = allTranslationKeys[session.mode];
let circularLabelClass, circularLabelKey, messageKey;
switch (session.status) {
case statusConstants.OFF: {
if (session.error) {
messageKey = translationKeys.errors[session.error]
|| translationKeys.errors[errorConstants.ERROR];
} else {
messageKey = translationKeys.status[statusConstants.OFF];
}
break;
}
case statusConstants.ON:
circularLabelClass = session.mode;
circularLabelKey = session.mode === modeConstants.STREAM
? 'recording.live' : 'recording.rec';
break;
case statusConstants.PENDING:
messageKey = translationKeys.status[statusConstants.PENDING];
break;
}
const className = `recording-label ${
messageKey ? 'center-message' : ''}`;
_renderLabel() {
return (
<div className = { className }>
{ messageKey
? <div>
{ this.props.t(messageKey) }
</div>
: <CircularLabel
className = { circularLabelClass }
label = { this.props.t(circularLabelKey) } /> }
<div>
<CircularLabel
className = { this.props.mode }
label = { this.props.t(this._getLabelKey()) } />
</div>
);
}
/**
* Clears the timeout for automatically hiding {@link RecordingLabel}.
*
* @private
* @returns {void}
*/
_clearAutoHideTimeout() {
clearTimeout(this._autohideTimeout);
}
/**
* Sets a timeout to automatically hide {@link RecordingLabel}.
*
* @private
* @returns {void}
*/
_setHideTimeout() {
this._autohideTimeout = setTimeout(() => {
this.setState({ hidden: true });
}, 5000);
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code RecordingLabel}'s props.
*
* NOTE: This component has no props other than the abstract ones but keeping
* the coding style the same for consistency reasons.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The component's own props.
* @private
* @returns {{
* }}
*/
function _mapStateToProps(state: Object, ownProps: Object) {
return {
..._abstractMapStateToProps(state, ownProps)
};
_getLabelKey: () => ?string
}
export default translate(connect(_mapStateToProps)(RecordingLabel));

View File

@ -1,3 +1,7 @@
export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream';
export { StartRecordingDialog, StopRecordingDialog } from './Recording';
export {
RecordButton,
StartRecordingDialog,
StopRecordingDialog
} from './Recording';
export { default as RecordingLabel } from './RecordingLabel';

View File

@ -1,6 +1,6 @@
// @flow
import { ColorPalette, createStyleSheet } from '../../base/styles';
import { BoxModel, ColorPalette, createStyleSheet } from '../../base/styles';
/**
* The styles of the React {@code Components} of the feature recording.
@ -19,5 +19,10 @@ export default createStyleSheet({
*/
indicatorRecording: {
backgroundColor: ColorPalette.red
},
messageContainer: {
paddingHorizontal: BoxModel.padding,
paddingVertical: 1.5 * BoxModel.padding
}
});

View File

@ -1,11 +1,11 @@
/* @flow */
import { CONFERENCE_WILL_JOIN } from '../base/conference';
import {
import { CONFERENCE_WILL_JOIN, getCurrentConference } from '../base/conference';
import JitsiMeetJS, {
JitsiConferenceEvents,
JitsiRecordingConstants
} from '../base/lib-jitsi-meet';
import { MiddlewareRegistry } from '../base/redux';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import {
playSound,
registerSound,
@ -15,7 +15,14 @@ import {
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
import { updateRecordingSessionData } from './actions';
import {
clearRecordingSessions,
hidePendingRecordingNotification,
showPendingRecordingNotification,
showRecordingError,
showStoppedRecordingNotification,
updateRecordingSessionData
} from './actions';
import { RECORDING_SESSION_UPDATED } from './actionTypes';
import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from './constants';
import { getSessionById } from './functions';
@ -24,37 +31,50 @@ import {
RECORDING_ON_SOUND_FILE
} from './sounds';
/**
* StateListenerRegistry provides a reliable way to detect the leaving of a
* conference, where we need to clean up the recording sessions.
*/
StateListenerRegistry.register(
/* selector */ state => getCurrentConference(state),
/* listener */ (conference, { dispatch }) => {
if (!conference) {
dispatch(clearRecordingSessions());
}
}
);
/**
* The redux middleware to handle the recorder updates in a React way.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
let oldSessionData;
if (action.type === RECORDING_SESSION_UPDATED) {
oldSessionData
= getSessionById(store.getState(), action.sessionData.id);
= getSessionById(getState(), action.sessionData.id);
}
const result = next(action);
switch (action.type) {
case APP_WILL_MOUNT:
store.dispatch(registerSound(
dispatch(registerSound(
RECORDING_OFF_SOUND_ID,
RECORDING_OFF_SOUND_FILE));
store.dispatch(registerSound(
dispatch(registerSound(
RECORDING_ON_SOUND_ID,
RECORDING_ON_SOUND_FILE));
break;
case APP_WILL_UNMOUNT:
store.dispatch(unregisterSound(RECORDING_OFF_SOUND_ID));
store.dispatch(unregisterSound(RECORDING_ON_SOUND_ID));
dispatch(unregisterSound(RECORDING_OFF_SOUND_ID));
dispatch(unregisterSound(RECORDING_ON_SOUND_ID));
break;
@ -65,12 +85,17 @@ MiddlewareRegistry.register(store => next => action => {
JitsiConferenceEvents.RECORDER_STATE_CHANGED,
recorderSession => {
if (recorderSession && recorderSession.getID()) {
store.dispatch(
updateRecordingSessionData(recorderSession));
if (recorderSession) {
recorderSession.getID()
&& dispatch(
updateRecordingSessionData(recorderSession));
return;
recorderSession.getError()
&& _showRecordingErrorNotification(
recorderSession, dispatch);
}
return;
});
break;
@ -78,18 +103,26 @@ MiddlewareRegistry.register(store => next => action => {
case RECORDING_SESSION_UPDATED: {
const updatedSessionData
= getSessionById(store.getState(), action.sessionData.id);
= getSessionById(getState(), action.sessionData.id);
if (updatedSessionData.mode === JitsiRecordingConstants.mode.FILE) {
const { OFF, ON } = JitsiRecordingConstants.status;
const { PENDING, OFF, ON } = JitsiRecordingConstants.status;
if (updatedSessionData.status === ON
&& (!oldSessionData || oldSessionData.status !== ON)) {
store.dispatch(playSound(RECORDING_ON_SOUND_ID));
} else if (updatedSessionData.status === OFF
&& (!oldSessionData || oldSessionData.status !== OFF)) {
store.dispatch(stopSound(RECORDING_ON_SOUND_ID));
store.dispatch(playSound(RECORDING_OFF_SOUND_ID));
if (updatedSessionData.status === PENDING
&& (!oldSessionData || oldSessionData.status !== PENDING)) {
dispatch(showPendingRecordingNotification());
} else if (updatedSessionData.status !== PENDING) {
dispatch(hidePendingRecordingNotification());
if (updatedSessionData.status === ON
&& (!oldSessionData || oldSessionData.status !== ON)) {
dispatch(playSound(RECORDING_ON_SOUND_ID));
} else if (updatedSessionData.status === OFF
&& (!oldSessionData || oldSessionData.status !== OFF)) {
dispatch(stopSound(RECORDING_ON_SOUND_ID));
dispatch(playSound(RECORDING_OFF_SOUND_ID));
dispatch(showStoppedRecordingNotification());
}
}
}
@ -99,3 +132,56 @@ MiddlewareRegistry.register(store => next => action => {
return result;
});
/**
* Shows a notification about an error in the recording session. A
* default notification will display if no error is specified in the passed
* in recording session.
*
* @private
* @param {Object} recorderSession - The recorder session model from the
* lib.
* @param {Dispatch} dispatch - The Redux Dispatch function.
* @returns {void}
*/
function _showRecordingErrorNotification(recorderSession, dispatch) {
const isStreamMode
= recorderSession.getMode()
=== JitsiMeetJS.constants.recording.mode.STREAM;
switch (recorderSession.getError()) {
case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE:
dispatch(showRecordingError({
descriptionKey: 'recording.unavailable',
descriptionArguments: {
serviceName: isStreamMode
? 'Live Streaming service'
: 'Recording service'
},
titleKey: isStreamMode
? 'liveStreaming.unavailableTitle'
: 'recording.unavailableTitle'
}));
break;
case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT:
dispatch(showRecordingError({
descriptionKey: isStreamMode
? 'liveStreaming.busy'
: 'recording.busy',
titleKey: isStreamMode
? 'liveStreaming.busyTitle'
: 'recording.busyTitle'
}));
break;
default:
dispatch(showRecordingError({
descriptionKey: isStreamMode
? 'liveStreaming.error'
: 'recording.error',
titleKey: isStreamMode
? 'liveStreaming.failedToStart'
: 'recording.failedToStart'
}));
break;
}
}

View File

@ -1,5 +1,9 @@
import { ReducerRegistry } from '../base/redux';
import { RECORDING_SESSION_UPDATED } from './actionTypes';
import {
CLEAR_RECORDING_SESSIONS,
RECORDING_SESSION_UPDATED,
SET_PENDING_RECORDING_NOTIFICATION_UID
} from './actionTypes';
const DEFAULT_STATE = {
sessionDatas: []
@ -11,6 +15,13 @@ const DEFAULT_STATE = {
ReducerRegistry.register('features/recording',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case CLEAR_RECORDING_SESSIONS:
return {
...state,
sessionDatas: []
};
case RECORDING_SESSION_UPDATED:
return {
...state,
@ -18,6 +29,12 @@ ReducerRegistry.register('features/recording',
_updateSessionDatas(state.sessionDatas, action.sessionData)
};
case SET_PENDING_RECORDING_NOTIFICATION_UID:
return {
...state,
pendingNotificationUid: action.uid
};
default:
return state;
}

View File

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { BottomSheet, hideDialog } from '../../../base/dialog';
import { AudioRouteButton } from '../../../mobile/audio-mode';
import { PictureInPictureButton } from '../../../mobile/picture-in-picture';
import { RecordButton } from '../../../recording';
import { RoomLockButton } from '../../../room-lock';
import AudioOnlyButton from './AudioOnlyButton';
@ -68,6 +69,7 @@ class OverflowMenu extends Component<Props> {
<ToggleCameraButton { ...buttonProps } />
<AudioOnlyButton { ...buttonProps } />
<RoomLockButton { ...buttonProps } />
<RecordButton { ...buttonProps } />
<PictureInPictureButton { ...buttonProps } />
</BottomSheet>
);