mirror of https://github.com/jitsi/jitsi-meet
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 broadcastpull/2642/head jitsi-meet_2873
parent
b5b99301ca
commit
823481dc1d
@ -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; |
||||
} |
||||
} |
||||
} |
||||
|
After Width: | Height: | Size: 909 B |
@ -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); |
@ -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> |
||||
); |
||||
} |
||||
} |
@ -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)); |
@ -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); |
@ -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); |
@ -0,0 +1,2 @@ |
||||
export { default as StartLiveStreamDialog } from './StartLiveStreamDialog'; |
||||
export { default as StopLiveStreamDialog } from './StopLiveStreamDialog'; |
@ -1 +1,2 @@ |
||||
export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; |
||||
export { default as RecordingLabel } from './RecordingLabel'; |
||||
|
@ -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; |
Loading…
Reference in new issue