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

@ -333,7 +333,6 @@ var config = {
// userRegion: "asia" // userRegion: "asia"
} }
// List of undocumented settings used in jitsi-meet // List of undocumented settings used in jitsi-meet
/** /**
alwaysVisibleToolbar alwaysVisibleToolbar
@ -353,6 +352,7 @@ var config = {
etherpad_base etherpad_base
externalConnectUrl externalConnectUrl
firefox_fake_device firefox_fake_device
googleApiApplicationClientID
iAmRecorder iAmRecorder
iAmSipGateway iAmSipGateway
peopleSearchQueryTypes peopleSearchQueryTypes

View File

@ -1,3 +1,81 @@
.recordingSpinner { .recordingSpinner {
vertical-align: top; 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;
}
}
}

11
images/googleLogo.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 48 48" class="abcRioButtonSvg">
<g>
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
<path fill="none" d="M0 0h48v48H0z"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 909 B

View File

@ -285,8 +285,8 @@
"thankYou": "Thank you for using __appName__!", "thankYou": "Thank you for using __appName__!",
"sorryFeedback": "We're sorry to hear that. Would you like to tell us more?", "sorryFeedback": "We're sorry to hear that. Would you like to tell us more?",
"liveStreaming": "Live Streaming", "liveStreaming": "Live Streaming",
"streamKey": "Stream name/key", "streamKey": "Live stream key",
"startLiveStreaming": "Start live streaming", "startLiveStreaming": "Go live now",
"stopStreamingWarning": "Are you sure you would like to stop the live streaming?", "stopStreamingWarning": "Are you sure you would like to stop the live streaming?",
"stopRecordingWarning": "Are you sure you would like to stop the recording?", "stopRecordingWarning": "Are you sure you would like to stop the recording?",
"stopLiveStreaming": "Stop live streaming", "stopLiveStreaming": "Stop live streaming",
@ -396,14 +396,21 @@
"busy": "We're working on freeing streaming resources. Please try again in a few minutes.", "busy": "We're working on freeing streaming resources. Please try again in a few minutes.",
"busyTitle": "All streamers are currently busy", "busyTitle": "All streamers are currently busy",
"buttonTooltip": "Start / Stop Live Stream", "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.", "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", "failedToStart": "Live Streaming failed to start",
"off": "Live Streaming stopped", "off": "Live Streaming stopped",
"on": "Live Streaming", "on": "Live Streaming",
"pending": "Starting Live Stream...", "pending": "Starting Live Stream...",
"serviceName": "Live Streaming service", "serviceName": "Live Streaming service",
"streamIdRequired": "Please fill in the stream id in order to launch the Live Streaming.", "signIn": "Sign in with Google",
"streamIdHelp": "Where do I find this?", "signInCTA": "Sign in or enter your live stream key from YouTube.",
"start": "Start a livestream",
"streamIdHelp": "What's this?",
"unavailableTitle": "Live Streaming unavailable" "unavailableTitle": "Live Streaming unavailable"
}, },
"videoSIPGW": "videoSIPGW":

View File

@ -20,6 +20,7 @@ import UIEvents from '../../../service/UI/UIEvents';
import UIUtil from '../util/UIUtil'; import UIUtil from '../util/UIUtil';
import VideoLayout from '../videolayout/VideoLayout'; import VideoLayout from '../videolayout/VideoLayout';
import { openDialog } from '../../../react/features/base/dialog';
import { import {
JitsiRecordingStatus JitsiRecordingStatus
} from '../../../react/features/base/lib-jitsi-meet'; } from '../../../react/features/base/lib-jitsi-meet';
@ -31,6 +32,8 @@ import {
import { setToolboxEnabled } from '../../../react/features/toolbox'; import { setToolboxEnabled } from '../../../react/features/toolbox';
import { setNotificationsEnabled } from '../../../react/features/notifications'; import { setNotificationsEnabled } from '../../../react/features/notifications';
import { import {
StartLiveStreamDialog,
StopLiveStreamDialog,
hideRecordingLabel, hideRecordingLabel,
updateRecordingState updateRecordingState
} from '../../../react/features/recording'; } from '../../../react/features/recording';
@ -102,91 +105,11 @@ function _isRecordingButtonEnabled() {
* @returns {Promise} * @returns {Promise}
*/ */
function _requestLiveStreamId() { function _requestLiveStreamId() {
const cancelButton return new Promise((resolve, reject) =>
= APP.translation.generateTranslationHTML('dialog.Cancel'); APP.store.dispatch(openDialog(StartLiveStreamDialog, {
const backButton = APP.translation.generateTranslationHTML('dialog.Back'); onCancel: reject,
const startStreamingButton onSubmit: resolve
= 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:
`<input class="input-control"
name="streamId" type="text"
data-i18n="[placeholder]dialog.streamKey"
autofocus><div style="text-align: right">
<a class="helper-link" target="_new"
href="${interfaceConfig.LIVE_STREAMING_HELP_LINK}">${
streamIdHelp
}</a></div>`,
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;
}
});
});
} }
/** /**
@ -232,25 +155,20 @@ function _requestRecordingToken() {
* @private * @private
*/ */
function _showStopRecordingPrompt(recordingType) { function _showStopRecordingPrompt(recordingType) {
let title;
let message;
let buttonKey;
if (recordingType === 'jibri') { if (recordingType === 'jibri') {
title = 'dialog.liveStreaming'; return new Promise((resolve, reject) => {
message = 'dialog.stopStreamingWarning'; APP.store.dispatch(openDialog(StopLiveStreamDialog, {
buttonKey = 'dialog.stopLiveStreaming'; onCancel: reject,
} else { onSubmit: resolve
title = 'dialog.recording'; }));
message = 'dialog.stopRecordingWarning'; });
buttonKey = 'dialog.stopRecording';
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
dialog = APP.UI.messageHandler.openTwoButtonDialog({ dialog = APP.UI.messageHandler.openTwoButtonDialog({
titleKey: title, titleKey: 'dialog.recording',
msgKey: message, msgKey: 'dialog.stopRecordingWarning',
leftButtonKey: buttonKey, leftButtonKey: 'dialog.stopRecording',
submitFunction: (e, v) => (v ? resolve : reject)(), submitFunction: (e, v) => (v ? resolve : reject)(),
closeFunction: () => { closeFunction: () => {
dialog = null; dialog = null;

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'; export { default as RecordingLabel } from './RecordingLabel';

View File

@ -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;