init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { translate } from '../../../../base/i18n/functions';
import AbstractLiveStreamButton, {
IProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractLiveStreamButton';
import StartLiveStreamDialog from './StartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
/**
* Button for opening the live stream settings dialog.
*/
class LiveStreamButton extends AbstractLiveStreamButton<IProps> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _onHandleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
dispatch(openDialog(
_isLiveStreamRunning ? StopLiveStreamDialog : StartLiveStreamDialog
));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code LiveStreamButton} component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the Component.
* @private
* @returns {{
* _conference: Object,
* _isLiveStreamRunning: boolean,
* _disabled: boolean,
* visible: boolean
* }}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const abstractProps = _abstractMapStateToProps(state, ownProps);
const { toolbarButtons } = state['features/toolbox'];
let { visible } = ownProps;
if (typeof visible === 'undefined') {
visible = Boolean(toolbarButtons?.includes('livestreaming') && abstractProps.visible);
}
return {
...abstractProps,
visible
};
}
export default translate(connect(_mapStateToProps)(LiveStreamButton));

View File

@@ -0,0 +1,361 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { translate } from '../../../../base/i18n/functions';
import Dialog from '../../../../base/ui/components/web/Dialog';
import Spinner from '../../../../base/ui/components/web/Spinner';
import {
loadGoogleAPI,
requestAvailableYouTubeBroadcasts,
requestLiveStreamsForYouTubeBroadcast,
showAccountSelection,
signIn,
updateProfile
} from '../../../../google-api/actions';
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.web';
import { GOOGLE_API_STATES } from '../../../../google-api/constants';
import AbstractStartLiveStreamDialog, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractStartLiveStreamDialog';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
interface IProps extends AbstractProps {
/**
* The ID for the Google client application used for making stream key
* related requests.
*/
_googleApiApplicationClientID?: string;
}
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*
* @augments Component
*/
class StartLiveStreamDialog
extends AbstractStartLiveStreamDialog<IProps> {
/**
* Initializes a new {@code StartLiveStreamDialog} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StartLiveStreamDialog} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onGoogleSignIn = this._onGoogleSignIn.bind(this);
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
this._onYouTubeBroadcastIDSelected
= this._onYouTubeBroadcastIDSelected.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
super.componentDidMount();
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
const { _googleApiApplicationClientID } = this.props;
return (
<Dialog
ok = {{ translationKey: 'dialog.startLiveStreaming' }}
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'liveStreaming.start'>
<div className = 'live-stream-dialog'>
{ _googleApiApplicationClientID
? this._renderYouTubePanel() : null }
<StreamKeyForm
onChange = { this._onStreamKeyChange }
value = {
this.state.streamKey || this.props._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 {void}
*/
_onInitializeGoogleApi() {
this.props.dispatch(loadGoogleAPI())
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* Automatically selects the input field's value after starting to edit the
* display name.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(previousProps: IProps) {
if (previousProps._googleAPIState === GOOGLE_API_STATES.LOADED
&& this.props._googleAPIState === GOOGLE_API_STATES.SIGNED_IN) {
this._onGetYouTubeBroadcasts();
}
}
/**
* Asks the user to sign in, if not already signed in, and then requests a
* list of the user's YouTube broadcasts.
*
* @private
* @returns {void}
*/
override _onGetYouTubeBroadcasts() {
this.props.dispatch(updateProfile())
.catch((response: any) => this._parseErrorFromResponse(response));
this.props.dispatch(requestAvailableYouTubeBroadcasts())
.then((broadcasts: { boundStreamID: string; }[]) => {
this._setStateIfMounted({
broadcasts
});
if (broadcasts.length === 1) {
const broadcast = broadcasts[0];
this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID);
}
})
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* 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}
*/
_onGoogleSignIn() {
this.props.dispatch(signIn())
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* 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() {
// when there is an error we show the google sign-in button.
// once we click it we want to clear the error from the state
this.props.dispatch(showAccountSelection())
.then(() =>
this._setStateIfMounted({
broadcasts: undefined,
errorType: undefined
}))
.then(() => this._onGetYouTubeBroadcasts());
}
/**
* 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: string) {
this.props.dispatch(
requestLiveStreamsForYouTubeBroadcast(boundStreamID))
.then(({ streamKey, selectedBoundStreamID }: { selectedBoundStreamID: string; streamKey: string; }) =>
this._setStateIfMounted({
streamKey,
selectedBoundStreamID
}));
}
/**
* Only show an error if an external request was made with the Google api.
* Do not error if the login in canceled.
* And searches in a Google API error response for the error type.
*
* @param {Object} response - The Google API response that may contain an
* error.
* @private
* @returns {string|null}
*/
_parseErrorFromResponse(response: any) {
if (!response?.result) {
return;
}
const result = response.result;
const error = result.error;
const errors = error?.errors;
const firstError = errors?.[0];
this._setStateIfMounted({
errorType: firstError?.reason || null
});
}
/**
* Renders a React Element for authenticating with the Google web client.
*
* @private
* @returns {ReactElement}
*/
_renderYouTubePanel() {
const {
t,
_googleProfileEmail
} = this.props;
const {
broadcasts,
selectedBoundStreamID
} = this.state;
let googleContent, helpText;
switch (this.props._googleAPIState) {
case GOOGLE_API_STATES.LOADED:
googleContent
= <GoogleSignInButton onClick = { this._onGoogleSignIn } />;
helpText = t('liveStreaming.signInCTA');
break;
case GOOGLE_API_STATES.SIGNED_IN:
if (broadcasts) {
googleContent = (
<StreamKeyPicker
broadcasts = { broadcasts }
onBroadcastSelected
= { this._onYouTubeBroadcastIDSelected }
selectedBoundStreamID = { selectedBoundStreamID } />
);
} else {
googleContent
= <Spinner />
;
}
/**
* 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 = (
<div>
{ `${t('liveStreaming.chooseCTA',
{ email: _googleProfileEmail })} ` }
<a onClick = { this._onRequestGoogleSignIn }>
{ t('liveStreaming.changeSignIn') }
</a>
</div>
);
break;
case GOOGLE_API_STATES.NEEDS_LOADING:
default:
googleContent
= <Spinner />
;
break;
}
if (this.state.errorType !== undefined) {
googleContent = (
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn } />
);
helpText = this._getGoogleErrorMessageToDisplay();
}
return (
<div className = 'google-panel'>
<div className = 'live-stream-cta'>
{ helpText }
</div>
<div className = 'google-api'>
{ googleContent }
</div>
</div>
);
}
/**
* Returns the error message to display for the current error state.
*
* @private
* @returns {string} The error message to display.
*/
_getGoogleErrorMessageToDisplay() {
let text;
switch (this.state.errorType) {
case 'liveStreamingNotEnabled':
text = this.props.t(
'liveStreaming.errorLiveStreamNotEnabled',
{ email: this.props._googleProfileEmail });
break;
default:
text = this.props.t('liveStreaming.errorAPI');
break;
}
return <div className = 'google-error'>{ text }</div>;
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _googleApiApplicationClientID: string
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID
};
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import Dialog from '../../../../base/ui/components/web/Dialog';
import AbstractStopLiveStreamDialog, {
_mapStateToProps
} from '../AbstractStopLiveStreamDialog';
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.stopLiveStreaming' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming'>
{ this.props.t('dialog.stopStreamingWarning') }
</Dialog>
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@@ -0,0 +1,112 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import Input from '../../../../base/ui/components/web/Input';
import AbstractStreamKeyForm, {
IProps as AbstractProps,
_mapStateToProps
} from '../AbstractStreamKeyForm';
interface IProps extends AbstractProps {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
}
const styles = (theme: Theme) => {
return {
helperLink: {
cursor: 'pointer',
color: theme.palette.link01,
transition: 'color .2s ease',
...theme.typography.labelBold,
marginLeft: 'auto',
marginTop: theme.spacing(1),
'&:hover': {
textDecoration: 'underline',
color: theme.palette.link01Hover
},
'&:active': {
color: theme.palette.link01Active
}
}
};
};
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @augments Component
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { t, value } = this.props;
const classes = withStyles.getClasses(this.props);
return (
<div className = 'stream-key-form'>
<Input
autoFocus = { true }
id = 'streamkey-input'
label = { t('dialog.streamKey') }
name = 'streamId'
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
type = 'text'
value = { value } />
<div className = 'form-footer'>
<div className = 'help-container'>
{
this.state.showValidationError
? <span className = 'warning-text'>
{ t('liveStreaming.invalidStreamKey') }
</span>
: null
}
{ this.props._liveStreaming.helpURL
? <a
className = { classes.helperLink }
href = { this.props._liveStreaming.helpURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.streamIdHelp') }
</a>
: null
}
</div>
<a
className = { classes.helperLink }
href = { this.props._liveStreaming.termsURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.youtubeTerms') }
</a>
<a
className = { classes.helperLink }
href = { this.props._liveStreaming.dataPrivacyURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.googlePrivacyPolicy') }
</a>
</div>
</div>
);
}
}
export default translate(connect(_mapStateToProps)(withStyles(StreamKeyForm, styles)));

View File

@@ -0,0 +1,126 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../../../base/i18n/functions';
import Select from '../../../../base/ui/components/web/Select';
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
/**
* The type of the React {@code Component} props of {@link StreamKeyPicker}.
*/
interface IProps extends WithTranslation {
/**
* 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: Array<{
boundStreamID: string;
title: string;
}>;
/**
* Callback invoked when an item in the dropdown is selected. The selected
* broadcast's boundStreamID will be passed back.
*/
onBroadcastSelected: Function;
/**
* The boundStreamID of the broadcast that should display as selected in the
* dropdown.
*/
selectedBoundStreamID?: string;
}
/**
* A dropdown to select a YouTube broadcast.
*
* @augments Component
*/
class StreamKeyPicker extends PureComponent<IProps> {
/**
* Default values for {@code StreamKeyForm} component's properties.
*
* @static
*/
static defaultProps = {
broadcasts: []
};
/**
* The initial state of a {@code StreamKeyForm} instance.
*/
override state = {
isDropdownOpen: false
};
/**
* Initializes a new {@code StreamKeyPicker} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyPicker} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onSelect = this._onSelect.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { broadcasts, selectedBoundStreamID, t } = this.props;
if (!broadcasts.length) {
return (
<a
className = 'warning-text'
href = { YOUTUBE_LIVE_DASHBOARD_URL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.getStreamKeyManually') }
</a>
);
}
const dropdownItems
= broadcasts.map(broadcast => {
return {
value: broadcast.boundStreamID,
label: broadcast.title
};
});
return (
<div className = 'broadcast-dropdown dropdown-menu'>
<Select
id = 'streamkeypicker-select'
label = { t('liveStreaming.choose') }
onChange = { this._onSelect }
options = { dropdownItems }
value = { selectedBoundStreamID ?? '' } />
</div>
);
}
/**
* Callback invoked when an item has been clicked in the dropdown menu.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const streamId = e.target.value;
this.props.onBroadcastSelected(streamId);
}
}
export default translate(StreamKeyPicker);