diff --git a/conference.js b/conference.js index 9c8c762c7..9968946f5 100644 --- a/conference.js +++ b/conference.js @@ -27,7 +27,7 @@ import { redirectWithStoredParams, reloadWithStoredParams } from './react/features/app'; -import { updateRecordingState } from './react/features/recording'; +import { updateRecordingSessionData } from './react/features/recording'; import EventEmitter from 'events'; @@ -1100,20 +1100,6 @@ export default { 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. * Its used by torture to check audio levels. @@ -1821,12 +1807,6 @@ export default { APP.store.dispatch(dominantSpeakerChanged(id)); }); - room.on(JitsiConferenceEvents.LIVE_STREAM_URL_CHANGED, - (from, liveStreamViewURL) => - APP.store.dispatch(updateRecordingState({ - liveStreamViewURL - }))); - if (!interfaceConfig.filmStripOnly) { room.on(JitsiConferenceEvents.CONNECTION_INTERRUPTED, () => { APP.UI.markVideoInterrupted(true); @@ -1951,14 +1931,36 @@ export default { }); /* eslint-enable max-params */ - room.on( JitsiConferenceEvents.RECORDER_STATE_CHANGED, - (status, error) => { - logger.log('Received recorder status change: ', status, error); - APP.UI.updateRecordingState(status); - } - ); + recorderSession => { + if (!recorderSession) { + 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, () => { 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, () => { AuthHandler.authenticate(room); }); @@ -2746,5 +2741,57 @@ export default { if (score === -1 || (score >= 1 && score <= 5)) { 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; + } } }; diff --git a/css/_vertical_filmstrip_overrides.scss b/css/_vertical_filmstrip_overrides.scss index 899d74fb3..291d56b68 100644 --- a/css/_vertical_filmstrip_overrides.scss +++ b/css/_vertical_filmstrip_overrides.scss @@ -182,17 +182,9 @@ * The class opening is for when the filmstrip is transitioning from hidden * to visible. */ - .video-state-indicator.moveToCorner { - transition: right 0.5s; - + .large-video-labels { &.with-filmstrip { - &#recordingLabel { - right: 200px; - } - - &#videoResolutionLabel { - right: 150px; - } + right: 150px; } &.with-filmstrip.opening { diff --git a/css/modals/video-quality/_video-quality.scss b/css/modals/video-quality/_video-quality.scss index 0b84a1d37..0051684db 100644 --- a/css/modals/video-quality/_video-quality.scss +++ b/css/modals/video-quality/_video-quality.scss @@ -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 { z-index: $zindex3 + 1; } -.centeredVideoLabel { - bottom: 45%; - border-radius: 2px; - display: none; - padding: 10px; - transform: translate(-50%, 0); - z-index: $centeredVideoLabelZ; - - &.moveToCorner { - bottom: auto; - transform: none; - } -} - -.moveToCorner { +.large-video-labels { + display: flex; position: absolute; top: 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 { - right: 80px; -} \ No newline at end of file +.circular-label { + 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; +} diff --git a/interface_config.js b/interface_config.js index 5a6914ec3..b997f775b 100644 --- a/interface_config.js +++ b/interface_config.js @@ -46,7 +46,7 @@ var interfaceConfig = { 'microphone', 'camera', 'desktop', 'fullscreen', 'fodeviceselection', 'hangup', // extended toolbar - 'profile', 'info', 'chat', 'recording', 'etherpad', + 'profile', 'info', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts' ], diff --git a/lang/main.json b/lang/main.json index 5ffc85176..80e64bf4f 100644 --- a/lang/main.json +++ b/lang/main.json @@ -206,6 +206,7 @@ }, "dialog": { "allow": "Allow", + "confirm": "Confirm", "kickMessage": "Ouch! You have been kicked out of the meet!", "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.", @@ -389,10 +390,13 @@ "buttonTooltip": "Start / Stop recording", "error": "Recording failed. Please try again.", "failedToStart": "Recording failed to start", + "live": "LIVE", "off": "Recording stopped", "on": "Recording", "pending": "Recording waiting for a member to join...", + "rec": "REC", "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.", "unavailableTitle": "Recording unavailable" }, @@ -448,6 +452,7 @@ "testAudio": "Play a test sound" }, "videoStatus": { + "audioOnly": "AUD", "callQuality": "Call Quality", "hd": "HD", "hdTooltip": "Viewing high definition video", diff --git a/modules/UI/UI.js b/modules/UI/UI.js index a71c5954e..d3947ed48 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -12,7 +12,6 @@ import UIUtil from './util/UIUtil'; import UIEvents from '../../service/UI/UIEvents'; import EtherpadManager from './etherpad/Etherpad'; import SharedVideoManager from './shared_video/SharedVideo'; -import Recording from './recording/Recording'; import VideoLayout from './videolayout/VideoLayout'; import Filmstrip from './videolayout/Filmstrip'; @@ -38,6 +37,7 @@ import { import { shouldShowOnlyDeviceSelection } from '../../react/features/settings'; import { dockToolbox, + setToolboxEnabled, showToolbox } from '../../react/features/toolbox'; @@ -337,7 +337,12 @@ UI.start = function() { if (!interfaceConfig.filmStripOnly) { 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); @@ -346,9 +351,20 @@ UI.start = function() { Filmstrip.setFilmstripOnly(); APP.store.dispatch(setNotificationsEnabled(false)); } else { - // Initialise the recording module. - config.enableRecording - && Recording.init(eventEmitter, config.recordingType); + // Initialize recording mode UI. + if (config.enableRecording && 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)); + UI.messageHandler.enablePopups(false); + } // Initialize side panels SidePanels.init(eventEmitter); @@ -520,13 +536,9 @@ UI.onPeerVideoTypeChanged UI.updateLocalRole = isModerator => { VideoLayout.showModeratorIndicator(); - if (isModerator) { - if (!interfaceConfig.DISABLE_FOCUS_INDICATOR) { - messageHandler.participantNotification( - null, 'notify.me', 'connected', 'notify.moderator'); - } - - Recording.checkAutoRecord(); + if (isModerator && !interfaceConfig.DISABLE_FOCUS_INDICATOR) { + messageHandler.participantNotification( + null, 'notify.me', 'connected', 'notify.moderator'); } }; @@ -881,10 +893,6 @@ UI.addMessage = function(from, displayName, message, stamp) { Chat.updateChatConversation(from, displayName, message, stamp); }; -UI.updateRecordingState = function(state) { - Recording.updateRecordingState(state); -}; - UI.notifyTokenAuthFailed = function() { messageHandler.showError({ descriptionKey: 'dialog.tokenAuthFailed', diff --git a/modules/UI/recording/Recording.js b/modules/UI/recording/Recording.js deleted file mode 100644 index 537b414ac..000000000 --- a/modules/UI/recording/Recording.js +++ /dev/null @@ -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 - = `` - - ; - - - 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; diff --git a/modules/UI/videolayout/VideoLayout.js b/modules/UI/videolayout/VideoLayout.js index 62b1b29be..448c0a7bd 100644 --- a/modules/UI/videolayout/VideoLayout.js +++ b/modules/UI/videolayout/VideoLayout.js @@ -854,6 +854,8 @@ const VideoLayout = { /** * 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 completeFunction a function to be called when the video area is * resized. diff --git a/package-lock.json b/package-lock.json index 8a9f190c3..e702ec49a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7608,7 +7608,7 @@ } }, "lib-jitsi-meet": { - "version": "github:jitsi/lib-jitsi-meet#fa24ac5289c5e73b2f5d4fe005cef8f9cfff8268", + "version": "github:jitsi/lib-jitsi-meet#fefd96e0e8968c553aab6952bc7cf2116b53362e", "requires": { "async": "0.9.0", "current-executing-script": "0.1.3", diff --git a/package.json b/package.json index 7419735c9..bce3d1331 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "jquery-i18next": "1.2.0", "js-md5": "0.6.1", "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", "moment": "2.19.4", "postis": "2.2.0", diff --git a/react/features/base/label/components/CircularLabel.native.js b/react/features/base/label/components/CircularLabel.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/base/label/components/CircularLabel.web.js b/react/features/base/label/components/CircularLabel.web.js new file mode 100644 index 000000000..2bf72c4ca --- /dev/null +++ b/react/features/base/label/components/CircularLabel.web.js @@ -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 { + /** + * 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 ( +
+ { children } +
+ ); + } +} diff --git a/react/features/base/label/components/index.js b/react/features/base/label/components/index.js new file mode 100644 index 000000000..37173931f --- /dev/null +++ b/react/features/base/label/components/index.js @@ -0,0 +1 @@ +export { default as CircularLabel } from './CircularLabel'; diff --git a/react/features/base/label/index.js b/react/features/base/label/index.js new file mode 100644 index 000000000..07635cbbc --- /dev/null +++ b/react/features/base/label/index.js @@ -0,0 +1 @@ +export * from './components'; diff --git a/react/features/base/lib-jitsi-meet/index.js b/react/features/base/lib-jitsi-meet/index.js index 6ad4188c4..616b075c9 100644 --- a/react/features/base/lib-jitsi-meet/index.js +++ b/react/features/base/lib-jitsi-meet/index.js @@ -17,7 +17,7 @@ export const JitsiConnectionQualityEvents export const JitsiMediaDevicesEvents = JitsiMeetJS.events.mediaDevices; export const JitsiParticipantConnectionStatus = JitsiMeetJS.constants.participantConnectionStatus; -export const JitsiRecordingStatus = JitsiMeetJS.constants.recordingStatus; +export const JitsiRecordingConstants = JitsiMeetJS.constants.recording; export const JitsiSIPVideoGWStatus = JitsiMeetJS.constants.sipVideoGW; export const JitsiTrackErrors = JitsiMeetJS.errors.track; export const JitsiTrackEvents = JitsiMeetJS.events.track; diff --git a/react/features/invite/components/InfoDialogButton.web.js b/react/features/invite/components/InfoDialogButton.web.js index 815847de2..30c1f785a 100644 --- a/react/features/invite/components/InfoDialogButton.web.js +++ b/react/features/invite/components/InfoDialogButton.web.js @@ -5,7 +5,9 @@ import { connect } from 'react-redux'; import { createToolbarEvent, sendAnalytics } from '../../analytics'; import { translate } from '../../base/i18n'; +import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet'; import { getParticipantCount } from '../../base/participants'; +import { getActiveSession } from '../../recording'; import { ToolbarButton } from '../../toolbox'; import { updateDialInNumbers } from '../actions'; @@ -228,10 +230,14 @@ class InfoDialogButton extends Component { * }} */ function _mapStateToProps(state) { + const currentLiveStreamingSession + = getActiveSession(state, JitsiRecordingConstants.mode.STREAM); + return { _dialIn: state['features/invite'], _disableAutoShow: state['features/base/config'].iAmRecorder, - _liveStreamViewURL: state['features/recording'].liveStreamViewURL, + _liveStreamViewURL: currentLiveStreamingSession + && currentLiveStreamingSession.liveStreamViewURL, _participantCount: getParticipantCount(state['features/base/participants']), _toolboxVisible: state['features/toolbox'].visible diff --git a/react/features/large-video/components/Labels.native.js b/react/features/large-video/components/Labels.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/large-video/components/Labels.web.js b/react/features/large-video/components/Labels.web.js new file mode 100644 index 000000000..90ecdd811 --- /dev/null +++ b/react/features/large-video/components/Labels.web.js @@ -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 +}; + +/** + * 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 { + /** + * 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 ( +
+ { _recordingSessions.map(this._renderRecordingLabel) } + +
+ ); + } + + _renderRecordingLabel: (Object) => React$Node; + + /** + * Renders a recording label. + * + * @param {Object} recordingSession - The recording session to render. + * @private + * @returns {ReactElement} + */ + _renderRecordingLabel(recordingSession) { + return ( + + ); + } +} + +/** + * 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 + * }} + */ +function _mapStateToProps(state) { + return { + _filmstripVisible: state['features/filmstrip'].visible, + _recordingSessions: state['features/recording'].sessionDatas + }; +} + +export default connect(_mapStateToProps)(Labels); diff --git a/react/features/large-video/components/LargeVideo.web.js b/react/features/large-video/components/LargeVideo.web.js index c1fdae166..27ea58b72 100644 --- a/react/features/large-video/components/LargeVideo.web.js +++ b/react/features/large-video/components/LargeVideo.web.js @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { Watermarks } from '../../base/react'; -import { VideoQualityLabel } from '../../video-quality'; -import { RecordingLabel } from '../../recording'; + +import Labels from './Labels'; declare var interfaceConfig: Object; @@ -72,8 +72,7 @@ export default class LargeVideo extends Component<*> { { this.props.hideVideoQualityLabel - ? null : } - + ? null : } ); } diff --git a/react/features/recording/actionTypes.js b/react/features/recording/actionTypes.js index e79472bf2..4e2e1f84d 100644 --- a/react/features/recording/actionTypes.js +++ b/react/features/recording/actionTypes.js @@ -1,44 +1,11 @@ /** - * The type of Redux action which signals for the label indicating current - * recording state to stop displaying. + * The type of Redux action which updates the current known state of a recording + * session. * * { - * type: HIDE_RECORDING_LABEL + * type: RECORDING_SESSION_UPDATED, + * sessionData: Object * } * @public */ -export const HIDE_RECORDING_LABEL = Symbol('HIDE_RECORDING_LABEL'); - -/** - * 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'); +export const RECORDING_SESSION_UPDATED = Symbol('RECORDING_SESSION_UPDATED'); diff --git a/react/features/recording/actions.js b/react/features/recording/actions.js index 419662c3c..dd3c2c37c 100644 --- a/react/features/recording/actions.js +++ b/react/features/recording/actions.js @@ -1,66 +1,24 @@ -import { - HIDE_RECORDING_LABEL, - RECORDING_STATE_UPDATED, - SET_RECORDING_TYPE, - TOGGLE_RECORDING -} from './actionTypes'; +import { RECORDING_SESSION_UPDATED } 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 {{ - * type: HIDE_RECORDING_LABEL + * type: RECORDING_SESSION_UPDATED, + * sessionData: Object * }} */ -export function hideRecordingLabel() { +export function updateRecordingSessionData(session) { return { - type: HIDE_RECORDING_LABEL - }; -} - -/** - * Sets what type of recording service will be used. - * - * @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 + type: RECORDING_SESSION_UPDATED, + sessionData: { + error: session.getError(), + id: session.getID(), + liveStreamViewURL: session.getLiveStreamViewURL(), + mode: session.getMode(), + status: session.getStatus() + } }; } diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js index 7f0c71045..064e11c22 100644 --- a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js +++ b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js @@ -1,12 +1,16 @@ -/* globals APP, interfaceConfig */ +// @flow import Spinner from '@atlaskit/spinner'; -import PropTypes from 'prop-types'; 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'; import googleApi from '../../googleApi'; @@ -14,6 +18,8 @@ import BroadcastsDropdown from './BroadcastsDropdown'; import GoogleSignInButton from './GoogleSignInButton'; import StreamKeyForm from './StreamKeyForm'; +declare var interfaceConfig: Object; + /** * An enumeration of the different states the Google API can be in while * interacting with {@code StartLiveStreamDialog}. @@ -44,63 +50,73 @@ const GOOGLE_API_STATES = { 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, + + /** + * 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 * streaming of the current conference. * * @extends Component */ -class StartLiveStreamDialog extends Component { - /** - * {@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: '' - }; +class StartLiveStreamDialog extends Component { + _isMounted: boolean; /** * Initializes a new {@code StartLiveStreamDialog} instance. @@ -108,9 +124,17 @@ class StartLiveStreamDialog extends Component { * @param {Props} props - The React {@code Component} props to initialize * the new {@code StartLiveStreamDialog} instance with. */ - constructor(props) { + constructor(props: 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 * 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. * 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 * {@code StartLiveStreamDialog}. @@ -222,11 +250,13 @@ class StartLiveStreamDialog extends Component { * @returns {boolean} True is returned to close the modal. */ _onCancel() { - this.props.onCancel(APP.UI.messageHandler.CANCEL); + sendAnalytics(createRecordingDialogEvent('start', 'cancel.button')); return true; } + _onGetYouTubeBroadcasts: () => Object; + /** * Asks the user to sign in, if not already signed in, and then requests a * 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 * when changing account, and will then fetch available YouTube broadcasts. @@ -282,6 +314,8 @@ class StartLiveStreamDialog extends Component { .then(() => this._onGetYouTubeBroadcasts()); } + _onStreamKeyChange: () => void; + /** * Callback invoked to update the {@code StartLiveStreamDialog} component's * 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 * key, and then closes {@code StartLiveStreamDialog}. @@ -306,7 +342,7 @@ class StartLiveStreamDialog extends Component { * closing, true to close the modal. */ _onSubmit() { - const { streamKey, selectedBoundStreamID } = this.state; + const { broadcasts, streamKey, selectedBoundStreamID } = this.state; if (!streamKey) { return false; @@ -315,17 +351,25 @@ class StartLiveStreamDialog extends Component { let selectedBroadcastID = null; if (selectedBoundStreamID) { - const selectedBroadcast = this.state.broadcasts.find( + const selectedBroadcast = broadcasts && broadcasts.find( broadcast => broadcast.boundStreamID === selectedBoundStreamID); 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; } + _onYouTubeBroadcastIDSelected: (string) => Object; + /** * Fetches the stream key for a YouTube broadcast and updates the internal * state to display the associated stream key as being entered. @@ -351,6 +395,8 @@ class StartLiveStreamDialog extends Component { }); } + _parseBroadcasts: (Array) => Array; + /** * Takes in a list of broadcasts from the YouTube API, removes dupes, * removes broadcasts that cannot get a stream key, and parses the @@ -487,13 +533,15 @@ class StartLiveStreamDialog extends Component { * {@code StartLiveStreamDialog}. * * @param {Object} state - The redux state. - * @protected + * @private * @returns {{ + * _conference: Object, * _googleApiApplicationClientID: string * }} */ function _mapStateToProps(state) { return { + _conference: state['features/base/conference'].conference, _googleApiApplicationClientID: state['features/base/config'].googleApiApplicationClientID }; diff --git a/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js index f54dee4a3..475ac6469 100644 --- a/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js +++ b/react/features/recording/components/LiveStream/StopLiveStreamDialog.web.js @@ -1,8 +1,36 @@ -import PropTypes from 'prop-types'; +// @flow + import React, { Component } from 'react'; +import { connect } from 'react-redux'; import { Dialog } from '../../../base/dialog'; 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 @@ -10,41 +38,17 @@ import { translate } from '../../../base/i18n'; * * @extends Component */ -class StopLiveStreamDialog extends Component { - /** - * {@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 - }; - +class StopLiveStreamDialog extends Component { /** * Initializes a new {@code StopLiveStreamDialog} instance. * * @param {Object} props - The read-only properties with which the new * instance is to be initialized. */ - constructor(props) { + constructor(props: Props) { super(props); // Bind event handler so it is only bound once for every instance. - this._onCancel = this._onCancel.bind(this); this._onSubmit = this._onSubmit.bind(this); } @@ -58,7 +62,6 @@ class StopLiveStreamDialog extends Component { return ( @@ -67,17 +70,7 @@ class StopLiveStreamDialog extends Component { ); } - /** - * Callback invoked when stopping of live streaming is canceled. - * - * @private - * @returns {boolean} True to close the modal. - */ - _onCancel() { - this.props.onCancel(); - - return true; - } + _onSubmit: () => boolean; /** * Callback invoked when stopping of live streaming is confirmed. @@ -86,10 +79,32 @@ class StopLiveStreamDialog extends Component { * @returns {boolean} True to close the modal. */ _onSubmit() { - this.props.onSubmit(); + sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button')); + + const { session } = this.props; + + if (session) { + this.props._conference.stopRecording(session.id); + } 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)); diff --git a/react/features/recording/components/Recording/StartRecordingDialog.native.js b/react/features/recording/components/Recording/StartRecordingDialog.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/Recording/StartRecordingDialog.web.js b/react/features/recording/components/Recording/StartRecordingDialog.web.js new file mode 100644 index 000000000..54c483a70 --- /dev/null +++ b/react/features/recording/components/Recording/StartRecordingDialog.web.js @@ -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 { + /** + * 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 ( + + { this.props.t('recording.startRecordingBody') } + + ); + } + + _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)); diff --git a/react/features/recording/components/Recording/StopRecordingDialog.native.js b/react/features/recording/components/Recording/StopRecordingDialog.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/Recording/StopRecordingDialog.web.js b/react/features/recording/components/Recording/StopRecordingDialog.web.js new file mode 100644 index 000000000..2c3dcbca2 --- /dev/null +++ b/react/features/recording/components/Recording/StopRecordingDialog.web.js @@ -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 { + /** + * 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 ( + + { this.props.t('dialog.stopRecordingWarning') } + + ); + } + + _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)); diff --git a/react/features/recording/components/Recording/index.js b/react/features/recording/components/Recording/index.js new file mode 100644 index 000000000..6d6f4bf05 --- /dev/null +++ b/react/features/recording/components/Recording/index.js @@ -0,0 +1,2 @@ +export { default as StartRecordingDialog } from './StartRecordingDialog'; +export { default as StopRecordingDialog } from './StopRecordingDialog'; diff --git a/react/features/recording/components/RecordingLabel.web.js b/react/features/recording/components/RecordingLabel.web.js index 2167559c3..99f0e147f 100644 --- a/react/features/recording/components/RecordingLabel.web.js +++ b/react/features/recording/components/RecordingLabel.web.js @@ -1,97 +1,142 @@ -import PropTypes from 'prop-types'; +// @flow + import React, { Component } from 'react'; -import { connect } from 'react-redux'; +import { CircularLabel } from '../../base/label'; import { translate } from '../../base/i18n'; -import { JitsiRecordingStatus } from '../../base/lib-jitsi-meet'; - -import { RECORDING_TYPES } from '../constants'; +import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet'; /** - * Implements a React {@link Component} which displays the current state of - * conference recording. Currently it uses CSS to display itself automatically - * when there is a recording state update. + * The translation keys to use when displaying messages. The values are set + * lazily to work around circular dependency issues with lib-jitsi-meet causing + * undefined imports. * - * @extends {Component} + * @private + * @type {Object} */ -class RecordingLabel extends Component { - /** - * {@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, +let TRANSLATION_KEYS_BY_MODE = null; - /** - * Whether or not the conference is currently being recorded. - */ - _isRecording: PropTypes.bool, +/** + * Lazily initializes TRANSLATION_KEYS_BY_MODE with translation keys to be used + * by the {@code RecordingLabel} for messaging recording session state. + * + * @private + * @returns {Object} + */ +function _getTranslationKeysByMode() { + if (!TRANSLATION_KEYS_BY_MODE) { + const { + error: errorConstants, + mode: modeConstants, + status: statusConstants + } = JitsiRecordingConstants; - /** - * An object to describe the {@code RecordingLabel} content. If no - * translation key to display is specified, the label will apply CSS to - * itself so it can be made invisible. - * {{ - * centered: boolean, - * key: string, - * showSpinner: boolean - * }} - */ - _labelDisplayConfiguration: PropTypes.object, - - /** - * Whether the recording feature is live streaming (jibri) or is file - * recording (jirecon). - */ - _recordingType: PropTypes.string, - - /** - * 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 + TRANSLATION_KEYS_BY_MODE = { + [modeConstants.FILE]: { + status: { + [statusConstants.PENDING]: 'recording.pending', + [statusConstants.OFF]: 'recording.off' + }, + errors: { + [errorConstants.BUSY]: 'recording.failedToStart', + [errorConstants.ERROR]: 'recording.error' + } + }, + [modeConstants.STREAM]: { + status: { + [statusConstants.PENDING]: 'liveStreaming.pending', + [statusConstants.OFF]: 'liveStreaming.off' + }, + errors: { + [errorConstants.BUSY]: 'liveStreaming.busy', + [errorConstants.ERROR]: 'liveStreaming.error' + } + } }; } + 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 - * display after having being hidden. + * The redux representation of a recording session. + */ + 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 { + _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 - * @param {Object} nextProps - The read-only props which this Component will - * receive. - * @returns {void} */ componentWillReceiveProps(nextProps) { - this.setState({ - filmstripBecomingVisible: nextProps._filmstripVisible - && !this.props._filmstripVisible - }); + const { status } = this.props.session; + const nextStatus = nextProps.session.status; + + 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} */ render() { - const { - _isRecording, - _labelDisplayConfiguration, - _recordingType - } = this.props; - const { centered, key, showSpinner } = _labelDisplayConfiguration || {}; + if (this.state.hidden) { + return null; + } - const isVisible = Boolean(key); - const rootClassName = [ - 'video-state-indicator centeredVideoLabel', - _isRecording ? 'is-recording' : '', - isVisible ? 'show-inline' : '', - centered ? '' : 'moveToCorner', - this.state.filmstripBecomingVisible ? 'opening' : '', - this.props._filmstripVisible - ? 'with-filmstrip' : 'without-filmstrip' - ].join(' '); + const { + error: errorConstants, + mode: modeConstants, + status: statusConstants + } = JitsiRecordingConstants; + const { session } = this.props; + const allTranslationKeys = _getTranslationKeysByMode(); + const translationKeys = allTranslationKeys[session.mode]; + let circularLabelClass, circularLabelKey, messageKey; + + 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 ( -
- { _isRecording - ?
-
- +
+ { messageKey + ?
+ { this.props.t(messageKey) }
- :
- { this.props.t(key) } -
} - { !_isRecording - && showSpinner - && } + : + { this.props.t(circularLabelKey) } + }
); } + + /** + * 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); + } } -/** - * 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)); +export default translate(RecordingLabel); diff --git a/react/features/recording/components/index.js b/react/features/recording/components/index.js index 70a2733bd..5320d60ea 100644 --- a/react/features/recording/components/index.js +++ b/react/features/recording/components/index.js @@ -1,2 +1,3 @@ export { StartLiveStreamDialog, StopLiveStreamDialog } from './LiveStream'; +export { StartRecordingDialog, StopRecordingDialog } from './Recording'; export { default as RecordingLabel } from './RecordingLabel'; diff --git a/react/features/recording/functions.js b/react/features/recording/functions.js new file mode 100644 index 000000000..ad215a4a9 --- /dev/null +++ b/react/features/recording/functions.js @@ -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)); +} diff --git a/react/features/recording/index.js b/react/features/recording/index.js index c2bba6b0a..755b50deb 100644 --- a/react/features/recording/index.js +++ b/react/features/recording/index.js @@ -1,6 +1,6 @@ export * from './actions'; export * from './components'; export * from './constants'; +export * from './functions'; -import './middleware'; import './reducer'; diff --git a/react/features/recording/middleware.js b/react/features/recording/middleware.js deleted file mode 100644 index 15041ed81..000000000 --- a/react/features/recording/middleware.js +++ /dev/null @@ -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); -}); diff --git a/react/features/recording/reducer.js b/react/features/recording/reducer.js index 188f866e5..047bcc348 100644 --- a/react/features/recording/reducer.js +++ b/react/features/recording/reducer.js @@ -1,34 +1,60 @@ import { ReducerRegistry } from '../base/redux'; -import { - HIDE_RECORDING_LABEL, - RECORDING_STATE_UPDATED, - SET_RECORDING_TYPE -} from './actionTypes'; +import { RECORDING_SESSION_UPDATED } from './actionTypes'; + +const DEFAULT_STATE = { + sessionDatas: [] +}; /** * Reduces the Redux actions of the feature features/recording. */ -ReducerRegistry.register('features/recording', (state = {}, action) => { - switch (action.type) { - case HIDE_RECORDING_LABEL: - return { - ...state, - labelDisplayConfiguration: null - }; +ReducerRegistry.register('features/recording', + (state = DEFAULT_STATE, action) => { + switch (action.type) { + case RECORDING_SESSION_UPDATED: + return { + ...state, + sessionDatas: + _updateSessionDatas(state.sessionDatas, action.sessionData) + }; - case RECORDING_STATE_UPDATED: - return { - ...state, - ...action.recordingState - }; + default: + return state; + } + }); - case SET_RECORDING_TYPE: - return { - ...state, - recordingType: action.recordingType - }; +/** + * Updates the known information on recording sessions. + * + * @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: - return state; + if (hasExistingSessionData) { + 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; +} diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index fb3b9bfcb..cd1018b6d 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -11,6 +11,7 @@ import { } from '../../../analytics'; import { openDialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; +import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; import { PARTICIPANT_ROLE, getLocalParticipant, @@ -27,7 +28,13 @@ import { isDialOutEnabled } from '../../../invite'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; -import { RECORDING_TYPES, toggleRecording } from '../../../recording'; +import { + StartLiveStreamDialog, + StartRecordingDialog, + StopLiveStreamDialog, + StopRecordingDialog, + getActiveSession +} from '../../../recording'; import { SettingsButton } from '../../../settings'; import { toggleSharedVideo } from '../../../shared-video'; import { toggleChat, toggleProfile } from '../../../side-panel'; @@ -95,6 +102,11 @@ type Props = { */ _feedbackConfigured: boolean, + /** + * The current file recording session, if any. + */ + _fileRecordingSession: Object, + /** * Whether or not the app is currently in full screen. */ @@ -112,10 +124,9 @@ type Props = { _isGuest: boolean, /** - * Whether or not the conference is currently being recorded by the local - * participant. + * The current live streaming session, if any. */ - _isRecording: boolean, + _liveStreamingSession: ?Object, /** * The ID of the local participant. @@ -137,12 +148,6 @@ type Props = { */ _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. */ @@ -214,12 +219,13 @@ class Toolbox extends Component { = this._onToolbarOpenSpeakerStats.bind(this); this._onToolbarOpenVideoQuality = this._onToolbarOpenVideoQuality.bind(this); - this._onToolbarToggleChat = this._onToolbarToggleChat.bind(this); this._onToolbarToggleEtherpad = this._onToolbarToggleEtherpad.bind(this); this._onToolbarToggleFullScreen = this._onToolbarToggleFullScreen.bind(this); + this._onToolbarToggleLiveStreaming + = this._onToolbarToggleLiveStreaming.bind(this); this._onToolbarToggleProfile = this._onToolbarToggleProfile.bind(this); this._onToolbarToggleRaiseHand @@ -462,6 +468,22 @@ class Toolbox extends Component { 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. * @@ -495,7 +517,12 @@ class Toolbox extends Component { * @returns {void} */ _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 { 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; /** @@ -805,8 +851,12 @@ class Toolbox extends Component { * @returns {void} */ _onToolbarToggleRecording() { - // No analytics handling is added here for the click as this action will - // exercise the old toolbar UI flow, which includes analytics handling. + sendAnalytics(createToolbarEvent( + 'recording.button', + { + 'is_recording': Boolean(this.props._fileRecordingSession), + type: JitsiRecordingConstants.mode.FILE + })); this._doToggleRecording(); } @@ -891,6 +941,30 @@ class Toolbox extends Component { ); } + /** + * 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 ( + + ); + } + /** * Renders the list elements of the overflow menu. * @@ -904,6 +978,7 @@ class Toolbox extends Component { _feedbackConfigured, _fullScreen, _isGuest, + _recordingEnabled, _sharingVideo, t } = this.props; @@ -929,7 +1004,12 @@ class Toolbox extends Component { text = { _fullScreen ? t('toolbar.exitFullScreen') : t('toolbar.enterFullScreen') } />, - this._renderRecordingButton(), + _recordingEnabled + && this._shouldShowButton('livestreaming') + && this._renderLiveStreamingButton(), + _recordingEnabled + && this._shouldShowButton('recording') + && this._renderRecordingButton(), this._shouldShowButton('sharedvideo') && { } /** - * Renders an {@code OverflowMenuItem} depending on the current recording - * state. + * Renders an {@code OverflowMenuItem} to start or stop recording of the + * current conference. * * @private * @returns {ReactElement|null} */ _renderRecordingButton() { - const { - _isRecording, - _recordingEnabled, - _recordingType, - t - } = this.props; + const { _fileRecordingSession, t } = this.props; - if (!_recordingEnabled || !this._shouldShowButton('recording')) { - return null; - } - - 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'; - } + const translationKey = _fileRecordingSession + ? 'dialog.stopRecording' + : 'dialog.startRecording'; return ( @@ -1055,7 +1116,6 @@ function _mapStateToProps(state) { enableRecording, iAmRecorder } = state['features/base/config']; - const { isRecording, recordingType } = state['features/recording']; const sharedVideoStatus = state['features/shared-video'].status; const { current } = state['features/side-panel']; const { @@ -1083,14 +1143,15 @@ function _mapStateToProps(state) { _hideInviteButton: iAmRecorder || (!addPeopleEnabled && !dialOutEnabled), _isGuest: state['features/base/jwt'].isGuest, - _isRecording: isRecording, + _fileRecordingSession: + getActiveSession(state, JitsiRecordingConstants.mode.FILE), _fullScreen: fullScreen, + _liveStreamingSession: + getActiveSession(state, JitsiRecordingConstants.mode.STREAM), _localParticipantID: localParticipant.id, _overflowMenuVisible: overflowMenuVisible, _raisedHand: localParticipant.raisedHand, - _recordingEnabled: isModerator && enableRecording - && (conference && conference.isRecordingSupported()), - _recordingType: recordingType, + _recordingEnabled: isModerator && enableRecording, _screensharing: localVideo && localVideo.videoType === 'desktop', _sharingVideo: sharedVideoStatus === 'playing' || sharedVideoStatus === 'start' diff --git a/react/features/toolbox/reducer.js b/react/features/toolbox/reducer.js index 468c7d6fb..88c9f1dc1 100644 --- a/react/features/toolbox/reducer.js +++ b/react/features/toolbox/reducer.js @@ -49,8 +49,7 @@ function _getInitialState() { alwaysVisible: false, /** - * The indicator which determines whether the Toolbox is enabled. For - * example, modules/UI/recording/Recording.js disables the Toolbox. + * The indicator which determines whether the Toolbox is enabled. * * @type {boolean} */ diff --git a/react/features/video-quality/components/VideoQualityLabel.web.js b/react/features/video-quality/components/VideoQualityLabel.web.js index f27e74a05..da063e683 100644 --- a/react/features/video-quality/components/VideoQualityLabel.web.js +++ b/react/features/video-quality/components/VideoQualityLabel.web.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { translate } from '../../base/i18n'; +import { CircularLabel } from '../../base/label'; import { MEDIA_TYPE } from '../../base/media'; import { getTrackByMediaTypeAndParticipant } from '../../base/tracks'; @@ -49,20 +50,14 @@ export class VideoQualityLabel extends Component { _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 - * determine display classes to set. + * The message to show within the label's tooltip. */ - _filmstripVisible: PropTypes.bool, - - /** - * The current video resolution (height) to display a label for. - */ - _resolution: PropTypes.number, + _tooltipKey: PropTypes.string, /** * The redux representation of the JitsiTrack displayed on large video. @@ -75,42 +70,6 @@ export class VideoQualityLabel extends Component { 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()}. * @@ -120,95 +79,76 @@ export class VideoQualityLabel extends Component { render() { const { _audioOnly, - _conferenceStarted, - _filmstripVisible, - _resolution, + _labelKey, + _tooltipKey, _videoTrack, t } = 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 - // 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; + let className, labelContent, tooltipKey; if (_audioOnly) { - labelContent = ; + className = 'audio-only'; + labelContent = t('videoStatus.audioOnly'); tooltipKey = 'videoStatus.labelTooltipAudioOnly'; } else if (!_videoTrack || _videoTrack.muted) { - labelContent = ; + className = 'no-video'; + labelContent = t('videoStatus.audioOnly'); tooltipKey = 'videoStatus.labelTooiltipNoVideo'; } else { - const translationKeys - = this._mapResolutionToTranslationsKeys(_resolution); - - labelContent = t(translationKeys.labelKey); - tooltipKey = translationKeys.tooltipKey; + className = 'current-video-quality'; + labelContent = t(_labelKey); + tooltipKey = _tooltipKey; } return ( -
- -
- { labelContent } -
-
-
+ + + { labelContent } + + ); } +} - /** - * Matches the passed in resolution with a translation keys for describing - * the resolution. The passed in resolution will be matched with a known - * resolution that it is at least greater than or equal to. - * - * @param {number} resolution - The video height to match with a - * translation. - * @private - * @returns {Object} - */ - _mapResolutionToTranslationsKeys(resolution) { - // Set the default matching resolution of the lowest just in case a - // match is not found. - let highestMatchingResolution = RESOLUTIONS[0]; +/** + * Matches the passed in resolution with a translation keys for describing + * the resolution. The passed in resolution will be matched with a known + * resolution that it is at least greater than or equal to. + * + * @param {number} resolution - The video height to match with a + * translation. + * @private + * @returns {Object} + */ +function _mapResolutionToTranslationsKeys(resolution) { + // Set the default matching resolution of the lowest just in case a match is + // not found. + let highestMatchingResolution = RESOLUTIONS[0]; - for (let i = 0; i < RESOLUTIONS.length; i++) { - const knownResolution = RESOLUTIONS[i]; + for (let i = 0; i < RESOLUTIONS.length; i++) { + const knownResolution = RESOLUTIONS[i]; - if (resolution >= knownResolution) { - highestMatchingResolution = knownResolution; - } else { - break; - } + if (resolution >= knownResolution) { + highestMatchingResolution = knownResolution; + } else { + 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 * @returns {{ * _audioOnly: boolean, - * _conferenceStarted: boolean, - * _filmstripVisible: true, - * _resolution: number, + * _labelKey: string, + * _tooltipKey: string, * _videoTrack: Object * }} */ function _mapStateToProps(state) { - const { audioOnly, conference } = state['features/base/conference']; - const { visible } = state['features/filmstrip']; + const { audioOnly } = state['features/base/conference']; const { resolution, participantId } = state['features/large-video']; const videoTrackOnLargeVideo = getTrackByMediaTypeAndParticipant( state['features/base/tracks'], @@ -235,11 +173,13 @@ function _mapStateToProps(state) { participantId ); + const translationKeys + = audioOnly ? {} : _mapResolutionToTranslationsKeys(resolution); + return { _audioOnly: audioOnly, - _conferenceStarted: Boolean(conference), - _filmstripVisible: visible, - _resolution: resolution, + _labelKey: translationKeys.labelKey, + _tooltipKey: translationKeys.tooltipKey, _videoTrack: videoTrackOnLargeVideo }; } diff --git a/react/features/videosipgw/middleware.js b/react/features/videosipgw/middleware.js index f29a3f7fd..f44c02de4 100644 --- a/react/features/videosipgw/middleware.js +++ b/react/features/videosipgw/middleware.js @@ -52,76 +52,9 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } - case SIP_GW_INVITE_ROOMS: { - const { status } = getState()['features/videosipgw']; - - 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)}`); - } - } - } + case SIP_GW_INVITE_ROOMS: + _inviteRooms(action.rooms, action.conference, dispatch); + break; } 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. * @@ -173,6 +162,17 @@ function _sessionStateChanged( 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 diff --git a/service/UI/UIEvents.js b/service/UI/UIEvents.js index 007bb42b3..630c31208 100644 --- a/service/UI/UIEvents.js +++ b/service/UI/UIEvents.js @@ -64,12 +64,10 @@ export default { * @see {TOGGLE_FILMSTRIP} */ TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip', - TOGGLE_RECORDING: 'UI.toggle_recording', TOGGLE_SCREENSHARING: 'UI.toggle_screensharing', TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document', HANGUP: 'UI.hangup', LOGOUT: 'UI.logout', - RECORDING_TOGGLED: 'UI.recording_toggled', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed', AUDIO_OUTPUT_DEVICE_CHANGED: 'UI.audio_output_device_changed',