From e27069447b0661387f658f0677a0613bd6135b01 Mon Sep 17 00:00:00 2001 From: Robert Pintilii Date: Fri, 3 Jun 2022 12:45:27 +0100 Subject: [PATCH] feat(local-video-recording) Allow users to record the meeting locally (#11338) --- config.js | 21 +- css/_recording.scss | 8 +- images/downloadLocalRecording.png | Bin 0 -> 490 bytes lang/main.json | 5 + package-lock.json | 107 ++++++++- package.json | 4 +- react/features/base/config/configWhitelist.js | 2 +- .../features/base/participants/actionTypes.ts | 9 + react/features/base/participants/actions.js | 23 +- .../features/base/participants/middleware.js | 79 ++++++- react/features/notifications/constants.js | 15 +- react/features/recording/actionTypes.ts | 18 ++ react/features/recording/actions.any.js | 26 ++- .../Recording/AbstractRecordButton.js | 5 +- .../Recording/AbstractStartRecordingDialog.js | 18 +- .../Recording/AbstractStopRecordingDialog.js | 23 +- .../Recording/LocalRecordingManager.ts | 221 ++++++++++++++++++ .../Recording/StartRecordingDialogContent.js | 174 ++++++++++++-- .../components/Recording/styles.native.js | 1 + .../components/Recording/styles.web.js | 2 + .../Recording/web/StartRecordingDialog.js | 2 + react/features/recording/constants.js | 3 +- react/features/recording/functions.js | 27 ++- react/features/recording/middleware.js | 61 ++++- 24 files changed, 785 insertions(+), 69 deletions(-) create mode 100644 images/downloadLocalRecording.png create mode 100644 react/features/recording/components/Recording/LocalRecordingManager.ts diff --git a/config.js b/config.js index 341cdff92f..bdb3cafea7 100644 --- a/config.js +++ b/config.js @@ -295,6 +295,9 @@ var config = { // Whether to enable live streaming or not. // liveStreamingEnabled: false, + // Whether to enable local recording or not. + // enableLocalRecording: false, + // Transcription (in interface_config, // subtitles and buttons can be configured) // transcribingEnabled: false, @@ -953,23 +956,6 @@ var config = { // ] // }, - // Local Recording - // - - // localRecording: { - // Enables local recording. - // Additionally, 'localrecording' (all lowercase) needs to be added to - // the `toolbarButtons`-array for the Local Recording button to show up - // on the toolbar. - // - // enabled: true, - // - - // The recording format, can be one of 'ogg', 'flac' or 'wav'. - // format: 'flac' - // - - // }, // e2ee: { // labels, // externallyManagedKey: false @@ -1305,7 +1291,6 @@ var config = { // 'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable // 'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected // 'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied - // 'localRecording.localRecording', // shown when a local recording is started // 'notify.chatMessages', // shown when receiving chat messages while the chat window is closed // 'notify.disconnected', // shown when a participant has left // 'notify.connectedOneMember', // show when a participant joined diff --git a/css/_recording.scss b/css/_recording.scss index a1f60b7dec..1478c2797a 100644 --- a/css/_recording.scss +++ b/css/_recording.scss @@ -23,7 +23,13 @@ .recording-header-line { border-top: 1px solid #5e6d7a; - padding-top: 32px; + padding-top: 16px; + margin-top: 16px; + } + + .local-recording-warning { + margin-top: 4px; + display: block; } .recording-switch-disabled { diff --git a/images/downloadLocalRecording.png b/images/downloadLocalRecording.png new file mode 100644 index 0000000000000000000000000000000000000000..3e499cd65c143affa4bb76cefc696a444a29c932 GIT binary patch literal 490 zcmV(`_OBtnR)3}2W7xtx+)>Z?(KnYbkA%SH}n5|E&P z!7RaWtRNRF$i)hBv4UK*3O25v(ej4SiPlHm=Kj4}-kq&O7j_PES!}`n-~;Y?fZ%03 zwV!Sp+J*y5@V5Vs+QkzNTK+^It*glB_Flmu4F=`X3U;@$Xm*Flt^S-VjcQ$vUb(ca z`Z>fdGnY3bB^G7deSr68q>6-56;?r{H5$h6P$ph@13?_g;H1^M zsNAevt%ClHn}I~yM~)Js=v4D+`Jdc*u;yTtP%LcgLf@XCS1csa9=+ZhL@HK_%1J^Z zK`JX0Nm$FqE4_)zOfX6|;fcMNb>2SV1mUkP8V0FqeP?8!(rq`jropy3qR> gV$6(388veI1lLEffnvRMUjP6A07*qoM6N<$f^!hnaR2}S literal 0 HcmV?d00001 diff --git a/lang/main.json b/lang/main.json index 192c690679..4818d7bb11 100644 --- a/lang/main.json +++ b/lang/main.json @@ -657,6 +657,8 @@ "linkToSalesforceKey": "Link this meeting", "linkToSalesforceProgress": "Linking meeting to Salesforce...", "linkToSalesforceSuccess": "The meeting was linked to Salesforce", + "localRecordingStarted": "{{name}} has started a local recording.", + "localRecordingStopped": "{{name}} has stopped a local recording.", "me": "Me", "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.", "moderationInEffectCSTitle": "Screen sharing is blocked by the moderator", @@ -887,6 +889,7 @@ "limitNotificationDescriptionWeb": "Due to high demand your recording will be limited to {{limit}} min. For unlimited recordings try {{app}}.", "linkGenerated": "We have generated a link to your recording.", "live": "LIVE", + "localRecordingWarning": "Make sure you select the current tab in order to use the right video and audio. The recording is currently limited to 1GB, which is around 100 minutes.", "loggedIn": "Logged in as {{userName}}", "off": "Recording stopped", "offBy": "{{name}} stopped the recording", @@ -894,6 +897,7 @@ "onBy": "{{name}} started the recording", "pending": "Preparing to record the meeting...", "rec": "REC", + "saveLocalRecording": "Save recording file locally", "serviceDescription": "Your recording will be saved by the recording service", "serviceDescriptionCloud": "Cloud recording", "serviceDescriptionCloudInfo": "Recorded meetings are automatically cleared 24h after their recording time.", @@ -901,6 +905,7 @@ "sessionAlreadyActive": "This session is already being recorded or live streamed.", "signIn": "Sign in", "signOut": "Sign out", + "surfaceError": "Please select the current tab.", "unavailable": "Oops! The {{serviceName}} is currently unavailable. We're working on resolving the issue. Please try again later.", "unavailableTitle": "Recording unavailable", "uploadToCloud": "Upload to the cloud" diff --git a/package-lock.json b/package-lock.json index e6954704ab..b47064ab1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "util": "0.12.1", "uuid": "8.3.2", "wasm-check": "2.0.1", + "webm-duration-fix": "1.0.4", "windows-iana": "^3.1.0", "zxcvbn": "4.4.2" }, @@ -141,6 +142,7 @@ "@babel/runtime": "7.16.0", "@jitsi/eslint-config": "4.0.0", "@types/react-native": "0.67.6", + "@types/uuid": "8.3.4", "babel-loader": "8.2.3", "babel-plugin-optional-require": "0.3.1", "circular-dependency-plugin": "5.2.0", @@ -163,7 +165,7 @@ "style-loader": "0.19.0", "traverse": "0.6.6", "ts-loader": "9.2.6", - "typescript": "4.3.5", + "typescript": "4.6.4", "unorm": "1.6.0", "webpack": "5.57.1", "webpack-bundle-analyzer": "4.4.2", @@ -5561,6 +5563,12 @@ "@types/node": "*" } }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "node_modules/@types/webgl-ext": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", @@ -8323,6 +8331,11 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/ebml-block": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/ebml-block/-/ebml-block-1.1.2.tgz", + "integrity": "sha512-HgNlIsRFP6D9VKU5atCeHRJY7XkJP8bOe8yEhd8NB7B3b4++VWTyauz6g650iiPmLfPLGlVpoJmGSgMfXDYusg==" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10657,6 +10670,14 @@ "css-in-js-utils": "^2.0.0" } }, + "node_modules/int64-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.0.1.tgz", + "integrity": "sha512-+3azY4pXrjAupJHU1V9uGERWlhoqNswJNji6aD/02xac7oxol508AsMC5lxKhEqyZeDFy3enq5OGWXF4u75hiw==", + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/internal-slot": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", @@ -18643,9 +18664,9 @@ } }, "node_modules/typescript": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", - "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -19123,6 +19144,40 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, + "node_modules/webm-duration-fix": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/webm-duration-fix/-/webm-duration-fix-1.0.4.tgz", + "integrity": "sha512-kvhmSmEnuohtK+j+mJswqCCM2ViKb9W8Ch0oAxcaeUvpok5CsMORQLnea+CYKDXPG6JH12H0CbRK85qhfeZLew==", + "dependencies": { + "buffer": "^6.0.3", + "ebml-block": "^1.1.2", + "events": "^3.3.0", + "int64-buffer": "^1.0.1" + } + }, + "node_modules/webm-duration-fix/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/webpack": { "version": "5.57.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz", @@ -24162,6 +24217,12 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", + "dev": true + }, "@types/webgl-ext": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", @@ -26348,6 +26409,11 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "ebml-block": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/ebml-block/-/ebml-block-1.1.2.tgz", + "integrity": "sha512-HgNlIsRFP6D9VKU5atCeHRJY7XkJP8bOe8yEhd8NB7B3b4++VWTyauz6g650iiPmLfPLGlVpoJmGSgMfXDYusg==" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -28173,6 +28239,11 @@ "css-in-js-utils": "^2.0.0" } }, + "int64-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.0.1.tgz", + "integrity": "sha512-+3azY4pXrjAupJHU1V9uGERWlhoqNswJNji6aD/02xac7oxol508AsMC5lxKhEqyZeDFy3enq5OGWXF4u75hiw==" + }, "internal-slot": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", @@ -34274,9 +34345,9 @@ } }, "typescript": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", - "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", "dev": true }, "ua-parser-js": { @@ -34621,6 +34692,28 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" }, + "webm-duration-fix": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/webm-duration-fix/-/webm-duration-fix-1.0.4.tgz", + "integrity": "sha512-kvhmSmEnuohtK+j+mJswqCCM2ViKb9W8Ch0oAxcaeUvpok5CsMORQLnea+CYKDXPG6JH12H0CbRK85qhfeZLew==", + "requires": { + "buffer": "^6.0.3", + "ebml-block": "^1.1.2", + "events": "^3.3.0", + "int64-buffer": "^1.0.1" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + } + } + }, "webpack": { "version": "5.57.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz", diff --git a/package.json b/package.json index 5827b6450e..694ec00e4e 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "util": "0.12.1", "uuid": "8.3.2", "wasm-check": "2.0.1", + "webm-duration-fix": "1.0.4", "windows-iana": "^3.1.0", "zxcvbn": "4.4.2" }, @@ -146,6 +147,7 @@ "@babel/runtime": "7.16.0", "@jitsi/eslint-config": "4.0.0", "@types/react-native": "0.67.6", + "@types/uuid": "8.3.4", "babel-loader": "8.2.3", "babel-plugin-optional-require": "0.3.1", "circular-dependency-plugin": "5.2.0", @@ -168,7 +170,7 @@ "style-loader": "0.19.0", "traverse": "0.6.6", "ts-loader": "9.2.6", - "typescript": "4.3.5", + "typescript": "4.6.4", "unorm": "1.6.0", "webpack": "5.57.1", "webpack-bundle-analyzer": "4.4.2", diff --git a/react/features/base/config/configWhitelist.js b/react/features/base/config/configWhitelist.js index 9bf2e6d679..f933b8a393 100644 --- a/react/features/base/config/configWhitelist.js +++ b/react/features/base/config/configWhitelist.js @@ -142,6 +142,7 @@ export default [ 'enableLayerSuspension', 'enableLipSync', 'enableLobbyChat', + 'enableLocalRecording', 'enableOpusRed', 'enableRemb', 'enableSaveLogs', @@ -183,7 +184,6 @@ export default [ 'ignoreStartMuted', 'inviteAppName', 'liveStreamingEnabled', - 'localRecording', 'localSubject', 'maxFullResolutionParticipants', 'mouseMoveCallbackInterval', diff --git a/react/features/base/participants/actionTypes.ts b/react/features/base/participants/actionTypes.ts index daa0ccf0d0..bd0f2f8ca0 100644 --- a/react/features/base/participants/actionTypes.ts +++ b/react/features/base/participants/actionTypes.ts @@ -231,3 +231,12 @@ export const OVERWRITE_PARTICIPANT_NAME = 'OVERWRITE_PARTICIPANT_NAME'; * } */ export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES'; + +/** + * Updates participants local recording status. + * { + * type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS, + * recording: boolean + * } + */ +export const SET_LOCAL_PARTICIPANT_RECORDING_STATUS = 'SET_LOCAL_PARTICIPANT_RECORDING_STATUS'; diff --git a/react/features/base/participants/actions.js b/react/features/base/participants/actions.js index 0842b3183b..fc8b25defa 100644 --- a/react/features/base/participants/actions.js +++ b/react/features/base/participants/actions.js @@ -10,17 +10,18 @@ import { LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED, LOCAL_PARTICIPANT_RAISE_HAND, MUTE_REMOTE_PARTICIPANT, + OVERWRITE_PARTICIPANT_NAME, + OVERWRITE_PARTICIPANTS_NAMES, PARTICIPANT_ID_CHANGED, PARTICIPANT_JOINED, PARTICIPANT_KICKED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED, PIN_PARTICIPANT, + RAISE_HAND_UPDATED, SCREENSHARE_PARTICIPANT_NAME_CHANGED, SET_LOADABLE_AVATAR_URL, - RAISE_HAND_UPDATED, - OVERWRITE_PARTICIPANT_NAME, - OVERWRITE_PARTICIPANTS_NAMES + SET_LOCAL_PARTICIPANT_RECORDING_STATUS } from './actionTypes'; import { DISCO_REMOTE_CONTROL_FEATURE @@ -683,3 +684,19 @@ export function overwriteParticipantsNames(participantList) { participantList }; } + +/** + * Local video recording status for the local participant. + * + * @param {boolean} recording - If local recording is ongoing. + * @returns {{ + * type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS, + * recording: boolean + * }} + */ +export function updateLocalRecordingStatus(recording) { + return { + type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS, + recording + }; +} diff --git a/react/features/base/participants/middleware.js b/react/features/base/participants/middleware.js index c9b1c5cce1..7db444e1a8 100644 --- a/react/features/base/participants/middleware.js +++ b/react/features/base/participants/middleware.js @@ -10,6 +10,7 @@ import { getBreakoutRooms } from '../../breakout-rooms/functions'; import { toggleE2EE } from '../../e2ee/actions'; import { MAX_MODE } from '../../e2ee/constants'; import { + LOCAL_RECORDING_NOTIFICATION_ID, NOTIFICATION_TIMEOUT_TYPE, RAISE_HAND_NOTIFICATION_ID, showNotification @@ -17,6 +18,7 @@ import { import { isForceMuted } from '../../participants-pane/functions'; import { CALLING, INVITED } from '../../presence-status'; import { RAISE_HAND_SOUND_ID } from '../../reactions/constants'; +import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../../recording'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app'; import { CONFERENCE_WILL_JOIN, @@ -42,7 +44,8 @@ import { PARTICIPANT_JOINED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED, - RAISE_HAND_UPDATED + RAISE_HAND_UPDATED, + SET_LOCAL_PARTICIPANT_RECORDING_STATUS } from './actionTypes'; import { localParticipantIdChanged, @@ -174,6 +177,25 @@ MiddlewareRegistry.register(store => next => action => { break; } + case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: { + const { recording } = action; + const localId = getLocalParticipant(store.getState())?.id; + + store.dispatch(participantUpdated({ + // XXX Only the local participant is allowed to update without + // stating the JitsiConference instance (i.e. participant property + // `conference` for a remote participant) because the local + // participant is uniquely identified by the very fact that there is + // only one local participant. + + id: localId, + local: true, + localRecording: recording + })); + + break; + } + case MUTE_REMOTE_PARTICIPANT: { const { conference } = store.getState()['features/base/conference']; @@ -389,6 +411,8 @@ StateListenerRegistry.register( id: participant.getId(), features: { 'screen-sharing': true } })), + 'localRecording': (participant, value) => + _localRecordingUpdated(store, conference, participant.getId(), value), 'raisedHand': (participant, value) => _raiseHandUpdated(store, conference, participant.getId(), value), 'region': (participant, value) => @@ -566,7 +590,15 @@ function _maybePlaySounds({ getState, dispatch }, action) { function _participantJoinedOrUpdated(store, next, action) { const { dispatch, getState } = store; const { overwrittenNameList } = store.getState()['features/base/participants']; - const { participant: { avatarURL, email, id, local, name, raisedHandTimestamp } } = action; + const { participant: { + avatarURL, + email, + id, + local, + localRecording, + name, + raisedHandTimestamp + } } = action; // Send an external update of the local participant's raised hand state // if a new raised hand state is defined in the action. @@ -587,6 +619,20 @@ function _participantJoinedOrUpdated(store, next, action) { action.participant.name = overwrittenNameList[id]; } + // Send an external update of the local participant's local recording state + // if a new local recording state is defined in the action. + if (typeof localRecording !== 'undefined') { + if (local) { + const conference = getCurrentConference(getState); + + // Send localRecording signalling only if there is a change + if (conference + && localRecording !== getLocalParticipant(getState()).localRecording) { + conference.setLocalParticipantProperty('localRecording', localRecording); + } + } + } + // Allow the redux update to go through and compare the old avatar // to the new avatar and emit out change events if necessary. const result = next(action); @@ -618,6 +664,35 @@ function _participantJoinedOrUpdated(store, next, action) { return result; } +/** + * Handles a local recording status update. + * + * @param {Function} dispatch - The Redux dispatch function. + * @param {Object} conference - The conference for which we got an update. + * @param {string} participantId - The ID of the participant from which we got an update. + * @param {boolean} newValue - The new value of the local recording status. + * @returns {void} + */ +function _localRecordingUpdated({ dispatch, getState }, conference, participantId, newValue) { + const state = getState(); + + dispatch(participantUpdated({ + conference, + id: participantId, + localRecording: newValue + })); + const participantName = getParticipantDisplayName(state, participantId); + + dispatch(showNotification({ + titleKey: 'notify.somebody', + title: participantName, + descriptionKey: newValue ? 'notify.localRecordingStarted' : 'notify.localRecordingStopped', + uid: LOCAL_RECORDING_NOTIFICATION_ID + }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); + dispatch(playSound(newValue ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID)); +} + + /** * Handles a raise hand status update. * diff --git a/react/features/notifications/constants.js b/react/features/notifications/constants.js index ed4fafe8f8..d480aa39ac 100644 --- a/react/features/notifications/constants.js +++ b/react/features/notifications/constants.js @@ -59,18 +59,18 @@ export const NOTIFICATION_ICON = { }; /** - * The identifier of the salesforce link notification. + * The identifier of the lobby notification. * * @type {string} */ -export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION'; +export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION'; /** - * The identifier of the lobby notification. + * The identifier of the local recording notification. * * @type {string} */ -export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION'; +export const LOCAL_RECORDING_NOTIFICATION_ID = 'LOCAL_RECORDING_NOTIFICATION_ID'; /** * The identifier of the raise hand notification. @@ -79,6 +79,13 @@ export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION'; */ export const RAISE_HAND_NOTIFICATION_ID = 'RAISE_HAND_NOTIFICATION'; +/** + * The identifier of the salesforce link notification. + * + * @type {string} + */ +export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION'; + /** * Amount of participants beyond which no join notification will be emitted. */ diff --git a/react/features/recording/actionTypes.ts b/react/features/recording/actionTypes.ts index 53c84a4f99..829d1b37aa 100644 --- a/react/features/recording/actionTypes.ts +++ b/react/features/recording/actionTypes.ts @@ -66,3 +66,21 @@ export const SET_STREAM_KEY = 'SET_STREAM_KEY'; * } */ export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_STATE'; + +/** + * Attempts to start the local recording. + * + * { + * type: START_LOCAL_RECORDING + * } + */ +export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING'; + +/** + * Stops local recording. + * + * { + * type: STOP_LOCAL_RECORDING + * } + */ +export const STOP_LOCAL_RECORDING = 'STOP_LOCAL_RECORDING'; diff --git a/react/features/recording/actions.any.js b/react/features/recording/actions.any.js index 9945dabb57..d6d572d01a 100644 --- a/react/features/recording/actions.any.js +++ b/react/features/recording/actions.any.js @@ -19,7 +19,9 @@ import { SET_MEETING_HIGHLIGHT_BUTTON_STATE, SET_PENDING_RECORDING_NOTIFICATION_UID, SET_SELECTED_RECORDING_SERVICE, - SET_STREAM_KEY + SET_STREAM_KEY, + START_LOCAL_RECORDING, + STOP_LOCAL_RECORDING } from './actionTypes'; import { getRecordingLink, @@ -332,3 +334,25 @@ function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) { uid }; } + +/** + * Starts local recording. + * + * @returns {Object} + */ +export function startLocalVideoRecording() { + return { + type: START_LOCAL_RECORDING + }; +} + +/** + * Stops local recording. + * + * @returns {Object} + */ +export function stopLocalVideoRecording() { + return { + type: STOP_LOCAL_RECORDING + }; +} diff --git a/react/features/recording/components/Recording/AbstractRecordButton.js b/react/features/recording/components/Recording/AbstractRecordButton.js index e51f9dfc7a..e7a9414868 100644 --- a/react/features/recording/components/Recording/AbstractRecordButton.js +++ b/react/features/recording/components/Recording/AbstractRecordButton.js @@ -11,6 +11,8 @@ import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions'; import { FEATURES } from '../../../jaas/constants'; import { getActiveSession, getRecordButtonProps } from '../../functions'; +import LocalRecordingManager from './LocalRecordingManager'; + /** * The type of the React {@code Component} props of * {@link AbstractRecordButton}. @@ -142,7 +144,8 @@ export function _mapStateToProps(state: Object): Object { return { _disabled, - _isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE)), + _isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE)) + || LocalRecordingManager.isRecordingLocally(), _tooltip, visible }; diff --git a/react/features/recording/components/Recording/AbstractStartRecordingDialog.js b/react/features/recording/components/Recording/AbstractStartRecordingDialog.js index 50af90550c..84a4267239 100644 --- a/react/features/recording/components/Recording/AbstractStartRecordingDialog.js +++ b/react/features/recording/components/Recording/AbstractStartRecordingDialog.js @@ -15,7 +15,7 @@ import { } from '../../../dropbox'; import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications'; import { toggleRequestingSubtitles } from '../../../subtitles'; -import { setSelectedRecordingService } from '../../actions'; +import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions'; import { RECORDING_TYPES } from '../../constants'; export type Props = { @@ -293,8 +293,9 @@ class AbstractStartRecordingDialog extends Component { let appData; const attributes = {}; - if (_isDropboxEnabled && this.state.selectedRecordingService === RECORDING_TYPES.DROPBOX) { - if (_token) { + switch (this.state.selectedRecordingService) { + case RECORDING_TYPES.DROPBOX: { + if (_isDropboxEnabled && _token) { appData = JSON.stringify({ 'file_recording_metadata': { 'upload_credentials': { @@ -313,13 +314,22 @@ class AbstractStartRecordingDialog extends Component { return; } - } else { + break; + } + case RECORDING_TYPES.JITSI_REC_SERVICE: { appData = JSON.stringify({ 'file_recording_metadata': { 'share': this.state.sharingEnabled } }); attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE; + break; + } + case RECORDING_TYPES.LOCAL: { + dispatch(startLocalVideoRecording()); + + return true; + } } sendAnalytics( diff --git a/react/features/recording/components/Recording/AbstractStopRecordingDialog.js b/react/features/recording/components/Recording/AbstractStopRecordingDialog.js index afcccee29b..483dc12f5f 100644 --- a/react/features/recording/components/Recording/AbstractStopRecordingDialog.js +++ b/react/features/recording/components/Recording/AbstractStopRecordingDialog.js @@ -7,8 +7,11 @@ import { sendAnalytics } from '../../../analytics'; import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; +import { stopLocalVideoRecording } from '../../actions'; import { getActiveSession } from '../../functions'; +import LocalRecordingManager from './LocalRecordingManager'; + /** * The type of the React {@code Component} props of * {@link AbstractStopRecordingDialog}. @@ -25,6 +28,11 @@ export type Props = { */ _fileRecordingSession: Object, + /** + * Whether the recording is a local recording or not. + */ + _localRecording: boolean, + /** * The redux dispatch function. */ @@ -68,11 +76,15 @@ export default class AbstractStopRecordingDialog _onSubmit() { sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button')); - const { _fileRecordingSession } = this.props; + if (this.props._localRecording) { + this.props.dispatch(stopLocalVideoRecording()); + } else { + const { _fileRecordingSession } = this.props; - if (_fileRecordingSession) { - this.props._conference.stopRecording(_fileRecordingSession.id); - this._toggleScreenshotCapture(); + if (_fileRecordingSession) { + this.props._conference.stopRecording(_fileRecordingSession.id); + this._toggleScreenshotCapture(); + } } return true; @@ -105,6 +117,7 @@ export function _mapStateToProps(state: Object) { return { _conference: state['features/base/conference'].conference, _fileRecordingSession: - getActiveSession(state, JitsiRecordingConstants.mode.FILE) + getActiveSession(state, JitsiRecordingConstants.mode.FILE), + _localRecording: LocalRecordingManager.isRecordingLocally() }; } diff --git a/react/features/recording/components/Recording/LocalRecordingManager.ts b/react/features/recording/components/Recording/LocalRecordingManager.ts new file mode 100644 index 0000000000..8450660bca --- /dev/null +++ b/react/features/recording/components/Recording/LocalRecordingManager.ts @@ -0,0 +1,221 @@ +import { v4 as uuidV4 } from 'uuid'; +import fixWebmDuration from 'webm-duration-fix'; + +// @ts-ignore +import { getRoomName } from '../../../base/conference'; +// @ts-ignore +import { MEDIA_TYPE } from '../../../base/media'; +// @ts-ignore +import { getTrackState } from '../../../base/tracks'; +// @ts-ignore +import { stopLocalVideoRecording } from '../../actions.any'; + +interface IReduxStore { + dispatch: Function; + getState: Function; +} + +interface ILocalRecordingManager { + recordingData: Blob[]; + recorder: MediaRecorder|undefined; + stream: MediaStream|undefined; + audioContext: AudioContext|undefined; + audioDestination: MediaStreamAudioDestinationNode|undefined; + roomName: string; + mediaType: string; + initializeAudioMixer: () => void; + mixAudioStream: (stream: MediaStream) => void; + addAudioTrackToLocalRecording: (track: MediaStreamTrack) => void; + getFilename: () => string; + saveRecording: (recordingData: Blob[], filename: string) => void; + stopLocalRecording: () => void; + startLocalRecording: (store: IReduxStore) => void; + isRecordingLocally: () => boolean; + totalSize: number; +} + +const getMimeType = (): string => { + const possibleTypes = [ + 'video/mp4;codecs=h264', + 'video/webm;codecs=h264', + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + ]; + for(let type of possibleTypes) { + if(MediaRecorder.isTypeSupported(type)) { + return type; + } + } + throw new Error("No MIME Type supported by MediaRecorder"); +} + +const VIDEO_BIT_RATE = 2500000; // 2.5Mbps in bits + +const LocalRecordingManager: ILocalRecordingManager = { + recordingData: [], + recorder: undefined, + stream: undefined, + audioContext: undefined, + audioDestination: undefined, + roomName: '', + mediaType: getMimeType(), + totalSize: 1073741824, // 1GB in bytes + + /** + * Initializes audio context used for mixing audio tracks. + */ + initializeAudioMixer() { + this.audioContext = new AudioContext(); + this.audioDestination = this.audioContext.createMediaStreamDestination(); + }, + + /** + * Mixes multiple audio tracks to the destination media stream. + * */ + mixAudioStream(stream) { + if (stream.getAudioTracks().length > 0 && this.audioDestination) { + this.audioContext?.createMediaStreamSource(stream).connect(this.audioDestination); + } + }, + + /** + * Adds audio track to the recording stream. + */ + addAudioTrackToLocalRecording(track) { + if (track) { + const stream = new MediaStream([ track ]); + + this.mixAudioStream(stream); + } + }, + + /** + * Returns a filename based ono the Jitsi room name in the URL and timestamp. + * */ + getFilename() { + const now = new Date(); + const timestamp = now.toISOString(); + + return `${this.roomName}_${timestamp}`; + }, + + /** + * Saves local recording to file. + * */ + async saveRecording(recordingData, filename) { + // @ts-ignore + const blob = await fixWebmDuration(new Blob(recordingData, { type: this.mediaType })); + // @ts-ignore + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + + const extension = this.mediaType.slice(this.mediaType.indexOf('/') + 1, this.mediaType.indexOf(';')) + a.style.display = 'none'; + a.href = url; + a.download = `${filename}.${extension}`; + a.click(); + }, + + /** + * Stops local recording. + * */ + stopLocalRecording() { + if (this.recorder) { + this.recorder.stop(); + this.recorder = undefined; + this.audioContext = undefined; + this.audioDestination = undefined; + setTimeout(() => this.saveRecording(this.recordingData, this.getFilename()), 1000); + } + }, + + /** + * Starts a local recording. + */ + async startLocalRecording(store) { + const { dispatch, getState } = store; + // @ts-ignore + const supportsCaptureHandle = Boolean(navigator.mediaDevices.setCaptureHandleConfig); + const tabId = uuidV4(); + + if (supportsCaptureHandle) { + // @ts-ignore + navigator.mediaDevices.setCaptureHandleConfig({ + handle: `JitsiMeet-${tabId}`, + permittedOrigins: [ '*' ] + }); + } + + this.recordingData = []; + // @ts-ignore + const gdmStream = await navigator.mediaDevices.getDisplayMedia({ + // @ts-ignore + video: { displaySurface: 'browser' }, + audio: true + }); + // @ts-ignore + const isBrowser = gdmStream.getVideoTracks()[0].getSettings().displaySurface === 'browser'; + + if (!isBrowser || (supportsCaptureHandle // @ts-ignore + && gdmStream.getVideoTracks()[0].getCaptureHandle()?.handle !== `JitsiMeet-${tabId}`)) { + gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + throw new Error('WrongSurfaceSelected'); + } + + this.initializeAudioMixer(); + this.mixAudioStream(gdmStream); + this.roomName = getRoomName(getState()); + const tracks = getTrackState(getState()); + + tracks.forEach((track: any) => { + if (track.mediaType === MEDIA_TYPE.AUDIO) { + const audioTrack = track?.jitsiTrack?.track; + + this.addAudioTrackToLocalRecording(audioTrack); + } + }); + + this.stream = new MediaStream([ + ...(this.audioDestination?.stream.getAudioTracks() || []), + gdmStream.getVideoTracks()[0] + ]); + this.recorder = new MediaRecorder(this.stream, { + mimeType: this.mediaType, + videoBitsPerSecond: VIDEO_BIT_RATE + }); + this.recorder.addEventListener('dataavailable', e => { + if (e.data && e.data.size > 0) { + this.recordingData.push(e.data); + this.totalSize -= e.data.size; + if (this.totalSize <= 0) { + this.stopLocalRecording(); + } + } + }); + + this.recorder.addEventListener('stop', () => { + this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop()); + }); + + gdmStream.addEventListener('inactive', () => { + dispatch(stopLocalVideoRecording()); + }); + + this.stream.addEventListener('inactive', () => { + dispatch(stopLocalVideoRecording()); + }); + + this.recorder.start(5000); + }, + + /** + * Whether or not we're currently recording locally. + */ + isRecordingLocally() { + return Boolean(this.recorder); + } + +}; + +export default LocalRecordingManager; diff --git a/react/features/recording/components/Recording/StartRecordingDialogContent.js b/react/features/recording/components/Recording/StartRecordingDialogContent.js index 3e93902c73..3eeecc4512 100644 --- a/react/features/recording/components/Recording/StartRecordingDialogContent.js +++ b/react/features/recording/components/Recording/StartRecordingDialogContent.js @@ -10,7 +10,9 @@ import { ColorSchemeRegistry } from '../../../base/color-scheme'; import { _abstractMapStateToProps } from '../../../base/dialog'; +import { isMobileBrowser } from '../../../base/environment/utils'; import { translate } from '../../../base/i18n'; +import { browser } from '../../../base/lib-jitsi-meet'; import { Button, Container, @@ -31,6 +33,7 @@ import { ICON_CLOUD, ICON_INFO, ICON_USERS, + LOCAL_RECORDING, TRACK_COLOR } from './styles'; @@ -41,6 +44,11 @@ type Props = { */ _dialogStyles: StyleType, + /** + * Whether local recording is enabled or not. + */ + _localRecordingEnabled: boolean, + /** * The color-schemed stylesheet of this component. */ @@ -126,6 +134,8 @@ type Props = { * @augments Component */ class StartRecordingDialogContent extends Component { + _localRecordingAvailable: boolean; + /** * Initializes a new {@code StartRecordingDialogContent} instance. * @@ -133,12 +143,29 @@ class StartRecordingDialogContent extends Component { */ constructor(props) { super(props); + const supportsLocalRecording = browser.isChromiumBased() && !browser.isElectron() && !isMobileBrowser(); + + this._localRecordingAvailable = props._localRecordingEnabled && supportsLocalRecording; // Bind event handler so it is only bound once for every instance. this._onSignIn = this._onSignIn.bind(this); this._onSignOut = this._onSignOut.bind(this); this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this); this._onRecordingServiceSwitchChange = this._onRecordingServiceSwitchChange.bind(this); + this._onLocalRecordingSwitchChange = this._onLocalRecordingSwitchChange.bind(this); + } + + /** + * Implements the Component's componentDidMount method. + * + * @inheritdoc + */ + componentDidMount() { + if (!this._shouldRenderNoIntegrationsContent() + && !this._shouldRenderIntegrationsContent() + && !this._shouldRenderFileSharingContent()) { + this._onLocalRecordingSwitchChange(); + } } /** @@ -158,21 +185,35 @@ class StartRecordingDialogContent extends Component { { this._renderFileSharingContent() } { this._renderUploadToTheCloudInfo() } { this._renderIntegrationsContent() } + { this._renderLocalRecordingContent() } ); } /** - * Renders the file recording service sharing options, if enabled. + * Whether the file sharing content should be rendered or not. * - * @returns {React$Component} + * @returns {boolean} */ - _renderFileSharingContent() { + _shouldRenderFileSharingContent() { const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props; if (!fileRecordingsServiceSharingEnabled || isVpaas || selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) { + return false; + } + + return true; + } + + /** + * Renders the file recording service sharing options, if enabled. + * + * @returns {React$Component} + */ + _renderFileSharingContent() { + if (!this._shouldRenderFileSharingContent()) { return null; } @@ -256,23 +297,35 @@ class StartRecordingDialogContent extends Component { } /** - * Renders the content in case no integrations were enabled. + * Whether the no integrations content should be rendered or not. * - * @returns {React$Component} + * @returns {boolean} */ - _renderNoIntegrationsContent() { - + _shouldRenderNoIntegrationsContent() { // show the non integrations part only if fileRecordingsServiceEnabled // is enabled or when there are no integrations enabled if (!(this.props.fileRecordingsServiceEnabled || !this.props.integrationsEnabled)) { + return false; + } + + return true; + } + + /** + * Renders the content in case no integrations were enabled. + * + * @returns {React$Component} + */ + _renderNoIntegrationsContent() { + if (!this._shouldRenderNoIntegrationsContent()) { return null; } const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props; const switchContent - = this.props.integrationsEnabled + = this.props.integrationsEnabled || this.props._localRecordingEnabled ? ( { const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription'); const jitsiContentRecordingIconContainer - = this.props.integrationsEnabled + = this.props.integrationsEnabled || this.props._localRecordingEnabled ? 'jitsi-content-recording-icon-container-with-switch' : 'jitsi-content-recording-icon-container-without-switch'; const contentRecordingClass = isVpaas @@ -317,6 +370,19 @@ class StartRecordingDialogContent extends Component { ); } + /** + * Whether the integrations content should be rendered or not. + * + * @returns {boolean} + */ + _shouldRenderIntegrationsContent() { + if (!this.props.integrationsEnabled) { + return false; + } + + return true; + } + /** * Renders the content in case integrations were enabled. * @@ -324,7 +390,7 @@ class StartRecordingDialogContent extends Component { * @returns {React$Component} */ _renderIntegrationsContent() { - if (!this.props.integrationsEnabled) { + if (!this._shouldRenderIntegrationsContent()) { return null; } @@ -376,7 +442,7 @@ class StartRecordingDialogContent extends Component { return ( @@ -405,6 +471,7 @@ class StartRecordingDialogContent extends Component { _onDropboxSwitchChange: () => void; _onRecordingServiceSwitchChange: () => void; + _onLocalRecordingSwitchChange: () => void; /** * Handler for onValueChange events from the Switch component. @@ -419,8 +486,7 @@ class StartRecordingDialogContent extends Component { } = this.props; // act like group, cannot toggle off - if (selectedRecordingService - === RECORDING_TYPES.JITSI_REC_SERVICE) { + if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) { return; } @@ -444,8 +510,7 @@ class StartRecordingDialogContent extends Component { } = this.props; // act like group, cannot toggle off - if (selectedRecordingService - === RECORDING_TYPES.DROPBOX) { + if (selectedRecordingService === RECORDING_TYPES.DROPBOX) { return; } @@ -456,6 +521,30 @@ class StartRecordingDialogContent extends Component { } } + /** + * Handler for onValueChange events from the Switch component. + * + * @returns {void} + */ + _onLocalRecordingSwitchChange() { + const { + onChange, + selectedRecordingService + } = this.props; + + if (!this._localRecordingAvailable) { + return; + } + + // act like group, cannot toggle off + if (selectedRecordingService + === RECORDING_TYPES.LOCAL) { + return; + } + + onChange(RECORDING_TYPES.LOCAL); + } + /** * Renders a spinner component. * @@ -511,6 +600,60 @@ class StartRecordingDialogContent extends Component { ); } + _renderLocalRecordingContent: () => void; + + /** + * Renders the content for local recordings. + * + * @protected + * @returns {React$Component} + */ + _renderLocalRecordingContent() { + const { _styles: styles, isValidating, t, _dialogStyles, selectedRecordingService } = this.props; + + if (!this._localRecordingAvailable) { + return null; + } + + return ( + + + + + + + { t('recording.saveLocalRecording') } + + + + {selectedRecordingService === RECORDING_TYPES.LOCAL + && + {t('recording.localRecordingWarning')} + + } + + + ); + } + _onSignIn: () => void; /** @@ -546,6 +689,7 @@ function _mapStateToProps(state) { return { ..._abstractMapStateToProps(state), isVpaas: isVpaasMeeting(state), + _localRecordingEnabled: state['features/base/config'].enableLocalRecording, _styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent') }; } diff --git a/react/features/recording/components/Recording/styles.native.js b/react/features/recording/components/Recording/styles.native.js index 7761c661d9..68bbf80c31 100644 --- a/react/features/recording/components/Recording/styles.native.js +++ b/react/features/recording/components/Recording/styles.native.js @@ -8,6 +8,7 @@ export const DROPBOX_LOGO = require('../../../../../images/dropboxLogo_square.pn export const ICON_CLOUD = require('../../../../../images/icon-cloud.png'); export const ICON_INFO = require('../../../../../images/icon-info.png'); export const ICON_USERS = require('../../../../../images/icon-users.png'); +export const LOCAL_RECORDING = require('../../../../../images/downloadLocalRecording.png'); export const TRACK_COLOR = BaseTheme.palette.ui15; diff --git a/react/features/recording/components/Recording/styles.web.js b/react/features/recording/components/Recording/styles.web.js index 0ce285bfe7..f9db609ea5 100644 --- a/react/features/recording/components/Recording/styles.web.js +++ b/react/features/recording/components/Recording/styles.web.js @@ -6,6 +6,8 @@ export default {}; export const DROPBOX_LOGO = 'images/dropboxLogo_square.png'; +export const LOCAL_RECORDING = 'images/downloadLocalRecording.png'; + export const ICON_CLOUD = 'images/icon-cloud.png'; export const ICON_INFO = 'images/icon-info.png'; diff --git a/react/features/recording/components/Recording/web/StartRecordingDialog.js b/react/features/recording/components/Recording/web/StartRecordingDialog.js index 0085ba1286..aa01c44e30 100644 --- a/react/features/recording/components/Recording/web/StartRecordingDialog.js +++ b/react/features/recording/components/Recording/web/StartRecordingDialog.js @@ -39,6 +39,8 @@ class StartRecordingDialog extends AbstractStartRecordingDialog { return false; } else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) { return !isTokenValid; + } else if (selectedRecordingService === RECORDING_TYPES.LOCAL) { + return false; } return true; diff --git a/react/features/recording/constants.js b/react/features/recording/constants.js index c72ac2d6b5..163d7eb91f 100644 --- a/react/features/recording/constants.js +++ b/react/features/recording/constants.js @@ -45,7 +45,8 @@ export const RECORDING_ON_SOUND_ID = 'RECORDING_ON_SOUND'; */ export const RECORDING_TYPES = { JITSI_REC_SERVICE: 'recording-service', - DROPBOX: 'dropbox' + DROPBOX: 'dropbox', + LOCAL: 'local' }; /** diff --git a/react/features/recording/functions.js b/react/features/recording/functions.js index 1d416d8460..26a3b482f7 100644 --- a/react/features/recording/functions.js +++ b/react/features/recording/functions.js @@ -1,11 +1,12 @@ // @flow import { JitsiRecordingConstants } from '../base/lib-jitsi-meet'; -import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants'; +import { getLocalParticipant, getRemoteParticipants, isLocalParticipantModerator } from '../base/participants'; import { isInBreakoutRoom } from '../breakout-rooms/functions'; import { isEnabled as isDropboxEnabled } from '../dropbox'; import { extractFqnFromPath } from '../dynamic-branding/functions.any'; +import LocalRecordingManager from './components/Recording/LocalRecordingManager'; import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants'; import logger from './logger'; @@ -116,6 +117,11 @@ export function getSessionStatusToShow(state: Object, mode: string): ?string { } } } + if ((!Array.isArray(recordingSessions) || recordingSessions.length === 0) + && mode === JitsiRecordingConstants.mode.FILE + && (LocalRecordingManager.isRecordingLocally() || isRemoteParticipantRecordingLocally(state))) { + status = JitsiRecordingConstants.status.ON; + } return status; } @@ -241,3 +247,22 @@ export async function sendMeetingHighlight(state: Object) { return false; } + +/** + * Whether a remote participant is recording locally or not. + * + * @param {Object} state - Redux state. + * @returns {boolean} + */ +function isRemoteParticipantRecordingLocally(state) { + const participants = getRemoteParticipants(state); + + // eslint-disable-next-line prefer-const + for (let value of participants.values()) { + if (value.localRecording) { + return true; + } + } + + return false; +} diff --git a/react/features/recording/middleware.js b/react/features/recording/middleware.js index 41811289a7..49bfb1bbe4 100644 --- a/react/features/recording/middleware.js +++ b/react/features/recording/middleware.js @@ -11,7 +11,8 @@ import JitsiMeetJS, { JitsiConferenceEvents, JitsiRecordingConstants } from '../base/lib-jitsi-meet'; -import { getParticipantDisplayName } from '../base/participants'; +import { MEDIA_TYPE } from '../base/media'; +import { getParticipantDisplayName, updateLocalRecordingStatus } from '../base/participants'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { playSound, @@ -19,8 +20,10 @@ import { stopSound, unregisterSound } from '../base/sounds'; +import { TRACK_ADDED } from '../base/tracks'; +import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification, showNotification } from '../notifications'; -import { RECORDING_SESSION_UPDATED } from './actionTypes'; +import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes'; import { clearRecordingSessions, hidePendingRecordingNotification, @@ -32,13 +35,18 @@ import { showStoppedRecordingNotification, updateRecordingSessionData } from './actions'; +import LocalRecordingManager from './components/Recording/LocalRecordingManager'; import { LIVE_STREAMING_OFF_SOUND_ID, LIVE_STREAMING_ON_SOUND_ID, RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from './constants'; -import { getSessionById, getResourceId } from './functions'; +import { + getSessionById, + getResourceId +} from './functions'; +import logger from './logger'; import { LIVE_STREAMING_OFF_SOUND_FILE, LIVE_STREAMING_ON_SOUND_FILE, @@ -68,7 +76,7 @@ StateListenerRegistry.register( * @param {Store} store - The redux store. * @returns {Function} */ -MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { +MiddlewareRegistry.register(({ dispatch, getState }) => next => async action => { let oldSessionData; if (action.type === RECORDING_SESSION_UPDATED) { @@ -123,6 +131,41 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } + case START_LOCAL_RECORDING: { + try { + await LocalRecordingManager.startLocalRecording({ dispatch, + getState }); + const props = { + descriptionKey: 'recording.on', + titleKey: 'dialog.recording' + }; + + dispatch(playSound(RECORDING_ON_SOUND_ID)); + dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); + dispatch(updateLocalRecordingStatus(true)); + } catch (err) { + logger.error('Capture failed', err); + + const noTabError = err.message === 'WrongSurfaceSelected'; + const props = { + descriptionKey: noTabError ? 'recording.surfaceError' : 'recording.error', + titleKey: 'recording.failedToStart' + }; + + dispatch(showErrorNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); + } + break; + } + + case STOP_LOCAL_RECORDING: { + if (LocalRecordingManager.isRecordingLocally()) { + LocalRecordingManager.stopLocalRecording(); + dispatch(playSound(RECORDING_OFF_SOUND_ID)); + dispatch(updateLocalRecordingStatus(false)); + } + break; + } + case RECORDING_SESSION_UPDATED: { // When in recorder mode no notifications are shown // or extra sounds are also not desired @@ -211,6 +254,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } + case TRACK_ADDED: { + const { track } = action; + + if (LocalRecordingManager.isRecordingLocally() && track.mediaType === MEDIA_TYPE.AUDIO) { + const audioTrack = track.jitsiTrack.track; + + LocalRecordingManager.addAudioTrackToLocalRecording(audioTrack); + } + break; + } } return result;