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,157 @@
import { IReduxState } from '../../../app/types';
import { IconSites } from '../../../base/icons/svg';
import { MEET_FEATURES } from '../../../base/jwt/constants';
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { isCloudRecordingRunning, isLiveStreamingButtonVisible, isLiveStreamingRunning } from '../../functions';
import { getLiveStreaming } from './functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractLiveStreamButton}.
*/
export interface IProps extends AbstractButtonProps {
/**
* True if the button needs to be disabled.
*/
_disabled: boolean;
/**
* True if there is a running active live stream, false otherwise.
*/
_isLiveStreamRunning: boolean;
/**
* The tooltip to display when hovering over the button.
*/
_tooltip?: string;
}
/**
* An abstract class of a button for starting and stopping live streaming.
*/
export default class AbstractLiveStreamButton<P extends IProps> extends AbstractButton<P> {
override accessibilityLabel = 'dialog.startLiveStreaming';
override toggledAccessibilityLabel = 'dialog.stopLiveStreaming';
override icon = IconSites;
override label = 'dialog.startLiveStreaming';
override toggledLabel = 'dialog.stopLiveStreaming';
/**
* Returns the tooltip that should be displayed when the button is disabled.
*
* @private
* @returns {string}
*/
override _getTooltip() {
return this.props._tooltip ?? '';
}
/**
* Helper function to be implemented by subclasses, which should be used
* to handle the live stream button being clicked / pressed.
*
* @protected
* @returns {void}
*/
_onHandleClick() {
// To be implemented by subclass.
}
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
if (!dialogShown) {
this._onHandleClick();
}
}
/**
* Returns a boolean value indicating if this button is disabled or not.
*
* @protected
* @returns {boolean}
*/
override _isDisabled() {
return this.props._disabled;
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._isLiveStreamRunning;
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code AbstractLiveStreamButton} component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the Component.
* @private
* @returns {{
* _disabled: boolean,
* _isLiveStreamRunning: boolean,
* visible: boolean
* }}
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
let { visible } = ownProps;
// A button can be disabled/enabled only if enableFeaturesBasedOnToken
// is on or if the recording is running.
let _disabled = false;
let _tooltip = '';
if (typeof visible === 'undefined') {
// If the containing component provides the visible prop, that is one
// above all, but if not, the button should be autonomous and decide on
// its own to be visible or not.
const liveStreaming = getLiveStreaming(state);
visible = isLiveStreamingButtonVisible({
liveStreamingAllowed: isJwtFeatureEnabled(state, MEET_FEATURES.LIVESTREAMING, false),
liveStreamingEnabled: liveStreaming?.enabled,
isInBreakoutRoom: isInBreakoutRoom(state)
});
}
// disable the button if the recording is running.
if (visible && (isCloudRecordingRunning(state) || isRecorderTranscriptionsRunning(state))) {
_disabled = true;
_tooltip = 'dialog.liveStreamingDisabledBecauseOfActiveRecordingTooltip';
}
// disable the button if we are in a breakout room.
if (isInBreakoutRoom(state)) {
_disabled = true;
}
return {
_disabled,
_isLiveStreamRunning: isLiveStreamingRunning(state),
_tooltip,
visible
};
}

View File

