This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
26
react/features/recording/components/LiveStream/constants.ts
Normal file
26
react/features/recording/components/LiveStream/constants.ts
Normal 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}/;
|
||||
|
||||
28
react/features/recording/components/LiveStream/functions.ts
Normal file
28
react/features/recording/components/LiveStream/functions.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
146
react/features/recording/components/LiveStream/native/styles.ts
Normal file
146
react/features/recording/components/LiveStream/native/styles.ts
Normal 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
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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)));
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user