[RN] Implement streaming on mobile

This commit is contained in:
Bettenbuk Zoltan 2018-07-05 13:17:45 +02:00 committed by Paweł Domas
parent 453c4b99dc
commit 5aee082bf9
25 changed files with 1279 additions and 598 deletions

View File

@ -467,7 +467,7 @@
"serviceName": "Live Streaming service", "serviceName": "Live Streaming service",
"signIn": "Sign in with Google", "signIn": "Sign in with Google",
"signInCTA": "Sign in or enter your live stream key from YouTube.", "signInCTA": "Sign in or enter your live stream key from YouTube.",
"start": "Start a livestream", "start": "Start a live stream",
"streamIdHelp": "What's this?", "streamIdHelp": "What's this?",
"unavailableTitle": "Live Streaming unavailable" "unavailableTitle": "Live Streaming unavailable"
}, },

View File

@ -29,9 +29,20 @@ export const RECORDING_SESSION_UPDATED = Symbol('RECORDING_SESSION_UPDATED');
* *
* { * {
* type: SET_PENDING_RECORDING_NOTIFICATION_UID, * type: SET_PENDING_RECORDING_NOTIFICATION_UID,
* streamType: string,
* uid: ?number * uid: ?number
* } * }
* @public * @public
*/ */
export const SET_PENDING_RECORDING_NOTIFICATION_UID export const SET_PENDING_RECORDING_NOTIFICATION_UID
= Symbol('SET_PENDING_RECORDING_NOTIFICATION_UID'); = Symbol('SET_PENDING_RECORDING_NOTIFICATION_UID');
/**
* Sets the stream key last used by the user for later reuse.
*
* {
* type: SET_STREAM_KEY,
* streamKey: string
* }
*/
export const SET_STREAM_KEY = Symbol('SET_STREAM_KEY');

View File

