feat(recording): frontend logic can support live streaming and recording (#2952)

* feat(recording): frontend logic can support live streaming and recording

Instead of either live streaming or recording, now both can live together. The
changes to facilitate such include the following:
- Killing the state storing in Recording.js. Instead state is stored in the lib
  and updated in redux for labels to display the necessary state updates.
- Creating a new container, Labels, for recording labels. Previously labels were
  manually created and positioned. The container can create a reasonable number
  of labels and only the container itself needs to be positioned with CSS. The
  VideoQualityLabel has been shoved into the container as well because it moves
  along with the recording labels.
- The action for updating recording state has been modified to enable updating
  an array of recording sessions to support having multiple sessions.
- Confirmation dialogs for stopping and starting a file recording session have
  been created, as they previously were jquery modals opened by Recording.js.
- Toolbox.web displays live streaming and recording buttons based on
  configuration instead of recording availability.
- VideoQualityLabel and RecordingLabel have been simplified to remove any
  positioning logic, as the Labels container handles such.
- Previous recording state update logic has been moved into the RecordingLabel
  component. Each RecordingLabel is in charge of displaying state for a
  recording session. The display UX has been left alone.
- Sipgw availability is no longer broadcast so remove logic depending on its
  state. Some moving around of code was necessary to get around linting errors
  about the existing code being too deeply nested (even though I didn't touch
  it).

* work around lib-jitsi-meet circular dependency issues

* refactor labels to use html base

* pass in translation keys to video quality label

* add video quality classnames for torture tests

* break up, rearrange recorder session update listener

* add comment about disabling startup resize animation

* rename session to sessionData

* chore(deps): update to latest lib for recording changes
This commit is contained in:
virtuacoplenny 2018-05-16 07:00:16 -07:00 committed by GitHub
parent 5fd0f95a89
commit ee74f11c3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1269 additions and 1249 deletions

View File

@ -27,7 +27,7 @@ import {
redirectWithStoredParams, redirectWithStoredParams,
reloadWithStoredParams reloadWithStoredParams
} from './react/features/app'; } from './react/features/app';
import { updateRecordingState } from './react/features/recording'; import { updateRecordingSessionData } from './react/features/recording';
import EventEmitter from 'events'; import EventEmitter from 'events';
@ -1100,20 +1100,6 @@ export default {
return this._room && this._room.myUserId(); return this._room && this._room.myUserId();
}, },
/**
* Indicates if recording is supported in this conference.
*/
isRecordingSupported() {
return this._room && this._room.isRecordingSupported();
},
/**
* Returns the recording state or undefined if the room is not defined.
*/
getRecordingState() {
return this._room ? this._room.getRecordingState() : undefined;
},
/** /**
* Will be filled with values only when config.debug is enabled. * Will be filled with values only when config.debug is enabled.
* Its used by torture to check audio levels. * Its used by torture to check audio levels.
@ -1821,12 +1807,6 @@ 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);
@ -1951,14 +1931,36 @@ export default {
}); });
/* eslint-enable max-params */ /* eslint-enable max-params */
room.on( room.on(
JitsiConferenceEvents.RECORDER_STATE_CHANGED, JitsiConferenceEvents.RECORDER_STATE_CHANGED,
(status, error) => { recorderSession => {
logger.log('Received recorder status change: ', status, error); if (!recorderSession) {
APP.UI.updateRecordingState(status); logger.error(
} 'Received invalid recorder status update',
); recorderSession);
return;
}
if (recorderSession.getID()) {
APP.store.dispatch(
updateRecordingSessionData(recorderSession));
return;
}
// These errors fire when the local participant has requested a
// recording but the request itself failed, hence the missing
// session ID because the recorder never started.
if (recorderSession.getError()) {
this._showRecordingErrorNotification(recorderSession);
return;
}
logger.error(
'Received a recorder status update with no ID or error');
});
room.on(JitsiConferenceEvents.KICKED, () => { room.on(JitsiConferenceEvents.KICKED, () => {
APP.UI.hideStats(); APP.UI.hideStats();
@ -2093,13 +2095,6 @@ export default {
})); }));
}); });
/* eslint-enable max-params */
// Starts or stops the recording for the conference.
APP.UI.addListener(UIEvents.RECORDING_TOGGLED, options => {
room.toggleRecording(options);
});
APP.UI.addListener(UIEvents.AUTH_CLICKED, () => { APP.UI.addListener(UIEvents.AUTH_CLICKED, () => {
AuthHandler.authenticate(room); AuthHandler.authenticate(room);
}); });
@ -2746,5 +2741,57 @@ export default {
if (score === -1 || (score >= 1 && score <= 5)) { if (score === -1 || (score >= 1 && score <= 5)) {
APP.store.dispatch(submitFeedback(score, message, room)); APP.store.dispatch(submitFeedback(score, message, room));
} }
},
/**
* Shows a notification about an error in the recording session. A
* default notification will display if no error is specified in the passed
* in recording session.
*
* @param {Object} recorderSession - The recorder session model from the
* lib.
* @private
* @returns {void}
*/
_showRecordingErrorNotification(recorderSession) {
const isStreamMode
= recorderSession.getMode()
=== JitsiMeetJS.constants.recording.mode.STREAM;
switch (recorderSession.getError()) {
case JitsiMeetJS.constants.recording.error.SERVICE_UNAVAILABLE:
APP.UI.messageHandler.showError({
descriptionKey: 'recording.unavailable',
descriptionArguments: {
serviceName: isStreamMode
? 'Live Streaming service'
: 'Recording service'
},
titleKey: isStreamMode
? 'liveStreaming.unavailableTitle'
: 'recording.unavailableTitle'
});
break;
case JitsiMeetJS.constants.recording.error.RESOURCE_CONSTRAINT:
APP.UI.messageHandler.showError({
descriptionKey: isStreamMode
? 'liveStreaming.busy'
: 'recording.busy',
titleKey: isStreamMode
? 'liveStreaming.busyTitle'
: 'recording.busyTitle'
});
break;
default:
APP.UI.messageHandler.showError({
descriptionKey: isStreamMode
? 'liveStreaming.error'
: 'recording.error',
titleKey: isStreamMode
? 'liveStreaming.failedToStart'
: 'recording.failedToStart'
});
break;
}
} }
}; };

View File

@ -182,17 +182,9 @@
* The class opening is for when the filmstrip is transitioning from hidden * The class opening is for when the filmstrip is transitioning from hidden
* to visible. * to visible.
*/ */
.video-state-indicator.moveToCorner { .large-video-labels {
transition: right 0.5s;
&.with-filmstrip { &.with-filmstrip {
&#recordingLabel { right: 150px;
right: 200px;
}
&#videoResolutionLabel {
right: 150px;
}
} }
&.with-filmstrip.opening { &.with-filmstrip.opening {

View File

@ -141,98 +141,58 @@
} }
} }
.video-state-indicator {
background: $videoStateIndicatorBackground;
cursor: default;
font-size: 13px;
height: $videoStateIndicatorSize;
line-height: 20px;
text-align: left;
min-width: $videoStateIndicatorSize;
border-radius: 50%;
position: absolute;
box-sizing: border-box;
&.is-recording {
background: none;
opacity: 0.9;
padding: 0;
}
i {
line-height: $videoStateIndicatorSize;
}
/**
* Give the label padding so it has more volume and can be easily clicked.
*/
.video-quality-label-status {
line-height: $videoStateIndicatorSize;
min-width: $videoStateIndicatorSize;
text-align: center;
}
.recording-icon,
.recording-icon i {
line-height: $videoStateIndicatorSize;
font-size: $videoStateIndicatorSize;
opacity: 0.9;
position: relative;
}
.icon-rec {
color: #FF5630;
}
.icon-live {
color: #0065FF;
}
.recording-icon-background {
background: white;
border-radius: 50%;
height: calc(#{$videoStateIndicatorSize} - 1px);
left: 50%;
opacity: 0.9;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: calc(#{$videoStateIndicatorSize} - 1px);
}
#recordingLabelText {
display: inline-block;
}
}
.centeredVideoLabel.moveToCorner {
z-index: $zindex3;
}
#videoResolutionLabel { #videoResolutionLabel {
z-index: $zindex3 + 1; z-index: $zindex3 + 1;
} }
.centeredVideoLabel { .large-video-labels {
bottom: 45%; display: flex;
border-radius: 2px;
display: none;
padding: 10px;
transform: translate(-50%, 0);
z-index: $centeredVideoLabelZ;
&.moveToCorner {
bottom: auto;
transform: none;
}
}
.moveToCorner {
position: absolute; position: absolute;
top: 30px; top: 30px;
right: 30px; right: 30px;
transition: right 0.5s;
z-index: $zindex3;
.circular-label {
color: white;
font-family: -apple-system, BlinkMacSystemFont, $baseFontFamily;
font-weight: bold;
margin-left: 8px;
opacity: 0.8;
}
.circular-label {
background: #B8C7E0;
}
.circular-label.file {
background: #FF5630;
}
.circular-label.stream {
background: #0065FF;
}
.recording-label.center-message {
background: $videoStateIndicatorBackground;
bottom: 50%;
display: block;
left: 50%;
padding: 10px;
position: fixed;
transform: translate(-50%, -50%);
z-index: $centeredVideoLabelZ;
}
} }
.moveToCorner + .moveToCorner { .circular-label {
right: 80px; background: $videoStateIndicatorBackground;
} border-radius: 50%;
box-sizing: border-box;
cursor: default;
font-size: 13px;
height: $videoStateIndicatorSize;
line-height: $videoStateIndicatorSize;
text-align: center;
min-width: $videoStateIndicatorSize;
}

View File

@ -46,7 +46,7 @@ var interfaceConfig = {
'microphone', 'camera', 'desktop', 'fullscreen', 'fodeviceselection', 'hangup', 'microphone', 'camera', 'desktop', 'fullscreen', 'fodeviceselection', 'hangup',
// extended toolbar // extended toolbar
'profile', 'info', 'chat', 'recording', 'etherpad', 'profile', 'info', 'chat', 'recording', 'livestreaming', 'etherpad',
'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip', 'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip',
'invite', 'feedback', 'stats', 'shortcuts' 'invite', 'feedback', 'stats', 'shortcuts'
], ],

View File

