From 7164cd49e4e0f4e4ef53216e6f3c39a218d307b8 Mon Sep 17 00:00:00 2001 From: Bettenbuk Zoltan Date: Thu, 14 Jun 2018 11:15:36 +0200 Subject: [PATCH] [RN] Implement Recording on mobile --- conference.js | 77 ------ react/features/recording/actionTypes.js | 26 ++ react/features/recording/actions.js | 109 ++++++++- .../components/AbstractRecordingLabel.js | 43 +++- .../Recording/AbstractStartRecordingDialog.js | 103 ++++++++ .../Recording/AbstractStopRecordingDialog.js | 120 +++++++++ .../Recording/RecordButton.native.js | 108 +++++++++ .../components/Recording/RecordButton.web.js | 0 .../Recording/StartRecordingDialog.native.js | 40 +++ .../Recording/StartRecordingDialog.web.js | 92 +------ .../Recording/StopRecordingDialog.native.js | 42 ++++ .../Recording/StopRecordingDialog.web.js | 98 +------- .../recording/components/Recording/index.js | 1 + .../components/RecordingLabel.native.js | 55 +---- .../components/RecordingLabel.web.js | 228 +----------------- react/features/recording/components/index.js | 6 +- react/features/recording/components/styles.js | 7 +- react/features/recording/middleware.js | 132 ++++++++-- react/features/recording/reducer.js | 19 +- .../toolbox/components/native/OverflowMenu.js | 2 + 20 files changed, 769 insertions(+), 539 deletions(-) create mode 100644 react/features/recording/components/Recording/AbstractStartRecordingDialog.js create mode 100644 react/features/recording/components/Recording/AbstractStopRecordingDialog.js create mode 100644 react/features/recording/components/Recording/RecordButton.native.js create mode 100644 react/features/recording/components/Recording/RecordButton.web.js diff --git a/conference.js b/conference.js index e4ba03115..5fe57778f 100644 --- a/conference.js +++ b/conference.js @@ -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; - } } }; diff --git a/react/features/recording/actionTypes.js b/react/features/recording/actionTypes.js index 4e2e1f84d..b6761a18f 100644 --- a/react/features/recording/actionTypes.js +++ b/react/features/recording/actionTypes.js @@ -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'); diff --git a/react/features/recording/actions.js b/react/features/recording/actions.js index dd3c2c37c..cf7334a22 100644 --- a/react/features/recording/actions.js +++ b/react/features/recording/actions.js @@ -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: { diff --git a/react/features/recording/components/AbstractRecordingLabel.js b/react/features/recording/components/AbstractRecordingLabel.js index 0e8bdaba5..ed0c775d0 100644 --- a/react/features/recording/components/AbstractRecordingLabel.js +++ b/react/features/recording/components/AbstractRecordingLabel.js @@ -34,8 +34,45 @@ export type Props = { /** * Abstract class for the {@code RecordingLabel} component. */ -export default class AbstractRecordingLabel - extends Component { +export default class AbstractRecordingLabel + extends Component

