diff --git a/config.js b/config.js
index 341cdff92..bdb3cafea 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 a1f60b7de..1478c2797 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 000000000..3e499cd65
Binary files /dev/null and b/images/downloadLocalRecording.png differ
diff --git a/lang/main.json b/lang/main.json
index 192c69067..4818d7bb1 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 e6954704a..b47064ab1 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 5827b6450..694ec00e4 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 9bf2e6d67..f933b8a39 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 daa0ccf0d..bd0f2f8ca 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 0842b3183..fc8b25def 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 c9b1c5cce..7db444e1a 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 ed4fafe8f..d480aa39a 100644
--- a/react/features/notifications/constants.js
+++ b/react/features/notifications/constants.js
@@ -58,13 +58,6 @@ export const NOTIFICATION_ICON = {
PARTICIPANTS: 'participants'
};
-/**
- * The identifier of the salesforce link notification.
- *
- * @type {string}
- */
-export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
-
/**
* The identifier of the lobby notification.
*
@@ -72,6 +65,13 @@ export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
*/
export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION';
+/**
+ * The identifier of the local recording notification.
+ *
+ * @type {string}
+ */
+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 53c84a4f9..829d1b37a 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 9945dabb5..d6d572d01 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 e51f9dfc7..e7a941486 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 50af90550..84a426723 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 afcccee29..483dc12f5 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 000000000..8450660bc
--- /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 3e93902c7..3eeecc451 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() }
);
}
+ /**
+ * Whether the file sharing content should be rendered or not.
+ *
+ * @returns {boolean}
+ */
+ _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() {
- const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props;
-
- if (!fileRecordingsServiceSharingEnabled
- || isVpaas
- || selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
+ if (!this._shouldRenderFileSharingContent()) {
return null;
}
@@ -255,24 +296,36 @@ class StartRecordingDialogContent extends Component {
);
}
+ /**
+ * Whether the no integrations content should be rendered or not.
+ *
+ * @returns {boolean}
+ */
+ _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() {
-
- // show the non integrations part only if fileRecordingsServiceEnabled
- // is enabled or when there are no integrations enabled
- if (!(this.props.fileRecordingsServiceEnabled
- || !this.props.integrationsEnabled)) {
+ 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 7761c661d..68bbf80c3 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 0ce285bfe..f9db609ea 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 0085ba128..aa01c44e3 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 c72ac2d6b..163d7eb91 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 1d416d846..26a3b482f 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 41811289a..49bfb1bbe 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;