diff --git a/config.js b/config.js
index f2e2084524..a815c49cdf 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 b3577f38eb..fbead3f207 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 0000000000..1e74ba4094
--- /dev/null
+++ b/images/googleLogo.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lang/main.json b/lang/main.json
index d1d68c00d1..a0eba2ba9a 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 2151d1b99f..fcbdca41e2 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:
- `
`,
- 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 0000000000..e69de29bb2
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 0000000000..fd4f1aafbd
--- /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 0000000000..e69de29bb2
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 0000000000..e6854a857e
--- /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 0000000000..e69de29bb2
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 0000000000..c8cae5bdc1
--- /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
+
+ );
+
+ 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 0000000000..e69de29bb2
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 0000000000..4aaba87ecb
--- /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 0000000000..e69de29bb2
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 0000000000..7e5c711ead
--- /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 0000000000..216336769f
--- /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 90ce231bdd..70a2733bd2 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 0000000000..92651bbbb4
--- /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;