From ee74f11c3dcccb9f4ba5114a91fa85fb18b9f67b Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Wed, 16 May 2018 07:00:16 -0700 Subject: [PATCH] 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 --- conference.js | 115 +++-- css/_vertical_filmstrip_overrides.scss | 12 +- css/modals/video-quality/_video-quality.scss | 132 ++--- interface_config.js | 2 +- lang/main.json | 5 + modules/UI/UI.js | 40 +- modules/UI/recording/Recording.js | 462 ------------------ modules/UI/videolayout/VideoLayout.js | 2 + package-lock.json | 2 +- package.json | 2 +- .../label/components/CircularLabel.native.js | 0 .../label/components/CircularLabel.web.js | 60 +++ react/features/base/label/components/index.js | 1 + react/features/base/label/index.js | 1 + react/features/base/lib-jitsi-meet/index.js | 2 +- .../invite/components/InfoDialogButton.web.js | 8 +- .../large-video/components/Labels.native.js | 0 .../large-video/components/Labels.web.js | 139 ++++++ .../large-video/components/LargeVideo.web.js | 7 +- react/features/recording/actionTypes.js | 43 +- react/features/recording/actions.js | 72 +-- .../LiveStream/StartLiveStreamDialog.web.js | 166 ++++--- .../LiveStream/StopLiveStreamDialog.web.js | 97 ++-- .../Recording/StartRecordingDialog.native.js | 0 .../Recording/StartRecordingDialog.web.js | 103 ++++ .../Recording/StopRecordingDialog.native.js | 0 .../Recording/StopRecordingDialog.web.js | 109 +++++ .../recording/components/Recording/index.js | 2 + .../components/RecordingLabel.web.js | 330 +++++++------ react/features/recording/components/index.js | 1 + react/features/recording/functions.js | 18 + react/features/recording/index.js | 2 +- react/features/recording/middleware.js | 27 - react/features/recording/reducer.js | 76 ++- .../toolbox/components/web/Toolbox.js | 153 ++++-- react/features/toolbox/reducer.js | 3 +- .../components/VideoQualityLabel.web.js | 182 +++---- react/features/videosipgw/middleware.js | 140 +++--- service/UI/UIEvents.js | 2 - 39 files changed, 1269 insertions(+), 1249 deletions(-) delete mode 100644 modules/UI/recording/Recording.js create mode 100644 react/features/base/label/components/CircularLabel.native.js create mode 100644 react/features/base/label/components/CircularLabel.web.js create mode 100644 react/features/base/label/components/index.js create mode 100644 react/features/base/label/index.js create mode 100644 react/features/large-video/components/Labels.native.js create mode 100644 react/features/large-video/components/Labels.web.js create mode 100644 react/features/recording/components/Recording/StartRecordingDialog.native.js create mode 100644 react/features/recording/components/Recording/StartRecordingDialog.web.js create mode 100644 react/features/recording/components/Recording/StopRecordingDialog.native.js create mode 100644 react/features/recording/components/Recording/StopRecordingDialog.web.js create mode 100644 react/features/recording/components/Recording/index.js create mode 100644 react/features/recording/functions.js delete mode 100644 react/features/recording/middleware.js 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',