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
This commit is contained in:
virtuacoplenny
2018-03-21 11:26:52 -07:00
committed by bbaldino
parent b5b99301ca
commit 823481dc1d
18 changed files with 1225 additions and 104 deletions

View File

@@ -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
<DropdownItem
key = { broadcast.boundStreamID }
// eslint-disable-next-line react/jsx-no-bind
onClick = { () => this._onSelect(broadcast.boundStreamID) }>
{ broadcast.title }
</DropdownItem>
);
const selected = this.props.broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBroadcastID);
const triggerText = (selected && selected.title)
|| t('liveStreaming.choose');
return (
<div className = 'broadcast-dropdown'>
<DropdownMenuStateless
isOpen = { this.state.isDropdownOpen }
onItemActivated = { this._onSelect }
onOpenChange = { this._onDropdownOpenChange }
shouldFitContainer = { true }
trigger = { triggerText }
triggerButtonProps = {{
className: 'broadcast-dropdown-trigger',
shouldFitContainer: true
}}
triggerType = 'button'>
<DropdownItemGroup>
{ dropdownItems }
</DropdownItemGroup>
</DropdownMenuStateless>
</div>
);
}
/**
* Transforms the passed in broadcasts into an array of objects that can
* be parsed by {@code DropdownMenuStateless}.
*
* @param {Array<Object>} broadcasts - The YouTube broadcasts to display.
* @private
* @returns {Array<Object>}
*/
_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);

View File

@@ -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 (
<div
className = 'google-sign-in'
onClick = { this.props.onClick }>
<img
className = 'google-logo'
src = 'images/googleLogo.svg' />
<div className = 'google-cta'>
{ this.props.text }
</div>
</div>
);
}
}

View File

@@ -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 (
<Dialog
cancelTitleKey = 'dialog.Cancel'
okTitleKey = 'dialog.startLiveStreaming'
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'liveStreaming.start'
width = { 'small' }>
<div className = 'live-stream-dialog'>
{ _googleApiApplicationClientID
? this._renderYouTubePanel() : null }
<StreamKeyForm
helpURL = { interfaceConfig.LIVE_STREAMING_HELP_LINK }
onChange = { this._onStreamKeyChange }
value = { this.state.streamKey } />
</div>
</Dialog>
);
}
/**
* 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
<GoogleSignInButton
onClick = { this._onGetYouTubeBroadcasts }
text = { t('liveStreaming.signIn') } />
);
helpText = t('liveStreaming.signInCTA');
break;
case GOOGLE_API_STATES.SIGNED_IN:
googleContent = ( // eslint-disable-line no-extra-parens
<BroadcastsDropdown
broadcasts = { broadcasts }
onBroadcastSelected = { this._onYouTubeBroadcastIDSelected }
selectedBroadcastID = { selectedBroadcastID } />
);
/**
* 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
<div>
{ `${t('liveStreaming.chooseCTA',
{ email: googleProfileEmail })} ` }
<a onClick = { this._onRequestGoogleSignIn }>
{ t('liveStreaming.changeSignIn') }
</a>
</div>
);
break;
case GOOGLE_API_STATES.ERROR:
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = t('liveStreaming.errorAPI');
break;
case GOOGLE_API_STATES.NEEDS_LOADING:
default:
googleContent = ( // eslint-disable-line no-extra-parens
<Spinner
isCompleting = { false }
size = 'medium' />
);
break;
}
return (
<div className = 'google-panel'>
<div className = 'live-stream-cta'>
{ helpText }
</div>
<div className = 'google-api'>
{ googleContent }
</div>
</div>
);
}
/**
* 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));

View File

@@ -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 (
<Dialog
okTitleKey = 'dialog.stopLiveStreaming'
onCancel = { this.props.onCancel }
onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming'
width = 'small'>
{ this.props.t('dialog.stopStreamingWarning') }
</Dialog>
);
}
/**
* 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);

View File

@@ -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 (
<div className = 'stream-key-form'>
<FieldTextStateless
autoFocus = { true }
compact = { true }
label = { t('dialog.streamKey') }
name = 'streamId'
okDisabled = { !this.props.value }
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
shouldFitContainer = { true }
type = 'text'
value = { this.props.value } />
{ this.props.helpURL
? <div className = 'form-footer'>
<a
className = 'helper-link'
onClick = { this._onOpenHelp }>
{ t('liveStreaming.streamIdHelp') }
</a>
</div>
: null
}
</div>
);
}
/**
* 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);

View File

@@ -0,0 +1,2 @@
export { default as StartLiveStreamDialog } from './StartLiveStreamDialog';
export { default as StopLiveStreamDialog } from './StopLiveStreamDialog';

View File

@@ -1 +1,2 @@
export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream';
export { default as RecordingLabel } from './RecordingLabel';