@ -1,5 +1,7 @@
// @flow // @flow
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { import {
hideNotification, hideNotification,
showErrorNotification, showErrorNotification,
@ -9,7 +11,8 @@ import {
import { import {
CLEAR_RECORDING_SESSIONS, CLEAR_RECORDING_SESSIONS,
RECORDING_SESSION_UPDATED, RECORDING_SESSION_UPDATED,
SET_PENDING_RECORDING_NOTIFICATION_UID SET_PENDING_RECORDING_NOTIFICATION_UID,
SET_STREAM_KEY
} from './actionTypes'; } from './actionTypes';
/** /**
@ -29,35 +32,37 @@ export function clearRecordingSessions() {
* Signals that the pending recording notification should be removed from the * Signals that the pending recording notification should be removed from the
* screen. * screen.
* *
* @param {string} streamType - The type of the stream (e.g. file or stream).
* @returns {Function} * @returns {Function}
*/ */
export function hidePendingRecordingNotification() { export function hidePendingRecordingNotification(streamType: string) {
return (dispatch: Function, getState: Function) => { return (dispatch: Function, getState: Function) => {
const { pendingNotificationUid } = getState()['features/recording']; const { pendingNotificationUids } = getState()['features/recording'];
const pendingNotificationUid = pendingNotificationUids[streamType];
if (pendingNotificationUid) { if (pendingNotificationUid) {
dispatch(hideNotification(pendingNotificationUid)); dispatch(hideNotification(pendingNotificationUid));
dispatch(setPendingRecordingNotificationUid()); dispatch(
_setPendingRecordingNotificationUid(
undefined, streamType));
} }
}; };
} }
/** /**
* Sets UID of the the pending recording notification to use it when hinding * Sets the stream key last used by the user for later reuse.
* the notification is necessary, or unsets it when
* undefined (or no param) is passed.
* *
* @param {?number} uid - The UID of the notification. * @param {string} streamKey - The stream key to set.
* redux. * redux.
* @returns {{ * @returns {{
* type: SET_PENDING_RECORDING_NOTIFICATION_UID, * type: SET_STREAM_KEY,
* uid: number * streamKey: string
* }} * }}
*/ */
export function setPendingRecordingNotificationUid(uid: ?number) { export function setLiveStreamKey(streamKey: string) {
return { return {
type: SET_PENDING_RECORDING_NOTIFICATION_UID, type: SET_STREAM_KEY,
uid streamKey
}; };
} }
@ -65,20 +70,29 @@ export function setPendingRecordingNotificationUid(uid: ?number) {
* Signals that the pending recording notification should be shown on the * Signals that the pending recording notification should be shown on the
* screen. * screen.
* *
* @param {string} streamType - The type of the stream (e.g. file or stream).
* @returns {Function} * @returns {Function}
*/ */
export function showPendingRecordingNotification() { export function showPendingRecordingNotification(streamType: string) {
return (dispatch: Function) => { return (dispatch: Function) => {
const showNotificationAction = showNotification({ const isLiveStreaming
= streamType === JitsiMeetJS.constants.recording.mode.STREAM;
const dialogProps = isLiveStreaming ? {
descriptionKey: 'liveStreaming.pending',
titleKey: 'dialog.liveStreaming'
} : {
descriptionKey: 'recording.pending', descriptionKey: 'recording.pending',
isDismissAllowed: false,
titleKey: 'dialog.recording' titleKey: 'dialog.recording'
};
const showNotificationAction = showNotification({
isDismissAllowed: false,
...dialogProps
}); });
dispatch(showNotificationAction); dispatch(showNotificationAction);
dispatch(setPendingRecordingNotificationUid( dispatch(_setPendingRecordingNotificationUid(
showNotificationAction.uid)); showNotificationAction.uid, streamType));
}; };
} }
@ -96,13 +110,21 @@ export function showRecordingError(props: Object) {
* Signals that the stopped recording notification should be shown on the * Signals that the stopped recording notification should be shown on the
* screen for a given period. * screen for a given period.
* *
* @param {string} streamType - The type of the stream (e.g. file or stream).
* @returns {showNotification} * @returns {showNotification}
*/ */
export function showStoppedRecordingNotification() { export function showStoppedRecordingNotification(streamType: string) {
return showNotification({ const isLiveStreaming
= streamType === JitsiMeetJS.constants.recording.mode.STREAM;
const dialogProps = isLiveStreaming ? {
descriptionKey: 'liveStreaming.off',
titleKey: 'dialog.liveStreaming'
} : {
descriptionKey: 'recording.off', descriptionKey: 'recording.off',
titleKey: 'dialog.recording' titleKey: 'dialog.recording'
}, 2500); };
return showNotification(dialogProps, 2500);
} }
/** /**
@ -127,3 +149,25 @@ export function updateRecordingSessionData(session: Object) {
} }
}; };
} }
/**
* Sets UID of the the pending streaming notification to use it when hinding
* the notification is necessary, or unsets it when undefined (or no param) is
* passed.
*
* @param {?number} uid - The UID of the notification.
* redux.
* @param {string} streamType - The type of the stream (e.g. file or stream).
* @returns {{
* type: SET_PENDING_RECORDING_NOTIFICATION_UID,
* streamType: string,
* uid: number
* }}
*/
function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) {
return {
type: SET_PENDING_RECORDING_NOTIFICATION_UID,
streamType,
uid
};
}

View File

@ -0,0 +1,114 @@
// @flow
import { openDialog } from '../../../base/dialog';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import {
isLocalParticipantModerator,
getLocalParticipant
} from '../../../base/participants';
import {
AbstractButton,
type AbstractButtonProps
} from '../../../base/toolbox';
import { getActiveSession } from '../../functions';
import StartLiveStreamDialog from './StartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
/**
* The type of the React {@code Component} props of
* {@link AbstractLiveStreamButton}.
*/
export type Props = AbstractButtonProps & {
/**
* True if there is a running active live stream, false otherwise.
*/
_isLiveStreamRunning: boolean,
/**
* The redux {@code dispatch} function.
*/
dispatch: Function,
/**
* The i18n translate function.
*/
t: Function
};
/**
* An abstract class of a button for starting and stopping live streaming.
*/
export default class AbstractLiveStreamButton<P: Props>
extends AbstractButton<P, *> {
accessibilityLabel = 'dialog.accessibilityLabel.liveStreaming';
label = 'dialog.startLiveStreaming';
toggledLabel = 'dialog.stopLiveStreaming';
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_handleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
dispatch(openDialog(
_isLiveStreamRunning ? StopLiveStreamDialog : StartLiveStreamDialog
));
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
_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 {Props} ownProps - The own props of the Component.
* @private
* @returns {{
* _isLiveStreamRunning: boolean,
* visible: boolean
* }}
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
let { visible } = ownProps;
if (typeof visible === 'undefined') {
// If the containing component provides the visible prop, that is one
// above all, but if not, the button should be autonomus and decide on
// its own to be visible or not.
const isModerator = isLocalParticipantModerator(state);
const {
enableFeaturesBasedOnToken,
liveStreamingEnabled
} = state['features/base/config'];
const { features = {} } = getLocalParticipant(state);
visible = isModerator
&& liveStreamingEnabled
&& (!enableFeaturesBasedOnToken
|| String(features.livestreaming) === 'true');
}
return {
_isLiveStreamRunning: Boolean(
getActiveSession(state, JitsiRecordingConstants.mode.STREAM)),
visible
};
}

View File

@ -0,0 +1,336 @@
// @flow
import React, { Component } from 'react';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
/**
* The type of the React {@code Component} props of
* {@link AbstractStartLiveStreamDialog}.
*/
export type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The ID for the Google client application used for making stream key
* related requests.
*/
_googleApiApplicationClientID: string,
/**
* The live stream key that was used before.
*/
_streamKey: string,
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* Invoked to obtain translated strings.
*/
t: Function
}
/**
* The type of the React {@code Component} state of
* {@link AbstractStartLiveStreamDialog}.
*/
export type State = {
/**
* Details about the broadcasts available for use for the logged in Google
* user's YouTube account.
*/
broadcasts: ?Array<Object>,
/**
* The error type, as provided by Google, for the most recent error
* encountered by the Google API.
*/
errorType: ?string,
/**
* 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 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
};
/**
* An enumeration of the different states the Google API can be in while
* interacting with {@code StartLiveStreamDialog}.
*
* @private
* @type {Object}
*/
export const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2,
/**
* The state in which the Google API encountered an error either loading
* or with an API request.
*/
ERROR: 3
};
/**
* 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
extends Component<Props, State> {
_isMounted: boolean;
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.state = {
broadcasts: undefined,
errorType: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
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}
*/
componentDidMount() {
this._isMounted = true;
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
componentWillUnmount() {
this._isMounted = false;
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
render() {
return (
<Dialog
cancelTitleKey = 'dialog.Cancel'
okTitleKey = 'dialog.startLiveStreaming'
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'liveStreaming.start'
width = { 'small' }>
{
this._renderDialogContent()
}
</Dialog>
);
}
_onCancel: () => boolean;
/**
* Invokes the passed in {@link onCancel} callback and closes
* {@code StartLiveStreamDialog}.
*
* @private
* @returns {boolean} True is returned to close the modal.
*/
_onCancel() {
sendAnalytics(createRecordingDialogEvent('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<*>;
/**
* Loads the Google client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube
* broadcasts is also made.
*/
_onInitializeGoogleApi: () => Object;
_onStreamKeyChange: string => void;
/**
* 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.
* changed text.
* @private
* @returns {void}
*/
_onStreamKeyChange(streamKey) {
this._setStateIfMounted({
streamKey,
selectedBoundStreamID: undefined
});
}
_onSubmit: () => boolean;
/**
* 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;
if (!key) {
return false;
}
let selectedBroadcastID = null;
if (selectedBoundStreamID) {
const selectedBroadcast = broadcasts && broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast && selectedBroadcast.id;
}
sendAnalytics(
createRecordingDialogEvent('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) {
if (this._isMounted) {
this.setState(newState);
}
}
/**
* Renders the platform specific dialog content.
*
* @returns {React$Component}
*/
_renderDialogContent: () => React$Component<*>
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _conference: Object,
* _googleApiApplicationClientID: string,
* _streamKey: string
* }}
*/
export function _mapStateToProps(state: Object) {
return {
_conference: state['features/base/conference'].conference,
_googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID,
_streamKey: state['features/recording'].streamKey
};
}

View File

@ -0,0 +1,119 @@
// @flow
import React, { Component } from 'react';
import { Dialog } from '../../../base/dialog';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { getActiveSession } from '../../functions';
/**
* The type of the React {@code Component} props of
* {@link StopLiveStreamDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The redux representation of the live stremaing to be stopped.
*/
_session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @extends Component
*/
export default class AbstractStopLiveStreamDialog extends Component<Props> {
/**
* Initializes a new {@code StopLiveStreamDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<Dialog
okTitleKey = 'dialog.stopLiveStreaming'
onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming'
width = 'small'>
{ this._renderDialogContent() }
</Dialog>
);
}
_onSubmit: () => boolean;
/**
* Callback invoked when stopping of live streaming is confirmed.
*
* @private
* @returns {boolean} True to close the modal.
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
const { _session } = this.props;
if (_session) {
this.props._conference.stopRecording(_session.id);
}
return true;
}
/**
* Function to be implemented by the platform specific implementations.
*
* @private
* @returns {React$Component<*>}
*/
_renderDialogContent: () => React$Component<*>
}
/**
* 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: Object) {
return {
_conference: state['features/base/conference'].conference,
_session: getActiveSession(state, JitsiRecordingConstants.mode.STREAM)
};
}

View File

@ -0,0 +1,107 @@
// @flow
import { Component } from 'react';
declare var interfaceConfig: Object;
/**
* The live streaming help link to display. On web it comes from
* interfaceConfig, but we don't have that on mobile.
*
* FIXME: This is in props now to prepare for the Redux-based interfaceConfig
*/
const LIVE_STREAMING_HELP_LINK = 'https://jitsi.org/live';
/**
* The props of the component.
*/
export type Props = {
/**
* Callback invoked when the entered stream key has changed.
*/
onChange: Function,
/**
* Invoked to obtain translated strings.
*/
t: Function,
/**
* The stream key value to display as having been entered so far.
*/
value: string
};
/**
* The state of the component.
*/
type State = {
/**
* The value entered in the field.
*/
value: string
}
/**
* An abstract React Component for entering a key for starting a YouTube live
* stream.
*
* @extends Component
*/
export default class AbstractStreamKeyForm extends Component<Props, State> {
helpURL: string;
/**
* Constructor for the component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.state = {
value: props.value
};
this.helpURL = (typeof interfaceConfig !== 'undefined'
&& interfaceConfig.LIVE_STREAMING_HELP_LINK)
|| LIVE_STREAMING_HELP_LINK;
// Bind event handlers so they are only bound once per instance.
this._onInputChange = this._onInputChange.bind(this);
}
/**
* Implements {@code Component}'s componentWillReceiveProps.
*
* @inheritdoc
*/
componentWillReceiveProps(newProps: Props) {
this.setState({
value: newProps.value
});
}
_onInputChange: Object => void
/**
* 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) {
const value = typeof change === 'object' ? change.target.value : change;
this.setState({
value
});
this.props.onChange(value);
}
}

View File

@ -0,0 +1,20 @@
// @flow
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import AbstractLiveStreamButton, {
_mapStateToProps,
type Props
} from './AbstractLiveStreamButton';
/**
* An implementation of a button for starting and stopping live streaming.
*/
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
iconName = 'public';
toggledIconName = 'public';
}
export default translate(connect(_mapStateToProps)(LiveStreamButton));

View File

@ -0,0 +1,122 @@
// @flow
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import { getLocalParticipant } from '../../../base/participants';
import AbstractLiveStreamButton, {
_mapStateToProps as _abstractMapStateToProps,
type Props as AbstractProps
} from './AbstractLiveStreamButton';
declare var interfaceConfig: Object;
type Props = AbstractProps & {
/**
* True if the button should be disabled, false otherwise.
*
* NOTE: On web, if the feature is not disabled on purpose, then we still
* show the button but disabled and with a tooltip rendered on it,
* explaining why it's not available.
*/
_disabled: boolean,
/**
* Tooltip for the button when it's disabled in a certain way.
*/
_liveStreamDisabledTooltipKey: ?string
}
/**
* An implementation of a button for starting and stopping live streaming.
*/
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
iconName = 'icon-public';
toggledIconName = 'icon-public';
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.tooltip = props._liveStreamDisabledTooltipKey;
}
/**
* Implements {@code Component}'s componentWillReceiveProps.
*
* @inheritdoc
*/
componentWillReceiveProps(newProps: Props) {
this.tooltip = newProps._liveStreamDisabledTooltipKey;
}
/**
* Helper function to be implemented by subclasses, which must return a
* boolean value indicating if this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
_isDisabled() {
return this.props._disabled;
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code LiveStreamButton} component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the Component.
* @private
* @returns {{
* _conference: Object,
* _isLiveStreamRunning: boolean,
* _disabled: boolean,
* visible: boolean
* }}
*/
function _mapStateToProps(state: Object, ownProps: Props) {
const abstractProps = _abstractMapStateToProps(state, ownProps);
const localParticipant = getLocalParticipant(state);
const { features = {} } = localParticipant;
let { visible } = ownProps;
let _disabled = false;
let _liveStreamDisabledTooltipKey;
if (!abstractProps.visible
&& String(features.livestreaming) !== 'disabled') {
_disabled = true;
// button and tooltip
if (state['features/base/jwt'].isGuest) {
_liveStreamDisabledTooltipKey
= 'dialog.liveStreamingDisabledForGuestTooltip';
} else {
_liveStreamDisabledTooltipKey
= 'dialog.liveStreamingDisabledTooltip';
}
}
if (typeof visible === 'undefined') {
visible = interfaceConfig.TOOLBAR_BUTTONS.includes('livestreaming')
&& (abstractProps.visible || _liveStreamDisabledTooltipKey);
}
return {
...abstractProps,
_disabled,
_liveStreamDisabledTooltipKey,
visible
};
}
export default translate(connect(_mapStateToProps)(LiveStreamButton));

View File

@ -0,0 +1,92 @@
// @flow
import React from 'react';
import { View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import { setLiveStreamKey } from '../../actions';
import AbstractStartLiveStreamDialog, {
_mapStateToProps,
type Props
} from './AbstractStartLiveStreamDialog';
import StreamKeyForm from './StreamKeyForm';
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*/
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onStreamKeyChangeNative
= this._onStreamKeyChangeNative.bind(this);
this._renderDialogContent = this._renderDialogContent.bind(this);
}
_onInitializeGoogleApi: () => Promise<*>
/**
* Loads the Google client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube
* broadcasts is also made.
*
* @private
* @returns {Promise}
*/
_onInitializeGoogleApi() {
// This is a placeholder method for the Google feature.
return Promise.resolve();
}
_onStreamKeyChange: string => void
_onStreamKeyChangeNative: string => void;
/**
* 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) {
this.props.dispatch(setLiveStreamKey(streamKey));
this._onStreamKeyChange(streamKey);
}
_renderDialogContent: () => React$Component<*>
/**
* Renders the platform specific dialog content.
*
* @returns {React$Component}
*/
_renderDialogContent() {
return (
<View>
<StreamKeyForm
onChange = { this._onStreamKeyChangeNative }
value = { this.props._streamKey } />
</View>
);
}
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@ -1,128 +1,32 @@
// @flow // @flow
import Spinner from '@atlaskit/spinner'; import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import googleApi from '../../googleApi'; import googleApi from '../../googleApi';
import AbstractStartLiveStreamDialog, {
_mapStateToProps,
GOOGLE_API_STATES,
type Props
} from './AbstractStartLiveStreamDialog';
import BroadcastsDropdown from './BroadcastsDropdown'; import BroadcastsDropdown from './BroadcastsDropdown';
import GoogleSignInButton from './GoogleSignInButton'; import GoogleSignInButton from './GoogleSignInButton';
import StreamKeyForm from './StreamKeyForm'; import StreamKeyForm from './StreamKeyForm';
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
/**
* An enumeration of the different states the Google API can be in while
* interacting with {@code StartLiveStreamDialog}.
*
* @private
* @type {Object}
*/
const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2,
/**
* The state in which the Google API encountered an error either loading
* or with an API request.
*/
ERROR: 3
};
/**
* The type of the React {@code Component} props of
* {@link StartLiveStreamDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The ID for the Google web client application used for making stream key
* related requests.
*/
_googleApiApplicationClientID: string,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* The type of the React {@code Component} state of
* {@link StartLiveStreamDialog}.
*/
type State = {
/**
* Details about the broadcasts available for use for the logged in Google
* user's YouTube account.
*/
broadcasts: ?Array<Object>,
/**
* The error type, as provided by Google, for the most recent error
* encountered by the Google API.
*/
errorType: ?string,
/**
* 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 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
};
/** /**
* A React Component for requesting a YouTube stream key to use for live * A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference. * streaming of the current conference.
* *
* @extends Component * @extends Component
*/ */
class StartLiveStreamDialog extends Component<Props, State> { class StartLiveStreamDialog
_isMounted: boolean; extends AbstractStartLiveStreamDialog {
/** /**
* Initializes a new {@code StartLiveStreamDialog} instance. * Initializes a new {@code StartLiveStreamDialog} instance.
@ -133,91 +37,17 @@ class StartLiveStreamDialog extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {
broadcasts: undefined,
errorType: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
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;
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onCancel = this._onCancel.bind(this);
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this); this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this); this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this); this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
this._onStreamKeyChange = this._onStreamKeyChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
this._onYouTubeBroadcastIDSelected this._onYouTubeBroadcastIDSelected
= this._onYouTubeBroadcastIDSelected.bind(this); = this._onYouTubeBroadcastIDSelected.bind(this);
this._renderDialogContent = this._renderDialogContent.bind(this);
} }
/** _onInitializeGoogleApi: () => Promise<*>;
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
this._isMounted = true;
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
componentWillUnmount() {
this._isMounted = false;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { _googleApiApplicationClientID } = this.props;
return (
<Dialog
cancelTitleKey = 'dialog.Cancel'
okTitleKey = 'dialog.startLiveStreaming'
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'liveStreaming.start'
width = { 'small' }>
<div className = 'live-stream-dialog'>
{ _googleApiApplicationClientID
? this._renderYouTubePanel() : null }
<StreamKeyForm
helpURL = { interfaceConfig.LIVE_STREAMING_HELP_LINK }
onChange = { this._onStreamKeyChange }
value = { this.state.streamKey } />
</div>
</Dialog>
);
}
_onInitializeGoogleApi: () => Object;
/** /**
* Loads the Google web client application used for fetching stream keys. * Loads the Google web client application used for fetching stream keys.
@ -247,22 +77,7 @@ class StartLiveStreamDialog extends Component<Props, State> {
}); });
} }
_onCancel: () => boolean; _onGetYouTubeBroadcasts: () => Promise<*>;
/**
* Invokes the passed in {@link onCancel} callback and closes
* {@code StartLiveStreamDialog}.
*
* @private
* @returns {boolean} True is returned to close the modal.
*/
_onCancel() {
sendAnalytics(createRecordingDialogEvent('start', 'cancel.button'));
return true;
}
_onGetYouTubeBroadcasts: () => Object;
/** /**
* Asks the user to sign in, if not already signed in, and then requests a * Asks the user to sign in, if not already signed in, and then requests a
@ -322,59 +137,7 @@ class StartLiveStreamDialog extends Component<Props, State> {
.then(() => this._onGetYouTubeBroadcasts()); .then(() => this._onGetYouTubeBroadcasts());
} }
_onStreamKeyChange: () => void; _onStreamKeyChange: string => void;
/**
* Callback invoked to update the {@code StartLiveStreamDialog} component's
* display of the entered YouTube stream key.
*
* @param {Object} event - DOM Event for value change.
* @private
* @returns {void}
*/
_onStreamKeyChange(event) {
this._setStateIfMounted({
streamKey: event.target.value,
selectedBoundStreamID: undefined
});
}
_onSubmit: () => boolean;
/**
* 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, streamKey, selectedBoundStreamID } = this.state;
if (!streamKey) {
return false;
}
let selectedBroadcastID = null;
if (selectedBoundStreamID) {
const selectedBroadcast = broadcasts && broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast && selectedBroadcast.id;
}
sendAnalytics(createRecordingDialogEvent('start', 'confirm.button'));
this.props._conference.startRecording({
broadcastId: selectedBroadcastID,
mode: JitsiRecordingConstants.mode.STREAM,
streamId: streamKey
});
return true;
}
_onYouTubeBroadcastIDSelected: (string) => Object; _onYouTubeBroadcastIDSelected: (string) => Object;
@ -452,6 +215,27 @@ class StartLiveStreamDialog extends Component<Props, State> {
return (firstError && firstError.reason) || null; return (firstError && firstError.reason) || null;
} }
_renderDialogContent: () => React$Component<*>
/**
* Renders the platform specific dialog content.
*
* @returns {React$Component}
*/
_renderDialogContent() {
const { _googleApiApplicationClientID } = this.props;
return (
<div className = 'live-stream-dialog'>
{ _googleApiApplicationClientID
? this._renderYouTubePanel() : null }
<StreamKeyForm
onChange = { this._onStreamKeyChange }
value = { this.state.streamKey || this.props._streamKey } />
</div>
);
}
/** /**
* Renders a React Element for authenticating with the Google web client. * Renders a React Element for authenticating with the Google web client.
* *
@ -537,6 +321,8 @@ class StartLiveStreamDialog extends Component<Props, State> {
); );
} }
_setStateIfMounted: Object => void
/** /**
* Returns the error message to display for the current error state. * Returns the error message to display for the current error state.
* *
@ -559,40 +345,6 @@ class StartLiveStreamDialog extends Component<Props, State> {
return <div className = 'google-error'>{ text }</div>; return <div className = 'google-error'>{ text }</div>;
} }
/**
* Updates the internal state if the component is still mounted. This is a
* workaround for all the state setting that occurs after ajax.
*
* @param {Object} newState - The new state to merge into the existing
* state.
* @private
* @returns {void}
*/
_setStateIfMounted(newState) {
if (this._isMounted) {
this.setState(newState);
}
}
}
/**
* Maps (parts of) the redux state to the React {@code Component} props of
* {@code StartLiveStreamDialog}.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _conference: Object,
* _googleApiApplicationClientID: string
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference,
_googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID
};
} }
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog)); export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@ -0,0 +1,41 @@
// @flow
import React from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import styles from '../styles';
import AbstractStopLiveStreamDialog, {
_mapStateToProps
} from './AbstractStopLiveStreamDialog';
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @extends Component
*/
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Renders the platform specific {@code Dialog} content.
*
* @inheritdoc
*/
_renderDialogContent() {
return (
<View style = { styles.messageContainer }>
<Text>
{
this.props.t('dialog.stopStreamingWarning')
}
</Text>
</View>
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@ -1,36 +1,12 @@
// @flow // @flow
import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
/** import AbstractStopLiveStreamDialog, {
* The type of the React {@code Component} props of _mapStateToProps
* {@link StopLiveStreamDialog}. } from './AbstractStopLiveStreamDialog';
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The redux representation of the live stremaing to be stopped.
*/
session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/** /**
* A React Component for confirming the participant wishes to stop the currently * A React Component for confirming the participant wishes to stop the currently
@ -38,73 +14,16 @@ type Props = {
* *
* @extends Component * @extends Component
*/ */
class StopLiveStreamDialog extends Component<Props> { class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Initializes a new {@code StopLiveStreamDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/** /**
* Implements React's {@link Component#render()}. * Renders the platform specific {@code Dialog} content.
* *
* @inheritdoc * @inheritdoc
* @returns {ReactElement}
*/ */
render() { _renderDialogContent() {
return ( return this.props.t('dialog.stopStreamingWarning');
<Dialog
okTitleKey = 'dialog.stopLiveStreaming'
onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming'
width = 'small'>
{ this.props.t('dialog.stopStreamingWarning') }
</Dialog>
);
} }
_onSubmit: () => boolean;
/**
* Callback invoked when stopping of live streaming is confirmed.
*
* @private
* @returns {boolean} True to close the modal.
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('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
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference
};
} }
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog)); export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@ -0,0 +1,86 @@
// @flow
import React from 'react';
import { Linking, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { translate } from '../../../base/i18n';
import AbstractStreamKeyForm, {
type Props
} from './AbstractStreamKeyForm';
import styles from './styles';
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @extends Component
*/
class StreamKeyForm extends AbstractStreamKeyForm {
/**
* Initializes a new {@code StreamKeyForm} instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with.
*/
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onOpenHelp = this._onOpenHelp.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { t } = this.props;
return (
<View style = { styles.streamKeyFormWrapper }>
<Text style = { styles.streamKeyInputLabel }>
{
t('dialog.streamKey')
}
</Text>
<TextInput
onChangeText = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
style = { styles.streamKeyInput }
value = { this.state.value } />
<TouchableOpacity
onPress = { this._onOpenHelp }
style = { styles.streamKeyHelp } >
<Text>
{
t('liveStreaming.streamIdHelp')
}
</Text>
</TouchableOpacity>
</View>
);
}
_onInputChange: Object => void
_onOpenHelp: () => void
/**
* Opens the information link on how to manually locate a YouTube broadcast
* stream key.
*
* @private
* @returns {void}
*/
_onOpenHelp() {
const { helpURL } = this;
if (typeof helpURL === 'string') {
Linking.openURL(helpURL);
}
}
}
export default translate(StreamKeyForm);

View File

@ -1,42 +1,20 @@
// @flow
import { FieldTextStateless } from '@atlaskit/field-text'; import { FieldTextStateless } from '@atlaskit/field-text';
import PropTypes from 'prop-types'; import React from 'react';
import React, { Component } from 'react';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import AbstractStreamKeyForm, {
type Props
} from './AbstractStreamKeyForm';
/** /**
* A React Component for entering a key for starting a YouTube live stream. * A React Component for entering a key for starting a YouTube live stream.
* *
* @extends Component * @extends Component
*/ */
class StreamKeyForm extends Component { class StreamKeyForm extends AbstractStreamKeyForm {
/**
* {@code StreamKeyForm} component's property types.
*
* @static
*/
static propTypes = {
/**
* The URL to the page with more information for manually finding the
* stream key for a YouTube broadcast.
*/
helpURL: PropTypes.string,
/**
* Callback invoked when the entered stream key has changed.
*/
onChange: PropTypes.func,
/**
* Invoked to obtain translated strings.
*/
t: PropTypes.func,
/**
* The stream key value to display as having been entered so far.
*/
value: PropTypes.string
};
/** /**
* Initializes a new {@code StreamKeyForm} instance. * Initializes a new {@code StreamKeyForm} instance.
@ -44,11 +22,10 @@ class StreamKeyForm extends Component {
* @param {Props} props - The React {@code Component} props to initialize * @param {Props} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with. * the new {@code StreamKeyForm} instance with.
*/ */
constructor(props) { constructor(props: Props) {
super(props); super(props);
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onInputChange = this._onInputChange.bind(this);
this._onOpenHelp = this._onOpenHelp.bind(this); this._onOpenHelp = this._onOpenHelp.bind(this);
} }
@ -59,7 +36,7 @@ class StreamKeyForm extends Component {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { t } = this.props; const { value, t } = this.props;
return ( return (
<div className = 'stream-key-form'> <div className = 'stream-key-form'>
@ -69,12 +46,12 @@ class StreamKeyForm extends Component {
isSpellCheckEnabled = { false } isSpellCheckEnabled = { false }
label = { t('dialog.streamKey') } label = { t('dialog.streamKey') }
name = 'streamId' name = 'streamId'
okDisabled = { !this.props.value } okDisabled = { !value }
onChange = { this._onInputChange } onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') } placeholder = { t('liveStreaming.enterStreamKey') }
shouldFitContainer = { true } shouldFitContainer = { true }
type = 'text' type = 'text'
value = { this.props.value } /> value = { this.state.value } />
{ this.props.helpURL { this.props.helpURL
? <div className = 'form-footer'> ? <div className = 'form-footer'>
<a <a
@ -89,17 +66,9 @@ class StreamKeyForm extends Component {
); );
} }
/** _onInputChange: Object => void
* Callback invoked when the value of the input field has updated through
* user input. _onOpenHelp: () => void
*
* @param {Object} event - DOM Event for value change.
* @private
* @returns {void}
*/
_onInputChange(event) {
this.props.onChange(event);
}
/** /**
* Opens a new tab with information on how to manually locate a YouTube * Opens a new tab with information on how to manually locate a YouTube
@ -109,7 +78,7 @@ class StreamKeyForm extends Component {
* @returns {void} * @returns {void}
*/ */
_onOpenHelp() { _onOpenHelp() {
window.open(this.props.helpURL, 'noopener'); window.open(this.helpURL, 'noopener');
} }
} }

View File

@ -1,2 +1,3 @@
export { default as LiveStreamButton } from './LiveStreamButton';
export { default as StartLiveStreamDialog } from './StartLiveStreamDialog'; export { default as StartLiveStreamDialog } from './StartLiveStreamDialog';
export { default as StopLiveStreamDialog } from './StopLiveStreamDialog'; export { default as StopLiveStreamDialog } from './StopLiveStreamDialog';

View File

@ -0,0 +1,28 @@
// @flow
import { BoxModel, createStyleSheet } from '../../../base/styles';
/**
* The styles of the React {@code Components} of LiveStream.
*/
export default createStyleSheet({
streamKeyFormWrapper: {
flexDirection: 'column',
padding: BoxModel.padding
},
streamKeyHelp: {
alignSelf: 'flex-end'
},
streamKeyInput: {
alignSelf: 'stretch',
height: 50
},
streamKeyInputLabel: {
alignSelf: 'flex-start'
}
});

View File

@ -107,9 +107,7 @@ export function _mapStateToProps(state: Object, ownProps: Props): Object {
} }
if (typeof visible === 'undefined') { if (typeof visible === 'undefined') {
const visibleButtons = new Set(interfaceConfig.TOOLBAR_BUTTONS); visible = interfaceConfig.TOOLBAR_BUTTONS.includes('recording')
visible = visibleButtons.has('recording')
&& (abstractProps.visible || _fileRecordingsDisabledTooltipKey); && (abstractProps.visible || _fileRecordingsDisabledTooltipKey);
} }

View File

@ -1,4 +1,8 @@
export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; export {
LiveStreamButton,
StartLiveStreamDialog,
StopLiveStreamDialog
} from './LiveStream';
export { export {
RecordButton, RecordButton,
StartRecordingDialog, StartRecordingDialog,

View File

@ -1,5 +1,14 @@
// @flow // @flow
/**
* The Google API scopes to request access to for streaming.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
];
/** /**
* The identifier of the sound to be played when a recording or live streaming * The identifier of the sound to be played when a recording or live streaming
* session is stopped. * session is stopped.

View File

@ -1,7 +1,6 @@
import { GOOGLE_API_SCOPES } from './constants';
const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js'; const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js';
const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
].join(' ');
/** /**
* A promise for dynamically loading the Google API Client Library. * A promise for dynamically loading the Google API Client Library.
@ -68,7 +67,7 @@ const googleApi = {
setTimeout(() => { setTimeout(() => {
api.client.init({ api.client.init({
clientId, clientId,
scope: GOOGLE_API_SCOPES scope: GOOGLE_API_SCOPES.join(' ')
}) })
.then(resolve) .then(resolve)
.catch(reject); .catch(reject);

View File

@ -104,24 +104,31 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
case RECORDING_SESSION_UPDATED: { case RECORDING_SESSION_UPDATED: {
const updatedSessionData const updatedSessionData
= getSessionById(getState(), action.sessionData.id); = getSessionById(getState(), action.sessionData.id);
const { PENDING, OFF, ON } = JitsiRecordingConstants.status;
if (updatedSessionData.mode === JitsiRecordingConstants.mode.FILE) { if (updatedSessionData.status === PENDING
const { PENDING, OFF, ON } = JitsiRecordingConstants.status; && (!oldSessionData || oldSessionData.status !== PENDING)) {
dispatch(
showPendingRecordingNotification(updatedSessionData.mode));
} else if (updatedSessionData.status !== PENDING) {
dispatch(
hidePendingRecordingNotification(updatedSessionData.mode));
if (updatedSessionData.status === PENDING if (updatedSessionData.status === ON
&& (!oldSessionData || oldSessionData.status !== PENDING)) { && (!oldSessionData || oldSessionData.status !== ON)
dispatch(showPendingRecordingNotification()); && updatedSessionData.mode
} else if (updatedSessionData.status !== PENDING) { === JitsiRecordingConstants.mode.FILE) {
dispatch(hidePendingRecordingNotification()); dispatch(playSound(RECORDING_ON_SOUND_ID));
} else if (updatedSessionData.status === OFF
&& (!oldSessionData || oldSessionData.status !== OFF)) {
dispatch(
showStoppedRecordingNotification(
updatedSessionData.mode));
if (updatedSessionData.status === ON if (updatedSessionData.mode
&& (!oldSessionData || oldSessionData.status !== ON)) { === JitsiRecordingConstants.mode.FILE) {
dispatch(playSound(RECORDING_ON_SOUND_ID));
} else if (updatedSessionData.status === OFF
&& (!oldSessionData || oldSessionData.status !== OFF)) {
dispatch(stopSound(RECORDING_ON_SOUND_ID)); dispatch(stopSound(RECORDING_ON_SOUND_ID));
dispatch(playSound(RECORDING_OFF_SOUND_ID)); dispatch(playSound(RECORDING_OFF_SOUND_ID));
dispatch(showStoppedRecordingNotification());
} }
} }
} }

View File

@ -1,18 +1,33 @@
import { ReducerRegistry } from '../base/redux'; import { ReducerRegistry } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import { import {
CLEAR_RECORDING_SESSIONS, CLEAR_RECORDING_SESSIONS,
RECORDING_SESSION_UPDATED, RECORDING_SESSION_UPDATED,
SET_PENDING_RECORDING_NOTIFICATION_UID SET_PENDING_RECORDING_NOTIFICATION_UID,
SET_STREAM_KEY
} from './actionTypes'; } from './actionTypes';
const DEFAULT_STATE = { const DEFAULT_STATE = {
pendingNotificationUids: {},
sessionDatas: [] sessionDatas: []
}; };
/**
* The name of the Redux store this feature stores its state in.
*/
const STORE_NAME = 'features/recording';
/**
* Sets up the persistence of the feature {@code recording}.
*/
PersistenceRegistry.register(STORE_NAME, {
streamKey: true
}, DEFAULT_STATE);
/** /**
* Reduces the Redux actions of the feature features/recording. * Reduces the Redux actions of the feature features/recording.
*/ */
ReducerRegistry.register('features/recording', ReducerRegistry.register(STORE_NAME,
(state = DEFAULT_STATE, action) => { (state = DEFAULT_STATE, action) => {
switch (action.type) { switch (action.type) {
@ -29,10 +44,23 @@ ReducerRegistry.register('features/recording',
_updateSessionDatas(state.sessionDatas, action.sessionData) _updateSessionDatas(state.sessionDatas, action.sessionData)
}; };
case SET_PENDING_RECORDING_NOTIFICATION_UID: case SET_PENDING_RECORDING_NOTIFICATION_UID: {
const pendingNotificationUids = {
...state.pendingNotificationUids
};
pendingNotificationUids[action.streamType] = action.uid;
return { return {
...state, ...state,
pendingNotificationUid: action.uid pendingNotificationUids
};
}
case SET_STREAM_KEY:
return {
...state,
streamKey: action.streamKey
}; };
default: default:

View File

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { BottomSheet, hideDialog } from '../../../base/dialog'; import { BottomSheet, hideDialog } from '../../../base/dialog';
import { AudioRouteButton } from '../../../mobile/audio-mode'; import { AudioRouteButton } from '../../../mobile/audio-mode';
import { PictureInPictureButton } from '../../../mobile/picture-in-picture'; import { PictureInPictureButton } from '../../../mobile/picture-in-picture';
import { RecordButton } from '../../../recording'; import { LiveStreamButton, RecordButton } from '../../../recording';
import { RoomLockButton } from '../../../room-lock'; import { RoomLockButton } from '../../../room-lock';
import AudioOnlyButton from './AudioOnlyButton'; import AudioOnlyButton from './AudioOnlyButton';
@ -70,6 +70,7 @@ class OverflowMenu extends Component<Props> {
<AudioOnlyButton { ...buttonProps } /> <AudioOnlyButton { ...buttonProps } />
<RoomLockButton { ...buttonProps } /> <RoomLockButton { ...buttonProps } />
<RecordButton { ...buttonProps } /> <RecordButton { ...buttonProps } />
<LiveStreamButton { ...buttonProps } />
<PictureInPictureButton { ...buttonProps } /> <PictureInPictureButton { ...buttonProps } />
</BottomSheet> </BottomSheet>
); );

View File

@ -11,11 +11,9 @@ import {
} from '../../../analytics'; } from '../../../analytics';
import { openDialog } from '../../../base/dialog'; import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipants, getParticipants,
isLocalParticipantModerator,
participantUpdated participantUpdated
} from '../../../base/participants'; } from '../../../base/participants';
import { getLocalVideoTrack, toggleScreensharing } from '../../../base/tracks'; import { getLocalVideoTrack, toggleScreensharing } from '../../../base/tracks';
@ -30,10 +28,8 @@ import {
} from '../../../invite'; } from '../../../invite';
import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts';
import { import {
RecordButton, LiveStreamButton,
StartLiveStreamDialog, RecordButton
StopLiveStreamDialog,
getActiveSession
} from '../../../recording'; } from '../../../recording';
import { import {
SETTINGS_TABS, SETTINGS_TABS,
@ -123,22 +119,6 @@ type Props = {
*/ */
_isGuest: boolean, _isGuest: boolean,
/**
* The tooltip key to use when live streaming is disabled. Or undefined
* if non to be shown and the button to be hidden.
*/
_liveStreamingDisabledTooltipKey: boolean,
/**
* Whether or not the live streaming feature is enabled for use.
*/
_liveStreamingEnabled: boolean,
/**
* The current live streaming session, if any.
*/
_liveStreamingSession: ?Object,
/** /**
* The ID of the local participant. * The ID of the local participant.
*/ */
@ -230,8 +210,6 @@ class Toolbox extends Component<Props> {
= this._onToolbarToggleEtherpad.bind(this); = this._onToolbarToggleEtherpad.bind(this);
this._onToolbarToggleFullScreen this._onToolbarToggleFullScreen
= this._onToolbarToggleFullScreen.bind(this); = this._onToolbarToggleFullScreen.bind(this);
this._onToolbarToggleLiveStreaming
= this._onToolbarToggleLiveStreaming.bind(this);
this._onToolbarToggleProfile this._onToolbarToggleProfile
= this._onToolbarToggleProfile.bind(this); = this._onToolbarToggleProfile.bind(this);
this._onToolbarToggleRaiseHand this._onToolbarToggleRaiseHand
@ -476,22 +454,6 @@ class Toolbox extends Component<Props> {
this.props.dispatch(setFullScreen(fullScreen)); this.props.dispatch(setFullScreen(fullScreen));
} }
/**
* Dispatches an action to show a dialog for starting or stopping a live
* streaming session.
*
* @private
* @returns {void}
*/
_doToggleLiveStreaming() {
const { _liveStreamingSession } = this.props;
const dialogToDisplay = _liveStreamingSession
? StopLiveStreamDialog : StartLiveStreamDialog;
this.props.dispatch(
openDialog(dialogToDisplay, { session: _liveStreamingSession }));
}
/** /**
* Dispatches an action to show or hide the profile edit panel. * Dispatches an action to show or hide the profile edit panel.
* *
@ -790,25 +752,6 @@ class Toolbox extends Component<Props> {
this._doToggleFullScreen(); this._doToggleFullScreen();
} }
_onToolbarToggleLiveStreaming: () => void;
/**
* Starts the process for enabling or disabling live streaming.
*
* @private
* @returns {void}
*/
_onToolbarToggleLiveStreaming() {
sendAnalytics(createToolbarEvent(
'livestreaming.button',
{
'is_streaming': Boolean(this.props._liveStreamingSession),
type: JitsiRecordingConstants.mode.STREAM
}));
this._doToggleLiveStreaming();
}
_onToolbarToggleProfile: () => void; _onToolbarToggleProfile: () => void;
/** /**
@ -919,43 +862,6 @@ class Toolbox extends Component<Props> {
); );
} }
/**
* Renders an {@code OverflowMenuItem} to start or stop live streaming of
* the current conference.
*
* @private
* @returns {ReactElement}
*/
_renderLiveStreamingButton() {
const {
_liveStreamingDisabledTooltipKey,
_liveStreamingEnabled,
_liveStreamingSession,
t
} = this.props;
const translationKey = _liveStreamingSession
? 'dialog.stopLiveStreaming'
: 'dialog.startLiveStreaming';
return (
<OverflowMenuItem
accessibilityLabel
= { t('dialog.accessibilityLabel.liveStreaming') }
disabled = { !_liveStreamingEnabled }
elementAfter = {
<span className = 'beta-tag'>
{ t('recording.beta') }
</span>
}
icon = 'icon-public'
key = 'livestreaming'
onClick = { this._onToolbarToggleLiveStreaming }
text = { t(translationKey) }
tooltip = { t(_liveStreamingDisabledTooltipKey) } />
);
}
/** /**
* Renders the list elements of the overflow menu. * Renders the list elements of the overflow menu.
* *
@ -969,8 +875,6 @@ class Toolbox extends Component<Props> {
_feedbackConfigured, _feedbackConfigured,
_fullScreen, _fullScreen,
_isGuest, _isGuest,
_liveStreamingDisabledTooltipKey,
_liveStreamingEnabled,
_sharingVideo, _sharingVideo,
t t
} = this.props; } = this.props;
@ -997,9 +901,9 @@ class Toolbox extends Component<Props> {
text = { _fullScreen text = { _fullScreen
? t('toolbar.exitFullScreen') ? t('toolbar.exitFullScreen')
: t('toolbar.enterFullScreen') } />, : t('toolbar.enterFullScreen') } />,
(_liveStreamingEnabled || _liveStreamingDisabledTooltipKey) <LiveStreamButton
&& this._shouldShowButton('livestreaming') key = 'livestreaming'
&& this._renderLiveStreamingButton(), showLabel = { true } />,
<RecordButton <RecordButton
key = 'record' key = 'record'
showLabel = { true } />, showLabel = { true } />,
@ -1086,7 +990,6 @@ function _mapStateToProps(state) {
callStatsID, callStatsID,
iAmRecorder iAmRecorder
} = state['features/base/config']; } = state['features/base/config'];
let { liveStreamingEnabled } = state['features/base/config'];
const sharedVideoStatus = state['features/shared-video'].status; const sharedVideoStatus = state['features/shared-video'].status;
const { current } = state['features/side-panel']; const { current } = state['features/side-panel'];
const { const {
@ -1102,10 +1005,6 @@ function _mapStateToProps(state) {
const dialOutEnabled = isDialOutEnabled(state); const dialOutEnabled = isDialOutEnabled(state);
let desktopSharingDisabledTooltipKey; let desktopSharingDisabledTooltipKey;
let liveStreamingDisabledTooltipKey;
liveStreamingEnabled
= isLocalParticipantModerator(state) && liveStreamingEnabled;
if (state['features/base/config'].enableFeaturesBasedOnToken) { if (state['features/base/config'].enableFeaturesBasedOnToken) {
// we enable desktop sharing if any participant already have this // we enable desktop sharing if any participant already have this
@ -1122,27 +1021,6 @@ function _mapStateToProps(state) {
desktopSharingDisabledTooltipKey desktopSharingDisabledTooltipKey
= 'dialog.shareYourScreenDisabled'; = 'dialog.shareYourScreenDisabled';
} }
// we enable recording if the local participant have this
// feature enabled
const { features = {} } = localParticipant;
const { isGuest } = state['features/base/jwt'];
liveStreamingEnabled
= liveStreamingEnabled && String(features.livestreaming) === 'true';
// if the feature is disabled on purpose, do no show it, no tooltip
if (!liveStreamingEnabled
&& String(features.livestreaming) !== 'disabled') {
// button and tooltip
if (isGuest) {
liveStreamingDisabledTooltipKey
= 'dialog.liveStreamingDisabledForGuestTooltip';
} else {
liveStreamingDisabledTooltipKey
= 'dialog.liveStreamingDisabledTooltip';
}
}
} }
return { return {
@ -1158,10 +1036,6 @@ function _mapStateToProps(state) {
iAmRecorder || (!addPeopleEnabled && !dialOutEnabled), iAmRecorder || (!addPeopleEnabled && !dialOutEnabled),
_isGuest: state['features/base/jwt'].isGuest, _isGuest: state['features/base/jwt'].isGuest,
_fullScreen: fullScreen, _fullScreen: fullScreen,
_liveStreamingDisabledTooltipKey: liveStreamingDisabledTooltipKey,
_liveStreamingEnabled: liveStreamingEnabled,
_liveStreamingSession:
getActiveSession(state, JitsiRecordingConstants.mode.STREAM),
_localParticipantID: localParticipant.id, _localParticipantID: localParticipant.id,
_overflowMenuVisible: overflowMenuVisible, _overflowMenuVisible: overflowMenuVisible,
_raisedHand: localParticipant.raisedHand, _raisedHand: localParticipant.raisedHand,