@@ -0,0 +1,251 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createLiveStreamingDialogEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { IJitsiConference } from '../../../base/conference/reducer';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
/**
* The type of the React {@code Component} props of
* {@link AbstractStartLiveStreamDialog}.
*/
export interface IProps extends WithTranslation {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
_googleAPIState: number;
/**
* The email of the user currently logged in to the Google web client
* application.
*/
_googleProfileEmail: string;
/**
* The live stream key that was used before.
*/
_streamKey?: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
navigation?: any;
}
/**
* The type of the React {@code Component} state of
* {@link AbstractStartLiveStreamDialog}.
*/
export interface IState {
/**
* Details about the broadcasts available for use for the logged in Google
* user's YouTube account.
*/
broadcasts?: Array<any>;
/**
* The error type, as provided by Google, for the most recent error
* encountered by the Google API.
*/
errorType?: string;
/**
* The boundStreamID of the broadcast currently selected in the broadcast
* dropdown.
*/
selectedBoundStreamID?: string;
/**
* The selected or entered stream key to use for YouTube live streaming.
*/
streamKey?: string;
}
/**
* Implements an abstract class for the StartLiveStreamDialog on both platforms.
*
* NOTE: Google log-in is not supported for mobile yet for later implementation
* but the abstraction of its properties are already present in this abstract
* class.
*/
export default class AbstractStartLiveStreamDialog<P extends IProps>
extends Component<P, IState> {
_isMounted: boolean;
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
broadcasts: undefined,
errorType: undefined,
selectedBoundStreamID: undefined,
streamKey: ''
};
/**
* 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;
this._onCancel = this._onCancel.bind(this);
this._onStreamKeyChange = this._onStreamKeyChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
this._isMounted = true;
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._isMounted = false;
}
/**
* Invokes the passed in {@link onCancel} callback and closes
* {@code StartLiveStreamDialog}.
*
* @private
* @returns {boolean} True is returned to close the modal.
*/
_onCancel() {
sendAnalytics(createLiveStreamingDialogEvent('start', 'cancel.button'));
return true;
}
/**
* Asks the user to sign in, if not already signed in, and then requests a
* list of the user's YouTube broadcasts.
*
* NOTE: To be implemented by platforms.
*
* @private
* @returns {Promise}
*/
_onGetYouTubeBroadcasts(): Promise<any> | void {
// to be overwritten by child classes.
}
/**
* Callback invoked to update the {@code StartLiveStreamDialog} component's
* display of the entered YouTube stream key.
*
* @param {string} streamKey - The stream key entered in the field.
* @private
* @returns {void}
*/
_onStreamKeyChange(streamKey: string) {
this._setStateIfMounted({
streamKey,
selectedBoundStreamID: 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() {
const { broadcasts, selectedBoundStreamID } = this.state;
const key
= (this.state.streamKey || this.props._streamKey || '').trim();
if (!key) {
return false;
}
let selectedBroadcastID = null;
if (selectedBoundStreamID) {
const selectedBroadcast = broadcasts?.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast?.id;
}
sendAnalytics(
createLiveStreamingDialogEvent('start', 'confirm.button'));
this.props._conference?.startRecording({
broadcastId: selectedBroadcastID,
mode: JitsiRecordingConstants.mode.STREAM,
streamId: key
});
return true;
}
/**
* 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: IState) {
if (this._isMounted) {
this.setState(newState);
}
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _conference: Object,
* _googleAPIState: number,
* _googleProfileEmail: string,
* _streamKey: string
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_conference: state['features/base/conference'].conference,
_googleAPIState: state['features/google-api'].googleAPIState,
_googleProfileEmail: state['features/google-api'].profileEmail,
_streamKey: state['features/recording'].streamKey
};
}

View File

@@ -0,0 +1,84 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createLiveStreamingDialogEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { IJitsiConference } from '../../../base/conference/reducer';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { getActiveSession } from '../../functions';
import { ISessionData } from '../../reducer';
/**
* The type of the React {@code Component} props of
* {@link StopLiveStreamDialog}.
*/
interface IProps extends WithTranslation {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
/**
* The redux representation of the live streaming to be stopped.
*/
_session?: ISessionData;
}
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
export default class AbstractStopLiveStreamDialog extends Component<IProps> {
/**
* Initializes a new {@code StopLiveStreamDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Callback invoked when stopping of live streaming is confirmed.
*
* @private
* @returns {boolean} True to close the modal.
*/
_onSubmit() {
sendAnalytics(createLiveStreamingDialogEvent('stop', 'confirm.button'));
const { _session } = this.props;
if (_session) {
this.props._conference?.stopRecording(_session.id);
}
return true;
}
}
/**
* Maps (parts of) the redux state to the React {@code Component} props of
* {@code StopLiveStreamDialog}.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _conference: Object,
* _session: Object
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_conference: state['features/base/conference'].conference,
_session: getActiveSession(state, JitsiRecordingConstants.mode.STREAM)
};
}

View File

@@ -0,0 +1,179 @@
import { type DebouncedFunc, debounce } from 'lodash-es';
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState } from '../../../app/types';
import { getLiveStreaming } from './functions';
export type LiveStreaming = {
// Terms link
dataPrivacyLink: string;
enabled: boolean;
helpLink: string;
// Documentation reference for the live streaming feature.
termsLink: string; // Data privacy link
validatorRegExpString: string; // RegExp string that validates the stream key input field
};
export type LiveStreamingProps = {
dataPrivacyURL?: string;
enabled: boolean;
helpURL?: string;
streamLinkRegexp: RegExp;
termsURL?: string;
};
/**
* The props of the component.
*/
export interface IProps extends WithTranslation {
/**
* The live streaming dialog properties.
*/
_liveStreaming: LiveStreamingProps;
/**
* Callback invoked when the entered stream key has changed.
*/
onChange: Function;
/**
* The stream key value to display as having been entered so far.
*/
value: string;
}
/**
* The state of the component.
*/
interface IState {
/**
* Whether or not to show the warnings that the passed in value seems like
* an improperly formatted stream key.
*/
showValidationError: boolean;
}
/**
* An abstract React Component for entering a key for starting a YouTube live
* stream.
*
* @augments Component
*/
export default class AbstractStreamKeyForm<P extends IProps>
extends Component<P, IState> {
_debouncedUpdateValidationErrorVisibility: DebouncedFunc<() => void>;
/**
* Constructor for the component.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
showValidationError: Boolean(this.props.value)
&& !this._validateStreamKey(this.props.value)
};
this._debouncedUpdateValidationErrorVisibility = debounce(
this._updateValidationErrorVisibility.bind(this),
800,
{ leading: false }
);
// Bind event handlers so they are only bound once per instance.
this._onInputChange = this._onInputChange.bind(this);
}
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: P) {
if (this.props.value !== prevProps.value) {
this._debouncedUpdateValidationErrorVisibility();
}
}
/**
* Implements React Component's componentWillUnmount.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._debouncedUpdateValidationErrorVisibility.cancel();
}
/**
* Callback invoked when the value of the input field has updated through
* user input. This forwards the value (string only, even if it was a dom
* event) to the onChange prop provided to the component.
*
* @param {Object | string} change - DOM Event for value change or the
* changed text.
* @private
* @returns {void}
*/
_onInputChange(change: any) {
const value = typeof change === 'object' ? change.target.value : change;
this.props.onChange(value);
}
/**
* Checks if the stream key value seems like a valid stream key and sets the
* state for showing or hiding the notification about the stream key seeming
* invalid.
*
* @private
* @returns {boolean}
*/
_updateValidationErrorVisibility() {
const newShowValidationError = Boolean(this.props.value)
&& !this._validateStreamKey(this.props.value);
if (newShowValidationError !== this.state.showValidationError) {
this.setState({
showValidationError: newShowValidationError
});
}
}
/**
* Checks if a passed in stream key appears to be in a valid format.
*
* @param {string} streamKey - The stream key to check for valid formatting.
* @returns {void}
* @returns {boolean}
*/
_validateStreamKey(streamKey = '') {
const trimmedKey = streamKey.trim();
const match = this.props._liveStreaming.streamLinkRegexp.exec(trimmedKey);
return Boolean(match);
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _liveStreaming: LiveStreamingProps
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_liveStreaming: getLiveStreaming(state)
};
}

View File

@@ -0,0 +1,26 @@
/**
* The URL for Google Privacy Policy.
*/
export const GOOGLE_PRIVACY_POLICY = 'https://policies.google.com/privacy';
/**
* The URL that is the main landing page for YouTube live streaming and should
* have a user's live stream key.
*/
export const YOUTUBE_LIVE_DASHBOARD_URL = 'https://www.youtube.com/live_dashboard';
/**
* The URL for YouTube terms and conditions.
*/
export const YOUTUBE_TERMS_URL = 'https://www.youtube.com/t/terms';
/**
* The live streaming help link to display.
*/
export const JITSI_LIVE_STREAMING_HELP_LINK = 'https://jitsi.org/live';
/**
* The YouTube stream link RegExp.
*/
export const FOUR_GROUPS_DASH_SEPARATED = /^(?:[a-zA-Z0-9]{4}(?:-(?!$)|$)){4}/;

View File

@@ -0,0 +1,28 @@
import { IReduxState } from '../../../app/types';
import { sanitizeUrl } from '../../../base/util/uri';
import {
FOUR_GROUPS_DASH_SEPARATED,
GOOGLE_PRIVACY_POLICY,
JITSI_LIVE_STREAMING_HELP_LINK,
YOUTUBE_TERMS_URL
} from './constants';
/**
* Get the live streaming options.
*
* @param {Object} state - The global state.
* @returns {LiveStreaming}
*/
export function getLiveStreaming(state: IReduxState) {
const { liveStreaming = {} } = state['features/base/config'];
const regexp = liveStreaming.validatorRegExpString && new RegExp(liveStreaming.validatorRegExpString);
return {
enabled: Boolean(liveStreaming.enabled),
helpURL: sanitizeUrl(liveStreaming.helpLink || JITSI_LIVE_STREAMING_HELP_LINK)?.toString(),
termsURL: sanitizeUrl(liveStreaming.termsLink || YOUTUBE_TERMS_URL)?.toString(),
dataPrivacyURL: sanitizeUrl(liveStreaming.dataPrivacyLink || GOOGLE_PRIVACY_POLICY)?.toString(),
streamLinkRegexp: regexp || FOUR_GROUPS_DASH_SEPARATED
};
}

View File

@@ -0,0 +1,229 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { setGoogleAPIState } from '../../../../google-api/actions';
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.native';
import {
GOOGLE_API_STATES,
GOOGLE_SCOPE_YOUTUBE
} from '../../../../google-api/constants';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import logger from '../../../logger';
import styles from './styles';
/**
* Prop type of the component {@code GoogleSigninForm}.
*/
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The Redux dispatch Function.
*/
dispatch: IStore['dispatch'];
/**
* The current state of the Google api as defined in {@code constants.js}.
*/
googleAPIState: number;
/**
* The recently received Google response.
*/
googleResponse: any;
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*/
onUserChanged: Function;
}
/**
* Class to render a google sign in form, or a google stream picker dialog.
*
* @augments Component
*/
class GoogleSigninForm extends Component<IProps> {
/**
* Instantiates a new {@code GoogleSigninForm} component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._logGoogleError = this._logGoogleError.bind(this);
this._onGoogleButtonPress = this._onGoogleButtonPress.bind(this);
}
/**
* Implements React's Component.componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
googleApi.hasPlayServices()
.then(() => {
googleApi.configure({
offlineAccess: false,
scopes: [ GOOGLE_SCOPE_YOUTUBE ]
});
googleApi.signInSilently().then((response: any) => {
this._setApiState(response
? GOOGLE_API_STATES.SIGNED_IN
: GOOGLE_API_STATES.LOADED,
response);
}, () => {
this._setApiState(GOOGLE_API_STATES.LOADED);
});
})
.catch((error: Error) => {
this._logGoogleError(error);
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
});
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, t } = this.props;
const { googleAPIState, googleResponse } = this.props;
const signedInUser = googleResponse?.user?.email;
if (googleAPIState === GOOGLE_API_STATES.NOT_AVAILABLE
|| googleAPIState === GOOGLE_API_STATES.NEEDS_LOADING
|| typeof googleAPIState === 'undefined') {
return null;
}
const userInfo = signedInUser
? `${t('liveStreaming.signedInAs')} ${signedInUser}`
: t('liveStreaming.signInCTA');
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.helpText as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ userInfo }
</Text>
</View>
<GoogleSignInButton
onClick = { this._onGoogleButtonPress }
signedIn = {
googleAPIState === GOOGLE_API_STATES.SIGNED_IN } />
</View>
);
}
/**
* A helper function to log developer related errors.
*
* @private
* @param {Object} error - The error to be logged.
* @returns {void}
*/
_logGoogleError(error: Error) {
// NOTE: This is a developer error message, not intended for the
// user to see.
logger.error('Google API error. Possible cause: bad config.', error);
}
/**
* Callback to be invoked when the user presses the Google button,
* regardless of being logged in or out.
*
* @private
* @returns {void}
*/
_onGoogleButtonPress() {
const { googleResponse } = this.props;
if (googleResponse?.user) {
// the user is signed in
this._onSignOut();
} else {
this._onSignIn();
}
}
/**
* Initiates a sign in if the user is not signed in yet.
*
* @private
* @returns {void}
*/
_onSignIn() {
googleApi.signIn().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.SIGNED_IN, response);
}, this._logGoogleError);
}
/**
* Initiates a sign out if the user is signed in.
*
* @private
* @returns {void}
*/
_onSignOut() {
googleApi.signOut().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.LOADED, response);
}, this._logGoogleError);
}
/**
* Updates the API (Google Auth) state.
*
* @private
* @param {number} apiState - The state of the API.
* @param {?Object} googleResponse - The response from the API.
* @returns {void}
*/
_setApiState(apiState: number, googleResponse?: Object) {
this.props.onUserChanged(googleResponse);
this.props.dispatch(setGoogleAPIState(apiState, googleResponse));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code GoogleSigninForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* googleAPIState: number,
* googleResponse: Object
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { googleAPIState, googleResponse } = state['features/google-api'];
return {
..._abstractMapStateToProps(state),
googleAPIState,
googleResponse
};
}
export default translate(connect(_mapStateToProps)(GoogleSigninForm));

