feat(local-video-recording) Allow users to record the meeting locally (#11338)

This commit is contained in:
Robert Pintilii 2022-06-03 12:45:27 +01:00 committed by GitHub
parent 7ac573d628
commit e27069447b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 791 additions and 75 deletions

View File

@ -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

View File

@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

View File

@ -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 <a href={{url}} rel='noopener noreferrer' target='_blank'>{{app}}</a>.",
"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"

107
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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',

View File

@ -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';

View File

@ -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
};
}

View File

@ -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.
*

View File

@ -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.
*/

View File

@ -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';

View File

@ -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
};
}

View File

@ -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
};

View File

@ -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<Props, State> {
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<Props, State> {
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(

View File

@ -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,12 +76,16 @@ export default class AbstractStopRecordingDialog<P: Props>
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
if (this.props._localRecording) {
this.props.dispatch(stopLocalVideoRecording());
} else {
const { _fileRecordingSession } = this.props;
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()
};
}

View File

@ -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;

View File

@ -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<Props> {
_localRecordingAvailable: boolean;
/**
* Initializes a new {@code StartRecordingDialogContent} instance.
*
@ -133,12 +143,29 @@ class StartRecordingDialogContent extends Component<Props> {
*/
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<Props> {
{ this._renderFileSharingContent() }
{ this._renderUploadToTheCloudInfo() }
{ this._renderIntegrationsContent() }
{ this._renderLocalRecordingContent() }
</Container>
);
}
/**
* 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<Props> {
);
}
/**
* 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
? (
<Switch
className = 'recording-switch'
@ -285,7 +338,7 @@ class StartRecordingDialogContent extends Component<Props> {
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<Props> {
);
}
/**
* 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<Props> {
* @returns {React$Component}
*/
_renderIntegrationsContent() {
if (!this.props.integrationsEnabled) {
if (!this._shouldRenderIntegrationsContent()) {
return null;
}
@ -376,7 +442,7 @@ class StartRecordingDialogContent extends Component<Props> {
return (
<Container>
<Container
className = 'recording-header recording-header-line'
className = 'recording-header'
style = { styles.headerIntegrations }>
<Container
className = 'recording-icon-container'>
@ -405,6 +471,7 @@ class StartRecordingDialogContent extends Component<Props> {
_onDropboxSwitchChange: () => void;
_onRecordingServiceSwitchChange: () => void;
_onLocalRecordingSwitchChange: () => void;
/**
* Handler for onValueChange events from the Switch component.
@ -419,8 +486,7 @@ class StartRecordingDialogContent extends Component<Props> {
} = 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<Props> {
} = 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<Props> {
}
}
/**
* 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<Props> {
);
}
_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 (
<Container>
<Container
className = 'recording-header recording-header-line'
style = { styles.header }>
<Container
className = 'recording-icon-container'>
<Image
className = 'recording-icon'
src = { LOCAL_RECORDING }
style = { styles.recordingIcon } />
</Container>
<Text
className = 'recording-title'
style = {{
..._dialogStyles.text,
...styles.title
}}>
{ t('recording.saveLocalRecording') }
</Text>
<Switch
className = 'recording-switch'
disabled = { isValidating }
onValueChange = { this._onLocalRecordingSwitchChange }
style = { styles.switch }
trackColor = {{ false: TRACK_COLOR }}
value = { this.props.selectedRecordingService
=== RECORDING_TYPES.LOCAL } />
</Container>
{selectedRecordingService === RECORDING_TYPES.LOCAL
&& <Text className = 'local-recording-warning'>
{t('recording.localRecordingWarning')}
</Text>
}
</Container>
);
}
_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')
};
}

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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'
};
/**

View File

@ -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;
}

View File

@ -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;