@ -206,6 +206,7 @@
}, },
"dialog": { "dialog": {
"allow": "Allow", "allow": "Allow",
"confirm": "Confirm",
"kickMessage": "Ouch! You have been kicked out of the meet!", "kickMessage": "Ouch! You have been kicked out of the meet!",
"popupErrorTitle": "Pop-up blocked", "popupErrorTitle": "Pop-up blocked",
"popupError": "Your browser is blocking pop-up windows from this site. Please enable pop-ups in your browser's security settings and try again.", "popupError": "Your browser is blocking pop-up windows from this site. Please enable pop-ups in your browser's security settings and try again.",
@ -389,10 +390,13 @@
"buttonTooltip": "Start / Stop recording", "buttonTooltip": "Start / Stop recording",
"error": "Recording failed. Please try again.", "error": "Recording failed. Please try again.",
"failedToStart": "Recording failed to start", "failedToStart": "Recording failed to start",
"live": "LIVE",
"off": "Recording stopped", "off": "Recording stopped",
"on": "Recording", "on": "Recording",
"pending": "Recording waiting for a member to join...", "pending": "Recording waiting for a member to join...",
"rec": "REC",
"serviceName": "Recording service", "serviceName": "Recording service",
"startRecordingBody": "Are you sure you would like to start recording?",
"unavailable": "Oops! The __serviceName__ is currently unavailable. We're working on resolving the issue. Please try again later.", "unavailable": "Oops! The __serviceName__ is currently unavailable. We're working on resolving the issue. Please try again later.",
"unavailableTitle": "Recording unavailable" "unavailableTitle": "Recording unavailable"
}, },
@ -448,6 +452,7 @@
"testAudio": "Play a test sound" "testAudio": "Play a test sound"
}, },
"videoStatus": { "videoStatus": {
"audioOnly": "AUD",
"callQuality": "Call Quality", "callQuality": "Call Quality",
"hd": "HD", "hd": "HD",
"hdTooltip": "Viewing high definition video", "hdTooltip": "Viewing high definition video",

View File

@ -12,7 +12,6 @@ import UIUtil from './util/UIUtil';
import UIEvents from '../../service/UI/UIEvents'; import UIEvents from '../../service/UI/UIEvents';
import EtherpadManager from './etherpad/Etherpad'; import EtherpadManager from './etherpad/Etherpad';
import SharedVideoManager from './shared_video/SharedVideo'; import SharedVideoManager from './shared_video/SharedVideo';
import Recording from './recording/Recording';
import VideoLayout from './videolayout/VideoLayout'; import VideoLayout from './videolayout/VideoLayout';
import Filmstrip from './videolayout/Filmstrip'; import Filmstrip from './videolayout/Filmstrip';
@ -38,6 +37,7 @@ import {
import { shouldShowOnlyDeviceSelection } from '../../react/features/settings'; import { shouldShowOnlyDeviceSelection } from '../../react/features/settings';
import { import {
dockToolbox, dockToolbox,
setToolboxEnabled,
showToolbox showToolbox
} from '../../react/features/toolbox'; } from '../../react/features/toolbox';
@ -337,7 +337,12 @@ UI.start = function() {
if (!interfaceConfig.filmStripOnly) { if (!interfaceConfig.filmStripOnly) {
VideoLayout.initLargeVideo(); VideoLayout.initLargeVideo();
} }
VideoLayout.resizeVideoArea(true, true);
// Do not animate the video area on UI start (second argument passed into
// resizeVideoArea) because the animation is not visible anyway. Plus with
// the current dom layout, the quality label is part of the video layout and
// will be seen animating in.
VideoLayout.resizeVideoArea(true, false);
sharedVideoManager = new SharedVideoManager(eventEmitter); sharedVideoManager = new SharedVideoManager(eventEmitter);
@ -346,9 +351,20 @@ UI.start = function() {
Filmstrip.setFilmstripOnly(); Filmstrip.setFilmstripOnly();
APP.store.dispatch(setNotificationsEnabled(false)); APP.store.dispatch(setNotificationsEnabled(false));
} else { } else {
// Initialise the recording module. // Initialize recording mode UI.
config.enableRecording if (config.enableRecording && config.iAmRecorder) {
&& Recording.init(eventEmitter, config.recordingType); VideoLayout.enableDeviceAvailabilityIcons(
APP.conference.getMyUserId(), false);
// in case of iAmSipGateway keep local video visible
if (!config.iAmSipGateway) {
VideoLayout.setLocalVideoVisible(false);
}
APP.store.dispatch(setToolboxEnabled(false));
APP.store.dispatch(setNotificationsEnabled(false));
UI.messageHandler.enablePopups(false);
}
// Initialize side panels // Initialize side panels
SidePanels.init(eventEmitter); SidePanels.init(eventEmitter);
@ -520,13 +536,9 @@ UI.onPeerVideoTypeChanged
UI.updateLocalRole = isModerator => { UI.updateLocalRole = isModerator => {
VideoLayout.showModeratorIndicator(); VideoLayout.showModeratorIndicator();
if (isModerator) { if (isModerator && !interfaceConfig.DISABLE_FOCUS_INDICATOR) {
if (!interfaceConfig.DISABLE_FOCUS_INDICATOR) { messageHandler.participantNotification(
messageHandler.participantNotification( null, 'notify.me', 'connected', 'notify.moderator');
null, 'notify.me', 'connected', 'notify.moderator');
}
Recording.checkAutoRecord();
} }
}; };
@ -881,10 +893,6 @@ UI.addMessage = function(from, displayName, message, stamp) {
Chat.updateChatConversation(from, displayName, message, stamp); Chat.updateChatConversation(from, displayName, message, stamp);
}; };
UI.updateRecordingState = function(state) {
Recording.updateRecordingState(state);
};
UI.notifyTokenAuthFailed = function() { UI.notifyTokenAuthFailed = function() {
messageHandler.showError({ messageHandler.showError({
descriptionKey: 'dialog.tokenAuthFailed', descriptionKey: 'dialog.tokenAuthFailed',

View File

@ -1,462 +0,0 @@
/* global APP, config, interfaceConfig */
/*
* Copyright @ 2015 Atlassian Pty Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const logger = require('jitsi-meet-logger').getLogger(__filename);
import UIEvents from '../../../service/UI/UIEvents';
import UIUtil from '../util/UIUtil';
import VideoLayout from '../videolayout/VideoLayout';
import { openDialog } from '../../../react/features/base/dialog';
import {
JitsiRecordingStatus
} from '../../../react/features/base/lib-jitsi-meet';
import {
createToolbarEvent,
createRecordingDialogEvent,
sendAnalytics
} from '../../../react/features/analytics';
import { setToolboxEnabled } from '../../../react/features/toolbox';
import { setNotificationsEnabled } from '../../../react/features/notifications';
import {
StartLiveStreamDialog,
StopLiveStreamDialog,
hideRecordingLabel,
setRecordingType,
updateRecordingState
} from '../../../react/features/recording';
/**
* Translation keys to use for display in the UI when recording the conference
* but not streaming live.
*
* @private
* @type {Object}
*/
export const RECORDING_TRANSLATION_KEYS = {
failedToStartKey: 'recording.failedToStart',
recordingBusy: 'recording.busy',
recordingBusyTitle: 'recording.busyTitle',
recordingButtonTooltip: 'recording.buttonTooltip',
recordingErrorKey: 'recording.error',
recordingOffKey: 'recording.off',
recordingOnKey: 'recording.on',
recordingPendingKey: 'recording.pending',
recordingTitle: 'dialog.recording',
recordingUnavailable: 'recording.unavailable',
recordingUnavailableParams: '$t(recording.serviceName)',
recordingUnavailableTitle: 'recording.unavailableTitle'
};
/**
* Translation keys to use for display in the UI when the recording mode is
* currently streaming live.
*
* @private
* @type {Object}
*/
export const STREAMING_TRANSLATION_KEYS = {
failedToStartKey: 'liveStreaming.failedToStart',
recordingBusy: 'liveStreaming.busy',
recordingBusyTitle: 'liveStreaming.busyTitle',
recordingButtonTooltip: 'liveStreaming.buttonTooltip',
recordingErrorKey: 'liveStreaming.error',
recordingOffKey: 'liveStreaming.off',
recordingOnKey: 'liveStreaming.on',
recordingPendingKey: 'liveStreaming.pending',
recordingTitle: 'dialog.liveStreaming',
recordingUnavailable: 'recording.unavailable',
recordingUnavailableParams: '$t(liveStreaming.serviceName)',
recordingUnavailableTitle: 'liveStreaming.unavailableTitle'
};
/**
* The dialog for user input.
*/
let dialog = null;
/**
* Indicates if the recording button should be enabled.
*
* @returns {boolean} {true} if the
* @private
*/
function _isRecordingButtonEnabled() {
return (
interfaceConfig.TOOLBAR_BUTTONS.indexOf('recording') !== -1
&& config.enableRecording
&& APP.conference.isRecordingSupported());
}
/**
* Request live stream token from the user.
* @returns {Promise}
*/
function _requestLiveStreamId() {
return new Promise((resolve, reject) =>
APP.store.dispatch(openDialog(StartLiveStreamDialog, {
onCancel: reject,
onSubmit: (streamId, broadcastId) => resolve({
broadcastId,
streamId
})
})));
}
/**
* Request recording token from the user.
* @returns {Promise}
*/
function _requestRecordingToken() {
const titleKey = 'dialog.recordingToken';
const msgString
= `<input name="recordingToken" type="text"
data-i18n="[placeholder]dialog.token"
class="input-control"
autofocus>`
;
return new Promise((resolve, reject) => {
dialog = APP.UI.messageHandler.openTwoButtonDialog({
titleKey,
msgString,
leftButtonKey: 'dialog.Save',
submitFunction(e, v, m, f) { // eslint-disable-line max-params
if (v && f.recordingToken) {
resolve(UIUtil.escapeHtml(f.recordingToken));
} else {
reject(APP.UI.messageHandler.CANCEL);
}
},
closeFunction() {
dialog = null;
},
focus: ':input:first'
});
});
}
/**
* Shows a prompt dialog to the user when they have toggled off the recording.
*
* @param recordingType the recording type
* @returns {Promise}
* @private
*/
function _showStopRecordingPrompt(recordingType) {
if (recordingType === 'jibri') {
return new Promise((resolve, reject) => {
APP.store.dispatch(openDialog(StopLiveStreamDialog, {
onCancel: reject,
onSubmit: resolve
}));
});
}
return new Promise((resolve, reject) => {
dialog = APP.UI.messageHandler.openTwoButtonDialog({
titleKey: 'dialog.recording',
msgKey: 'dialog.stopRecordingWarning',
leftButtonKey: 'dialog.stopRecording',
submitFunction: (e, v) => (v ? resolve : reject)(),
closeFunction: () => {
dialog = null;
}
});
});
}
/**
* Checks whether if the given status is either PENDING or RETRYING
* @param status {JitsiRecordingStatus} Jibri status to be checked
* @returns {boolean} true if the condition is met or false otherwise.
*/
function isStartingStatus(status) {
return (
status === JitsiRecordingStatus.PENDING
|| status === JitsiRecordingStatus.RETRYING
);
}
/**
* Manages the recording user interface and user experience.
* @type {{init, updateRecordingState, updateRecordingUI, checkAutoRecord}}
*/
const Recording = {
/**
* Initializes the recording UI.
*/
init(eventEmitter, recordingType) {
this.eventEmitter = eventEmitter;
this.recordingType = recordingType;
APP.store.dispatch(setRecordingType(recordingType));
this.updateRecordingState(APP.conference.getRecordingState());
if (recordingType === 'jibri') {
this.baseClass = 'fa fa-play-circle';
Object.assign(this, STREAMING_TRANSLATION_KEYS);
} else {
this.baseClass = 'icon-recEnable';
Object.assign(this, RECORDING_TRANSLATION_KEYS);
}
this.eventEmitter.on(UIEvents.TOGGLE_RECORDING,
() => this._onToolbarButtonClick());
// If I am a recorder then I publish my recorder custom role to notify
// everyone.
if (config.iAmRecorder) {
VideoLayout.enableDeviceAvailabilityIcons(
APP.conference.getMyUserId(), false);
// in case of iAmSipGateway keep local video visible
if (!config.iAmSipGateway) {
VideoLayout.setLocalVideoVisible(false);
}
APP.store.dispatch(setToolboxEnabled(false));
APP.store.dispatch(setNotificationsEnabled(false));
APP.UI.messageHandler.enablePopups(false);
}
},
/**
* Updates the recording state UI.
* @param recordingState gives us the current recording state
*/
updateRecordingState(recordingState) {
// I'm the recorder, so I don't want to see any UI related to states.
if (config.iAmRecorder) {
return;
}
// If there's no state change, we ignore the update.
if (!recordingState || this.currentState === recordingState) {
return;
}
this.updateRecordingUI(recordingState);
},
/**
* Sets the state of the recording button.
* @param recordingState gives us the current recording state
*/
updateRecordingUI(recordingState) {
const oldState = this.currentState;
this.currentState = recordingState;
let labelDisplayConfiguration;
let isRecording = false;
switch (recordingState) {
case JitsiRecordingStatus.ON:
case JitsiRecordingStatus.RETRYING: {
labelDisplayConfiguration = {
centered: false,
key: this.recordingOnKey,
showSpinner: recordingState === JitsiRecordingStatus.RETRYING
};
isRecording = true;
break;
}
case JitsiRecordingStatus.OFF:
case JitsiRecordingStatus.BUSY:
case JitsiRecordingStatus.FAILED:
case JitsiRecordingStatus.UNAVAILABLE: {
const wasInStartingStatus = isStartingStatus(oldState);
// We don't want UI changes if this is an availability change.
if (oldState !== JitsiRecordingStatus.ON && !wasInStartingStatus) {
APP.store.dispatch(updateRecordingState({ recordingState }));
return;
}
labelDisplayConfiguration = {
centered: true,
key: wasInStartingStatus
? this.failedToStartKey
: this.recordingOffKey
};
setTimeout(() => {
APP.store.dispatch(hideRecordingLabel());
}, 5000);
break;
}
case JitsiRecordingStatus.PENDING: {
labelDisplayConfiguration = {
centered: true,
key: this.recordingPendingKey
};
break;
}
case JitsiRecordingStatus.ERROR: {
labelDisplayConfiguration = {
centered: true,
key: this.recordingErrorKey
};
break;
}
// Return an empty label display configuration to indicate no label
// should be displayed. The JitsiRecordingStatus.AVAIABLE case is
// handled here.
default: {
labelDisplayConfiguration = null;
}
}
APP.store.dispatch(updateRecordingState({
isRecording,
labelDisplayConfiguration,
recordingState
}));
},
// checks whether recording is enabled and whether we have params
// to start automatically recording (XXX: No, it doesn't do that).
checkAutoRecord() {
if (_isRecordingButtonEnabled && config.autoRecord) {
this.predefinedToken = UIUtil.escapeHtml(config.autoRecordToken);
this.eventEmitter.emit(
UIEvents.RECORDING_TOGGLED,
{ token: this.predefinedToken });
}
},
/**
* Handles {@code click} on {@code toolbar_button_record}.
*
* @returns {void}
*/
_onToolbarButtonClick() {
sendAnalytics(createToolbarEvent(
'recording.button',
{
'dialog_present': Boolean(dialog)
}));
if (dialog) {
return;
}
switch (this.currentState) {
case JitsiRecordingStatus.ON:
case JitsiRecordingStatus.RETRYING:
case JitsiRecordingStatus.PENDING: {
_showStopRecordingPrompt(this.recordingType).then(
() => {
this.eventEmitter.emit(UIEvents.RECORDING_TOGGLED);
// The confirm button on the stop recording dialog was
// clicked
sendAnalytics(
createRecordingDialogEvent(
'stop',
'confirm.button'));
},
() => {}); // eslint-disable-line no-empty-function
break;
}
case JitsiRecordingStatus.AVAILABLE:
case JitsiRecordingStatus.OFF: {
if (this.recordingType === 'jibri') {
_requestLiveStreamId()
.then(({ broadcastId, streamId }) => {
this.eventEmitter.emit(
UIEvents.RECORDING_TOGGLED,
{
broadcastId,
streamId
});
// The confirm button on the start recording dialog was
// clicked
sendAnalytics(
createRecordingDialogEvent(
'start',
'confirm.button'));
})
.catch(reason => {
if (reason === APP.UI.messageHandler.CANCEL) {
// The cancel button on the start recording dialog was
// clicked
sendAnalytics(
createRecordingDialogEvent(
'start',
'cancel.button'));
} else {
logger.error(reason);
}
});
} else {
// Note that we only fire analytics events for Jibri.
if (this.predefinedToken) {
this.eventEmitter.emit(
UIEvents.RECORDING_TOGGLED,
{ token: this.predefinedToken });
return;
}
_requestRecordingToken().then(token => {
this.eventEmitter.emit(
UIEvents.RECORDING_TOGGLED,
{ token });
})
.catch(reason => {
if (reason !== APP.UI.messageHandler.CANCEL) {
logger.error(reason);
}
});
}
break;
}
case JitsiRecordingStatus.BUSY: {
APP.UI.messageHandler.showWarning({
descriptionKey: this.recordingBusy,
titleKey: this.recordingBusyTitle
});
break;
}
default: {
APP.UI.messageHandler.showError({
descriptionKey: this.recordingUnavailable,
descriptionArguments: {
serviceName: this.recordingUnavailableParams },
titleKey: this.recordingUnavailableTitle
});
}
}
}
};
export default Recording;

View File

@ -854,6 +854,8 @@ const VideoLayout = {
/** /**
* Resizes the video area. * Resizes the video area.
* *
* TODO: Remove the "animate" param as it is no longer passed in as true.
*
* @param forceUpdate indicates that hidden thumbnails will be shown * @param forceUpdate indicates that hidden thumbnails will be shown
* @param completeFunction a function to be called when the video area is * @param completeFunction a function to be called when the video area is
* resized. * resized.

2
package-lock.json generated
View File

@ -7608,7 +7608,7 @@
} }
}, },
"lib-jitsi-meet": { "lib-jitsi-meet": {
"version": "github:jitsi/lib-jitsi-meet#fa24ac5289c5e73b2f5d4fe005cef8f9cfff8268", "version": "github:jitsi/lib-jitsi-meet#fefd96e0e8968c553aab6952bc7cf2116b53362e",
"requires": { "requires": {
"async": "0.9.0", "async": "0.9.0",
"current-executing-script": "0.1.3", "current-executing-script": "0.1.3",

View File

@ -46,7 +46,7 @@
"jquery-i18next": "1.2.0", "jquery-i18next": "1.2.0",
"js-md5": "0.6.1", "js-md5": "0.6.1",
"jwt-decode": "2.2.0", "jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#fa24ac5289c5e73b2f5d4fe005cef8f9cfff8268", "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#fefd96e0e8968c553aab6952bc7cf2116b53362e",
"lodash": "4.17.4", "lodash": "4.17.4",
"moment": "2.19.4", "moment": "2.19.4",
"postis": "2.2.0", "postis": "2.2.0",

View File

@ -0,0 +1,60 @@
// @flow
import React, { Component } from 'react';
type Props = {
/**
* The children to be displayed within {@code CircularLabel}.
*/
children: React$Node,
/**
* Additional CSS class names to add to the root of {@code CircularLabel}.
*/
className: string,
/**
* HTML ID attribute to add to the root of {@code CircularLabel}.
*/
id: string
};
/**
* React Component for showing short text in a circle.
*
* @extends Component
*/
export default class CircularLabel extends Component<Props> {
/**
* Default values for {@code CircularLabel} component's properties.
*
* @static
*/
static defaultProps = {
className: ''
};
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
children,
className,
id
} = this.props;
return (
<div
className = { `circular-label ${className}` }
id = { id }>
{ children }
</div>
);
}
}

View File

@ -0,0 +1 @@
export { default as CircularLabel } from './CircularLabel';

View File

@ -0,0 +1 @@
export * from './components';

View File

@ -17,7 +17,7 @@ export const JitsiConnectionQualityEvents
export const JitsiMediaDevicesEvents = JitsiMeetJS.events.mediaDevices; export const JitsiMediaDevicesEvents = JitsiMeetJS.events.mediaDevices;
export const JitsiParticipantConnectionStatus export const JitsiParticipantConnectionStatus
= JitsiMeetJS.constants.participantConnectionStatus; = JitsiMeetJS.constants.participantConnectionStatus;
export const JitsiRecordingStatus = JitsiMeetJS.constants.recordingStatus; export const JitsiRecordingConstants = JitsiMeetJS.constants.recording;
export const JitsiSIPVideoGWStatus = JitsiMeetJS.constants.sipVideoGW; export const JitsiSIPVideoGWStatus = JitsiMeetJS.constants.sipVideoGW;
export const JitsiTrackErrors = JitsiMeetJS.errors.track; export const JitsiTrackErrors = JitsiMeetJS.errors.track;
export const JitsiTrackEvents = JitsiMeetJS.events.track; export const JitsiTrackEvents = JitsiMeetJS.events.track;

View File

@ -5,7 +5,9 @@ import { connect } from 'react-redux';
import { createToolbarEvent, sendAnalytics } from '../../analytics'; import { createToolbarEvent, sendAnalytics } from '../../analytics';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import { getParticipantCount } from '../../base/participants'; import { getParticipantCount } from '../../base/participants';
import { getActiveSession } from '../../recording';
import { ToolbarButton } from '../../toolbox'; import { ToolbarButton } from '../../toolbox';
import { updateDialInNumbers } from '../actions'; import { updateDialInNumbers } from '../actions';
@ -228,10 +230,14 @@ class InfoDialogButton extends Component {
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const currentLiveStreamingSession
= getActiveSession(state, JitsiRecordingConstants.mode.STREAM);
return { return {
_dialIn: state['features/invite'], _dialIn: state['features/invite'],
_disableAutoShow: state['features/base/config'].iAmRecorder, _disableAutoShow: state['features/base/config'].iAmRecorder,
_liveStreamViewURL: state['features/recording'].liveStreamViewURL, _liveStreamViewURL: currentLiveStreamingSession
&& currentLiveStreamingSession.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

@ -0,0 +1,139 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { RecordingLabel } from '../../recording';
import { VideoQualityLabel } from '../../video-quality';
/**
* The type of the React {@code Component} props of {@link Labels}.
*/
type Props = {
/**
* Whether or not the filmstrip is displayed with remote videos. Used to
* determine display classes to set.
*/
_filmstripVisible: boolean,
/**
* The redux state for all known recording sessions.
*/
_recordingSessions: Array<Object>
};
/**
* The type of the React {@code Component} state of {@link Labels}.
*/
type State = {
/**
* Whether or not the filmstrip was not visible but has transitioned in the
* latest component update to visible. This boolean is used to set a class
* for position animations.
*
* @type {boolean}
*/
filmstripBecomingVisible: boolean
}
/**
* A container to hold video status labels, including recording status and
* current large video quality.
*
* @extends Component
*/
class Labels extends Component<Props, State> {
/**
* Initializes a new {@code Labels} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
this.state = {
filmstripBecomingVisible: false
};
// Bind event handler so it is only bound once for every instance.
this._renderRecordingLabel = this._renderRecordingLabel.bind(this);
}
/**
* Updates the state for whether or not the filmstrip is being toggled to
* display after having being hidden.
*
* @inheritdoc
* @param {Object} nextProps - The read-only props which this Component will
* receive.
* @returns {void}
*/
componentWillReceiveProps(nextProps) {
this.setState({
filmstripBecomingVisible: nextProps._filmstripVisible
&& !this.props._filmstripVisible
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { _filmstripVisible, _recordingSessions } = this.props;
const { filmstripBecomingVisible } = this.state;
const className = `large-video-labels ${
filmstripBecomingVisible ? 'opening' : ''} ${
_filmstripVisible ? 'with-filmstrip' : 'without-filmstrip'}`;
return (
<div className = { className } >
{ _recordingSessions.map(this._renderRecordingLabel) }
<VideoQualityLabel />
</div>
);
}
_renderRecordingLabel: (Object) => React$Node;
/**
* Renders a recording label.
*
* @param {Object} recordingSession - The recording session to render.
* @private
* @returns {ReactElement}
*/
_renderRecordingLabel(recordingSession) {
return (
<RecordingLabel
key = { recordingSession.id }
session = { recordingSession } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code Labels} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _filmstripVisible: boolean,
* _recordingSessions: Array<Object>
* }}
*/
function _mapStateToProps(state) {
return {
_filmstripVisible: state['features/filmstrip'].visible,
_recordingSessions: state['features/recording'].sessionDatas
};
}
export default connect(_mapStateToProps)(Labels);

View File

@ -4,8 +4,8 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Watermarks } from '../../base/react'; import { Watermarks } from '../../base/react';
import { VideoQualityLabel } from '../../video-quality';
import { RecordingLabel } from '../../recording'; import Labels from './Labels';
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
@ -72,8 +72,7 @@ export default class LargeVideo extends Component<*> {
</div> </div>
<span id = 'localConnectionMessage' /> <span id = 'localConnectionMessage' />
{ this.props.hideVideoQualityLabel { this.props.hideVideoQualityLabel
? null : <VideoQualityLabel /> } ? null : <Labels /> }
<RecordingLabel />
</div> </div>
); );
} }

View File

@ -1,44 +1,11 @@
/** /**
* The type of Redux action which signals for the label indicating current * The type of Redux action which updates the current known state of a recording
* recording state to stop displaying. * session.
* *
* { * {
* type: HIDE_RECORDING_LABEL * type: RECORDING_SESSION_UPDATED,
* sessionData: Object
* } * }
* @public * @public
*/ */
export const HIDE_RECORDING_LABEL = Symbol('HIDE_RECORDING_LABEL'); export const RECORDING_SESSION_UPDATED = Symbol('RECORDING_SESSION_UPDATED');
/**
* The type of Redux action which updates the current known state of the
* recording feature.
*
* {
* type: RECORDING_STATE_UPDATED,
* recordingState: string
* }
* @public
*/
export const RECORDING_STATE_UPDATED = Symbol('RECORDING_STATE_UPDATED');
/**
* The type of Redux action which updates the current known type of configured
* recording. For example, type "jibri" is used for live streaming.
*
* {
* type: RECORDING_STATE_UPDATED,
* recordingType: string
* }
* @public
*/
export const SET_RECORDING_TYPE = Symbol('SET_RECORDING_TYPE');
/**
* The type of Redux action triggers the flow to start or stop recording.
*
* {
* type: TOGGLE_RECORDING
* }
* @public
*/
export const TOGGLE_RECORDING = Symbol('TOGGLE_RECORDING');

View File

@ -1,66 +1,24 @@
import { import { RECORDING_SESSION_UPDATED } from './actionTypes';
HIDE_RECORDING_LABEL,
RECORDING_STATE_UPDATED,
SET_RECORDING_TYPE,
TOGGLE_RECORDING
} from './actionTypes';
/** /**
* Hides any displayed recording label, regardless of current recording state. * Updates the known state for a given recording session.
* *
* @param {Object} session - The new state to merge with the existing state in
* redux.
* @returns {{ * @returns {{
* type: HIDE_RECORDING_LABEL * type: RECORDING_SESSION_UPDATED,
* sessionData: Object
* }} * }}
*/ */
export function hideRecordingLabel() { export function updateRecordingSessionData(session) {
return { return {
type: HIDE_RECORDING_LABEL type: RECORDING_SESSION_UPDATED,
}; sessionData: {
} error: session.getError(),
id: session.getID(),
/** liveStreamViewURL: session.getLiveStreamViewURL(),
* Sets what type of recording service will be used. mode: session.getMode(),
* status: session.getStatus()
* @param {string} recordingType - The type of recording service to be used. }
* Should be one of the enumerated types in {@link RECORDING_TYPES}.
* @returns {{
* type: SET_RECORDING_TYPE,
* recordingType: string
* }}
*/
export function setRecordingType(recordingType) {
return {
type: SET_RECORDING_TYPE,
recordingType
};
}
/**
* Start or stop recording.
*
* @returns {{
* type: TOGGLE_RECORDING
* }}
*/
export function toggleRecording() {
return {
type: TOGGLE_RECORDING
};
}
/**
* Updates the redux state for the recording feature.
*
* @param {Object} recordingState - The new state to merge with the existing
* state in redux.
* @returns {{
* type: RECORDING_STATE_UPDATED,
* recordingState: Object
* }}
*/
export function updateRecordingState(recordingState = {}) {
return {
type: RECORDING_STATE_UPDATED,
recordingState
}; };
} }

View File

@ -1,12 +1,16 @@
/* globals APP, interfaceConfig */ // @flow
import Spinner from '@atlaskit/spinner'; import Spinner from '@atlaskit/spinner';
import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog'; 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';
@ -14,6 +18,8 @@ import BroadcastsDropdown from './BroadcastsDropdown';
import GoogleSignInButton from './GoogleSignInButton'; import GoogleSignInButton from './GoogleSignInButton';
import StreamKeyForm from './StreamKeyForm'; import StreamKeyForm from './StreamKeyForm';
declare var interfaceConfig: Object;
/** /**
* An enumeration of the different states the Google API can be in while * An enumeration of the different states the Google API can be in while
* interacting with {@code StartLiveStreamDialog}. * interacting with {@code StartLiveStreamDialog}.
@ -44,63 +50,73 @@ const GOOGLE_API_STATES = {
ERROR: 3 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 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 { class StartLiveStreamDialog extends Component<Props, State> {
/** _isMounted: boolean;
* {@code StartLiveStreamDialog} component's property types.
*
* @static
*/
static propTypes = {
/**
* The ID for the Google web client application used for making stream
* key related requests.
*/
_googleApiApplicationClientID: PropTypes.string,
/**
* Callback to invoke when the dialog is dismissed without submitting a
* stream key.
*/
onCancel: PropTypes.func,
/**
* Callback to invoke when a stream key is submitted for use.
*/
onSubmit: PropTypes.func,
/**
* Invoked to obtain translated strings.
*/
t: PropTypes.func
};
/**
* {@code StartLiveStreamDialog} component's local state.
*
* @property {boolean} googleAPIState - The current state of interactions
* with the Google API. Determines what Google related UI should display.
* @property {Object[]|undefined} broadcasts - Details about the broadcasts
* available for use for the logged in Google user's YouTube account.
* @property {string} googleProfileEmail - The email of the user currently
* 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
* for YouTube live streaming.
*/
state = {
broadcasts: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
selectedBoundStreamID: undefined,
streamKey: ''
};
/** /**
* Initializes a new {@code StartLiveStreamDialog} instance. * Initializes a new {@code StartLiveStreamDialog} instance.
@ -108,9 +124,17 @@ class StartLiveStreamDialog 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 StartLiveStreamDialog} instance with. * the new {@code StartLiveStreamDialog} instance with.
*/ */
constructor(props) { constructor(props: Props) {
super(props); super(props);
this.state = {
broadcasts: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
selectedBoundStreamID: undefined,
streamKey: ''
};
/** /**
* Instance variable used to flag whether the component is or is not * Instance variable used to flag whether the component is or is not
* mounted. Used as a hack to avoid setting state on an unmounted * mounted. Used as a hack to avoid setting state on an unmounted
@ -186,6 +210,8 @@ class StartLiveStreamDialog extends Component {
); );
} }
_onInitializeGoogleApi: () => Object;
/** /**
* Loads the Google web client application used for fetching stream keys. * Loads the Google web client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube * If the user is already logged in, then a request for available YouTube
@ -214,6 +240,8 @@ class StartLiveStreamDialog extends Component {
}); });
} }
_onCancel: () => boolean;
/** /**
* Invokes the passed in {@link onCancel} callback and closes * Invokes the passed in {@link onCancel} callback and closes
* {@code StartLiveStreamDialog}. * {@code StartLiveStreamDialog}.
@ -222,11 +250,13 @@ class StartLiveStreamDialog extends Component {
* @returns {boolean} True is returned to close the modal. * @returns {boolean} True is returned to close the modal.
*/ */
_onCancel() { _onCancel() {
this.props.onCancel(APP.UI.messageHandler.CANCEL); sendAnalytics(createRecordingDialogEvent('start', 'cancel.button'));
return true; 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
* list of the user's YouTube broadcasts. * list of the user's YouTube broadcasts.
@ -269,6 +299,8 @@ class StartLiveStreamDialog extends Component {
}); });
} }
_onRequestGoogleSignIn: () => Object;
/** /**
* Forces the Google web client application to prompt for a sign in, such as * Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts. * when changing account, and will then fetch available YouTube broadcasts.
@ -282,6 +314,8 @@ class StartLiveStreamDialog extends Component {
.then(() => this._onGetYouTubeBroadcasts()); .then(() => this._onGetYouTubeBroadcasts());
} }
_onStreamKeyChange: () => void;
/** /**
* Callback invoked to update the {@code StartLiveStreamDialog} component's * Callback invoked to update the {@code StartLiveStreamDialog} component's
* display of the entered YouTube stream key. * display of the entered YouTube stream key.
@ -297,6 +331,8 @@ class StartLiveStreamDialog extends Component {
}); });
} }
_onSubmit: () => boolean;
/** /**
* Invokes the passed in {@link onSubmit} callback with the entered stream * Invokes the passed in {@link onSubmit} callback with the entered stream
* key, and then closes {@code StartLiveStreamDialog}. * key, and then closes {@code StartLiveStreamDialog}.
@ -306,7 +342,7 @@ class StartLiveStreamDialog extends Component {
* closing, true to close the modal. * closing, true to close the modal.
*/ */
_onSubmit() { _onSubmit() {
const { streamKey, selectedBoundStreamID } = this.state; const { broadcasts, streamKey, selectedBoundStreamID } = this.state;
if (!streamKey) { if (!streamKey) {
return false; return false;
@ -315,17 +351,25 @@ class StartLiveStreamDialog extends Component {
let selectedBroadcastID = null; let selectedBroadcastID = null;
if (selectedBoundStreamID) { if (selectedBoundStreamID) {
const selectedBroadcast = this.state.broadcasts.find( const selectedBroadcast = broadcasts && broadcasts.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID); broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast && selectedBroadcast.id; selectedBroadcastID = selectedBroadcast && selectedBroadcast.id;
} }
this.props.onSubmit(streamKey, selectedBroadcastID); sendAnalytics(createRecordingDialogEvent('start', 'confirm.button'));
this.props._conference.startRecording({
broadcastId: selectedBroadcastID,
mode: JitsiRecordingConstants.mode.STREAM,
streamId: streamKey
});
return true; return true;
} }
_onYouTubeBroadcastIDSelected: (string) => Object;
/** /**
* Fetches the stream key for a YouTube broadcast and updates the internal * Fetches the stream key for a YouTube broadcast and updates the internal
* state to display the associated stream key as being entered. * state to display the associated stream key as being entered.
@ -351,6 +395,8 @@ class StartLiveStreamDialog extends Component {
}); });
} }
_parseBroadcasts: (Array<Object>) => Array<Object>;
/** /**
* Takes in a list of broadcasts from the YouTube API, removes dupes, * Takes in a list of broadcasts from the YouTube API, removes dupes,
* removes broadcasts that cannot get a stream key, and parses the * removes broadcasts that cannot get a stream key, and parses the
@ -487,13 +533,15 @@ class StartLiveStreamDialog extends Component {
* {@code StartLiveStreamDialog}. * {@code StartLiveStreamDialog}.
* *
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @protected * @private
* @returns {{ * @returns {{
* _conference: Object,
* _googleApiApplicationClientID: string * _googleApiApplicationClientID: string
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
return { return {
_conference: state['features/base/conference'].conference,
_googleApiApplicationClientID: _googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID state['features/base/config'].googleApiApplicationClientID
}; };

View File

@ -1,8 +1,36 @@
import PropTypes from 'prop-types'; // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Dialog } from '../../../base/dialog'; import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
/**
* 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 * A React Component for confirming the participant wishes to stop the currently
@ -10,41 +38,17 @@ import { translate } from '../../../base/i18n';
* *
* @extends Component * @extends Component
*/ */
class StopLiveStreamDialog extends Component { class StopLiveStreamDialog extends Component<Props> {
/**
* {@code StopLiveStreamDialog} component's property types.
*
* @static
*/
static propTypes = {
/**
* Callback to invoke when the dialog is dismissed without confirming
* the live stream should be stopped.
*/
onCancel: PropTypes.func,
/**
* Callback to invoke when confirming the live stream should be stopped.
*/
onSubmit: PropTypes.func,
/**
* Invoked to obtain translated strings.
*/
t: PropTypes.func
};
/** /**
* Initializes a new {@code StopLiveStreamDialog} instance. * Initializes a new {@code StopLiveStreamDialog} instance.
* *
* @param {Object} props - The read-only properties with which the new * @param {Object} props - The read-only properties with which the new
* instance is to be initialized. * instance is to be initialized.
*/ */
constructor(props) { constructor(props: Props) {
super(props); super(props);
// Bind event handler so it is only bound once for every instance. // Bind event handler so it is only bound once for every instance.
this._onCancel = this._onCancel.bind(this);
this._onSubmit = this._onSubmit.bind(this); this._onSubmit = this._onSubmit.bind(this);
} }
@ -58,7 +62,6 @@ class StopLiveStreamDialog extends Component {
return ( return (
<Dialog <Dialog
okTitleKey = 'dialog.stopLiveStreaming' okTitleKey = 'dialog.stopLiveStreaming'
onCancel = { this._onCancel }
onSubmit = { this._onSubmit } onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming' titleKey = 'dialog.liveStreaming'
width = 'small'> width = 'small'>
@ -67,17 +70,7 @@ class StopLiveStreamDialog extends Component {
); );
} }
/** _onSubmit: () => boolean;
* Callback invoked when stopping of live streaming is canceled.
*
* @private
* @returns {boolean} True to close the modal.
*/
_onCancel() {
this.props.onCancel();
return true;
}
/** /**
* Callback invoked when stopping of live streaming is confirmed. * Callback invoked when stopping of live streaming is confirmed.
@ -86,10 +79,32 @@ class StopLiveStreamDialog extends Component {
* @returns {boolean} True to close the modal. * @returns {boolean} True to close the modal.
*/ */
_onSubmit() { _onSubmit() {
this.props.onSubmit(); sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
const { session } = this.props;
if (session) {
this.props._conference.stopRecording(session.id);
}
return true; return true;
} }
} }
export default translate(StopLiveStreamDialog); /**
* 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));

View File

@ -0,0 +1,103 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
/**
* The type of the React {@code Component} props of
* {@link StartRecordingDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialog extends Component<Props> {
/**
* Initializes a new {@code StartRecordingDialog} instance.
*
* @param {Props} 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.confirm'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this.props.t('recording.startRecordingBody') }
</Dialog>
);
}
_onSubmit: () => boolean;
/**
* Starts a file recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('start', 'confirm.button'));
this.props._conference.startRecording({
mode: JitsiRecordingConstants.mode.FILE
});
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code StartRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference
};
}
export default translate(connect(_mapStateToProps)(StartRecordingDialog));

View File

@ -0,0 +1,109 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
/**
* The type of the React {@code Component} props of {@link StopRecordingDialog}.
*/
type Props = {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference: Object,
/**
* The redux representation of the recording session to be stopped.
*/
session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* React Component for getting confirmation to stop a file recording session in
* progress.
*
* @extends Component
*/
class StopRecordingDialog extends Component<Props> {
/**
* Initializes a new {@code StopRecordingDialog} instance.
*
* @param {Props} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(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.stopRecording'
onSubmit = { this._onSubmit }
titleKey = 'dialog.recording'
width = 'small'>
{ this.props.t('dialog.stopRecordingWarning') }
</Dialog>
);
}
_onSubmit: () => boolean;
/**
* Stops the recording session.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_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 associated props for the
* {@code StopRecordingDialog} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference
* }}
*/
function _mapStateToProps(state) {
return {
_conference: state['features/base/conference'].conference
};
}
export default translate(connect(_mapStateToProps)(StopRecordingDialog));

View File

@ -0,0 +1,2 @@
export { default as StartRecordingDialog } from './StartRecordingDialog';
export { default as StopRecordingDialog } from './StopRecordingDialog';

View File

@ -1,97 +1,142 @@
import PropTypes from 'prop-types'; // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux';
import { CircularLabel } from '../../base/label';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { JitsiRecordingStatus } from '../../base/lib-jitsi-meet'; import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import { RECORDING_TYPES } from '../constants';
/** /**
* Implements a React {@link Component} which displays the current state of * The translation keys to use when displaying messages. The values are set
* conference recording. Currently it uses CSS to display itself automatically * lazily to work around circular dependency issues with lib-jitsi-meet causing
* when there is a recording state update. * undefined imports.
* *
* @extends {Component} * @private
* @type {Object}
*/ */
class RecordingLabel extends Component { let TRANSLATION_KEYS_BY_MODE = null;
/**
* {@code RecordingLabel} component's property types.
*
* @static
*/
static propTypes = {
/**
* Whether or not the filmstrip is currently visible or toggled to
* hidden. Depending on the filmstrip state, different CSS classes will
* be set to allow for adjusting of {@code RecordingLabel} positioning.
*/
_filmstripVisible: PropTypes.bool,
/** /**
* Whether or not the conference is currently being recorded. * Lazily initializes TRANSLATION_KEYS_BY_MODE with translation keys to be used
*/ * by the {@code RecordingLabel} for messaging recording session state.
_isRecording: PropTypes.bool, *
* @private
* @returns {Object}
*/
function _getTranslationKeysByMode() {
if (!TRANSLATION_KEYS_BY_MODE) {
const {
error: errorConstants,
mode: modeConstants,
status: statusConstants
} = JitsiRecordingConstants;
/** TRANSLATION_KEYS_BY_MODE = {
* An object to describe the {@code RecordingLabel} content. If no [modeConstants.FILE]: {
* translation key to display is specified, the label will apply CSS to status: {
* itself so it can be made invisible. [statusConstants.PENDING]: 'recording.pending',
* {{ [statusConstants.OFF]: 'recording.off'
* centered: boolean, },
* key: string, errors: {
* showSpinner: boolean [errorConstants.BUSY]: 'recording.failedToStart',
* }} [errorConstants.ERROR]: 'recording.error'
*/ }
_labelDisplayConfiguration: PropTypes.object, },
[modeConstants.STREAM]: {
/** status: {
* Whether the recording feature is live streaming (jibri) or is file [statusConstants.PENDING]: 'liveStreaming.pending',
* recording (jirecon). [statusConstants.OFF]: 'liveStreaming.off'
*/ },
_recordingType: PropTypes.string, errors: {
[errorConstants.BUSY]: 'liveStreaming.busy',
/** [errorConstants.ERROR]: 'liveStreaming.error'
* Invoked to obtain translated string. }
*/ }
t: PropTypes.func
};
/**
* Initializes a new {@code RecordingLabel} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
/**
* Whether or not the filmstrip was not visible but has transitioned
* in the latest component update to visible. This boolean is used
* to set a class for position animations.
*
* @type {boolean}
*/
filmstripBecomingVisible: false
}; };
} }
return TRANSLATION_KEYS_BY_MODE;
}
/**
* The type of the React {@code Component} props of {@link RecordingLabel}.
*/
type Props = {
/** /**
* Updates the state for whether or not the filmstrip is being toggled to * The redux representation of a recording session.
* display after having being hidden. */
session: Object,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* The type of the React {@code Component} state of {@link RecordingLabel}.
*/
type State = {
/**
* Whether or not the {@link RecordingLabel} should be invisible.
*/
hidden: boolean
};
/**
* Implements a React {@link Component} which displays the current state of
* conference recording.
*
* @extends {Component}
*/
class RecordingLabel extends Component<Props, State> {
_autohideTimeout: number;
state = {
hidden: false
};
static defaultProps = {
session: {}
};
/**
* Sets a timeout to automatically hide the {@link RecordingLabel} if the
* recording session started as failed.
*
* @inheritdoc
*/
componentDidMount() {
if (this.props.session.status === JitsiRecordingConstants.status.OFF) {
this._setHideTimeout();
}
}
/**
* Sets a timeout to automatically hide {the @link RecordingLabel} if it has
* transitioned to off.
* *
* @inheritdoc * @inheritdoc
* @param {Object} nextProps - The read-only props which this Component will
* receive.
* @returns {void}
*/ */
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
this.setState({ const { status } = this.props.session;
filmstripBecomingVisible: nextProps._filmstripVisible const nextStatus = nextProps.session.status;
&& !this.props._filmstripVisible
}); if (status !== JitsiRecordingConstants.status.OFF
&& nextStatus === JitsiRecordingConstants.status.OFF) {
this._setHideTimeout();
}
}
/**
* Clears the timeout for automatically hiding the {@link RecordingLabel}.
*
* @inheritdoc
*/
componentWillUnmount() {
this._clearAutoHideTimeout();
} }
/** /**
@ -101,78 +146,77 @@ class RecordingLabel extends Component {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { if (this.state.hidden) {
_isRecording, return null;
_labelDisplayConfiguration, }
_recordingType
} = this.props;
const { centered, key, showSpinner } = _labelDisplayConfiguration || {};
const isVisible = Boolean(key); const {
const rootClassName = [ error: errorConstants,
'video-state-indicator centeredVideoLabel', mode: modeConstants,
_isRecording ? 'is-recording' : '', status: statusConstants
isVisible ? 'show-inline' : '', } = JitsiRecordingConstants;
centered ? '' : 'moveToCorner', const { session } = this.props;
this.state.filmstripBecomingVisible ? 'opening' : '', const allTranslationKeys = _getTranslationKeysByMode();
this.props._filmstripVisible const translationKeys = allTranslationKeys[session.mode];
? 'with-filmstrip' : 'without-filmstrip' let circularLabelClass, circularLabelKey, messageKey;
].join(' ');
switch (session.status) {
case statusConstants.OFF: {
if (session.error) {
messageKey = translationKeys.errors[session.error]
|| translationKeys.errors[errorConstants.ERROR];
} else {
messageKey = translationKeys.status[statusConstants.OFF];
}
break;
}
case statusConstants.ON:
circularLabelClass = session.mode;
circularLabelKey = session.mode === modeConstants.STREAM
? 'recording.live' : 'recording.rec';
break;
case statusConstants.PENDING:
messageKey = translationKeys.status[statusConstants.PENDING];
break;
}
const className = `recording-label ${
messageKey ? 'center-message' : ''}`;
return ( return (
<div <div className = { className }>
className = { rootClassName } { messageKey
id = 'recordingLabel'> ? <div>
{ _isRecording { this.props.t(messageKey) }
? <div className = 'recording-icon'>
<div className = 'recording-icon-background' />
<i
className = {
_recordingType === RECORDING_TYPES.JIBRI
? 'icon-live'
: 'icon-rec' } />
</div> </div>
: <div id = 'recordingLabelText'> : <CircularLabel className = { circularLabelClass }>
{ this.props.t(key) } { this.props.t(circularLabelKey) }
</div> } </CircularLabel> }
{ !_isRecording
&& showSpinner
&& <img
className = 'recordingSpinner'
id = 'recordingSpinner'
src = 'images/spin.svg' /> }
</div> </div>
); );
} }
/**
* Clears the timeout for automatically hiding {@link RecordingLabel}.
*
* @private
* @returns {void}
*/
_clearAutoHideTimeout() {
clearTimeout(this._autohideTimeout);
}
/**
* Sets a timeout to automatically hide {@link RecordingLabel}.
*
* @private
* @returns {void}
*/
_setHideTimeout() {
this._autohideTimeout = setTimeout(() => {
this.setState({ hidden: true });
}, 5000);
}
} }
/** export default translate(RecordingLabel);
* Maps (parts of) the Redux state to the associated {@code RecordingLabel}
* component's props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _filmstripVisible: boolean,
* _isRecording: boolean,
* _labelDisplayConfiguration: Object,
* _recordingType: string
* }}
*/
function _mapStateToProps(state) {
const { visible } = state['features/filmstrip'];
const {
labelDisplayConfiguration,
recordingState,
recordingType
} = state['features/recording'];
return {
_filmstripVisible: visible,
_isRecording: recordingState === JitsiRecordingStatus.ON,
_labelDisplayConfiguration: labelDisplayConfiguration,
_recordingType: recordingType
};
}
export default translate(connect(_mapStateToProps)(RecordingLabel));

View File

@ -1,2 +1,3 @@
export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream';
export { StartRecordingDialog, StopRecordingDialog } from './Recording';
export { default as RecordingLabel } from './RecordingLabel'; export { default as RecordingLabel } from './RecordingLabel';

View File

@ -0,0 +1,18 @@
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
/**
* Searches in the passed in redux state for an active recording session of the
* passed in mode.
*
* @param {Object} state - The redux state to search in.
* @param {string} mode - Find an active recording session of the given mode.
* @returns {Object|undefined}
*/
export function getActiveSession(state, mode) {
const { sessionDatas } = state['features/recording'];
const { status: statusConstants } = JitsiRecordingConstants;
return sessionDatas.find(sessionData => sessionData.mode === mode
&& (sessionData.status === statusConstants.ON
|| sessionData.status === statusConstants.PENDING));
}

View File

@ -1,6 +1,6 @@
export * from './actions'; export * from './actions';
export * from './components'; export * from './components';
export * from './constants'; export * from './constants';
export * from './functions';
import './middleware';
import './reducer'; import './reducer';

View File

@ -1,27 +0,0 @@
// @flow
import { MiddlewareRegistry } from '../base/redux';
import UIEvents from '../../../service/UI/UIEvents';
import { TOGGLE_RECORDING } from './actionTypes';
declare var APP: Object;
/**
* Implements the middleware of the feature recording.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
// eslint-disable-next-line no-unused-vars
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case TOGGLE_RECORDING:
if (typeof APP === 'object') {
APP.UI.emitEvent(UIEvents.TOGGLE_RECORDING);
}
break;
}
return next(action);
});

View File

@ -1,34 +1,60 @@
import { ReducerRegistry } from '../base/redux'; import { ReducerRegistry } from '../base/redux';
import { import { RECORDING_SESSION_UPDATED } from './actionTypes';
HIDE_RECORDING_LABEL,
RECORDING_STATE_UPDATED, const DEFAULT_STATE = {
SET_RECORDING_TYPE sessionDatas: []
} from './actionTypes'; };
/** /**
* Reduces the Redux actions of the feature features/recording. * Reduces the Redux actions of the feature features/recording.
*/ */
ReducerRegistry.register('features/recording', (state = {}, action) => { ReducerRegistry.register('features/recording',
switch (action.type) { (state = DEFAULT_STATE, action) => {
case HIDE_RECORDING_LABEL: switch (action.type) {
return { case RECORDING_SESSION_UPDATED:
...state, return {
labelDisplayConfiguration: null ...state,
}; sessionDatas:
_updateSessionDatas(state.sessionDatas, action.sessionData)
};
case RECORDING_STATE_UPDATED: default:
return { return state;
...state, }
...action.recordingState });
};
case SET_RECORDING_TYPE: /**
return { * Updates the known information on recording sessions.
...state, *
recordingType: action.recordingType * @param {Array} sessionDatas - The current sessions in the redux store.
}; * @param {Object} newSessionData - The updated session data.
* @private
* @returns {Array} The session datas with the updated session data added.
*/
function _updateSessionDatas(sessionDatas, newSessionData) {
const hasExistingSessionData = sessionDatas.find(
sessionData => sessionData.id === newSessionData.id);
let newSessionDatas;
default: if (hasExistingSessionData) {
return state; newSessionDatas = sessionDatas.map(sessionData => {
if (sessionData.id === newSessionData.id) {
return {
...newSessionData
};
}
// Nothing to update for this session data so pass it back in.
return sessionData;
});
} else {
// If the session data is not present, then there is nothing to update
// and instead it needs to be added to the known session datas.
newSessionDatas = [
...sessionDatas,
{ ...newSessionData }
];
} }
});
return newSessionDatas;
}

View File

@ -11,6 +11,7 @@ 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 {
PARTICIPANT_ROLE, PARTICIPANT_ROLE,
getLocalParticipant, getLocalParticipant,
@ -27,7 +28,13 @@ import {
isDialOutEnabled isDialOutEnabled
} from '../../../invite'; } from '../../../invite';
import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts';
import { RECORDING_TYPES, toggleRecording } from '../../../recording'; import {
StartLiveStreamDialog,
StartRecordingDialog,
StopLiveStreamDialog,
StopRecordingDialog,
getActiveSession
} from '../../../recording';
import { SettingsButton } from '../../../settings'; import { SettingsButton } from '../../../settings';
import { toggleSharedVideo } from '../../../shared-video'; import { toggleSharedVideo } from '../../../shared-video';
import { toggleChat, toggleProfile } from '../../../side-panel'; import { toggleChat, toggleProfile } from '../../../side-panel';
@ -95,6 +102,11 @@ type Props = {
*/ */
_feedbackConfigured: boolean, _feedbackConfigured: boolean,
/**
* The current file recording session, if any.
*/
_fileRecordingSession: Object,
/** /**
* Whether or not the app is currently in full screen. * Whether or not the app is currently in full screen.
*/ */
@ -112,10 +124,9 @@ type Props = {
_isGuest: boolean, _isGuest: boolean,
/** /**
* Whether or not the conference is currently being recorded by the local * The current live streaming session, if any.
* participant.
*/ */
_isRecording: boolean, _liveStreamingSession: ?Object,
/** /**
* The ID of the local participant. * The ID of the local participant.
@ -137,12 +148,6 @@ type Props = {
*/ */
_recordingEnabled: boolean, _recordingEnabled: boolean,
/**
* Whether the recording feature is live streaming (jibri) or is file
* recording (jirecon).
*/
_recordingType: String,
/** /**
* Whether or not the local participant is screensharing. * Whether or not the local participant is screensharing.
*/ */
@ -214,12 +219,13 @@ class Toolbox extends Component<Props> {
= this._onToolbarOpenSpeakerStats.bind(this); = this._onToolbarOpenSpeakerStats.bind(this);
this._onToolbarOpenVideoQuality this._onToolbarOpenVideoQuality
= this._onToolbarOpenVideoQuality.bind(this); = this._onToolbarOpenVideoQuality.bind(this);
this._onToolbarToggleChat = this._onToolbarToggleChat.bind(this); this._onToolbarToggleChat = this._onToolbarToggleChat.bind(this);
this._onToolbarToggleEtherpad this._onToolbarToggleEtherpad
= 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
@ -462,6 +468,22 @@ 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.
* *
@ -495,7 +517,12 @@ class Toolbox extends Component<Props> {
* @returns {void} * @returns {void}
*/ */
_doToggleRecording() { _doToggleRecording() {
this.props.dispatch(toggleRecording()); const { _fileRecordingSession } = this.props;
const dialog = _fileRecordingSession
? StopRecordingDialog : StartRecordingDialog;
this.props.dispatch(
openDialog(dialog, { session: _fileRecordingSession }));
} }
/** /**
@ -764,6 +791,25 @@ 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;
/** /**
@ -805,8 +851,12 @@ class Toolbox extends Component<Props> {
* @returns {void} * @returns {void}
*/ */
_onToolbarToggleRecording() { _onToolbarToggleRecording() {
// No analytics handling is added here for the click as this action will sendAnalytics(createToolbarEvent(
// exercise the old toolbar UI flow, which includes analytics handling. 'recording.button',
{
'is_recording': Boolean(this.props._fileRecordingSession),
type: JitsiRecordingConstants.mode.FILE
}));
this._doToggleRecording(); this._doToggleRecording();
} }
@ -891,6 +941,30 @@ class Toolbox extends Component<Props> {
); );
} }
/**
* Renders an {@code OverflowMenuItem} for starting or stopping a live
* streaming of the current conference.
*
* @private
* @returns {ReactElement}
*/
_renderLiveStreamingButton() {
const { _liveStreamingSession, t } = this.props;
const translationKey = _liveStreamingSession
? 'dialog.stopLiveStreaming'
: 'dialog.startLiveStreaming';
return (
<OverflowMenuItem
accessibilityLabel = 'Live stream'
icon = 'icon-public'
key = 'liveStreaming'
onClick = { this._onToolbarToggleLiveStreaming }
text = { t(translationKey) } />
);
}
/** /**
* Renders the list elements of the overflow menu. * Renders the list elements of the overflow menu.
* *
@ -904,6 +978,7 @@ class Toolbox extends Component<Props> {
_feedbackConfigured, _feedbackConfigured,
_fullScreen, _fullScreen,
_isGuest, _isGuest,
_recordingEnabled,
_sharingVideo, _sharingVideo,
t t
} = this.props; } = this.props;
@ -929,7 +1004,12 @@ class Toolbox extends Component<Props> {
text = { _fullScreen text = { _fullScreen
? t('toolbar.exitFullScreen') ? t('toolbar.exitFullScreen')
: t('toolbar.enterFullScreen') } />, : t('toolbar.enterFullScreen') } />,
this._renderRecordingButton(), _recordingEnabled
&& this._shouldShowButton('livestreaming')
&& this._renderLiveStreamingButton(),
_recordingEnabled
&& this._shouldShowButton('recording')
&& this._renderRecordingButton(),
this._shouldShowButton('sharedvideo') this._shouldShowButton('sharedvideo')
&& <OverflowMenuItem && <OverflowMenuItem
accessibilityLabel = 'Shared video' accessibilityLabel = 'Shared video'
@ -979,42 +1059,23 @@ class Toolbox extends Component<Props> {
} }
/** /**
* Renders an {@code OverflowMenuItem} depending on the current recording * Renders an {@code OverflowMenuItem} to start or stop recording of the
* state. * current conference.
* *
* @private * @private
* @returns {ReactElement|null} * @returns {ReactElement|null}
*/ */
_renderRecordingButton() { _renderRecordingButton() {
const { const { _fileRecordingSession, t } = this.props;
_isRecording,
_recordingEnabled,
_recordingType,
t
} = this.props;
if (!_recordingEnabled || !this._shouldShowButton('recording')) { const translationKey = _fileRecordingSession
return null; ? 'dialog.stopRecording'
} : 'dialog.startRecording';
let iconClass, translationKey;
if (_recordingType === RECORDING_TYPES.JIBRI) {
iconClass = 'icon-public';
translationKey = _isRecording
? 'dialog.stopLiveStreaming'
: 'dialog.startLiveStreaming';
} else {
iconClass = 'icon-camera-take-picture';
translationKey = _isRecording
? 'dialog.stopRecording'
: 'dialog.startRecording';
}
return ( return (
<OverflowMenuItem <OverflowMenuItem
accessibilityLabel = 'Record' accessibilityLabel = 'Record'
icon = { iconClass } icon = 'icon-camera-take-picture'
key = 'recording' key = 'recording'
onClick = { this._onToolbarToggleRecording } onClick = { this._onToolbarToggleRecording }
text = { t(translationKey) } /> text = { t(translationKey) } />
@ -1055,7 +1116,6 @@ function _mapStateToProps(state) {
enableRecording, enableRecording,
iAmRecorder iAmRecorder
} = state['features/base/config']; } = state['features/base/config'];
const { isRecording, recordingType } = state['features/recording'];
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 {
@ -1083,14 +1143,15 @@ function _mapStateToProps(state) {
_hideInviteButton: _hideInviteButton:
iAmRecorder || (!addPeopleEnabled && !dialOutEnabled), iAmRecorder || (!addPeopleEnabled && !dialOutEnabled),
_isGuest: state['features/base/jwt'].isGuest, _isGuest: state['features/base/jwt'].isGuest,
_isRecording: isRecording, _fileRecordingSession:
getActiveSession(state, JitsiRecordingConstants.mode.FILE),
_fullScreen: fullScreen, _fullScreen: fullScreen,
_liveStreamingSession:
getActiveSession(state, JitsiRecordingConstants.mode.STREAM),
_localParticipantID: localParticipant.id, _localParticipantID: localParticipant.id,
_overflowMenuVisible: overflowMenuVisible, _overflowMenuVisible: overflowMenuVisible,
_raisedHand: localParticipant.raisedHand, _raisedHand: localParticipant.raisedHand,
_recordingEnabled: isModerator && enableRecording _recordingEnabled: isModerator && enableRecording,
&& (conference && conference.isRecordingSupported()),
_recordingType: recordingType,
_screensharing: localVideo && localVideo.videoType === 'desktop', _screensharing: localVideo && localVideo.videoType === 'desktop',
_sharingVideo: sharedVideoStatus === 'playing' _sharingVideo: sharedVideoStatus === 'playing'
|| sharedVideoStatus === 'start' || sharedVideoStatus === 'start'

View File

@ -49,8 +49,7 @@ function _getInitialState() {
alwaysVisible: false, alwaysVisible: false,
/** /**
* The indicator which determines whether the Toolbox is enabled. For * The indicator which determines whether the Toolbox is enabled.
* example, modules/UI/recording/Recording.js disables the Toolbox.
* *
* @type {boolean} * @type {boolean}
*/ */

View File

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { translate } from '../../base/i18n'; import { translate } from '../../base/i18n';
import { CircularLabel } from '../../base/label';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { getTrackByMediaTypeAndParticipant } from '../../base/tracks'; import { getTrackByMediaTypeAndParticipant } from '../../base/tracks';
@ -49,20 +50,14 @@ export class VideoQualityLabel extends Component {
_audioOnly: PropTypes.bool, _audioOnly: PropTypes.bool,
/** /**
* Whether or not a connection to a conference has been established. * The message to show within the label.
*/ */
_conferenceStarted: PropTypes.bool, _labelKey: PropTypes.string,
/** /**
* Whether or not the filmstrip is displayed with remote videos. Used to * The message to show within the label's tooltip.
* determine display classes to set.
*/ */
_filmstripVisible: PropTypes.bool, _tooltipKey: PropTypes.string,
/**
* The current video resolution (height) to display a label for.
*/
_resolution: PropTypes.number,
/** /**
* The redux representation of the JitsiTrack displayed on large video. * The redux representation of the JitsiTrack displayed on large video.
@ -75,42 +70,6 @@ export class VideoQualityLabel extends Component {
t: PropTypes.func t: PropTypes.func
}; };
/**
* Initializes a new {@code VideoQualityLabel} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
/**
* Whether or not the filmstrip is transitioning from not visible
* to visible. Used to set a transition class for animation.
*
* @type {boolean}
*/
togglingToVisible: false
};
}
/**
* Updates the state for whether or not the filmstrip is being toggled to
* display after having being hidden.
*
* @inheritdoc
* @param {Object} nextProps - The read-only props which this Component will
* receive.
* @returns {void}
*/
componentWillReceiveProps(nextProps) {
this.setState({
togglingToVisible: nextProps._filmstripVisible
&& !this.props._filmstripVisible
});
}
/** /**
* Implements React's {@link Component#render()}. * Implements React's {@link Component#render()}.
* *
@ -120,95 +79,76 @@ export class VideoQualityLabel extends Component {
render() { render() {
const { const {
_audioOnly, _audioOnly,
_conferenceStarted, _labelKey,
_filmstripVisible, _tooltipKey,
_resolution,
_videoTrack, _videoTrack,
t t
} = this.props; } = this.props;
// FIXME The _conferenceStarted check is used to be defensive against
// toggling audio only mode while there is no conference and hides the
// need for error handling around audio only mode toggling.
if (!_conferenceStarted) {
return null;
}
// Determine which classes should be set on the component. These classes let className, labelContent, tooltipKey;
// will used to help with animations and setting position.
const baseClasses = 'video-state-indicator moveToCorner';
const filmstrip
= _filmstripVisible ? 'with-filmstrip' : 'without-filmstrip';
const opening = this.state.togglingToVisible ? 'opening' : '';
const classNames
= `${baseClasses} ${filmstrip} ${opening}`;
let labelContent;
let tooltipKey;
if (_audioOnly) { if (_audioOnly) {
labelContent = <i className = 'icon-visibility-off' />; className = 'audio-only';
labelContent = t('videoStatus.audioOnly');
tooltipKey = 'videoStatus.labelTooltipAudioOnly'; tooltipKey = 'videoStatus.labelTooltipAudioOnly';
} else if (!_videoTrack || _videoTrack.muted) { } else if (!_videoTrack || _videoTrack.muted) {
labelContent = <i className = 'icon-visibility-off' />; className = 'no-video';
labelContent = t('videoStatus.audioOnly');
tooltipKey = 'videoStatus.labelTooiltipNoVideo'; tooltipKey = 'videoStatus.labelTooiltipNoVideo';
} else { } else {
const translationKeys className = 'current-video-quality';
= this._mapResolutionToTranslationsKeys(_resolution); labelContent = t(_labelKey);
tooltipKey = _tooltipKey;
labelContent = t(translationKeys.labelKey);
tooltipKey = translationKeys.tooltipKey;
} }
return ( return (
<div <Tooltip
className = { classNames } content = { t(tooltipKey) }
id = 'videoResolutionLabel'> position = { 'left' }>
<Tooltip <CircularLabel
content = { t(tooltipKey) } className = { className }
position = { 'left' }> id = 'videoResolutionLabel'>
<div className = 'video-quality-label-status'> { labelContent }
{ labelContent } </CircularLabel>
</div> </Tooltip>
</Tooltip>
</div>
); );
} }
}
/** /**
* Matches the passed in resolution with a translation keys for describing * Matches the passed in resolution with a translation keys for describing
* the resolution. The passed in resolution will be matched with a known * the resolution. The passed in resolution will be matched with a known
* resolution that it is at least greater than or equal to. * resolution that it is at least greater than or equal to.
* *
* @param {number} resolution - The video height to match with a * @param {number} resolution - The video height to match with a
* translation. * translation.
* @private * @private
* @returns {Object} * @returns {Object}
*/ */
_mapResolutionToTranslationsKeys(resolution) { function _mapResolutionToTranslationsKeys(resolution) {
// Set the default matching resolution of the lowest just in case a // Set the default matching resolution of the lowest just in case a match is
// match is not found. // not found.
let highestMatchingResolution = RESOLUTIONS[0]; let highestMatchingResolution = RESOLUTIONS[0];
for (let i = 0; i < RESOLUTIONS.length; i++) { for (let i = 0; i < RESOLUTIONS.length; i++) {
const knownResolution = RESOLUTIONS[i]; const knownResolution = RESOLUTIONS[i];
if (resolution >= knownResolution) { if (resolution >= knownResolution) {
highestMatchingResolution = knownResolution; highestMatchingResolution = knownResolution;
} else { } else {
break; break;
}
} }
const labelKey
= RESOLUTION_TO_TRANSLATION_KEY[highestMatchingResolution];
return {
labelKey,
tooltipKey: `${labelKey}Tooltip`
};
} }
const labelKey
= RESOLUTION_TO_TRANSLATION_KEY[highestMatchingResolution];
return {
labelKey,
tooltipKey: `${labelKey}Tooltip`
};
} }
/** /**
@ -219,15 +159,13 @@ export class VideoQualityLabel extends Component {
* @private * @private
* @returns {{ * @returns {{
* _audioOnly: boolean, * _audioOnly: boolean,
* _conferenceStarted: boolean, * _labelKey: string,
* _filmstripVisible: true, * _tooltipKey: string,
* _resolution: number,
* _videoTrack: Object * _videoTrack: Object
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const { audioOnly, conference } = state['features/base/conference']; const { audioOnly } = state['features/base/conference'];
const { visible } = state['features/filmstrip'];
const { resolution, participantId } = state['features/large-video']; const { resolution, participantId } = state['features/large-video'];
const videoTrackOnLargeVideo = getTrackByMediaTypeAndParticipant( const videoTrackOnLargeVideo = getTrackByMediaTypeAndParticipant(
state['features/base/tracks'], state['features/base/tracks'],
@ -235,11 +173,13 @@ function _mapStateToProps(state) {
participantId participantId
); );
const translationKeys
= audioOnly ? {} : _mapResolutionToTranslationsKeys(resolution);
return { return {
_audioOnly: audioOnly, _audioOnly: audioOnly,
_conferenceStarted: Boolean(conference), _labelKey: translationKeys.labelKey,
_filmstripVisible: visible, _tooltipKey: translationKeys.tooltipKey,
_resolution: resolution,
_videoTrack: videoTrackOnLargeVideo _videoTrack: videoTrackOnLargeVideo
}; };
} }

View File

@ -52,76 +52,9 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break; break;
} }
case SIP_GW_INVITE_ROOMS: { case SIP_GW_INVITE_ROOMS:
const { status } = getState()['features/videosipgw']; _inviteRooms(action.rooms, action.conference, dispatch);
break;
if (status === JitsiSIPVideoGWStatus.STATUS_UNDEFINED) {
dispatch(showErrorNotification({
descriptionKey: 'recording.unavailable',
descriptionArguments: {
serviceName: '$t(videoSIPGW.serviceName)'
},
titleKey: 'videoSIPGW.unavailableTitle'
}));
return;
} else if (status === JitsiSIPVideoGWStatus.STATUS_BUSY) {
dispatch(showWarningNotification({
descriptionKey: 'videoSIPGW.busy',
titleKey: 'videoSIPGW.busyTitle'
}));
return;
} else if (status !== JitsiSIPVideoGWStatus.STATUS_AVAILABLE) {
logger.error(`Unknown sip videogw status ${status}`);
return;
}
for (const room of action.rooms) {
const { id: sipAddress, name: displayName } = room;
if (sipAddress && displayName) {
const newSession = action.conference
.createVideoSIPGWSession(sipAddress, displayName);
if (newSession instanceof Error) {
const e = newSession;
if (e) {
switch (e.message) {
case JitsiSIPVideoGWStatus.ERROR_NO_CONNECTION: {
dispatch(showErrorNotification({
descriptionKey: 'videoSIPGW.errorInvite',
titleKey: 'videoSIPGW.errorInviteTitle'
}));
return;
}
case JitsiSIPVideoGWStatus.ERROR_SESSION_EXISTS: {
dispatch(showWarningNotification({
titleKey: 'videoSIPGW.errorAlreadyInvited',
titleArguments: { displayName }
}));
return;
}
}
}
logger.error(
'Unknown error trying to create sip videogw session',
e);
return;
}
newSession.start();
} else {
logger.error(`No display name or sip number for ${
JSON.stringify(room)}`);
}
}
}
} }
return result; return result;
@ -144,6 +77,62 @@ function _availabilityChanged(status: string) {
}; };
} }
/**
* Processes the action from the actionType {@code SIP_GW_INVITE_ROOMS} by
* inviting rooms into the conference or showing an error message.
*
* @param {Array} rooms - The conference rooms to invite.
* @param {Object} conference - The JitsiConference to invite the rooms to.
* @param {Function} dispatch - The redux dispatch function for emitting state
* changes (queuing error notifications).
* @private
* @returns {void}
*/
function _inviteRooms(rooms, conference, dispatch) {
for (const room of rooms) {
const { id: sipAddress, name: displayName } = room;
if (sipAddress && displayName) {
const newSession = conference
.createVideoSIPGWSession(sipAddress, displayName);
if (newSession instanceof Error) {
const e = newSession;
switch (e.message) {
case JitsiSIPVideoGWStatus.ERROR_NO_CONNECTION: {
dispatch(showErrorNotification({
descriptionKey: 'videoSIPGW.errorInvite',
titleKey: 'videoSIPGW.errorInviteTitle'
}));
return;
}
case JitsiSIPVideoGWStatus.ERROR_SESSION_EXISTS: {
dispatch(showWarningNotification({
titleKey: 'videoSIPGW.errorAlreadyInvited',
titleArguments: { displayName }
}));
return;
}
}
logger.error(
'Unknown error trying to create sip videogw session',
e);
return;
}
newSession.start();
} else {
logger.error(`No display name or sip number for ${
JSON.stringify(room)}`);
}
}
}
/** /**
* Signals that a session we created has a change in its status. * Signals that a session we created has a change in its status.
* *
@ -173,6 +162,17 @@ function _sessionStateChanged(
descriptionKey: 'videoSIPGW.errorInviteFailed' descriptionKey: 'videoSIPGW.errorInviteFailed'
}); });
} }
case JitsiSIPVideoGWStatus.STATE_OFF: {
if (event.failureReason === JitsiSIPVideoGWStatus.STATUS_BUSY) {
return showErrorNotification({
descriptionKey: 'videoSIPGW.busy',
titleKey: 'videoSIPGW.busyTitle'
});
} else if (event.failureReason) {
logger.error(`Unknown sip videogw error ${event.newState} ${
event.failureReason}`);
}
}
} }
// nothing to show // nothing to show

View File

@ -64,12 +64,10 @@ export default {
* @see {TOGGLE_FILMSTRIP} * @see {TOGGLE_FILMSTRIP}
*/ */
TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip', TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip',
TOGGLE_RECORDING: 'UI.toggle_recording',
TOGGLE_SCREENSHARING: 'UI.toggle_screensharing', TOGGLE_SCREENSHARING: 'UI.toggle_screensharing',
TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document', TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document',
HANGUP: 'UI.hangup', HANGUP: 'UI.hangup',
LOGOUT: 'UI.logout', LOGOUT: 'UI.logout',
RECORDING_TOGGLED: 'UI.recording_toggled',
VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed',
AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed', AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed',
AUDIO_OUTPUT_DEVICE_CHANGED: 'UI.audio_output_device_changed', AUDIO_OUTPUT_DEVICE_CHANGED: 'UI.audio_output_device_changed',