From 5aee082bf98a460a59adb8739ded75e0a567ae21 Mon Sep 17 00:00:00 2001 From: Bettenbuk Zoltan Date: Thu, 5 Jul 2018 13:17:45 +0200 Subject: [PATCH] [RN] Implement streaming on mobile --- lang/main.json | 2 +- react/features/recording/actionTypes.js | 11 + react/features/recording/actions.js | 86 +++-- .../LiveStream/AbstractLiveStreamButton.js | 114 ++++++ .../AbstractStartLiveStreamDialog.js | 336 ++++++++++++++++++ .../AbstractStopLiveStreamDialog.js | 119 +++++++ .../LiveStream/AbstractStreamKeyForm.js | 107 ++++++ .../LiveStream/LiveStreamButton.native.js | 20 ++ .../LiveStream/LiveStreamButton.web.js | 122 +++++++ .../StartLiveStreamDialog.native.js | 92 +++++ .../LiveStream/StartLiveStreamDialog.web.js | 320 ++--------------- .../LiveStream/StopLiveStreamDialog.native.js | 41 +++ .../LiveStream/StopLiveStreamDialog.web.js | 95 +---- .../LiveStream/StreamKeyForm.native.js | 86 +++++ .../LiveStream/StreamKeyForm.web.js | 63 +--- .../recording/components/LiveStream/index.js | 1 + .../recording/components/LiveStream/styles.js | 28 ++ .../components/Recording/RecordButton.web.js | 4 +- react/features/recording/components/index.js | 6 +- react/features/recording/constants.js | 9 + react/features/recording/googleApi.js | 7 +- react/features/recording/middleware.js | 33 +- react/features/recording/reducer.js | 36 +- .../toolbox/components/native/OverflowMenu.js | 3 +- .../toolbox/components/web/Toolbox.js | 136 +------ 25 files changed, 1279 insertions(+), 598 deletions(-) create mode 100644 react/features/recording/components/LiveStream/AbstractLiveStreamButton.js create mode 100644 react/features/recording/components/LiveStream/AbstractStartLiveStreamDialog.js create mode 100644 react/features/recording/components/LiveStream/AbstractStopLiveStreamDialog.js create mode 100644 react/features/recording/components/LiveStream/AbstractStreamKeyForm.js create mode 100644 react/features/recording/components/LiveStream/LiveStreamButton.native.js create mode 100644 react/features/recording/components/LiveStream/LiveStreamButton.web.js create mode 100644 react/features/recording/components/LiveStream/styles.js diff --git a/lang/main.json b/lang/main.json index 974d5f62d..276e6d7fe 100644 --- a/lang/main.json +++ b/lang/main.json @@ -467,7 +467,7 @@ "serviceName": "Live Streaming service", "signIn": "Sign in with Google", "signInCTA": "Sign in or enter your live stream key from YouTube.", - "start": "Start a livestream", + "start": "Start a live stream", "streamIdHelp": "What's this?", "unavailableTitle": "Live Streaming unavailable" }, diff --git a/react/features/recording/actionTypes.js b/react/features/recording/actionTypes.js index b6761a18f..c9ae58152 100644 --- a/react/features/recording/actionTypes.js +++ b/react/features/recording/actionTypes.js @@ -29,9 +29,20 @@ export const RECORDING_SESSION_UPDATED = Symbol('RECORDING_SESSION_UPDATED'); * * { * type: SET_PENDING_RECORDING_NOTIFICATION_UID, + * streamType: string, * uid: ?number * } * @public */ export const SET_PENDING_RECORDING_NOTIFICATION_UID = Symbol('SET_PENDING_RECORDING_NOTIFICATION_UID'); + +/** + * Sets the stream key last used by the user for later reuse. + * + * { + * type: SET_STREAM_KEY, + * streamKey: string + * } + */ +export const SET_STREAM_KEY = Symbol('SET_STREAM_KEY'); diff --git a/react/features/recording/actions.js b/react/features/recording/actions.js index cf7334a22..aa4143488 100644 --- a/react/features/recording/actions.js +++ b/react/features/recording/actions.js @@ -1,5 +1,7 @@ // @flow +import JitsiMeetJS from '../base/lib-jitsi-meet'; + import { hideNotification, showErrorNotification, @@ -9,7 +11,8 @@ import { import { CLEAR_RECORDING_SESSIONS, RECORDING_SESSION_UPDATED, - SET_PENDING_RECORDING_NOTIFICATION_UID + SET_PENDING_RECORDING_NOTIFICATION_UID, + SET_STREAM_KEY } from './actionTypes'; /** @@ -29,35 +32,37 @@ export function clearRecordingSessions() { * Signals that the pending recording notification should be removed from the * screen. * + * @param {string} streamType - The type of the stream (e.g. file or stream). * @returns {Function} */ -export function hidePendingRecordingNotification() { +export function hidePendingRecordingNotification(streamType: string) { return (dispatch: Function, getState: Function) => { - const { pendingNotificationUid } = getState()['features/recording']; + const { pendingNotificationUids } = getState()['features/recording']; + const pendingNotificationUid = pendingNotificationUids[streamType]; if (pendingNotificationUid) { dispatch(hideNotification(pendingNotificationUid)); - dispatch(setPendingRecordingNotificationUid()); + dispatch( + _setPendingRecordingNotificationUid( + undefined, streamType)); } }; } /** - * 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. + * Sets the stream key last used by the user for later reuse. * - * @param {?number} uid - The UID of the notification. + * @param {string} streamKey - The stream key to set. * redux. * @returns {{ - * type: SET_PENDING_RECORDING_NOTIFICATION_UID, - * uid: number + * type: SET_STREAM_KEY, + * streamKey: string * }} */ -export function setPendingRecordingNotificationUid(uid: ?number) { +export function setLiveStreamKey(streamKey: string) { return { - type: SET_PENDING_RECORDING_NOTIFICATION_UID, - uid + type: SET_STREAM_KEY, + streamKey }; } @@ -65,20 +70,29 @@ export function setPendingRecordingNotificationUid(uid: ?number) { * Signals that the pending recording notification should be shown on the * screen. * + * @param {string} streamType - The type of the stream (e.g. file or stream). * @returns {Function} */ -export function showPendingRecordingNotification() { +export function showPendingRecordingNotification(streamType: string) { return (dispatch: Function) => { - const showNotificationAction = showNotification({ + const isLiveStreaming + = streamType === JitsiMeetJS.constants.recording.mode.STREAM; + const dialogProps = isLiveStreaming ? { + descriptionKey: 'liveStreaming.pending', + titleKey: 'dialog.liveStreaming' + } : { descriptionKey: 'recording.pending', - isDismissAllowed: false, titleKey: 'dialog.recording' + }; + const showNotificationAction = showNotification({ + isDismissAllowed: false, + ...dialogProps }); dispatch(showNotificationAction); - dispatch(setPendingRecordingNotificationUid( - showNotificationAction.uid)); + dispatch(_setPendingRecordingNotificationUid( + showNotificationAction.uid, streamType)); }; } @@ -96,13 +110,21 @@ export function showRecordingError(props: Object) { * Signals that the stopped recording notification should be shown on the * screen for a given period. * + * @param {string} streamType - The type of the stream (e.g. file or stream). * @returns {showNotification} */ -export function showStoppedRecordingNotification() { - return showNotification({ +export function showStoppedRecordingNotification(streamType: string) { + const isLiveStreaming + = streamType === JitsiMeetJS.constants.recording.mode.STREAM; + const dialogProps = isLiveStreaming ? { + descriptionKey: 'liveStreaming.off', + titleKey: 'dialog.liveStreaming' + } : { descriptionKey: 'recording.off', titleKey: 'dialog.recording' - }, 2500); + }; + + return showNotification(dialogProps, 2500); } /** @@ -127,3 +149,25 @@ export function updateRecordingSessionData(session: Object) { } }; } + +/** + * Sets UID of the the pending streaming 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. + * @param {string} streamType - The type of the stream (e.g. file or stream). + * @returns {{ + * type: SET_PENDING_RECORDING_NOTIFICATION_UID, + * streamType: string, + * uid: number + * }} + */ +function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) { + return { + type: SET_PENDING_RECORDING_NOTIFICATION_UID, + streamType, + uid + }; +} diff --git a/react/features/recording/components/LiveStream/AbstractLiveStreamButton.js b/react/features/recording/components/LiveStream/AbstractLiveStreamButton.js new file mode 100644 index 000000000..1ae8306b0 --- /dev/null +++ b/react/features/recording/components/LiveStream/AbstractLiveStreamButton.js @@ -0,0 +1,114 @@ +// @flow + +import { openDialog } from '../../../base/dialog'; +import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; +import { + isLocalParticipantModerator, + getLocalParticipant +} from '../../../base/participants'; +import { + AbstractButton, + type AbstractButtonProps +} from '../../../base/toolbox'; + +import { getActiveSession } from '../../functions'; + +import StartLiveStreamDialog from './StartLiveStreamDialog'; +import StopLiveStreamDialog from './StopLiveStreamDialog'; + +/** + * The type of the React {@code Component} props of + * {@link AbstractLiveStreamButton}. + */ +export type Props = AbstractButtonProps & { + + /** + * True if there is a running active live stream, false otherwise. + */ + _isLiveStreamRunning: boolean, + + /** + * The redux {@code dispatch} function. + */ + dispatch: Function, + + /** + * The i18n translate function. + */ + t: Function +}; + +/** + * An abstract class of a button for starting and stopping live streaming. + */ +export default class AbstractLiveStreamButton + extends AbstractButton { + accessibilityLabel = 'dialog.accessibilityLabel.liveStreaming'; + label = 'dialog.startLiveStreaming'; + toggledLabel = 'dialog.stopLiveStreaming'; + + /** + * Handles clicking / pressing the button. + * + * @override + * @protected + * @returns {void} + */ + _handleClick() { + const { _isLiveStreamRunning, dispatch } = this.props; + + dispatch(openDialog( + _isLiveStreamRunning ? StopLiveStreamDialog : StartLiveStreamDialog + )); + } + + /** + * Indicates whether this button is in toggled state or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isToggled() { + return this.props._isLiveStreamRunning; + } +} + +/** + * Maps (parts of) the redux state to the associated props for the + * {@code AbstractLiveStreamButton} component. + * + * @param {Object} state - The Redux state. + * @param {Props} ownProps - The own props of the Component. + * @private + * @returns {{ + * _isLiveStreamRunning: boolean, + * visible: boolean + * }} + */ +export function _mapStateToProps(state: Object, ownProps: Props) { + let { visible } = ownProps; + + if (typeof visible === 'undefined') { + // If the containing component provides the visible prop, that is one + // above all, but if not, the button should be autonomus and decide on + // its own to be visible or not. + const isModerator = isLocalParticipantModerator(state); + const { + enableFeaturesBasedOnToken, + liveStreamingEnabled + } = state['features/base/config']; + const { features = {} } = getLocalParticipant(state); + + visible = isModerator + && liveStreamingEnabled + && (!enableFeaturesBasedOnToken + || String(features.livestreaming) === 'true'); + } + + return { + _isLiveStreamRunning: Boolean( + getActiveSession(state, JitsiRecordingConstants.mode.STREAM)), + visible + }; +} diff --git a/react/features/recording/components/LiveStream/AbstractStartLiveStreamDialog.js b/react/features/recording/components/LiveStream/AbstractStartLiveStreamDialog.js new file mode 100644 index 000000000..4afb555b6 --- /dev/null +++ b/react/features/recording/components/LiveStream/AbstractStartLiveStreamDialog.js @@ -0,0 +1,336 @@ +// @flow + +import React, { Component } from 'react'; + +import { + createRecordingDialogEvent, + sendAnalytics +} from '../../../analytics'; +import { Dialog } from '../../../base/dialog'; +import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; + +/** + * The type of the React {@code Component} props of + * {@link AbstractStartLiveStreamDialog}. + */ +export type Props = { + + /** + * The {@code JitsiConference} for the current conference. + */ + _conference: Object, + + /** + * The ID for the Google client application used for making stream key + * related requests. + */ + _googleApiApplicationClientID: string, + + /** + * The live stream key that was used before. + */ + _streamKey: string, + + /** + * The Redux dispatch function. + */ + dispatch: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function +} + +/** + * The type of the React {@code Component} state of + * {@link AbstractStartLiveStreamDialog}. + */ +export type State = { + + /** + * Details about the broadcasts available for use for the logged in Google + * user's YouTube account. + */ + broadcasts: ?Array, + + /** + * The error type, as provided by Google, for the most recent error + * encountered by the Google API. + */ + errorType: ?string, + + /** + * The current state of interactions with the Google API. Determines what + * Google related UI should display. + */ + googleAPIState: number, + + /** + * The email of the user currently logged in to the Google web client + * application. + */ + googleProfileEmail: string, + + /** + * The boundStreamID of the broadcast currently selected in the broadcast + * dropdown. + */ + selectedBoundStreamID: ?string, + + /** + * The selected or entered stream key to use for YouTube live streaming. + */ + streamKey: string +}; + +/** + * An enumeration of the different states the Google API can be in while + * interacting with {@code StartLiveStreamDialog}. + * + * @private + * @type {Object} + */ +export const GOOGLE_API_STATES = { + /** + * The state in which the Google API still needs to be loaded. + */ + NEEDS_LOADING: 0, + + /** + * The state in which the Google API is loaded and ready for use. + */ + LOADED: 1, + + /** + * The state in which a user has been logged in through the Google API. + */ + SIGNED_IN: 2, + + /** + * The state in which the Google API encountered an error either loading + * or with an API request. + */ + ERROR: 3 +}; + +/** + * Implements an abstract class for the StartLiveStreamDialog on both platforms. + * + * NOTE: Google log-in is not supported for mobile yet for later implementation + * but the abstraction of its properties are already present in this abstract + * class. + */ +export default class AbstractStartLiveStreamDialog + extends Component { + _isMounted: boolean; + + /** + * Constructor of the component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this.state = { + broadcasts: undefined, + errorType: undefined, + googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING, + googleProfileEmail: '', + selectedBoundStreamID: undefined, + streamKey: '' + }; + + /** + * Instance variable used to flag whether the component is or is not + * mounted. Used as a hack to avoid setting state on an unmounted + * component. + * + * @private + * @type {boolean} + */ + this._isMounted = false; + + this._onCancel = this._onCancel.bind(this); + this._onStreamKeyChange = this._onStreamKeyChange.bind(this); + this._onSubmit = this._onSubmit.bind(this); + } + + /** + * Implements {@link Component#componentDidMount()}. Invoked immediately + * after this component is mounted. + * + * @inheritdoc + * @returns {void} + */ + componentDidMount() { + this._isMounted = true; + + if (this.props._googleApiApplicationClientID) { + this._onInitializeGoogleApi(); + } + } + + /** + * Implements React's {@link Component#componentWillUnmount()}. Invoked + * immediately before this component is unmounted and destroyed. + * + * @inheritdoc + */ + componentWillUnmount() { + this._isMounted = false; + } + + /** + * Implements {@code Component}'s render. + * + * @inheritdoc + */ + render() { + return ( + + { + this._renderDialogContent() + } + + ); + } + + _onCancel: () => boolean; + + /** + * Invokes the passed in {@link onCancel} callback and closes + * {@code StartLiveStreamDialog}. + * + * @private + * @returns {boolean} True is returned to close the modal. + */ + _onCancel() { + sendAnalytics(createRecordingDialogEvent('start', 'cancel.button')); + + return true; + } + + /** + * Asks the user to sign in, if not already signed in, and then requests a + * list of the user's YouTube broadcasts. + * + * NOTE: To be implemented by platforms. + * + * @private + * @returns {Promise} + */ + _onGetYouTubeBroadcasts: () => Promise<*>; + + /** + * Loads the Google client application used for fetching stream keys. + * If the user is already logged in, then a request for available YouTube + * broadcasts is also made. + */ + _onInitializeGoogleApi: () => Object; + + _onStreamKeyChange: string => void; + + /** + * Callback invoked to update the {@code StartLiveStreamDialog} component's + * display of the entered YouTube stream key. + * + * @param {string} streamKey - The stream key entered in the field. + * changed text. + * @private + * @returns {void} + */ + _onStreamKeyChange(streamKey) { + this._setStateIfMounted({ + streamKey, + selectedBoundStreamID: undefined + }); + } + + _onSubmit: () => boolean; + + /** + * Invokes the passed in {@link onSubmit} callback with the entered stream + * key, and then closes {@code StartLiveStreamDialog}. + * + * @private + * @returns {boolean} False if no stream key is entered to preventing + * closing, true to close the modal. + */ + _onSubmit() { + const { broadcasts, selectedBoundStreamID } = this.state; + const key = this.state.streamKey || this.props._streamKey; + + if (!key) { + return false; + } + + let selectedBroadcastID = null; + + if (selectedBoundStreamID) { + const selectedBroadcast = broadcasts && broadcasts.find( + broadcast => broadcast.boundStreamID === selectedBoundStreamID); + + selectedBroadcastID = selectedBroadcast && selectedBroadcast.id; + } + + sendAnalytics( + createRecordingDialogEvent('start', 'confirm.button')); + + this.props._conference.startRecording({ + broadcastId: selectedBroadcastID, + mode: JitsiRecordingConstants.mode.STREAM, + streamId: key + }); + + return true; + } + + /** + * Updates the internal state if the component is still mounted. This is a + * workaround for all the state setting that occurs after ajax. + * + * @param {Object} newState - The new state to merge into the existing + * state. + * @private + * @returns {void} + */ + _setStateIfMounted(newState) { + if (this._isMounted) { + this.setState(newState); + } + } + + /** + * Renders the platform specific dialog content. + * + * @returns {React$Component} + */ + _renderDialogContent: () => React$Component<*> +} + +/** + * Maps part of the Redux state to the component's props. + * + * @param {Object} state - The Redux state. + * @returns {{ + * _conference: Object, + * _googleApiApplicationClientID: string, + * _streamKey: string + * }} + */ +export function _mapStateToProps(state: Object) { + return { + _conference: state['features/base/conference'].conference, + _googleApiApplicationClientID: + state['features/base/config'].googleApiApplicationClientID, + _streamKey: state['features/recording'].streamKey + }; +} diff --git a/react/features/recording/components/LiveStream/AbstractStopLiveStreamDialog.js b/react/features/recording/components/LiveStream/AbstractStopLiveStreamDialog.js new file mode 100644 index 000000000..190894c35 --- /dev/null +++ b/react/features/recording/components/LiveStream/AbstractStopLiveStreamDialog.js @@ -0,0 +1,119 @@ +// @flow + +import React, { Component } from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { + createRecordingDialogEvent, + sendAnalytics +} from '../../../analytics'; +import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; + +import { getActiveSession } from '../../functions'; + +/** + * The type of the React {@code Component} props of + * {@link StopLiveStreamDialog}. + */ +type Props = { + + /** + * The {@code JitsiConference} for the current conference. + */ + _conference: Object, + + /** + * The redux representation of the live stremaing to be stopped. + */ + _session: Object, + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * A React Component for confirming the participant wishes to stop the currently + * active live stream of the conference. + * + * @extends Component + */ +export default class AbstractStopLiveStreamDialog extends Component { + /** + * Initializes a new {@code StopLiveStreamDialog} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: Props) { + 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; + + /** + * Callback invoked when stopping of live streaming is confirmed. + * + * @private + * @returns {boolean} True to close the modal. + */ + _onSubmit() { + sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button')); + + const { _session } = this.props; + + if (_session) { + this.props._conference.stopRecording(_session.id); + } + + return true; + } + + /** + * Function to be implemented by the platform specific implementations. + * + * @private + * @returns {React$Component<*>} + */ + _renderDialogContent: () => React$Component<*> +} + +/** + * Maps (parts of) the redux state to the React {@code Component} props of + * {@code StopLiveStreamDialog}. + * + * @param {Object} state - The redux state. + * @private + * @returns {{ + * _conference: Object, + * _session: Object + * }} + */ +export function _mapStateToProps(state: Object) { + return { + _conference: state['features/base/conference'].conference, + _session: getActiveSession(state, JitsiRecordingConstants.mode.STREAM) + }; +} diff --git a/react/features/recording/components/LiveStream/AbstractStreamKeyForm.js b/react/features/recording/components/LiveStream/AbstractStreamKeyForm.js new file mode 100644 index 000000000..0a393267e --- /dev/null +++ b/react/features/recording/components/LiveStream/AbstractStreamKeyForm.js @@ -0,0 +1,107 @@ +// @flow + +import { Component } from 'react'; + +declare var interfaceConfig: Object; + +/** + * The live streaming help link to display. On web it comes from + * interfaceConfig, but we don't have that on mobile. + * + * FIXME: This is in props now to prepare for the Redux-based interfaceConfig + */ +const LIVE_STREAMING_HELP_LINK = 'https://jitsi.org/live'; + +/** + * The props of the component. + */ +export type Props = { + + /** + * Callback invoked when the entered stream key has changed. + */ + onChange: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function, + + /** + * The stream key value to display as having been entered so far. + */ + value: string +}; + +/** + * The state of the component. + */ +type State = { + + /** + * The value entered in the field. + */ + value: string +} + +/** + * An abstract React Component for entering a key for starting a YouTube live + * stream. + * + * @extends Component + */ +export default class AbstractStreamKeyForm extends Component { + helpURL: string; + + /** + * Constructor for the component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this.state = { + value: props.value + }; + + this.helpURL = (typeof interfaceConfig !== 'undefined' + && interfaceConfig.LIVE_STREAMING_HELP_LINK) + || LIVE_STREAMING_HELP_LINK; + + // Bind event handlers so they are only bound once per instance. + this._onInputChange = this._onInputChange.bind(this); + } + + /** + * Implements {@code Component}'s componentWillReceiveProps. + * + * @inheritdoc + */ + componentWillReceiveProps(newProps: Props) { + this.setState({ + value: newProps.value + }); + } + + _onInputChange: Object => void + + /** + * Callback invoked when the value of the input field has updated through + * user input. This forwards the value (string only, even if it was a dom + * event) to the onChange prop provided to the component. + * + * @param {Object | string} change - DOM Event for value change or the + * changed text. + * @private + * @returns {void} + */ + _onInputChange(change) { + const value = typeof change === 'object' ? change.target.value : change; + + this.setState({ + value + }); + this.props.onChange(value); + } +} diff --git a/react/features/recording/components/LiveStream/LiveStreamButton.native.js b/react/features/recording/components/LiveStream/LiveStreamButton.native.js new file mode 100644 index 000000000..5e13f58ea --- /dev/null +++ b/react/features/recording/components/LiveStream/LiveStreamButton.native.js @@ -0,0 +1,20 @@ +// @flow + +import { connect } from 'react-redux'; + +import { translate } from '../../../base/i18n'; + +import AbstractLiveStreamButton, { + _mapStateToProps, + type Props +} from './AbstractLiveStreamButton'; + +/** + * An implementation of a button for starting and stopping live streaming. + */ +class LiveStreamButton extends AbstractLiveStreamButton { + iconName = 'public'; + toggledIconName = 'public'; +} + +export default translate(connect(_mapStateToProps)(LiveStreamButton)); diff --git a/react/features/recording/components/LiveStream/LiveStreamButton.web.js b/react/features/recording/components/LiveStream/LiveStreamButton.web.js new file mode 100644 index 000000000..df508fa22 --- /dev/null +++ b/react/features/recording/components/LiveStream/LiveStreamButton.web.js @@ -0,0 +1,122 @@ +// @flow + +import { connect } from 'react-redux'; + +import { translate } from '../../../base/i18n'; +import { getLocalParticipant } from '../../../base/participants'; + +import AbstractLiveStreamButton, { + _mapStateToProps as _abstractMapStateToProps, + type Props as AbstractProps +} from './AbstractLiveStreamButton'; + +declare var interfaceConfig: Object; + +type Props = AbstractProps & { + + /** + * True if the button should be disabled, false otherwise. + * + * NOTE: On web, if the feature is not disabled on purpose, then we still + * show the button but disabled and with a tooltip rendered on it, + * explaining why it's not available. + */ + _disabled: boolean, + + /** + * Tooltip for the button when it's disabled in a certain way. + */ + _liveStreamDisabledTooltipKey: ?string +} + +/** + * An implementation of a button for starting and stopping live streaming. + */ +class LiveStreamButton extends AbstractLiveStreamButton { + iconName = 'icon-public'; + toggledIconName = 'icon-public'; + + /** + * Constructor of the component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this.tooltip = props._liveStreamDisabledTooltipKey; + } + + /** + * Implements {@code Component}'s componentWillReceiveProps. + * + * @inheritdoc + */ + componentWillReceiveProps(newProps: Props) { + this.tooltip = newProps._liveStreamDisabledTooltipKey; + } + + /** + * Helper function to be implemented by subclasses, which must return a + * boolean value indicating if this button is disabled or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isDisabled() { + return this.props._disabled; + } +} + +/** + * Maps (parts of) the redux state to the associated props for the + * {@code LiveStreamButton} component. + * + * @param {Object} state - The Redux state. + * @param {Props} ownProps - The own props of the Component. + * @private + * @returns {{ + * _conference: Object, + * _isLiveStreamRunning: boolean, + * _disabled: boolean, + * visible: boolean + * }} + */ +function _mapStateToProps(state: Object, ownProps: Props) { + const abstractProps = _abstractMapStateToProps(state, ownProps); + const localParticipant = getLocalParticipant(state); + const { features = {} } = localParticipant; + let { visible } = ownProps; + + let _disabled = false; + let _liveStreamDisabledTooltipKey; + + if (!abstractProps.visible + && String(features.livestreaming) !== 'disabled') { + _disabled = true; + + // button and tooltip + if (state['features/base/jwt'].isGuest) { + _liveStreamDisabledTooltipKey + = 'dialog.liveStreamingDisabledForGuestTooltip'; + } else { + _liveStreamDisabledTooltipKey + = 'dialog.liveStreamingDisabledTooltip'; + } + } + + if (typeof visible === 'undefined') { + visible = interfaceConfig.TOOLBAR_BUTTONS.includes('livestreaming') + && (abstractProps.visible || _liveStreamDisabledTooltipKey); + } + + return { + ...abstractProps, + _disabled, + _liveStreamDisabledTooltipKey, + visible + }; +} + +export default translate(connect(_mapStateToProps)(LiveStreamButton)); diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js index e69de29bb..66cf0005d 100644 --- a/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js +++ b/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js @@ -0,0 +1,92 @@ +// @flow + +import React from 'react'; +import { View } from 'react-native'; +import { connect } from 'react-redux'; + +import { translate } from '../../../base/i18n'; + +import { setLiveStreamKey } from '../../actions'; + +import AbstractStartLiveStreamDialog, { + _mapStateToProps, + type Props +} from './AbstractStartLiveStreamDialog'; +import StreamKeyForm from './StreamKeyForm'; + +/** + * A React Component for requesting a YouTube stream key to use for live + * streaming of the current conference. + */ +class StartLiveStreamDialog extends AbstractStartLiveStreamDialog { + /** + * Constructor of the component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this); + this._onStreamKeyChangeNative + = this._onStreamKeyChangeNative.bind(this); + this._renderDialogContent = this._renderDialogContent.bind(this); + } + + _onInitializeGoogleApi: () => Promise<*> + + /** + * Loads the Google client application used for fetching stream keys. + * If the user is already logged in, then a request for available YouTube + * broadcasts is also made. + * + * @private + * @returns {Promise} + */ + _onInitializeGoogleApi() { + // This is a placeholder method for the Google feature. + return Promise.resolve(); + } + + _onStreamKeyChange: string => void + + _onStreamKeyChangeNative: string => void; + + /** + * Callback to handle stream key changes. + * + * FIXME: This is a temporary method to store the streaming key on mobile + * for easier use, until the Google sign-in is implemented. We don't store + * the key on web for security reasons (e.g. we don't want to have the key + * stored if the used signed out). + * + * @private + * @param {string} streamKey - The new key value. + * @returns {void} + */ + _onStreamKeyChangeNative(streamKey) { + this.props.dispatch(setLiveStreamKey(streamKey)); + this._onStreamKeyChange(streamKey); + } + + _renderDialogContent: () => React$Component<*> + + /** + * Renders the platform specific dialog content. + * + * @returns {React$Component} + */ + _renderDialogContent() { + return ( + + + + ); + } + +} + +export default translate(connect(_mapStateToProps)(StartLiveStreamDialog)); diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js index a4a0fad49..38e6a7e31 100644 --- a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js +++ b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js @@ -1,128 +1,32 @@ // @flow import Spinner from '@atlaskit/spinner'; -import React, { Component } from 'react'; +import React 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'; import googleApi from '../../googleApi'; +import AbstractStartLiveStreamDialog, { + _mapStateToProps, + GOOGLE_API_STATES, + type Props +} from './AbstractStartLiveStreamDialog'; import BroadcastsDropdown from './BroadcastsDropdown'; import GoogleSignInButton from './GoogleSignInButton'; import StreamKeyForm from './StreamKeyForm'; declare var interfaceConfig: Object; -/** - * An enumeration of the different states the Google API can be in while - * interacting with {@code StartLiveStreamDialog}. - * - * @private - * @type {Object} - */ -const GOOGLE_API_STATES = { - /** - * The state in which the Google API still needs to be loaded. - */ - NEEDS_LOADING: 0, - - /** - * The state in which the Google API is loaded and ready for use. - */ - LOADED: 1, - - /** - * The state in which a user has been logged in through the Google API. - */ - SIGNED_IN: 2, - - /** - * The state in which the Google API encountered an error either loading - * or with an API request. - */ - ERROR: 3 -}; - -/** - * The type of the React {@code Component} props of - * {@link StartLiveStreamDialog}. - */ -type Props = { - - /** - * The {@code JitsiConference} for the current conference. - */ - _conference: Object, - - /** - * The ID for the Google web client application used for making stream key - * related requests. - */ - _googleApiApplicationClientID: string, - - /** - * Invoked to obtain translated strings. - */ - t: Function -}; - -/** - * The type of the React {@code Component} state of - * {@link StartLiveStreamDialog}. - */ -type State = { - - /** - * Details about the broadcasts available for use for the logged in Google - * user's YouTube account. - */ - broadcasts: ?Array, - - /** - * The error type, as provided by Google, for the most recent error - * encountered by the Google API. - */ - errorType: ?string, - - /** - * The current state of interactions with the Google API. Determines what - * Google related UI should display. - */ - googleAPIState: number, - - /** - * The email of the user currently logged in to the Google web client - * application. - */ - googleProfileEmail: string, - - /** - * The boundStreamID of the broadcast currently selected in the broadcast - * dropdown. - */ - selectedBoundStreamID: ?string, - - /** - * The selected or entered stream key to use for YouTube live streaming. - */ - streamKey: string -}; - /** * A React Component for requesting a YouTube stream key to use for live * streaming of the current conference. * * @extends Component */ -class StartLiveStreamDialog extends Component { - _isMounted: boolean; +class StartLiveStreamDialog + extends AbstractStartLiveStreamDialog { /** * Initializes a new {@code StartLiveStreamDialog} instance. @@ -133,91 +37,17 @@ class StartLiveStreamDialog extends Component { constructor(props: Props) { super(props); - this.state = { - broadcasts: undefined, - errorType: undefined, - googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING, - googleProfileEmail: '', - selectedBoundStreamID: undefined, - streamKey: '' - }; - - /** - * Instance variable used to flag whether the component is or is not - * mounted. Used as a hack to avoid setting state on an unmounted - * component. - * - * @private - * @type {boolean} - */ - this._isMounted = false; - // Bind event handlers so they are only bound once per instance. - this._onCancel = this._onCancel.bind(this); this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this); this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this); this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this); - this._onStreamKeyChange = this._onStreamKeyChange.bind(this); - this._onSubmit = this._onSubmit.bind(this); this._onYouTubeBroadcastIDSelected = this._onYouTubeBroadcastIDSelected.bind(this); + + this._renderDialogContent = this._renderDialogContent.bind(this); } - /** - * Implements {@link Component#componentDidMount()}. Invoked immediately - * after this component is mounted. - * - * @inheritdoc - * @returns {void} - */ - componentDidMount() { - this._isMounted = true; - - if (this.props._googleApiApplicationClientID) { - this._onInitializeGoogleApi(); - } - } - - /** - * Implements React's {@link Component#componentWillUnmount()}. Invoked - * immediately before this component is unmounted and destroyed. - * - * @inheritdoc - */ - componentWillUnmount() { - this._isMounted = false; - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { _googleApiApplicationClientID } = this.props; - - return ( - -
- { _googleApiApplicationClientID - ? this._renderYouTubePanel() : null } - -
-
- ); - } - - _onInitializeGoogleApi: () => Object; + _onInitializeGoogleApi: () => Promise<*>; /** * Loads the Google web client application used for fetching stream keys. @@ -247,22 +77,7 @@ class StartLiveStreamDialog extends Component { }); } - _onCancel: () => boolean; - - /** - * Invokes the passed in {@link onCancel} callback and closes - * {@code StartLiveStreamDialog}. - * - * @private - * @returns {boolean} True is returned to close the modal. - */ - _onCancel() { - sendAnalytics(createRecordingDialogEvent('start', 'cancel.button')); - - return true; - } - - _onGetYouTubeBroadcasts: () => Object; + _onGetYouTubeBroadcasts: () => Promise<*>; /** * Asks the user to sign in, if not already signed in, and then requests a @@ -322,59 +137,7 @@ class StartLiveStreamDialog extends Component { .then(() => this._onGetYouTubeBroadcasts()); } - _onStreamKeyChange: () => void; - - /** - * Callback invoked to update the {@code StartLiveStreamDialog} component's - * display of the entered YouTube stream key. - * - * @param {Object} event - DOM Event for value change. - * @private - * @returns {void} - */ - _onStreamKeyChange(event) { - this._setStateIfMounted({ - streamKey: event.target.value, - selectedBoundStreamID: undefined - }); - } - - _onSubmit: () => boolean; - - /** - * Invokes the passed in {@link onSubmit} callback with the entered stream - * key, and then closes {@code StartLiveStreamDialog}. - * - * @private - * @returns {boolean} False if no stream key is entered to preventing - * closing, true to close the modal. - */ - _onSubmit() { - const { broadcasts, streamKey, selectedBoundStreamID } = this.state; - - if (!streamKey) { - return false; - } - - let selectedBroadcastID = null; - - if (selectedBoundStreamID) { - const selectedBroadcast = broadcasts && broadcasts.find( - broadcast => broadcast.boundStreamID === selectedBoundStreamID); - - selectedBroadcastID = selectedBroadcast && selectedBroadcast.id; - } - - sendAnalytics(createRecordingDialogEvent('start', 'confirm.button')); - - this.props._conference.startRecording({ - broadcastId: selectedBroadcastID, - mode: JitsiRecordingConstants.mode.STREAM, - streamId: streamKey - }); - - return true; - } + _onStreamKeyChange: string => void; _onYouTubeBroadcastIDSelected: (string) => Object; @@ -452,6 +215,27 @@ class StartLiveStreamDialog extends Component { return (firstError && firstError.reason) || null; } + _renderDialogContent: () => React$Component<*> + + /** + * Renders the platform specific dialog content. + * + * @returns {React$Component} + */ + _renderDialogContent() { + const { _googleApiApplicationClientID } = this.props; + + return ( +
+ { _googleApiApplicationClientID + ? this._renderYouTubePanel() : null } + +
+ ); + } + /** * Renders a React Element for authenticating with the Google web client. * @@ -537,6 +321,8 @@ class StartLiveStreamDialog extends Component { ); } + _setStateIfMounted: Object => void + /** * Returns the error message to display for the current error state. * @@ -559,40 +345,6 @@ class StartLiveStreamDialog extends Component { return
{ text }
; } - - /** - * Updates the internal state if the component is still mounted. This is a - * workaround for all the state setting that occurs after ajax. - * - * @param {Object} newState - The new state to merge into the existing - * state. - * @private - * @returns {void} - */ - _setStateIfMounted(newState) { - if (this._isMounted) { - this.setState(newState); - } - } -} - -/** - * Maps (parts of) the redux state to the React {@code Component} props of - * {@code StartLiveStreamDialog}. - * - * @param {Object} state - The redux state. - * @private - * @returns {{ - * _conference: Object, - * _googleApiApplicationClientID: string - * }} - */ -function _mapStateToProps(state) { - return { - _conference: state['features/base/conference'].conference, - _googleApiApplicationClientID: - state['features/base/config'].googleApiApplicationClientID - }; } export default translate(connect(_mapStateToProps)(StartLiveStreamDialog)); diff --git a/react/features/recording/components/LiveStream/StopLiveStreamDialog.native.js b/react/features/recording/components/LiveStream/StopLiveStreamDialog.native.js index e69de29bb..0d1c63a22 100644 --- a/react/features/recording/components/LiveStream/StopLiveStreamDialog.native.js +++ b/react/features/recording/components/LiveStream/StopLiveStreamDialog.native.js @@ -0,0 +1,41 @@ +// @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 AbstractStopLiveStreamDialog, { + _mapStateToProps +} from './AbstractStopLiveStreamDialog'; + +/** + * A React Component for confirming the participant wishes to stop the currently + * active live stream of the conference. + * + * @extends Component + */ +class StopLiveStreamDialog extends AbstractStopLiveStreamDialog { + + /** + * Renders the platform specific {@code Dialog} content. + * + * @inheritdoc + */ + _renderDialogContent() { + return ( + + + { + this.props.t('dialog.stopStreamingWarning') + } + + + ); + } +} + +export default translate(connect(_mapStateToProps)(StopLiveStreamDialog)); diff --git a/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js index 475ac6469..fb334deab 100644 --- a/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js +++ b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js @@ -1,36 +1,12 @@ // @flow -import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { Dialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; -import { - createRecordingDialogEvent, - sendAnalytics -} from '../../../analytics'; -/** - * The type of the React {@code Component} props of - * {@link StopLiveStreamDialog}. - */ -type Props = { - - /** - * The {@code JitsiConference} for the current conference. - */ - _conference: Object, - - /** - * The redux representation of the live stremaing to be stopped. - */ - session: Object, - - /** - * Invoked to obtain translated strings. - */ - t: Function -}; +import AbstractStopLiveStreamDialog, { + _mapStateToProps +} from './AbstractStopLiveStreamDialog'; /** * A React Component for confirming the participant wishes to stop the currently @@ -38,73 +14,16 @@ type Props = { * * @extends Component */ -class StopLiveStreamDialog extends Component { - /** - * Initializes a new {@code StopLiveStreamDialog} instance. - * - * @param {Object} props - The read-only properties with which the new - * instance is to be initialized. - */ - constructor(props: Props) { - super(props); - - // Bind event handler so it is only bound once for every instance. - this._onSubmit = this._onSubmit.bind(this); - } +class StopLiveStreamDialog extends AbstractStopLiveStreamDialog { /** - * Implements React's {@link Component#render()}. + * Renders the platform specific {@code Dialog} content. * * @inheritdoc - * @returns {ReactElement} */ - render() { - return ( - - { this.props.t('dialog.stopStreamingWarning') } - - ); + _renderDialogContent() { + return this.props.t('dialog.stopStreamingWarning'); } - - _onSubmit: () => boolean; - - /** - * Callback invoked when stopping of live streaming is confirmed. - * - * @private - * @returns {boolean} True to close the modal. - */ - _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 React {@code Component} props of - * {@code StopLiveStreamDialog}. - * - * @param {Object} state - The redux state. - * @private - * @returns {{ - * _conference: Object - * }} - */ -function _mapStateToProps(state) { - return { - _conference: state['features/base/conference'].conference - }; } export default translate(connect(_mapStateToProps)(StopLiveStreamDialog)); diff --git a/react/features/recording/components/LiveStream/StreamKeyForm.native.js b/react/features/recording/components/LiveStream/StreamKeyForm.native.js index e69de29bb..dab1d4cbf 100644 --- a/react/features/recording/components/LiveStream/StreamKeyForm.native.js +++ b/react/features/recording/components/LiveStream/StreamKeyForm.native.js @@ -0,0 +1,86 @@ +// @flow + +import React from 'react'; +import { Linking, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +import { translate } from '../../../base/i18n'; + +import AbstractStreamKeyForm, { + type Props +} from './AbstractStreamKeyForm'; +import styles from './styles'; + +/** + * A React Component for entering a key for starting a YouTube live stream. + * + * @extends Component + */ +class StreamKeyForm extends AbstractStreamKeyForm { + /** + * Initializes a new {@code StreamKeyForm} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code StreamKeyForm} instance with. + */ + constructor(props: Props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onOpenHelp = this._onOpenHelp.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { t } = this.props; + + return ( + + + { + t('dialog.streamKey') + } + + + + + { + t('liveStreaming.streamIdHelp') + } + + + + ); + } + + _onInputChange: Object => void + + _onOpenHelp: () => void + + /** + * Opens the information link on how to manually locate a YouTube broadcast + * stream key. + * + * @private + * @returns {void} + */ + _onOpenHelp() { + const { helpURL } = this; + + if (typeof helpURL === 'string') { + Linking.openURL(helpURL); + } + } +} + +export default translate(StreamKeyForm); diff --git a/react/features/recording/components/LiveStream/StreamKeyForm.web.js b/react/features/recording/components/LiveStream/StreamKeyForm.web.js index eed95aabb..e88b906c9 100644 --- a/react/features/recording/components/LiveStream/StreamKeyForm.web.js +++ b/react/features/recording/components/LiveStream/StreamKeyForm.web.js @@ -1,42 +1,20 @@ +// @flow + import { FieldTextStateless } from '@atlaskit/field-text'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React from 'react'; import { translate } from '../../../base/i18n'; +import AbstractStreamKeyForm, { + type Props +} from './AbstractStreamKeyForm'; + /** * A React Component for entering a key for starting a YouTube live stream. * * @extends Component */ -class StreamKeyForm extends Component { - /** - * {@code StreamKeyForm} component's property types. - * - * @static - */ - static propTypes = { - /** - * The URL to the page with more information for manually finding the - * stream key for a YouTube broadcast. - */ - helpURL: PropTypes.string, - - /** - * Callback invoked when the entered stream key has changed. - */ - onChange: PropTypes.func, - - /** - * Invoked to obtain translated strings. - */ - t: PropTypes.func, - - /** - * The stream key value to display as having been entered so far. - */ - value: PropTypes.string - }; +class StreamKeyForm extends AbstractStreamKeyForm { /** * Initializes a new {@code StreamKeyForm} instance. @@ -44,11 +22,10 @@ class StreamKeyForm extends Component { * @param {Props} props - The React {@code Component} props to initialize * the new {@code StreamKeyForm} instance with. */ - constructor(props) { + constructor(props: Props) { super(props); // Bind event handlers so they are only bound once per instance. - this._onInputChange = this._onInputChange.bind(this); this._onOpenHelp = this._onOpenHelp.bind(this); } @@ -59,7 +36,7 @@ class StreamKeyForm extends Component { * @returns {ReactElement} */ render() { - const { t } = this.props; + const { value, t } = this.props; return (
@@ -69,12 +46,12 @@ class StreamKeyForm extends Component { isSpellCheckEnabled = { false } label = { t('dialog.streamKey') } name = 'streamId' - okDisabled = { !this.props.value } + okDisabled = { !value } onChange = { this._onInputChange } placeholder = { t('liveStreaming.enterStreamKey') } shouldFitContainer = { true } type = 'text' - value = { this.props.value } /> + value = { this.state.value } /> { this.props.helpURL ?
void + + _onOpenHelp: () => void /** * Opens a new tab with information on how to manually locate a YouTube @@ -109,7 +78,7 @@ class StreamKeyForm extends Component { * @returns {void} */ _onOpenHelp() { - window.open(this.props.helpURL, 'noopener'); + window.open(this.helpURL, 'noopener'); } } diff --git a/react/features/recording/components/LiveStream/index.js b/react/features/recording/components/LiveStream/index.js index 216336769..07b9ab7b6 100644 --- a/react/features/recording/components/LiveStream/index.js +++ b/react/features/recording/components/LiveStream/index.js @@ -1,2 +1,3 @@ +export { default as LiveStreamButton } from './LiveStreamButton'; export { default as StartLiveStreamDialog } from './StartLiveStreamDialog'; export { default as StopLiveStreamDialog } from './StopLiveStreamDialog'; diff --git a/react/features/recording/components/LiveStream/styles.js b/react/features/recording/components/LiveStream/styles.js new file mode 100644 index 000000000..fa08b8f87 --- /dev/null +++ b/react/features/recording/components/LiveStream/styles.js @@ -0,0 +1,28 @@ +// @flow + +import { BoxModel, createStyleSheet } from '../../../base/styles'; + +/** + * The styles of the React {@code Components} of LiveStream. + */ +export default createStyleSheet({ + + streamKeyFormWrapper: { + flexDirection: 'column', + padding: BoxModel.padding + }, + + streamKeyHelp: { + alignSelf: 'flex-end' + }, + + streamKeyInput: { + alignSelf: 'stretch', + height: 50 + }, + + streamKeyInputLabel: { + alignSelf: 'flex-start' + } + +}); diff --git a/react/features/recording/components/Recording/RecordButton.web.js b/react/features/recording/components/Recording/RecordButton.web.js index 0b6c6b290..0fb08b568 100644 --- a/react/features/recording/components/Recording/RecordButton.web.js +++ b/react/features/recording/components/Recording/RecordButton.web.js @@ -107,9 +107,7 @@ export function _mapStateToProps(state: Object, ownProps: Props): Object { } if (typeof visible === 'undefined') { - const visibleButtons = new Set(interfaceConfig.TOOLBAR_BUTTONS); - - visible = visibleButtons.has('recording') + visible = interfaceConfig.TOOLBAR_BUTTONS.includes('recording') && (abstractProps.visible || _fileRecordingsDisabledTooltipKey); } diff --git a/react/features/recording/components/index.js b/react/features/recording/components/index.js index 11b3ba7b6..b00cf19ba 100644 --- a/react/features/recording/components/index.js +++ b/react/features/recording/components/index.js @@ -1,4 +1,8 @@ -export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; +export { + LiveStreamButton, + StartLiveStreamDialog, + StopLiveStreamDialog +} from './LiveStream'; export { RecordButton, StartRecordingDialog, diff --git a/react/features/recording/constants.js b/react/features/recording/constants.js index a8e98b978..d7cd5e5d6 100644 --- a/react/features/recording/constants.js +++ b/react/features/recording/constants.js @@ -1,5 +1,14 @@ // @flow +/** + * The Google API scopes to request access to for streaming. + * + * @type {Array} + */ +export const GOOGLE_API_SCOPES = [ + 'https://www.googleapis.com/auth/youtube.readonly' +]; + /** * The identifier of the sound to be played when a recording or live streaming * session is stopped. diff --git a/react/features/recording/googleApi.js b/react/features/recording/googleApi.js index f26538c36..025f01804 100644 --- a/react/features/recording/googleApi.js +++ b/react/features/recording/googleApi.js @@ -1,7 +1,6 @@ +import { GOOGLE_API_SCOPES } from './constants'; + const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js'; -const GOOGLE_API_SCOPES = [ - 'https://www.googleapis.com/auth/youtube.readonly' -].join(' '); /** * A promise for dynamically loading the Google API Client Library. @@ -68,7 +67,7 @@ const googleApi = { setTimeout(() => { api.client.init({ clientId, - scope: GOOGLE_API_SCOPES + scope: GOOGLE_API_SCOPES.join(' ') }) .then(resolve) .catch(reject); diff --git a/react/features/recording/middleware.js b/react/features/recording/middleware.js index c34dd5a02..69db6d1d2 100644 --- a/react/features/recording/middleware.js +++ b/react/features/recording/middleware.js @@ -104,24 +104,31 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { case RECORDING_SESSION_UPDATED: { const updatedSessionData = getSessionById(getState(), action.sessionData.id); + const { PENDING, OFF, ON } = JitsiRecordingConstants.status; - if (updatedSessionData.mode === JitsiRecordingConstants.mode.FILE) { - const { PENDING, OFF, ON } = JitsiRecordingConstants.status; + if (updatedSessionData.status === PENDING + && (!oldSessionData || oldSessionData.status !== PENDING)) { + dispatch( + showPendingRecordingNotification(updatedSessionData.mode)); + } else if (updatedSessionData.status !== PENDING) { + dispatch( + hidePendingRecordingNotification(updatedSessionData.mode)); - 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) + && updatedSessionData.mode + === JitsiRecordingConstants.mode.FILE) { + dispatch(playSound(RECORDING_ON_SOUND_ID)); + } else if (updatedSessionData.status === OFF + && (!oldSessionData || oldSessionData.status !== OFF)) { + dispatch( + showStoppedRecordingNotification( + updatedSessionData.mode)); - if (updatedSessionData.status === ON - && (!oldSessionData || oldSessionData.status !== ON)) { - dispatch(playSound(RECORDING_ON_SOUND_ID)); - } else if (updatedSessionData.status === OFF - && (!oldSessionData || oldSessionData.status !== OFF)) { + if (updatedSessionData.mode + === JitsiRecordingConstants.mode.FILE) { dispatch(stopSound(RECORDING_ON_SOUND_ID)); dispatch(playSound(RECORDING_OFF_SOUND_ID)); - dispatch(showStoppedRecordingNotification()); } } } diff --git a/react/features/recording/reducer.js b/react/features/recording/reducer.js index 7b8a87060..c5ba2c906 100644 --- a/react/features/recording/reducer.js +++ b/react/features/recording/reducer.js @@ -1,18 +1,33 @@ import { ReducerRegistry } from '../base/redux'; +import { PersistenceRegistry } from '../base/storage'; import { CLEAR_RECORDING_SESSIONS, RECORDING_SESSION_UPDATED, - SET_PENDING_RECORDING_NOTIFICATION_UID + SET_PENDING_RECORDING_NOTIFICATION_UID, + SET_STREAM_KEY } from './actionTypes'; const DEFAULT_STATE = { + pendingNotificationUids: {}, sessionDatas: [] }; +/** + * The name of the Redux store this feature stores its state in. + */ +const STORE_NAME = 'features/recording'; + +/** + * Sets up the persistence of the feature {@code recording}. + */ +PersistenceRegistry.register(STORE_NAME, { + streamKey: true +}, DEFAULT_STATE); + /** * Reduces the Redux actions of the feature features/recording. */ -ReducerRegistry.register('features/recording', +ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => { switch (action.type) { @@ -29,10 +44,23 @@ ReducerRegistry.register('features/recording', _updateSessionDatas(state.sessionDatas, action.sessionData) }; - case SET_PENDING_RECORDING_NOTIFICATION_UID: + case SET_PENDING_RECORDING_NOTIFICATION_UID: { + const pendingNotificationUids = { + ...state.pendingNotificationUids + }; + + pendingNotificationUids[action.streamType] = action.uid; + return { ...state, - pendingNotificationUid: action.uid + pendingNotificationUids + }; + } + + case SET_STREAM_KEY: + return { + ...state, + streamKey: action.streamKey }; default: diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 7744e0eb5..0011d4721 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -6,7 +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 { LiveStreamButton, RecordButton } from '../../../recording'; import { RoomLockButton } from '../../../room-lock'; import AudioOnlyButton from './AudioOnlyButton'; @@ -70,6 +70,7 @@ class OverflowMenu extends Component { + ); diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index ba673b702..9a3f0668a 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -11,11 +11,9 @@ import { } from '../../../analytics'; import { openDialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; -import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; import { getLocalParticipant, getParticipants, - isLocalParticipantModerator, participantUpdated } from '../../../base/participants'; import { getLocalVideoTrack, toggleScreensharing } from '../../../base/tracks'; @@ -30,10 +28,8 @@ import { } from '../../../invite'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; import { - RecordButton, - StartLiveStreamDialog, - StopLiveStreamDialog, - getActiveSession + LiveStreamButton, + RecordButton } from '../../../recording'; import { SETTINGS_TABS, @@ -123,22 +119,6 @@ type Props = { */ _isGuest: boolean, - /** - * The tooltip key to use when live streaming is disabled. Or undefined - * if non to be shown and the button to be hidden. - */ - _liveStreamingDisabledTooltipKey: boolean, - - /** - * Whether or not the live streaming feature is enabled for use. - */ - _liveStreamingEnabled: boolean, - - /** - * The current live streaming session, if any. - */ - _liveStreamingSession: ?Object, - /** * The ID of the local participant. */ @@ -230,8 +210,6 @@ class Toolbox extends Component { = this._onToolbarToggleEtherpad.bind(this); this._onToolbarToggleFullScreen = this._onToolbarToggleFullScreen.bind(this); - this._onToolbarToggleLiveStreaming - = this._onToolbarToggleLiveStreaming.bind(this); this._onToolbarToggleProfile = this._onToolbarToggleProfile.bind(this); this._onToolbarToggleRaiseHand @@ -476,22 +454,6 @@ class Toolbox extends Component { this.props.dispatch(setFullScreen(fullScreen)); } - /** - * Dispatches an action to show a dialog for starting or stopping a live - * streaming session. - * - * @private - * @returns {void} - */ - _doToggleLiveStreaming() { - const { _liveStreamingSession } = this.props; - const dialogToDisplay = _liveStreamingSession - ? StopLiveStreamDialog : StartLiveStreamDialog; - - this.props.dispatch( - openDialog(dialogToDisplay, { session: _liveStreamingSession })); - } - /** * Dispatches an action to show or hide the profile edit panel. * @@ -790,25 +752,6 @@ class Toolbox extends Component { this._doToggleFullScreen(); } - _onToolbarToggleLiveStreaming: () => void; - - /** - * Starts the process for enabling or disabling live streaming. - * - * @private - * @returns {void} - */ - _onToolbarToggleLiveStreaming() { - sendAnalytics(createToolbarEvent( - 'livestreaming.button', - { - 'is_streaming': Boolean(this.props._liveStreamingSession), - type: JitsiRecordingConstants.mode.STREAM - })); - - this._doToggleLiveStreaming(); - } - _onToolbarToggleProfile: () => void; /** @@ -919,43 +862,6 @@ class Toolbox extends Component { ); } - /** - * Renders an {@code OverflowMenuItem} to start or stop live streaming of - * the current conference. - * - * @private - * @returns {ReactElement} - */ - _renderLiveStreamingButton() { - const { - _liveStreamingDisabledTooltipKey, - _liveStreamingEnabled, - _liveStreamingSession, - t - } = this.props; - - const translationKey = _liveStreamingSession - ? 'dialog.stopLiveStreaming' - : 'dialog.startLiveStreaming'; - - return ( - - { t('recording.beta') } - - } - icon = 'icon-public' - key = 'livestreaming' - onClick = { this._onToolbarToggleLiveStreaming } - text = { t(translationKey) } - tooltip = { t(_liveStreamingDisabledTooltipKey) } /> - ); - } - /** * Renders the list elements of the overflow menu. * @@ -969,8 +875,6 @@ class Toolbox extends Component { _feedbackConfigured, _fullScreen, _isGuest, - _liveStreamingDisabledTooltipKey, - _liveStreamingEnabled, _sharingVideo, t } = this.props; @@ -997,9 +901,9 @@ class Toolbox extends Component { text = { _fullScreen ? t('toolbar.exitFullScreen') : t('toolbar.enterFullScreen') } />, - (_liveStreamingEnabled || _liveStreamingDisabledTooltipKey) - && this._shouldShowButton('livestreaming') - && this._renderLiveStreamingButton(), + , , @@ -1086,7 +990,6 @@ function _mapStateToProps(state) { callStatsID, iAmRecorder } = state['features/base/config']; - let { liveStreamingEnabled } = state['features/base/config']; const sharedVideoStatus = state['features/shared-video'].status; const { current } = state['features/side-panel']; const { @@ -1102,10 +1005,6 @@ function _mapStateToProps(state) { const dialOutEnabled = isDialOutEnabled(state); let desktopSharingDisabledTooltipKey; - let liveStreamingDisabledTooltipKey; - - liveStreamingEnabled - = isLocalParticipantModerator(state) && liveStreamingEnabled; if (state['features/base/config'].enableFeaturesBasedOnToken) { // we enable desktop sharing if any participant already have this @@ -1122,27 +1021,6 @@ function _mapStateToProps(state) { desktopSharingDisabledTooltipKey = 'dialog.shareYourScreenDisabled'; } - - // we enable recording if the local participant have this - // feature enabled - const { features = {} } = localParticipant; - const { isGuest } = state['features/base/jwt']; - - liveStreamingEnabled - = liveStreamingEnabled && String(features.livestreaming) === 'true'; - - // if the feature is disabled on purpose, do no show it, no tooltip - if (!liveStreamingEnabled - && String(features.livestreaming) !== 'disabled') { - // button and tooltip - if (isGuest) { - liveStreamingDisabledTooltipKey - = 'dialog.liveStreamingDisabledForGuestTooltip'; - } else { - liveStreamingDisabledTooltipKey - = 'dialog.liveStreamingDisabledTooltip'; - } - } } return { @@ -1158,10 +1036,6 @@ function _mapStateToProps(state) { iAmRecorder || (!addPeopleEnabled && !dialOutEnabled), _isGuest: state['features/base/jwt'].isGuest, _fullScreen: fullScreen, - _liveStreamingDisabledTooltipKey: liveStreamingDisabledTooltipKey, - _liveStreamingEnabled: liveStreamingEnabled, - _liveStreamingSession: - getActiveSession(state, JitsiRecordingConstants.mode.STREAM), _localParticipantID: localParticipant.id, _overflowMenuVisible: overflowMenuVisible, _raisedHand: localParticipant.raisedHand,