View File

@@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { LIVE_STREAMING_ENABLED } from '../../../../base/flags/constants';
import { getFeatureFlag } from '../../../../base/flags/functions';
import { translate } from '../../../../base/i18n/functions';
import { navigate }
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../../mobile/navigation/routes';
import AbstractLiveStreamButton, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractLiveStreamButton';
import { IProps } from '../AbstractStartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
type Props = IProps & AbstractProps;
/**
* Button for opening the live stream settings screen.
*/
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_onHandleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
if (_isLiveStreamRunning) {
dispatch(openDialog(StopLiveStreamDialog));
} else {
navigate(screen.conference.liveStream);
}
}
}
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component
* instance.
* @private
* @returns {Props}
*/
export function mapStateToProps(state: IReduxState, ownProps: any) {
const enabled = getFeatureFlag(state, LIVE_STREAMING_ENABLED, true);
const abstractProps = _abstractMapStateToProps(state, ownProps);
return {
...abstractProps,
visible: enabled && abstractProps.visible
};
}
export default translate(connect(mapStateToProps)(LiveStreamButton));

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../../base/styles/functions.any';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import HeaderNavigationButton
from '../../../../mobile/navigation/components/HeaderNavigationButton';
import { goBack }
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setLiveStreamKey } from '../../../actions';
import AbstractStartLiveStreamDialog, { IProps, _mapStateToProps } from '../AbstractStartLiveStreamDialog';
import GoogleSigninForm from './GoogleSigninForm';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
import styles from './styles';
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*/
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onStartPress = this._onStartPress.bind(this);
this._onStreamKeyChangeNative
= this._onStreamKeyChangeNative.bind(this);
this._onStreamKeyPick = this._onStreamKeyPick.bind(this);
this._onUserChanged = this._onUserChanged.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerRight: () => (
<HeaderNavigationButton
label = { t('dialog.start') }
onPress = { this._onStartPress }
twoActions = { true } />
)
});
}
/**
* Starts live stream session and goes back to the previous screen.
*
* @returns {void}
*/
_onStartPress() {
this._onSubmit() && goBack();
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
return (
<JitsiScreen style = { styles.startLiveStreamContainer as StyleType }>
<GoogleSigninForm
onUserChanged = { this._onUserChanged } />
<StreamKeyPicker
broadcasts = { this.state.broadcasts }
onChange = { this._onStreamKeyPick } />
<StreamKeyForm
onChange = { this._onStreamKeyChangeNative }
value = {
this.state.streamKey || this.props._streamKey || ''
} />
</JitsiScreen>
);
}
/**
* Callback to handle stream key changes.
*
* FIXME: This is a temporary method to store the streaming key on mobile
* for easier use, until the Google sign-in is implemented. We don't store
* the key on web for security reasons (e.g. We don't want to have the key
* stored if the used signed out).
*
* @private
* @param {string} streamKey - The new key value.
* @returns {void}
*/
_onStreamKeyChangeNative(streamKey: string) {
this.props.dispatch(setLiveStreamKey(streamKey));
this._onStreamKeyChange(streamKey);
}
/**
* Callback to be invoked when the user selects a stream from the picker.
*
* @private
* @param {string} streamKey - The key of the selected stream.
* @returns {void}
*/
_onStreamKeyPick(streamKey: string) {
this.setState({
streamKey
});
}
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*
* TODO: Handle errors by showing some indication to the user.
*
* @private
* @param {Object} response - The retrieved signin response.
* @returns {void}
*/
_onUserChanged(response: Object) {
if (response) {
googleApi.getTokens()
.then((tokens: any) => {
googleApi.getYouTubeLiveStreams(tokens.accessToken)
.then((broadcasts: any) => {
this.setState({
broadcasts
});
});
})
.catch(() => {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
});
} else {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
}
}
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../../base/i18n/functions';
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 (
<ConfirmDialog
descriptionKey = 'dialog.stopStreamingWarning'
onSubmit = { this._onSubmit } />
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { Linking, Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import Button from '../../../../base/ui/components/native/Button';
import Input from '../../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
import AbstractStreamKeyForm, {
IProps as AbstractProps
} from '../AbstractStreamKeyForm';
import { getLiveStreaming } from '../functions';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
}
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @augments Component
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Initializes a new {@code StreamKeyForm} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onOpenGooglePrivacyPolicy = this._onOpenGooglePrivacyPolicy.bind(this);
this._onOpenHelp = this._onOpenHelp.bind(this);
this._onOpenYoutubeTerms = this._onOpenYoutubeTerms.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _dialogStyles, t } = this.props;
return (
<>
<View style = { styles.formWrapper as ViewStyle }>
<Input
customStyles = {{
input: styles.streamKeyInput,
container: styles.streamKeyContainer }}
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
value = { this.props.value } />
<View style = { styles.formValidationItem as ViewStyle }>
{
this.state.showValidationError && <Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ t('liveStreaming.invalidStreamKey') }
</Text>
}
</View>
</View>
<View style = { styles.formButtonsWrapper as ViewStyle }>
<Button
accessibilityLabel = 'liveStreaming.streamIdHelp'
labelKey = 'liveStreaming.streamIdHelp'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenHelp }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.youtubeTerms'
labelKey = 'liveStreaming.youtubeTerms'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenYoutubeTerms }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.googlePrivacyPolicy'
labelKey = 'liveStreaming.googlePrivacyPolicy'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenGooglePrivacyPolicy }
type = { BUTTON_TYPES.TERTIARY } />
</View>
</>
);
}
/**
* Opens the Google Privacy Policy web page.
*
* @private
* @returns {void}
*/
_onOpenGooglePrivacyPolicy() {
const url = this.props._liveStreaming.dataPrivacyURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the information link on how to manually locate a YouTube broadcast
* stream key.
*
* @private
* @returns {void}
*/
_onOpenHelp() {
const url = this.props._liveStreaming.helpURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the YouTube terms and conditions web page.
*
* @private
* @returns {void}
*/
_onOpenYoutubeTerms() {
const url = this.props._liveStreaming.termsURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code StreamKeyForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _liveStreaming: LiveStreamingProps
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_liveStreaming: getLiveStreaming(state)
};
}
export default translate(connect(_mapStateToProps)(StreamKeyForm));

View File

@@ -0,0 +1,168 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import {
Linking,
Text,
TouchableHighlight,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { connect } from 'react-redux';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
import styles, { ACTIVE_OPACITY, TOUCHABLE_UNDERLAY } from './styles';
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The list of broadcasts the user can pick from.
*/
broadcasts?: Array<{ key: string; title: string; }>;
/**
* Callback to be invoked when the user picked a broadcast. To be invoked
* with a single key (string).
*/
onChange: Function;
}
interface IState {
/**
* The key of the currently selected stream.
*/
streamKey?: string | null;
}
/**
* Class to implement a stream key picker (dropdown) component to allow the user
* to choose from the available Google Broadcasts/Streams.
*
* NOTE: This component is currently only used on mobile, but it is advised at
* a later point to unify mobile and web logic for this functionality. But it's
* out of the scope for now of the mobile live streaming functionality.
*/
class StreamKeyPicker extends Component<IProps, IState> {
/**
* Instantiates a new instance of StreamKeyPicker.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
streamKey: null
};
this._onOpenYoutubeDashboard = this._onOpenYoutubeDashboard.bind(this);
this._onStreamPick = this._onStreamPick.bind(this);
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, broadcasts } = this.props;
if (!broadcasts) {
return null;
}
if (!broadcasts.length) {
return (
<View style = { styles.formWrapper as ViewStyle }>
<TouchableOpacity
onPress = { this._onOpenYoutubeDashboard }>
<Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ this.props.t(
'liveStreaming.getStreamKeyManually') }
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.streamKeyPickerCta as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ this.props.t('liveStreaming.choose') }
</Text>
</View>
<View style = { styles.streamKeyPickerWrapper as ViewStyle } >
{ broadcasts.map((broadcast, index) =>
(<TouchableHighlight
activeOpacity = { ACTIVE_OPACITY }
key = { index }
onPress = { this._onStreamPick(broadcast.key) }
style = { [
styles.streamKeyPickerItem,
this.state.streamKey === broadcast.key
? styles.streamKeyPickerItemHighlight : null
] as ViewStyle[] }
underlayColor = { TOUCHABLE_UNDERLAY }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ broadcast.title }
</Text>
</TouchableHighlight>))
}
</View>
</View>
);
}
/**
* Opens the link which should display the YouTube broadcast live stream
* key.
*
* @private
* @returns {void}
*/
_onOpenYoutubeDashboard() {
Linking.openURL(YOUTUBE_LIVE_DASHBOARD_URL);
}
/**
* Callback to be invoked when the user picks a stream from the list.
*
* @private
* @param {string} streamKey - The key of the stream selected.
* @returns {Function}
*/
_onStreamPick(streamKey: string) {
return () => {
this.setState({
streamKey
});
this.props.onChange(streamKey);
};
}
}
export default translate(
connect(_abstractMapStateToProps)(StreamKeyPicker));

View File

@@ -0,0 +1,146 @@
import { createStyleSheet } from '../../../../base/styles/functions.native';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* Opacity of the TouchableHighlight.
*/
export const ACTIVE_OPACITY = 0.3;
/**
* Underlay of the TouchableHighlight.
*/
export const TOUCHABLE_UNDERLAY = BaseTheme.palette.ui06;
/**
* The styles of the React {@code Components} of LiveStream.
*/
export default createStyleSheet({
/**
* Generic component to wrap form sections into achieving a unified look.
*/
formWrapper: {
alignItems: 'stretch',
flexDirection: 'column',
paddingHorizontal: BaseTheme.spacing[2]
},
formValidationItem: {
alignSelf: 'flex-start',
flexDirection: 'row',
height: BaseTheme.spacing[4],
marginTop: BaseTheme.spacing[2]
},
formButtonsWrapper: {
alignSelf: 'center',
display: 'flex',
maxWidth: 200
},
buttonLabelStyle: {
color: BaseTheme.palette.link01
},
/**
* Explaining text on the top of the sign in form.
*/
helpText: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Container for the live stream screen.
*/
startLiveStreamContainer: {
backgroundColor: BaseTheme.palette.ui01,
display: 'flex',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[3]
},
/**
* Helper link text.
*/
streamKeyHelp: {
alignSelf: 'flex-end'
},
/**
* Input field to manually enter stream key.
*/
streamKeyInput: {
alignSelf: 'stretch',
color: BaseTheme.palette.text01,
textAlign: 'left'
},
streamKeyContainer: {
marginTop: BaseTheme.spacing[3]
},
/**
* Custom component to pick a broadcast from the list fetched from Google.
*/
streamKeyPicker: {
alignSelf: 'stretch',
flex: 1,
height: 40,
marginHorizontal: BaseTheme.spacing[1],
width: 300
},
/**
* CTA (label) of the picker.
*/
streamKeyPickerCta: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Style of a single item in the list.
*/
streamKeyPickerItem: {
padding: BaseTheme.spacing[1]
},
/**
* Additional style for the selected item.
*/
streamKeyPickerItemHighlight: {
backgroundColor: BaseTheme.palette.ui04
},
/**
* Overall wrapper for the picker.
*/
streamKeyPickerWrapper: {
borderColor: BaseTheme.palette.ui07,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 1,
flexDirection: 'column'
},
/**
* Terms and Conditions texts.
*/
tcText: {
textAlign: 'right'
},
text: {
color: BaseTheme.palette.text01,
fontSize: 14,
textAlign: 'left'
},
/**
* A different colored text to indicate information needing attention.
*/
warningText: {
color: BaseTheme.palette.warning02
}
});

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