Show the YouTube live stream URL (#2837)

* feat(recording): show the YouTube live stream URL

- From the start live stream dialog, push up the broadcast ID
  of the chosen broadcast. It is assumed the ID can be used to
  create the YouTube link.
- Listen for lib-jitsi-meet to emit updates of the known live
  stream URL, shove it into redux, and have InfoDialog display
  it.

* ref(info): pass in dial in and live stream url

Passing these values in should trigger AtlasKit InlineDialog
to re-render and reposition itself.

* ref(info): use conference existence as trigger for autoshowing dialog

* feat(info): add live stream link to invite copy

* Revert "ref(info): use conference existence as trigger for autoshowing dialog"

This reverts commit 1072102267.

* hidden -> url

* _onClickHiddenURL -> _onClickURLText
This commit is contained in:
virtuacoplenny 2018-04-20 10:28:16 -07:00 committed by bbaldino
parent d82f172db8
commit 2c4a3b0f60
8 changed files with 124 additions and 80 deletions

View File

@ -28,6 +28,7 @@ import {
redirectWithStoredParams, redirectWithStoredParams,
reloadWithStoredParams reloadWithStoredParams
} from './react/features/app'; } from './react/features/app';
import { updateRecordingState } from './react/features/recording';
import EventEmitter from 'events'; import EventEmitter from 'events';
@ -1851,6 +1852,12 @@ export default {
APP.store.dispatch(dominantSpeakerChanged(id)); APP.store.dispatch(dominantSpeakerChanged(id));
}); });
room.on(JitsiConferenceEvents.LIVE_STREAM_URL_CHANGED,
(from, liveStreamViewURL) =>
APP.store.dispatch(updateRecordingState({
liveStreamViewURL
})));
if (!interfaceConfig.filmStripOnly) { if (!interfaceConfig.filmStripOnly) {
room.on(JitsiConferenceEvents.CONNECTION_INTERRUPTED, () => { room.on(JitsiConferenceEvents.CONNECTION_INTERRUPTED, () => {
APP.UI.markVideoInterrupted(true); APP.UI.markVideoInterrupted(true);

View File

@ -59,7 +59,8 @@
} }
} }
.info-dialog-conference-url { .info-dialog-conference-url,
.info-dialog-live-stream-url {
width: max-content; width: max-content;
width: -moz-max-content; width: -moz-max-content;
width: -webkit-max-content; width: -webkit-max-content;
@ -81,8 +82,8 @@
font-size: 16px; font-size: 16px;
} }
.info-dialog-invite-link, .info-dialog-url-text,
.info-dialog-invite-link:hover { .info-dialog-url-text:hover {
color: inherit; color: inherit;
cursor: inherit; cursor: inherit;
} }

View File

@ -516,9 +516,11 @@
"dialInConferenceID": "PIN:", "dialInConferenceID": "PIN:",
"dialInNotSupported": "Sorry, dialing in is currently not suppported.", "dialInNotSupported": "Sorry, dialing in is currently not suppported.",
"genericError": "Whoops, something went wrong.", "genericError": "Whoops, something went wrong.",
"inviteLiveStream": "To view the live stream of this meeting, click this link: __url__",
"invitePhone": "To join by phone, dial __number__ and enter this PIN: __conferenceID__#", "invitePhone": "To join by phone, dial __number__ and enter this PIN: __conferenceID__#",
"invitePhoneAlternatives": "To view more phone numbers, click this link: __url__", "invitePhoneAlternatives": "To view more phone numbers, click this link: __url__",
"inviteURL": "To join the video meeting, click this link: __url__", "inviteURL": "To join the video meeting, click this link: __url__",
"liveStreamURL": "Live stream:",
"moreNumbers": "More numbers", "moreNumbers": "More numbers",
"noNumbers": "No dial-in numbers.", "noNumbers": "No dial-in numbers.",
"noPassword": "None", "noPassword": "None",

View File

@ -109,7 +109,10 @@ function _requestLiveStreamId() {
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
APP.store.dispatch(openDialog(StartLiveStreamDialog, { APP.store.dispatch(openDialog(StartLiveStreamDialog, {
onCancel: reject, onCancel: reject,
onSubmit: resolve onSubmit: (streamId, broadcastId) => resolve({
broadcastId,
streamId
})
}))); })));
} }
@ -257,7 +260,6 @@ const Recording = {
* @param recordingState gives us the current recording state * @param recordingState gives us the current recording state
*/ */
updateRecordingUI(recordingState) { updateRecordingUI(recordingState) {
const oldState = this.currentState; const oldState = this.currentState;
this.currentState = recordingState; this.currentState = recordingState;
@ -388,10 +390,13 @@ const Recording = {
case JitsiRecordingStatus.OFF: { case JitsiRecordingStatus.OFF: {
if (this.recordingType === 'jibri') { if (this.recordingType === 'jibri') {
_requestLiveStreamId() _requestLiveStreamId()
.then(streamId => { .then(({ broadcastId, streamId }) => {
this.eventEmitter.emit( this.eventEmitter.emit(
UIEvents.RECORDING_TOGGLED, UIEvents.RECORDING_TOGGLED,
{ streamId }); {
broadcastId,
streamId
});
// The confirm button on the start recording dialog was // The confirm button on the start recording dialog was
// clicked // clicked

View File

@ -35,12 +35,9 @@ class InfoDialogButton extends Component {
static propTypes = { static propTypes = {
/** /**
* Phone numbers for dialing into the conference. * The redux state representing the dial-in numbers feature.
*/ */
_dialInNumbers: PropTypes.oneOfType([ _dialIn: PropTypes.object,
PropTypes.object,
PropTypes.array
]),
/** /**
* Whether or not the {@code InfoDialog} should display automatically * Whether or not the {@code InfoDialog} should display automatically
@ -48,6 +45,11 @@ class InfoDialogButton extends Component {
*/ */
_disableAutoShow: PropTypes.bool, _disableAutoShow: PropTypes.bool,
/**
* The URL for a currently active live broadcast
*/
_liveStreamViewURL: PropTypes.string,
/** /**
* The number of real participants in the call. If in a lonely call, * The number of real participants in the call. If in a lonely call,
* the {@code InfoDialog} will be automatically shown. * the {@code InfoDialog} will be automatically shown.
@ -117,7 +119,7 @@ class InfoDialogButton extends Component {
this._maybeAutoShowDialog(); this._maybeAutoShowDialog();
}, INFO_DIALOG_AUTO_SHOW_TIMEOUT); }, INFO_DIALOG_AUTO_SHOW_TIMEOUT);
if (!this.props._dialInNumbers) { if (!this.props._dialIn.numbers) {
this.props.dispatch(updateDialInNumbers()); this.props.dispatch(updateDialInNumbers());
} }
} }
@ -150,7 +152,7 @@ class InfoDialogButton extends Component {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { t } = this.props; const { _dialIn, _liveStreamViewURL, t } = this.props;
const { showDialog } = this.state; const { showDialog } = this.state;
const iconClass = `icon-info ${showDialog ? 'toggled' : ''}`; const iconClass = `icon-info ${showDialog ? 'toggled' : ''}`;
@ -158,7 +160,10 @@ class InfoDialogButton extends Component {
<div className = 'toolbox-button-wth-dialog'> <div className = 'toolbox-button-wth-dialog'>
<InlineDialog <InlineDialog
content = { content = {
<InfoDialog onClose = { this._onDialogClose } /> } <InfoDialog
dialIn = { _dialIn }
liveStreamViewURL = { _liveStreamViewURL }
onClose = { this._onDialogClose } /> }
isOpen = { showDialog } isOpen = { showDialog }
onClose = { this._onDialogClose } onClose = { this._onDialogClose }
position = { 'top right' }> position = { 'top right' }>
@ -215,16 +220,18 @@ class InfoDialogButton extends Component {
* @param {Object} state - The Redux state. * @param {Object} state - The Redux state.
* @private * @private
* @returns {{ * @returns {{
* _dialInNumbers: Array, * _dialIn: Object,
* _disableAutoShow: bolean, * _disableAutoShow: boolean,
* _liveStreamViewURL: string,
* _participantCount: number, * _participantCount: number,
* _toolboxVisible: boolean * _toolboxVisible: boolean
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
return { return {
_dialInNumbers: state['features/invite'].numbers, _dialIn: state['features/invite'],
_disableAutoShow: state['features/base/config'].iAmRecorder, _disableAutoShow: state['features/base/config'].iAmRecorder,
_liveStreamViewURL: state['features/recording'].liveStreamViewURL,
_participantCount: _participantCount:
getParticipantCount(state['features/base/participants']), getParticipantCount(state['features/base/participants']),
_toolboxVisible: state['features/toolbox'].visible _toolboxVisible: state['features/toolbox'].visible

View File

@ -10,8 +10,6 @@ import {
getLocalParticipant getLocalParticipant
} from '../../../base/participants'; } from '../../../base/participants';
import { updateDialInNumbers } from '../../actions';
import DialInNumber from './DialInNumber'; import DialInNumber from './DialInNumber';
import PasswordForm from './PasswordForm'; import PasswordForm from './PasswordForm';
@ -24,15 +22,6 @@ const logger = require('jitsi-meet-logger').getLogger(__filename);
* @extends Component * @extends Component
*/ */
class InfoDialog extends Component { class InfoDialog extends Component {
/**
* Default values for {@code InfoDialog} component's properties.
*
* @static
*/
static defaultProps = {
autoUpdateNumbers: true
};
/** /**
* {@code InfoDialog} component's property types. * {@code InfoDialog} component's property types.
* *
@ -57,11 +46,6 @@ class InfoDialog extends Component {
*/ */
_conferenceName: PropTypes.string, _conferenceName: PropTypes.string,
/**
* The redux state representing the dial-in numbers feature.
*/
_dialIn: PropTypes.object,
/** /**
* The current url of the conference to be copied onto the clipboard. * The current url of the conference to be copied onto the clipboard.
*/ */
@ -79,17 +63,20 @@ class InfoDialog extends Component {
_password: PropTypes.string, _password: PropTypes.string,
/** /**
* Whether or not this component should make a request for dial-in * The object representing the dialIn feature.
* numbers. If false, this component will rely on an outside source
* updating and passing in numbers through the _dialIn prop.
*/ */
autoUpdateNumbers: PropTypes.bool, dialIn: PropTypes.object,
/** /**
* Invoked to open a dialog for adding participants to the conference. * Invoked to open a dialog for adding participants to the conference.
*/ */
dispatch: PropTypes.func, dispatch: PropTypes.func,
/**
* The current known URL for a live stream in progress.
*/
liveStreamViewURL: PropTypes.string,
/** /**
* Callback invoked when the dialog should be closed. * Callback invoked when the dialog should be closed.
*/ */
@ -129,7 +116,7 @@ class InfoDialog extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { defaultCountry, numbers } = props._dialIn; const { defaultCountry, numbers } = props.dialIn;
if (numbers) { if (numbers) {
this.state.phoneNumber this.state.phoneNumber
@ -147,7 +134,7 @@ class InfoDialog extends Component {
this._copyElement = null; this._copyElement = null;
// Bind event handlers so they are only bound once for every instance. // Bind event handlers so they are only bound once for every instance.
this._onClickInviteURL = this._onClickInviteURL.bind(this); this._onClickURLText = this._onClickURLText.bind(this);
this._onCopyInviteURL = this._onCopyInviteURL.bind(this); this._onCopyInviteURL = this._onCopyInviteURL.bind(this);
this._onPasswordRemove = this._onPasswordRemove.bind(this); this._onPasswordRemove = this._onPasswordRemove.bind(this);
this._onPasswordSubmit = this._onPasswordSubmit.bind(this); this._onPasswordSubmit = this._onPasswordSubmit.bind(this);
@ -156,20 +143,6 @@ class InfoDialog extends Component {
this._setCopyElement = this._setCopyElement.bind(this); this._setCopyElement = this._setCopyElement.bind(this);
} }
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted. Requests dial-in numbers if not
* already known.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
if (!this.state.phoneNumber && this.props.autoUpdateNumbers) {
this.props.dispatch(updateDialInNumbers());
}
}
/** /**
* Implements React's {@link Component#componentWillReceiveProps()}. Invoked * Implements React's {@link Component#componentWillReceiveProps()}. Invoked
* before this mounted component receives new props. * before this mounted component receives new props.
@ -182,8 +155,8 @@ class InfoDialog extends Component {
this.setState({ passwordEditEnabled: false }); this.setState({ passwordEditEnabled: false });
} }
if (!this.state.phoneNumber && nextProps._dialIn.numbers) { if (!this.state.phoneNumber && nextProps.dialIn.numbers) {
const { defaultCountry, numbers } = nextProps._dialIn; const { defaultCountry, numbers } = nextProps.dialIn;
this.setState({ this.setState({
phoneNumber: phoneNumber:
@ -199,7 +172,7 @@ class InfoDialog extends Component {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { onMouseOver, t } = this.props; const { liveStreamViewURL, onMouseOver, t } = this.props;
return ( return (
<div <div
@ -221,9 +194,9 @@ class InfoDialog extends Component {
<span className = 'spacer'>&nbsp;</span> <span className = 'spacer'>&nbsp;</span>
<span className = 'info-value'> <span className = 'info-value'>
<a <a
className = 'info-dialog-invite-link' className = 'info-dialog-url-text'
href = { this.props._inviteURL } href = { this.props._inviteURL }
onClick = { this._onClickInviteURL } > onClick = { this._onClickURLText } >
{ this._getURLToDisplay() } { this._getURLToDisplay() }
</a> </a>
</span> </span>
@ -231,6 +204,7 @@ class InfoDialog extends Component {
<div className = 'info-dialog-dial-in'> <div className = 'info-dialog-dial-in'>
{ this._renderDialInDisplay() } { this._renderDialInDisplay() }
</div> </div>
{ liveStreamViewURL && this._renderLiveStreamURL() }
<div className = 'info-dialog-password'> <div className = 'info-dialog-password'>
<PasswordForm <PasswordForm
editEnabled = { this.state.passwordEditEnabled } editEnabled = { this.state.passwordEditEnabled }
@ -321,16 +295,24 @@ class InfoDialog extends Component {
* @returns {string} * @returns {string}
*/ */
_getTextToCopy() { _getTextToCopy() {
const { t } = this.props; const { liveStreamViewURL, t } = this.props;
let invite = t('info.inviteURL', { let invite = t('info.inviteURL', {
url: this.props._inviteURL url: this.props._inviteURL
}); });
if (liveStreamViewURL) {
const liveStream = t('info.inviteLiveStream', {
url: liveStreamViewURL
});
invite = `${invite}\n${liveStream}`;
}
if (this._shouldDisplayDialIn()) { if (this._shouldDisplayDialIn()) {
const dial = t('info.invitePhone', { const dial = t('info.invitePhone', {
number: this.state.phoneNumber, number: this.state.phoneNumber,
conferenceID: this.props._dialIn.conferenceID conferenceID: this.props.dialIn.conferenceID
}); });
const moreNumbers = t('info.invitePhoneAlternatives', { const moreNumbers = t('info.invitePhoneAlternatives', {
url: this._getDialInfoPageURL() url: this._getDialInfoPageURL()
@ -353,16 +335,16 @@ class InfoDialog extends Component {
} }
/** /**
* Callback invoked when the displayed invite URL link is clicked to prevent * Callback invoked when a displayed URL link is clicked to prevent actual
* actual navigation from happening. The invite URL link has an href to * navigation from happening. The URL links have an href to display "Copy
* display "Copy Link Address" in the context menu but otherwise it should * Link Address" in the context menu but otherwise it should not behave like
* not behave like a link. * links.
* *
* @param {Object} event - The click event from clicking on the link. * @param {Object} event - The click event from clicking on the link.
* @private * @private
* @returns {void} * @returns {void}
*/ */
_onClickInviteURL(event) { _onClickURLText(event) {
event.preventDefault(); event.preventDefault();
} }
@ -439,7 +421,7 @@ class InfoDialog extends Component {
return ( return (
<div> <div>
<DialInNumber <DialInNumber
conferenceID = { this.props._dialIn.conferenceID } conferenceID = { this.props.dialIn.conferenceID }
phoneNumber = { this.state.phoneNumber } /> phoneNumber = { this.state.phoneNumber } />
<a <a
className = 'more-numbers' className = 'more-numbers'
@ -490,6 +472,34 @@ class InfoDialog extends Component {
: null; : null;
} }
/**
* Returns a ReactElement for display a link to the current url of a
* live stream in progress.
*
* @private
* @returns {null|ReactElement}
*/
_renderLiveStreamURL() {
const { liveStreamViewURL, t } = this.props;
return (
<div className = 'info-dialog-live-stream-url'>
<span className = 'info-label'>
{ t('info.liveStreamURL') }
</span>
<span className = 'spacer'>&nbsp;</span>
<span className = 'info-value'>
<a
className = 'info-dialog-url-text'
href = { liveStreamViewURL }
onClick = { this._onClickURLText } >
{ liveStreamViewURL }
</a>
</span>
</div>
);
}
/** /**
* Returns whether or not dial-in related UI should be displayed. * Returns whether or not dial-in related UI should be displayed.
* *
@ -497,7 +507,7 @@ class InfoDialog extends Component {
* @returns {boolean} * @returns {boolean}
*/ */
_shouldDisplayDialIn() { _shouldDisplayDialIn() {
const { conferenceID, numbers, numbersEnabled } = this.props._dialIn; const { conferenceID, numbers, numbersEnabled } = this.props.dialIn;
const { phoneNumber } = this.state; const { phoneNumber } = this.state;
return Boolean( return Boolean(
@ -531,7 +541,6 @@ class InfoDialog extends Component {
* _canEditPassword: boolean, * _canEditPassword: boolean,
* _conference: Object, * _conference: Object,
* _conferenceName: string, * _conferenceName: string,
* _dialIn: Object,
* _inviteURL: string, * _inviteURL: string,
* _locked: string, * _locked: string,
* _password: string * _password: string
@ -558,7 +567,6 @@ function _mapStateToProps(state) {
_canEditPassword: canEditPassword, _canEditPassword: canEditPassword,
_conference: conference, _conference: conference,
_conferenceName: room, _conferenceName: room,
_dialIn: state['features/invite'],
_inviteURL: getInviteURL(state), _inviteURL: getInviteURL(state),
_locked: locked, _locked: locked,
_password: password _password: password

View File

@ -44,7 +44,7 @@ class BroadcastsDropdown extends PureComponent {
* The boundStreamID of the broadcast that should display as selected in * The boundStreamID of the broadcast that should display as selected in
* the dropdown. * the dropdown.
*/ */
selectedBroadcastID: PropTypes.string, selectedBoundStreamID: PropTypes.string,
/** /**
* Invoked to obtain translated strings. * Invoked to obtain translated strings.
@ -84,7 +84,7 @@ class BroadcastsDropdown extends PureComponent {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { broadcasts, selectedBroadcastID, t } = this.props; const { broadcasts, selectedBoundStreamID, t } = this.props;
const dropdownItems = broadcasts.map(broadcast => const dropdownItems = broadcasts.map(broadcast =>
// eslint-disable-next-line react/jsx-wrap-multilines // eslint-disable-next-line react/jsx-wrap-multilines
@ -96,7 +96,7 @@ class BroadcastsDropdown extends PureComponent {
</DropdownItem> </DropdownItem>
); );
const selected = this.props.broadcasts.find( const selected = this.props.broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBroadcastID); broadcast => broadcast.boundStreamID === selectedBoundStreamID);
const triggerText = (selected && selected.title) const triggerText = (selected && selected.title)
|| t('liveStreaming.choose'); || t('liveStreaming.choose');

View File

@ -89,6 +89,8 @@ class StartLiveStreamDialog extends Component {
* available for use for the logged in Google user's YouTube account. * available for use for the logged in Google user's YouTube account.
* @property {string} googleProfileEmail - The email of the user currently * @property {string} googleProfileEmail - The email of the user currently
* logged in to the Google web client application. * logged in to the Google web client application.
* @property {string} selectedBoundStreamID - The boundStreamID of the
* broadcast currently selected in the broadcast dropdown.
* @property {string} streamKey - The selected or entered stream key to use * @property {string} streamKey - The selected or entered stream key to use
* for YouTube live streaming. * for YouTube live streaming.
*/ */
@ -96,7 +98,7 @@ class StartLiveStreamDialog extends Component {
broadcasts: undefined, broadcasts: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING, googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '', googleProfileEmail: '',
selectedBroadcastID: undefined, selectedBoundStreamID: undefined,
streamKey: '' streamKey: ''
}; };
@ -291,7 +293,7 @@ class StartLiveStreamDialog extends Component {
_onStreamKeyChange(event) { _onStreamKeyChange(event) {
this._setStateIfMounted({ this._setStateIfMounted({
streamKey: event.target.value, streamKey: event.target.value,
selectedBroadcastID: undefined selectedBoundStreamID: undefined
}); });
} }
@ -304,11 +306,22 @@ class StartLiveStreamDialog extends Component {
* closing, true to close the modal. * closing, true to close the modal.
*/ */
_onSubmit() { _onSubmit() {
if (!this.state.streamKey) { const { streamKey, selectedBoundStreamID } = this.state;
if (!streamKey) {
return false; return false;
} }
this.props.onSubmit(this.state.streamKey); let selectedBroadcastID = null;
if (selectedBoundStreamID) {
const selectedBroadcast = this.state.broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast && selectedBroadcast.id;
}
this.props.onSubmit(streamKey, selectedBroadcastID);
return true; return true;
} }
@ -333,7 +346,7 @@ class StartLiveStreamDialog extends Component {
this._setStateIfMounted({ this._setStateIfMounted({
streamKey, streamKey,
selectedBroadcastID: boundStreamID selectedBoundStreamID: boundStreamID
}); });
}); });
} }
@ -358,6 +371,7 @@ class StartLiveStreamDialog extends Component {
if (boundStreamID && !parsedBroadcasts[boundStreamID]) { if (boundStreamID && !parsedBroadcasts[boundStreamID]) {
parsedBroadcasts[boundStreamID] = { parsedBroadcasts[boundStreamID] = {
boundStreamID, boundStreamID,
id: broadcast.id,
status: broadcast.status.lifeCycleStatus, status: broadcast.status.lifeCycleStatus,
title: broadcast.snippet.title title: broadcast.snippet.title
}; };
@ -378,7 +392,7 @@ class StartLiveStreamDialog extends Component {
const { const {
broadcasts, broadcasts,
googleProfileEmail, googleProfileEmail,
selectedBroadcastID selectedBoundStreamID
} = this.state; } = this.state;
let googleContent, helpText; let googleContent, helpText;
@ -399,7 +413,7 @@ class StartLiveStreamDialog extends Component {
<BroadcastsDropdown <BroadcastsDropdown
broadcasts = { broadcasts } broadcasts = { broadcasts }
onBroadcastSelected = { this._onYouTubeBroadcastIDSelected } onBroadcastSelected = { this._onYouTubeBroadcastIDSelected }
selectedBroadcastID = { selectedBroadcastID } /> selectedBoundStreamID = { selectedBoundStreamID } />
); );
/** /**