From 823481dc1da66899bea1557d3b5f684e2ba844d7 Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Wed, 21 Mar 2018 11:26:52 -0700 Subject: [PATCH] feat(recording): use google api to get stream key (#2481) * feat(recording): use google api to get stream key * squash: renaming pass * squash: return full load promise * sqush: use google api state enum * squash: workaround for lib not loading * another new design... * increase timeout workaround for gapi load issue * styling pass * tweak copy * squash: auto select first broadcast --- config.js | 2 +- css/_recording.scss | 78 +++ images/googleLogo.svg | 11 + lang/main.json | 15 +- modules/UI/recording/Recording.js | 116 +---- .../LiveStream/BroadcastsDropdown.native.js | 0 .../LiveStream/BroadcastsDropdown.web.js | 168 +++++++ .../LiveStream/GoogleSignInButton.native.js | 0 .../LiveStream/GoogleSignInButton.web.js | 47 ++ .../StartLiveStreamDialog.native.js | 0 .../LiveStream/StartLiveStreamDialog.web.js | 462 ++++++++++++++++++ .../LiveStream/StopLiveStreamDialog.native.js | 0 .../LiveStream/StopLiveStreamDialog.web.js | 82 ++++ .../LiveStream/StreamKeyForm.native.js | 0 .../LiveStream/StreamKeyForm.web.js | 115 +++++ .../recording/components/LiveStream/index.js | 2 + react/features/recording/components/index.js | 1 + react/features/recording/googleApi.js | 230 +++++++++ 18 files changed, 1225 insertions(+), 104 deletions(-) create mode 100644 images/googleLogo.svg create mode 100644 react/features/recording/components/LiveStream/BroadcastsDropdown.native.js create mode 100644 react/features/recording/components/LiveStream/BroadcastsDropdown.web.js create mode 100644 react/features/recording/components/LiveStream/GoogleSignInButton.native.js create mode 100644 react/features/recording/components/LiveStream/GoogleSignInButton.web.js create mode 100644 react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js create mode 100644 react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js create mode 100644 react/features/recording/components/LiveStream/StopLiveStreamDialog.native.js create mode 100644 react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js create mode 100644 react/features/recording/components/LiveStream/StreamKeyForm.native.js create mode 100644 react/features/recording/components/LiveStream/StreamKeyForm.web.js create mode 100644 react/features/recording/components/LiveStream/index.js create mode 100644 react/features/recording/googleApi.js diff --git a/config.js b/config.js index f2e208452..a815c49cd 100644 --- a/config.js +++ b/config.js @@ -333,7 +333,6 @@ var config = { // userRegion: "asia" } - // List of undocumented settings used in jitsi-meet /** alwaysVisibleToolbar @@ -353,6 +352,7 @@ var config = { etherpad_base externalConnectUrl firefox_fake_device + googleApiApplicationClientID iAmRecorder iAmSipGateway peopleSearchQueryTypes diff --git a/css/_recording.scss b/css/_recording.scss index b3577f38e..fbead3f20 100644 --- a/css/_recording.scss +++ b/css/_recording.scss @@ -1,3 +1,81 @@ .recordingSpinner { vertical-align: top; } + +.live-stream-dialog { + /** + * Set font-size to be consistent with Atlaskit FieldText. + */ + font-size: 14px; + + .broadcast-dropdown, + .broadcast-dropdown-trigger { + text-align: left; + } + + .form-footer { + text-align: right; + } + + .live-stream-cta { + a { + cursor: pointer; + } + } + + .google-api { + margin-top: 10px; + min-height: 36px; + text-align: center; + width: 100%; + } + + /** + * The Google sign in button must follow Google's design guidelines. + * See: https://developers.google.com/identity/branding-guidelines + */ + .google-sign-in { + background-color: #4285f4; + border-radius: 2px; + cursor: pointer; + display: inline-flex; + font-family: Roboto, arial, sans-serif; + font-size: 14px; + padding: 1px; + + .google-cta { + color: white; + display: inline-block; + /** + * Hack the line height for vertical centering of text. + */ + line-height: 32px; + margin: 0 15px; + } + + .google-logo { + background-color: white; + border-radius: 2px; + display: inline-block; + padding: 8px; + height: 18px; + width: 18px; + } + } + + .google-panel { + align-items: center; + border-bottom: 2px solid rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + padding-bottom: 10px; + } + + .stream-key-form { + .helper-link { + display: inline-block; + cursor: pointer; + margin-top: 5px; + } + } +} diff --git a/images/googleLogo.svg b/images/googleLogo.svg new file mode 100644 index 000000000..1e74ba409 --- /dev/null +++ b/images/googleLogo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/lang/main.json b/lang/main.json index d1d68c00d..a0eba2ba9 100644 --- a/lang/main.json +++ b/lang/main.json @@ -285,8 +285,8 @@ "thankYou": "Thank you for using __appName__!", "sorryFeedback": "We're sorry to hear that. Would you like to tell us more?", "liveStreaming": "Live Streaming", - "streamKey": "Stream name/key", - "startLiveStreaming": "Start live streaming", + "streamKey": "Live stream key", + "startLiveStreaming": "Go live now", "stopStreamingWarning": "Are you sure you would like to stop the live streaming?", "stopRecordingWarning": "Are you sure you would like to stop the recording?", "stopLiveStreaming": "Stop live streaming", @@ -396,14 +396,21 @@ "busy": "We're working on freeing streaming resources. Please try again in a few minutes.", "busyTitle": "All streamers are currently busy", "buttonTooltip": "Start / Stop Live Stream", + "changeSignIn": "Switch accounts.", + "choose": "Choose a live stream", + "chooseCTA": "Choose a streaming option. You're currently logged in as __email__.", + "enterStreamKey": "Enter your YouTube live stream key here.", "error": "Live Streaming failed. Please try again.", + "errorAPI": "An error occurred while accessing your YouTube broadcasts. Please try logging in again.", "failedToStart": "Live Streaming failed to start", "off": "Live Streaming stopped", "on": "Live Streaming", "pending": "Starting Live Stream...", "serviceName": "Live Streaming service", - "streamIdRequired": "Please fill in the stream id in order to launch the Live Streaming.", - "streamIdHelp": "Where do I find this?", + "signIn": "Sign in with Google", + "signInCTA": "Sign in or enter your live stream key from YouTube.", + "start": "Start a livestream", + "streamIdHelp": "What's this?", "unavailableTitle": "Live Streaming unavailable" }, "videoSIPGW": diff --git a/modules/UI/recording/Recording.js b/modules/UI/recording/Recording.js index 2151d1b99..fcbdca41e 100644 --- a/modules/UI/recording/Recording.js +++ b/modules/UI/recording/Recording.js @@ -20,6 +20,7 @@ import UIEvents from '../../../service/UI/UIEvents'; import UIUtil from '../util/UIUtil'; import VideoLayout from '../videolayout/VideoLayout'; +import { openDialog } from '../../../react/features/base/dialog'; import { JitsiRecordingStatus } from '../../../react/features/base/lib-jitsi-meet'; @@ -31,6 +32,8 @@ import { import { setToolboxEnabled } from '../../../react/features/toolbox'; import { setNotificationsEnabled } from '../../../react/features/notifications'; import { + StartLiveStreamDialog, + StopLiveStreamDialog, hideRecordingLabel, updateRecordingState } from '../../../react/features/recording'; @@ -102,91 +105,11 @@ function _isRecordingButtonEnabled() { * @returns {Promise} */ function _requestLiveStreamId() { - const cancelButton - = APP.translation.generateTranslationHTML('dialog.Cancel'); - const backButton = APP.translation.generateTranslationHTML('dialog.Back'); - const startStreamingButton - = APP.translation.generateTranslationHTML('dialog.startLiveStreaming'); - const streamIdRequired - = APP.translation.generateTranslationHTML( - 'liveStreaming.streamIdRequired'); - const streamIdHelp - = APP.translation.generateTranslationHTML( - 'liveStreaming.streamIdHelp'); - - return new Promise((resolve, reject) => { - dialog = APP.UI.messageHandler.openDialogWithStates({ - state0: { - titleKey: 'dialog.liveStreaming', - html: - `
- ${ - streamIdHelp -}
`, - persistent: false, - buttons: [ - { title: cancelButton, - value: false }, - { title: startStreamingButton, - value: true } - ], - focus: ':input:first', - defaultButton: 1, - submit(e, v, m, f) { // eslint-disable-line max-params - e.preventDefault(); - - if (v) { - if (f.streamId && f.streamId.length > 0) { - resolve(UIUtil.escapeHtml(f.streamId)); - dialog.close(); - - return; - } - dialog.goToState('state1'); - - return false; - - } - reject(APP.UI.messageHandler.CANCEL); - dialog.close(); - - return false; - - } - }, - - state1: { - titleKey: 'dialog.liveStreaming', - html: streamIdRequired, - persistent: false, - buttons: [ - { title: cancelButton, - value: false }, - { title: backButton, - value: true } - ], - focus: ':input:first', - defaultButton: 1, - submit(e, v) { - e.preventDefault(); - if (v === 0) { - reject(APP.UI.messageHandler.CANCEL); - dialog.close(); - } else { - dialog.goToState('state0'); - } - } - } - }, { - close() { - dialog = null; - } - }); - }); + return new Promise((resolve, reject) => + APP.store.dispatch(openDialog(StartLiveStreamDialog, { + onCancel: reject, + onSubmit: resolve + }))); } /** @@ -232,25 +155,20 @@ function _requestRecordingToken() { * @private */ function _showStopRecordingPrompt(recordingType) { - let title; - let message; - let buttonKey; - if (recordingType === 'jibri') { - title = 'dialog.liveStreaming'; - message = 'dialog.stopStreamingWarning'; - buttonKey = 'dialog.stopLiveStreaming'; - } else { - title = 'dialog.recording'; - message = 'dialog.stopRecordingWarning'; - buttonKey = 'dialog.stopRecording'; + return new Promise((resolve, reject) => { + APP.store.dispatch(openDialog(StopLiveStreamDialog, { + onCancel: reject, + onSubmit: resolve + })); + }); } return new Promise((resolve, reject) => { dialog = APP.UI.messageHandler.openTwoButtonDialog({ - titleKey: title, - msgKey: message, - leftButtonKey: buttonKey, + titleKey: 'dialog.recording', + msgKey: 'dialog.stopRecordingWarning', + leftButtonKey: 'dialog.stopRecording', submitFunction: (e, v) => (v ? resolve : reject)(), closeFunction: () => { dialog = null; diff --git a/react/features/recording/components/LiveStream/BroadcastsDropdown.native.js b/react/features/recording/components/LiveStream/BroadcastsDropdown.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/BroadcastsDropdown.web.js b/react/features/recording/components/LiveStream/BroadcastsDropdown.web.js new file mode 100644 index 000000000..fd4f1aafb --- /dev/null +++ b/react/features/recording/components/LiveStream/BroadcastsDropdown.web.js @@ -0,0 +1,168 @@ +import { + DropdownItem, + DropdownItemGroup, + DropdownMenuStateless +} from '@atlaskit/dropdown-menu'; +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { translate } from '../../../base/i18n'; + +/** + * A dropdown to select a YouTube broadcast. + * + * @extends Component + */ +class BroadcastsDropdown extends PureComponent { + /** + * Default values for {@code StreamKeyForm} component's properties. + * + * @static + */ + static defaultProps = { + broadcasts: [] + }; + + /** + * {@code BroadcastsDropdown} component's property types. + */ + static propTypes = { + /** + * Broadcasts available for selection. Each broadcast item should be an + * object with a title for display in the dropdown and a boundStreamID + * to return in the {@link onBroadcastSelected} callback. + */ + broadcasts: PropTypes.array, + + /** + * Callback invoked when an item in the dropdown is selected. The + * selected broadcast's boundStreamID will be passed back. + */ + onBroadcastSelected: PropTypes.func, + + /** + * The boundStreamID of the broadcast that should display as selected in + * the dropdown. + */ + selectedBroadcastID: PropTypes.string, + + /** + * Invoked to obtain translated strings. + */ + t: PropTypes.func + }; + + /** + * The initial state of a {@code StreamKeyForm} instance. + * + * @type {{ + * isDropdownOpen: boolean + * }} + */ + state = { + isDropdownOpen: false + }; + + /** + * Initializes a new {@code BroadcastsDropdown} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code BroadcastsDropdown} instance with. + */ + constructor(props) { + super(props); + + // Bind event handlers so they are only bound once per instance. + this._onDropdownOpenChange = this._onDropdownOpenChange.bind(this); + this._onSelect = this._onSelect.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { broadcasts, selectedBroadcastID, t } = this.props; + + const dropdownItems = broadcasts.map(broadcast => + // eslint-disable-next-line react/jsx-wrap-multilines + this._onSelect(broadcast.boundStreamID) }> + { broadcast.title } + + ); + const selected = this.props.broadcasts.find( + broadcast => broadcast.boundStreamID === selectedBroadcastID); + const triggerText = (selected && selected.title) + || t('liveStreaming.choose'); + + return ( +
+ + + { dropdownItems } + + +
+ ); + } + + /** + * Transforms the passed in broadcasts into an array of objects that can + * be parsed by {@code DropdownMenuStateless}. + * + * @param {Array} broadcasts - The YouTube broadcasts to display. + * @private + * @returns {Array} + */ + _formatBroadcasts(broadcasts) { + return broadcasts.map(broadcast => { + return { + content: broadcast.title, + value: broadcast + }; + }); + } + + /** + * Sets the dropdown to be displayed or not based on the passed in event. + * + * @param {Object} dropdownEvent - The event passed from + * {@code DropdownMenuStateless} indicating if the dropdown should be open + * or closed. + * @private + * @returns {void} + */ + _onDropdownOpenChange(dropdownEvent) { + this.setState({ + isDropdownOpen: dropdownEvent.isOpen + }); + } + + /** + * Callback invoked when an item has been clicked in the dropdown menu. + * + * @param {Object} boundStreamID - The bound stream ID for the selected + * broadcast. + * @returns {void} + */ + _onSelect(boundStreamID) { + this.props.onBroadcastSelected(boundStreamID); + } +} + +export default translate(BroadcastsDropdown); diff --git a/react/features/recording/components/LiveStream/GoogleSignInButton.native.js b/react/features/recording/components/LiveStream/GoogleSignInButton.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/GoogleSignInButton.web.js b/react/features/recording/components/LiveStream/GoogleSignInButton.web.js new file mode 100644 index 000000000..e6854a857 --- /dev/null +++ b/react/features/recording/components/LiveStream/GoogleSignInButton.web.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +/** + * A React Component showing a button to sign in with Google. + * + * @extends Component + */ +export default class GoogleSignInButton extends Component { + /** + * {@code GoogleSignInButton} component's property types. + * + * @static + */ + static propTypes = { + /** + * The callback to invoke when the button is clicked. + */ + onClick: PropTypes.func, + + /** + * The text to display in the button. + */ + text: PropTypes.string + }; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( +
+ +
+ { this.props.text } +
+
+ ); + } +} diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js new file mode 100644 index 000000000..c8cae5bdc --- /dev/null +++ b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js @@ -0,0 +1,462 @@ +/* globals APP, interfaceConfig */ + +import Spinner from '@atlaskit/spinner'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; + +import googleApi from '../../googleApi'; + +import BroadcastsDropdown from './BroadcastsDropdown'; +import GoogleSignInButton from './GoogleSignInButton'; +import StreamKeyForm from './StreamKeyForm'; + +/** + * 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 +}; + +/** + * A React Component for requesting a YouTube stream key to use for live + * streaming of the current conference. + * + * @extends Component + */ +class StartLiveStreamDialog extends Component { + /** + * {@code StartLiveStreamDialog} component's property types. + * + * @static + */ + static propTypes = { + /** + * The ID for the Google web client application used for making stream + * key related requests. + */ + _googleApiApplicationClientID: PropTypes.string, + + /** + * Callback to invoke when the dialog is dismissed without submitting a + * stream key. + */ + onCancel: PropTypes.func, + + /** + * Callback to invoke when a stream key is submitted for use. + */ + onSubmit: PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: PropTypes.func + }; + + /** + * {@code StartLiveStreamDialog} component's local state. + * + * @property {boolean} googleAPIState - The current state of interactions + * with the Google API. Determines what Google related UI should display. + * @property {Object[]|undefined} broadcasts - Details about the broadcasts + * available for use for the logged in Google user's YouTube account. + * @property {string} googleProfileEmail - The email of the user currently + * logged in to the Google web client application. + * @property {string} streamKey - The selected or entered stream key to use + * for YouTube live streaming. + */ + state = { + broadcasts: undefined, + googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING, + googleProfileEmail: '', + selectedBroadcastID: undefined, + streamKey: '' + }; + + /** + * Initializes a new {@code StartLiveStreamDialog} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code StartLiveStreamDialog} instance with. + */ + constructor(props) { + super(props); + + /** + * 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); + } + + /** + * 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 } + +
+
+ ); + } + + /** + * Loads the Google web 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() { + return googleApi.get() + .then(() => googleApi.initializeClient( + this.props._googleApiApplicationClientID)) + .then(() => this._setStateIfMounted({ + googleAPIState: GOOGLE_API_STATES.LOADED + })) + .then(() => googleApi.isSignedIn()) + .then(isSignedIn => { + if (isSignedIn) { + return this._onGetYouTubeBroadcasts(); + } + }) + .catch(() => { + this._setStateIfMounted({ + googleAPIState: GOOGLE_API_STATES.ERROR + }); + }); + } + + /** + * Invokes the passed in {@link onCancel} callback and closes + * {@code StartLiveStreamDialog}. + * + * @private + * @returns {boolean} True is returned to close the modal. + */ + _onCancel() { + this.props.onCancel(APP.UI.messageHandler.CANCEL); + + return true; + } + + /** + * Asks the user to sign in, if not already signed in, and then requests a + * list of the user's YouTube broadcasts. + * + * @private + * @returns {Promise} + */ + _onGetYouTubeBroadcasts() { + return googleApi.get() + .then(() => googleApi.signInIfNotSignedIn()) + .then(() => googleApi.getCurrentUserProfile()) + .then(profile => { + this._setStateIfMounted({ + googleProfileEmail: profile.getEmail(), + googleAPIState: GOOGLE_API_STATES.SIGNED_IN + }); + }) + .then(() => googleApi.requestAvailableYouTubeBroadcasts()) + .then(response => { + const broadcasts = response.result.items.map(item => { + return { + title: item.snippet.title, + boundStreamID: item.contentDetails.boundStreamId, + status: item.status.lifeCycleStatus + }; + }); + + this._setStateIfMounted({ + broadcasts + }); + + if (broadcasts.length === 1 && !this.state.streamKey) { + const broadcast = broadcasts[0]; + + this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID); + } + }) + .catch(response => { + // Only show an error if an external request was made with the + // Google api. Do not error if the login in canceled. + if (response && response.result) { + this._setStateIfMounted({ + googleAPIState: GOOGLE_API_STATES.ERROR + }); + } + }); + } + + /** + * Forces the Google web client application to prompt for a sign in, such as + * when changing account, and will then fetch available YouTube broadcasts. + * + * @private + * @returns {Promise} + */ + _onRequestGoogleSignIn() { + return googleApi.showAccountSelection() + .then(() => this._setStateIfMounted({ broadcasts: undefined })) + .then(() => this._onGetYouTubeBroadcasts()); + } + + /** + * 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, + selectedBroadcastID: undefined + }); + } + + /** + * 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() { + if (!this.state.streamKey) { + return false; + } + + this.props.onSubmit(this.state.streamKey); + + return true; + } + + /** + * Fetches the stream key for a YouTube broadcast and updates the internal + * state to display the associated stream key as being entered. + * + * @param {string} boundStreamID - The bound stream ID associated with the + * broadcast from which to get the stream key. + * @private + * @returns {Promise} + */ + _onYouTubeBroadcastIDSelected(boundStreamID) { + return googleApi.requestLiveStreamsForYouTubeBroadcast(boundStreamID) + .then(response => { + const found = response.result.items[0]; + const streamKey = found.cdn.ingestionInfo.streamName; + + this._setStateIfMounted({ + streamKey, + selectedBroadcastID: boundStreamID + }); + }); + } + + /** + * Renders a React Element for authenticating with the Google web client. + * + * @private + * @returns {ReactElement} + */ + _renderYouTubePanel() { + const { t } = this.props; + const { + broadcasts, + googleProfileEmail, + selectedBroadcastID + } = this.state; + + let googleContent, helpText; + + switch (this.state.googleAPIState) { + case GOOGLE_API_STATES.LOADED: + googleContent = ( // eslint-disable-line no-extra-parens + + ); + helpText = t('liveStreaming.signInCTA'); + + break; + + case GOOGLE_API_STATES.SIGNED_IN: + googleContent = ( // eslint-disable-line no-extra-parens + + ); + + /** + * FIXME: Ideally this help text would be one translation string + * that also accepts the anchor. This can be done using the Trans + * component of react-i18next but I couldn't get it working... + */ + helpText = ( // eslint-disable-line no-extra-parens +
+ { `${t('liveStreaming.chooseCTA', + { email: googleProfileEmail })} ` } + + { t('liveStreaming.changeSignIn') } + +
+ ); + + break; + + case GOOGLE_API_STATES.ERROR: + googleContent = ( // eslint-disable-line no-extra-parens + + ); + helpText = t('liveStreaming.errorAPI'); + + break; + + case GOOGLE_API_STATES.NEEDS_LOADING: + default: + googleContent = ( // eslint-disable-line no-extra-parens + + ); + + break; + } + + return ( +
+
+ { helpText } +
+
+ { googleContent } +
+
+ ); + } + + /** + * 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. + * @protected + * @returns {{ + * _googleApiApplicationClientID: string + * }} + */ +function _mapStateToProps(state) { + return { + _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 new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js new file mode 100644 index 000000000..4aaba87ec --- /dev/null +++ b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; + +/** + * A React Component for confirming the participant wishes to stop the currently + * active live stream of the conference. + * + * @extends Component + */ +class StopLiveStreamDialog extends Component { + /** + * {@code StopLiveStreamDialog} component's property types. + * + * @static + */ + static propTypes = { + /** + * Callback to invoke when the dialog is dismissed without confirming + * the live stream should be stopped. + */ + onCancel: PropTypes.func, + + /** + * Callback to invoke when confirming the live stream should be stopped. + */ + onSubmit: PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: PropTypes.func + }; + + /** + * Initializes a new {@code StopLiveStreamDialog} instance. + * + * @param {Object} 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); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + + { this.props.t('dialog.stopStreamingWarning') } + + ); + } + + /** + * Callback invoked when stopping of live streaming is confirmed. + * + * @private + * @returns {boolean} True to close the modal. + */ + _onSubmit() { + this.props.onSubmit(); + + return true; + } +} + +export default translate(StopLiveStreamDialog); diff --git a/react/features/recording/components/LiveStream/StreamKeyForm.native.js b/react/features/recording/components/LiveStream/StreamKeyForm.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/StreamKeyForm.web.js b/react/features/recording/components/LiveStream/StreamKeyForm.web.js new file mode 100644 index 000000000..7e5c711ea --- /dev/null +++ b/react/features/recording/components/LiveStream/StreamKeyForm.web.js @@ -0,0 +1,115 @@ +import { FieldTextStateless } from '@atlaskit/field-text'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +import { translate } from '../../../base/i18n'; + +/** + * 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 + }; + + /** + * Initializes a new {@code StreamKeyForm} instance. + * + * @param {Props} props - The React {@code Component} props to initialize + * the new {@code StreamKeyForm} instance with. + */ + constructor(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); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { t } = this.props; + + return ( +
+ + { this.props.helpURL + ? + : null + } +
+ ); + } + + /** + * Callback invoked when the value of the input field has updated through + * user input. + * + * @param {Object} event - DOM Event for value change. + * @private + * @returns {void} + */ + _onInputChange(event) { + this.props.onChange(event); + } + + /** + * Opens a new tab with information on how to manually locate a YouTube + * broadcast stream key. + * + * @private + * @returns {void} + */ + _onOpenHelp() { + window.open(this.props.helpURL, 'noopener'); + } +} + +export default translate(StreamKeyForm); diff --git a/react/features/recording/components/LiveStream/index.js b/react/features/recording/components/LiveStream/index.js new file mode 100644 index 000000000..216336769 --- /dev/null +++ b/react/features/recording/components/LiveStream/index.js @@ -0,0 +1,2 @@ +export { default as StartLiveStreamDialog } from './StartLiveStreamDialog'; +export { default as StopLiveStreamDialog } from './StopLiveStreamDialog'; diff --git a/react/features/recording/components/index.js b/react/features/recording/components/index.js index 90ce231bd..70a2733bd 100644 --- a/react/features/recording/components/index.js +++ b/react/features/recording/components/index.js @@ -1 +1,2 @@ +export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; export { default as RecordingLabel } from './RecordingLabel'; diff --git a/react/features/recording/googleApi.js b/react/features/recording/googleApi.js new file mode 100644 index 000000000..92651bbbb --- /dev/null +++ b/react/features/recording/googleApi.js @@ -0,0 +1,230 @@ +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. + * + * @private + * @type {Promise} + */ +let googleClientLoadPromise; + +/** + * A singleton for loading and interacting with the Google API. + */ +const googleApi = { + /** + * Obtains Google API Client Library, loading the library dynamically if + * needed. + * + * @returns {Promise} + */ + get() { + const globalGoogleApi = this._getGoogleApiClient(); + + if (!globalGoogleApi) { + return this.load(); + } + + return Promise.resolve(globalGoogleApi); + }, + + /** + * Gets the profile for the user signed in to the Google API Client Library. + * + * @returns {Promise} + */ + getCurrentUserProfile() { + return this.get() + .then(() => this.isSignedIn()) + .then(isSignedIn => { + if (!isSignedIn) { + return null; + } + + return this._getGoogleApiClient() + .auth2.getAuthInstance() + .currentUser.get() + .getBasicProfile(); + }); + }, + + /** + * Sets the Google Web Client ID used for authenticating with Google and + * making Google API requests. + * + * @param {string} clientId - The client ID to be used with the API library. + * @returns {Promise} + */ + initializeClient(clientId) { + return this.get() + .then(api => new Promise((resolve, reject) => { + // setTimeout is used as a workaround for api.client.init not + // resolving consistently when the Google API Client Library is + // loaded asynchronously. See: + // github.com/google/google-api-javascript-client/issues/399 + setTimeout(() => { + api.client.init({ + clientId, + scope: GOOGLE_API_SCOPES + }) + .then(resolve) + .catch(reject); + }, 500); + })); + }, + + /** + * Checks whether a user is currently authenticated with Google through an + * initialized Google API Client Library. + * + * @returns {Promise} + */ + isSignedIn() { + return this.get() + .then(api => Boolean(api + && api.auth2 + && api.auth2.getAuthInstance + && api.auth2.getAuthInstance().isSignedIn + && api.auth2.getAuthInstance().isSignedIn.get())); + }, + + /** + * Generates a script tag and downloads the Google API Client Library. + * + * @returns {Promise} + */ + load() { + if (googleClientLoadPromise) { + return googleClientLoadPromise; + } + + googleClientLoadPromise = new Promise((resolve, reject) => { + const scriptTag = document.createElement('script'); + + scriptTag.async = true; + scriptTag.addEventListener('error', () => { + scriptTag.remove(); + + googleClientLoadPromise = null; + + reject(); + }); + scriptTag.addEventListener('load', resolve); + scriptTag.type = 'text/javascript'; + + scriptTag.src = GOOGLE_API_CLIENT_LIBRARY_URL; + + document.head.appendChild(scriptTag); + }) + .then(() => new Promise((resolve, reject) => + this._getGoogleApiClient().load('client:auth2', { + callback: resolve, + onerror: reject + }))) + .then(() => this._getGoogleApiClient()); + + return googleClientLoadPromise; + }, + + /** + * Executes a request for a list of all YouTube broadcasts associated with + * user currently signed in to the Google API Client Library. + * + * @returns {Promise} + */ + requestAvailableYouTubeBroadcasts() { + const url = this._getURLForLiveBroadcasts(); + + return this.get() + .then(api => api.client.request(url)); + }, + + /** + * Executes a request to get all live streams associated with a broadcast + * in YouTube. + * + * @param {string} boundStreamID - The bound stream ID associated with a + * broadcast in YouTube. + * @returns {Promise} + */ + requestLiveStreamsForYouTubeBroadcast(boundStreamID) { + const url = this._getURLForLiveStreams(boundStreamID); + + return this.get() + .then(api => api.client.request(url)); + }, + + /** + * Prompts the participant to sign in to the Google API Client Library, even + * if already signed in. + * + * @returns {Promise} + */ + showAccountSelection() { + return this.get() + .then(api => api.auth2.getAuthInstance().signIn()); + }, + + /** + * Prompts the participant to sign in to the Google API Client Library, if + * not already signed in. + * + * @returns {Promise} + */ + signInIfNotSignedIn() { + return this.get() + .then(() => this.isSignedIn()) + .then(isSignedIn => { + if (!isSignedIn) { + return this.showAccountSelection(); + } + }); + }, + + /** + * Returns the global Google API Client Library object. Direct use of this + * method is discouraged; instead use the {@link get} method. + * + * @private + * @returns {Object|undefined} + */ + _getGoogleApiClient() { + return window.gapi; + }, + + /** + * Returns the URL to the Google API endpoint for retrieving the currently + * signed in user's YouTube broadcasts. + * + * @private + * @returns {string} + */ + _getURLForLiveBroadcasts() { + return [ + 'https://content.googleapis.com/youtube/v3/liveBroadcasts', + '?broadcastType=persistent', + '&mine=true&part=id%2Csnippet%2CcontentDetails%2Cstatus' + ].join(''); + }, + + /** + * Returns the URL to the Google API endpoint for retrieving the live + * streams associated with a YouTube broadcast's bound stream. + * + * @param {string} boundStreamID - The bound stream ID associated with a + * broadcast in YouTube. + * @returns {string} + */ + _getURLForLiveStreams(boundStreamID) { + return [ + 'https://content.googleapis.com/youtube/v3/liveStreams', + '?part=id%2Csnippet%2Ccdn%2Cstatus', + `&id=${boundStreamID}` + ].join(''); + } +}; + +export default googleApi;