{ + + /** + * 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 * _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 diff --git a/react/features/recording/components/Recording/AbstractStartRecordingDialog.js b/react/features/recording/components/Recording/AbstractStartRecordingDialog.js new file mode 100644 index 000000000..8f88905a0 --- /dev/null +++ b/react/features/recording/components/Recording/AbstractStartRecordingDialog.js @@ -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 + extends Component

{ + /** + * 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 ( +

+ { this._renderDialogContent() } + + ); + } + + _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 + }; +} diff --git a/react/features/recording/components/Recording/AbstractStopRecordingDialog.js b/react/features/recording/components/Recording/AbstractStopRecordingDialog.js new file mode 100644 index 000000000..d952ed5b4 --- /dev/null +++ b/react/features/recording/components/Recording/AbstractStopRecordingDialog.js @@ -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 + extends Component

{ + /** + * 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 ( +

+ { this._renderDialogContent() } + + ); + } + + _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) + }; +} diff --git a/react/features/recording/components/Recording/RecordButton.native.js b/react/features/recording/components/Recording/RecordButton.native.js new file mode 100644 index 000000000..585f6402a --- /dev/null +++ b/react/features/recording/components/Recording/RecordButton.native.js @@ -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 { + 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)); diff --git a/react/features/recording/components/Recording/RecordButton.web.js b/react/features/recording/components/Recording/RecordButton.web.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/Recording/StartRecordingDialog.native.js b/react/features/recording/components/Recording/StartRecordingDialog.native.js index e69de29bb..3e41b6c23 100644 --- a/react/features/recording/components/Recording/StartRecordingDialog.native.js +++ b/react/features/recording/components/Recording/StartRecordingDialog.native.js @@ -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 { + /** + * Renders the platform specific dialog content. + * + * @inheritdoc + */ + _renderDialogContent() { + const { t } = this.props; + + return ( + + + { t('recording.startRecordingBody') } + + + ); + } +} + +export default translate(connect(_mapStateToProps)(StartRecordingDialog)); diff --git a/react/features/recording/components/Recording/StartRecordingDialog.web.js b/react/features/recording/components/Recording/StartRecordingDialog.web.js index 54c483a70..421f13a77 100644 --- a/react/features/recording/components/Recording/StartRecordingDialog.web.js +++ b/react/features/recording/components/Recording/StartRecordingDialog.web.js @@ -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 { +class StartRecordingDialog extends AbstractStartRecordingDialog { /** - * 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 ( - - { this.props.t('recording.startRecordingBody') } - + 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)); diff --git a/react/features/recording/components/Recording/StopRecordingDialog.native.js b/react/features/recording/components/Recording/StopRecordingDialog.native.js index e69de29bb..808436206 100644 --- a/react/features/recording/components/Recording/StopRecordingDialog.native.js +++ b/react/features/recording/components/Recording/StopRecordingDialog.native.js @@ -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 { + + /** + * Renders the platform specific dialog content. + * + * @inheritdoc + */ + _renderDialogContent() { + const { t } = this.props; + + return ( + + + { t('dialog.stopRecordingWarning') } + + + ); + } +} + +export default translate(connect(_mapStateToProps)(StopRecordingDialog)); diff --git a/react/features/recording/components/Recording/StopRecordingDialog.web.js b/react/features/recording/components/Recording/StopRecordingDialog.web.js index 2c3dcbca2..f4eb44ac6 100644 --- a/react/features/recording/components/Recording/StopRecordingDialog.web.js +++ b/react/features/recording/components/Recording/StopRecordingDialog.web.js @@ -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 { - /** - * 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 { /** - * 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 ( - - { this.props.t('dialog.stopRecordingWarning') } - + 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)); diff --git a/react/features/recording/components/Recording/index.js b/react/features/recording/components/Recording/index.js index 6d6f4bf05..198854835 100644 --- a/react/features/recording/components/Recording/index.js +++ b/react/features/recording/components/Recording/index.js @@ -1,2 +1,3 @@ +export { default as RecordButton } from './RecordButton'; export { default as StartRecordingDialog } from './StartRecordingDialog'; export { default as StopRecordingDialog } from './StopRecordingDialog'; diff --git a/react/features/recording/components/RecordingLabel.native.js b/react/features/recording/components/RecordingLabel.native.js index e4dba3257..4260cd784 100644 --- a/react/features/recording/components/RecordingLabel.native.js +++ b/react/features/recording/components/RecordingLabel.native.js @@ -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 { +class RecordingLabel extends AbstractRecordingLabel { /** - * 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 { return ( + 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)); diff --git a/react/features/recording/components/RecordingLabel.web.js b/react/features/recording/components/RecordingLabel.web.js index 21fd30cb9..7fd98e5b8 100644 --- a/react/features/recording/components/RecordingLabel.web.js +++ b/react/features/recording/components/RecordingLabel.web.js @@ -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 { - _autohideTimeout: TimeoutID; - - state = { - hidden: false - }; - - static defaultProps = { - session: {} - }; - +class RecordingLabel extends AbstractRecordingLabel { /** - * 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 ( -
- { messageKey - ?
- { this.props.t(messageKey) } -
- : } +
+
); } - /** - * 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)); diff --git a/react/features/recording/components/index.js b/react/features/recording/components/index.js index 5320d60ea..11b3ba7b6 100644 --- a/react/features/recording/components/index.js +++ b/react/features/recording/components/index.js @@ -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'; diff --git a/react/features/recording/components/styles.js b/react/features/recording/components/styles.js index 0d38df68e..7e3367af7 100644 --- a/react/features/recording/components/styles.js +++ b/react/features/recording/components/styles.js @@ -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 } }); diff --git a/react/features/recording/middleware.js b/react/features/recording/middleware.js index 5afc8fb26..c34dd5a02 100644 --- a/react/features/recording/middleware.js +++ b/react/features/recording/middleware.js @@ -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; + } +} diff --git a/react/features/recording/reducer.js b/react/features/recording/reducer.js index 047bcc348..7b8a87060 100644 --- a/react/features/recording/reducer.js +++ b/react/features/recording/reducer.js @@ -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; } diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 01fd32b47..7744e0eb5 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -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 { + );