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
24 changed files with 791 additions and 75 deletions

View File

@@ -295,6 +295,9 @@ var config = {
// Whether to enable live streaming or not. // Whether to enable live streaming or not.
// liveStreamingEnabled: false, // liveStreamingEnabled: false,
// Whether to enable local recording or not.
// enableLocalRecording: false,
// Transcription (in interface_config, // Transcription (in interface_config,
// subtitles and buttons can be configured) // subtitles and buttons can be configured)
// transcribingEnabled: false, // 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: { // e2ee: {
// labels, // labels,
// externallyManagedKey: false // externallyManagedKey: false
@@ -1305,7 +1291,6 @@ var config = {
// 'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable // '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.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 // '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.chatMessages', // shown when receiving chat messages while the chat window is closed
// 'notify.disconnected', // shown when a participant has left // 'notify.disconnected', // shown when a participant has left
// 'notify.connectedOneMember', // show when a participant joined // 'notify.connectedOneMember', // show when a participant joined

View File

@@ -23,7 +23,13 @@
.recording-header-line { .recording-header-line {
border-top: 1px solid #5e6d7a; 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 { .recording-switch-disabled {

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -657,6 +657,8 @@
"linkToSalesforceKey": "Link this meeting", "linkToSalesforceKey": "Link this meeting",
"linkToSalesforceProgress": "Linking meeting to Salesforce...", "linkToSalesforceProgress": "Linking meeting to Salesforce...",
"linkToSalesforceSuccess": "The meeting was linked 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", "me": "Me",
"moderationInEffectCSDescription": "Please raise hand if you want to share your screen.", "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.",
"moderationInEffectCSTitle": "Screen sharing is blocked by the moderator", "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>.", "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.", "linkGenerated": "We have generated a link to your recording.",
"live": "LIVE", "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}}", "loggedIn": "Logged in as {{userName}}",
"off": "Recording stopped", "off": "Recording stopped",
"offBy": "{{name}} stopped the recording", "offBy": "{{name}} stopped the recording",
@@ -894,6 +897,7 @@
"onBy": "{{name}} started the recording", "onBy": "{{name}} started the recording",
"pending": "Preparing to record the meeting...", "pending": "Preparing to record the meeting...",
"rec": "REC", "rec": "REC",
"saveLocalRecording": "Save recording file locally",
"serviceDescription": "Your recording will be saved by the recording service", "serviceDescription": "Your recording will be saved by the recording service",
"serviceDescriptionCloud": "Cloud recording", "serviceDescriptionCloud": "Cloud recording",
"serviceDescriptionCloudInfo": "Recorded meetings are automatically cleared 24h after their recording time.", "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.", "sessionAlreadyActive": "This session is already being recorded or live streamed.",
"signIn": "Sign in", "signIn": "Sign in",
"signOut": "Sign out", "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.", "unavailable": "Oops! The {{serviceName}} is currently unavailable. We're working on resolving the issue. Please try again later.",
"unavailableTitle": "Recording unavailable", "unavailableTitle": "Recording unavailable",
"uploadToCloud": "Upload to the cloud" "uploadToCloud": "Upload to the cloud"

107
package-lock.json generated
View File

@@ -128,6 +128,7 @@
"util": "0.12.1", "util": "0.12.1",
"uuid": "8.3.2", "uuid": "8.3.2",
"wasm-check": "2.0.1", "wasm-check": "2.0.1",
"webm-duration-fix": "1.0.4",
"windows-iana": "^3.1.0", "windows-iana": "^3.1.0",
"zxcvbn": "4.4.2" "zxcvbn": "4.4.2"
}, },
@@ -141,6 +142,7 @@
"@babel/runtime": "7.16.0", "@babel/runtime": "7.16.0",
"@jitsi/eslint-config": "4.0.0", "@jitsi/eslint-config": "4.0.0",
"@types/react-native": "0.67.6", "@types/react-native": "0.67.6",
"@types/uuid": "8.3.4",
"babel-loader": "8.2.3", "babel-loader": "8.2.3",
"babel-plugin-optional-require": "0.3.1", "babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0", "circular-dependency-plugin": "5.2.0",
@@ -163,7 +165,7 @@
"style-loader": "0.19.0", "style-loader": "0.19.0",
"traverse": "0.6.6", "traverse": "0.6.6",
"ts-loader": "9.2.6", "ts-loader": "9.2.6",
"typescript": "4.3.5", "typescript": "4.6.4",
"unorm": "1.6.0", "unorm": "1.6.0",
"webpack": "5.57.1", "webpack": "5.57.1",
"webpack-bundle-analyzer": "4.4.2", "webpack-bundle-analyzer": "4.4.2",
@@ -5561,6 +5563,12 @@
"@types/node": "*" "@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": { "node_modules/@types/webgl-ext": {
"version": "0.0.30", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
@@ -8323,6 +8331,11 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -10657,6 +10670,14 @@
"css-in-js-utils": "^2.0.0" "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": { "node_modules/internal-slot": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -18643,9 +18664,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.3.5", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
"dev": true, "dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -19123,6 +19144,40 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" "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": { "node_modules/webpack": {
"version": "5.57.1", "version": "5.57.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",
@@ -24162,6 +24217,12 @@
"@types/node": "*" "@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": { "@types/webgl-ext": {
"version": "0.0.30", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz", "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
@@ -26348,6 +26409,11 @@
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true "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": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -28173,6 +28239,11 @@
"css-in-js-utils": "^2.0.0" "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": { "internal-slot": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -34274,9 +34345,9 @@
} }
}, },
"typescript": { "typescript": {
"version": "4.3.5", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
"dev": true "dev": true
}, },
"ua-parser-js": { "ua-parser-js": {
@@ -34621,6 +34692,28 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" "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": { "webpack": {
"version": "5.57.1", "version": "5.57.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",

View File

@@ -133,6 +133,7 @@
"util": "0.12.1", "util": "0.12.1",
"uuid": "8.3.2", "uuid": "8.3.2",
"wasm-check": "2.0.1", "wasm-check": "2.0.1",
"webm-duration-fix": "1.0.4",
"windows-iana": "^3.1.0", "windows-iana": "^3.1.0",
"zxcvbn": "4.4.2" "zxcvbn": "4.4.2"
}, },
@@ -146,6 +147,7 @@
"@babel/runtime": "7.16.0", "@babel/runtime": "7.16.0",
"@jitsi/eslint-config": "4.0.0", "@jitsi/eslint-config": "4.0.0",
"@types/react-native": "0.67.6", "@types/react-native": "0.67.6",
"@types/uuid": "8.3.4",
"babel-loader": "8.2.3", "babel-loader": "8.2.3",
"babel-plugin-optional-require": "0.3.1", "babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0", "circular-dependency-plugin": "5.2.0",
@@ -168,7 +170,7 @@
"style-loader": "0.19.0", "style-loader": "0.19.0",
"traverse": "0.6.6", "traverse": "0.6.6",
"ts-loader": "9.2.6", "ts-loader": "9.2.6",
"typescript": "4.3.5", "typescript": "4.6.4",
"unorm": "1.6.0", "unorm": "1.6.0",
"webpack": "5.57.1", "webpack": "5.57.1",
"webpack-bundle-analyzer": "4.4.2", "webpack-bundle-analyzer": "4.4.2",

View File

@@ -142,6 +142,7 @@ export default [
'enableLayerSuspension', 'enableLayerSuspension',
'enableLipSync', 'enableLipSync',
'enableLobbyChat', 'enableLobbyChat',
'enableLocalRecording',
'enableOpusRed', 'enableOpusRed',
'enableRemb', 'enableRemb',
'enableSaveLogs', 'enableSaveLogs',
@@ -183,7 +184,6 @@ export default [
'ignoreStartMuted', 'ignoreStartMuted',
'inviteAppName', 'inviteAppName',
'liveStreamingEnabled', 'liveStreamingEnabled',
'localRecording',
'localSubject', 'localSubject',
'maxFullResolutionParticipants', 'maxFullResolutionParticipants',
'mouseMoveCallbackInterval', 'mouseMoveCallbackInterval',

View File

@@ -231,3 +231,12 @@ export const OVERWRITE_PARTICIPANT_NAME = 'OVERWRITE_PARTICIPANT_NAME';
* } * }
*/ */
export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES'; 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_AUDIO_LEVEL_CHANGED,
LOCAL_PARTICIPANT_RAISE_HAND, LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT, MUTE_REMOTE_PARTICIPANT,
OVERWRITE_PARTICIPANT_NAME,
OVERWRITE_PARTICIPANTS_NAMES,
PARTICIPANT_ID_CHANGED, PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
PARTICIPANT_KICKED, PARTICIPANT_KICKED,
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
PARTICIPANT_UPDATED, PARTICIPANT_UPDATED,
PIN_PARTICIPANT, PIN_PARTICIPANT,
RAISE_HAND_UPDATED,
SCREENSHARE_PARTICIPANT_NAME_CHANGED, SCREENSHARE_PARTICIPANT_NAME_CHANGED,
SET_LOADABLE_AVATAR_URL, SET_LOADABLE_AVATAR_URL,
RAISE_HAND_UPDATED, SET_LOCAL_PARTICIPANT_RECORDING_STATUS
OVERWRITE_PARTICIPANT_NAME,
OVERWRITE_PARTICIPANTS_NAMES
} from './actionTypes'; } from './actionTypes';
import { import {
DISCO_REMOTE_CONTROL_FEATURE DISCO_REMOTE_CONTROL_FEATURE
@@ -683,3 +684,19 @@ export function overwriteParticipantsNames(participantList) {
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 { toggleE2EE } from '../../e2ee/actions';
import { MAX_MODE } from '../../e2ee/constants'; import { MAX_MODE } from '../../e2ee/constants';
import { import {
LOCAL_RECORDING_NOTIFICATION_ID,
NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TIMEOUT_TYPE,
RAISE_HAND_NOTIFICATION_ID, RAISE_HAND_NOTIFICATION_ID,
showNotification showNotification
@@ -17,6 +18,7 @@ import {
import { isForceMuted } from '../../participants-pane/functions'; import { isForceMuted } from '../../participants-pane/functions';
import { CALLING, INVITED } from '../../presence-status'; import { CALLING, INVITED } from '../../presence-status';
import { RAISE_HAND_SOUND_ID } from '../../reactions/constants'; 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 { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
import { import {
CONFERENCE_WILL_JOIN, CONFERENCE_WILL_JOIN,
@@ -42,7 +44,8 @@ import {
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
PARTICIPANT_UPDATED, PARTICIPANT_UPDATED,
RAISE_HAND_UPDATED RAISE_HAND_UPDATED,
SET_LOCAL_PARTICIPANT_RECORDING_STATUS
} from './actionTypes'; } from './actionTypes';
import { import {
localParticipantIdChanged, localParticipantIdChanged,
@@ -174,6 +177,25 @@ MiddlewareRegistry.register(store => next => action => {
break; 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: { case MUTE_REMOTE_PARTICIPANT: {
const { conference } = store.getState()['features/base/conference']; const { conference } = store.getState()['features/base/conference'];
@@ -389,6 +411,8 @@ StateListenerRegistry.register(
id: participant.getId(), id: participant.getId(),
features: { 'screen-sharing': true } features: { 'screen-sharing': true }
})), })),
'localRecording': (participant, value) =>
_localRecordingUpdated(store, conference, participant.getId(), value),
'raisedHand': (participant, value) => 'raisedHand': (participant, value) =>
_raiseHandUpdated(store, conference, participant.getId(), value), _raiseHandUpdated(store, conference, participant.getId(), value),
'region': (participant, value) => 'region': (participant, value) =>
@@ -566,7 +590,15 @@ function _maybePlaySounds({ getState, dispatch }, action) {
function _participantJoinedOrUpdated(store, next, action) { function _participantJoinedOrUpdated(store, next, action) {
const { dispatch, getState } = store; const { dispatch, getState } = store;
const { overwrittenNameList } = store.getState()['features/base/participants']; 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 // Send an external update of the local participant's raised hand state
// if a new raised hand state is defined in the action. // 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]; 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 // Allow the redux update to go through and compare the old avatar
// to the new avatar and emit out change events if necessary. // to the new avatar and emit out change events if necessary.
const result = next(action); const result = next(action);
@@ -618,6 +664,35 @@ function _participantJoinedOrUpdated(store, next, action) {
return result; 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. * Handles a raise hand status update.
* *

View File

@@ -58,13 +58,6 @@ export const NOTIFICATION_ICON = {
PARTICIPANTS: 'participants' 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. * 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'; 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. * 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'; 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. * 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'; 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_MEETING_HIGHLIGHT_BUTTON_STATE,
SET_PENDING_RECORDING_NOTIFICATION_UID, SET_PENDING_RECORDING_NOTIFICATION_UID,
SET_SELECTED_RECORDING_SERVICE, SET_SELECTED_RECORDING_SERVICE,
SET_STREAM_KEY SET_STREAM_KEY,
START_LOCAL_RECORDING,
STOP_LOCAL_RECORDING
} from './actionTypes'; } from './actionTypes';
import { import {
getRecordingLink, getRecordingLink,
@@ -332,3 +334,25 @@ function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) {
uid 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 { FEATURES } from '../../../jaas/constants';
import { getActiveSession, getRecordButtonProps } from '../../functions'; import { getActiveSession, getRecordButtonProps } from '../../functions';
import LocalRecordingManager from './LocalRecordingManager';
/** /**
* The type of the React {@code Component} props of * The type of the React {@code Component} props of
* {@link AbstractRecordButton}. * {@link AbstractRecordButton}.
@@ -142,7 +144,8 @@ export function _mapStateToProps(state: Object): Object {
return { return {
_disabled, _disabled,
_isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE)), _isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE))
|| LocalRecordingManager.isRecordingLocally(),
_tooltip, _tooltip,
visible visible
}; };

View File

@@ -15,7 +15,7 @@ import {
} from '../../../dropbox'; } from '../../../dropbox';
import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications'; import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications';
import { toggleRequestingSubtitles } from '../../../subtitles'; import { toggleRequestingSubtitles } from '../../../subtitles';
import { setSelectedRecordingService } from '../../actions'; import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions';
import { RECORDING_TYPES } from '../../constants'; import { RECORDING_TYPES } from '../../constants';
export type Props = { export type Props = {
@@ -293,8 +293,9 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
let appData; let appData;
const attributes = {}; const attributes = {};
if (_isDropboxEnabled && this.state.selectedRecordingService === RECORDING_TYPES.DROPBOX) { switch (this.state.selectedRecordingService) {
if (_token) { case RECORDING_TYPES.DROPBOX: {
if (_isDropboxEnabled && _token) {
appData = JSON.stringify({ appData = JSON.stringify({
'file_recording_metadata': { 'file_recording_metadata': {
'upload_credentials': { 'upload_credentials': {
@@ -313,13 +314,22 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
return; return;
} }
} else { break;
}
case RECORDING_TYPES.JITSI_REC_SERVICE: {
appData = JSON.stringify({ appData = JSON.stringify({
'file_recording_metadata': { 'file_recording_metadata': {
'share': this.state.sharingEnabled 'share': this.state.sharingEnabled
} }
}); });
attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE; attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
break;
}
case RECORDING_TYPES.LOCAL: {
dispatch(startLocalVideoRecording());
return true;
}
} }
sendAnalytics( sendAnalytics(

View File

@@ -7,8 +7,11 @@ import {
sendAnalytics sendAnalytics
} from '../../../analytics'; } from '../../../analytics';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet'; import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { stopLocalVideoRecording } from '../../actions';
import { getActiveSession } from '../../functions'; import { getActiveSession } from '../../functions';
import LocalRecordingManager from './LocalRecordingManager';
/** /**
* The type of the React {@code Component} props of * The type of the React {@code Component} props of
* {@link AbstractStopRecordingDialog}. * {@link AbstractStopRecordingDialog}.
@@ -25,6 +28,11 @@ export type Props = {
*/ */
_fileRecordingSession: Object, _fileRecordingSession: Object,
/**
* Whether the recording is a local recording or not.
*/
_localRecording: boolean,
/** /**
* The redux dispatch function. * The redux dispatch function.
*/ */
@@ -68,11 +76,15 @@ export default class AbstractStopRecordingDialog<P: Props>
_onSubmit() { _onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button')); sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
const { _fileRecordingSession } = this.props; if (this.props._localRecording) {
this.props.dispatch(stopLocalVideoRecording());
} else {
const { _fileRecordingSession } = this.props;
if (_fileRecordingSession) { if (_fileRecordingSession) {
this.props._conference.stopRecording(_fileRecordingSession.id); this.props._conference.stopRecording(_fileRecordingSession.id);
this._toggleScreenshotCapture(); this._toggleScreenshotCapture();
}
} }
return true; return true;
@@ -105,6 +117,7 @@ export function _mapStateToProps(state: Object) {
return { return {
_conference: state['features/base/conference'].conference, _conference: state['features/base/conference'].conference,
_fileRecordingSession: _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 { import {
_abstractMapStateToProps _abstractMapStateToProps
} from '../../../base/dialog'; } from '../../../base/dialog';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { browser } from '../../../base/lib-jitsi-meet';
import { import {
Button, Button,
Container, Container,
@@ -31,6 +33,7 @@ import {
ICON_CLOUD, ICON_CLOUD,
ICON_INFO, ICON_INFO,
ICON_USERS, ICON_USERS,
LOCAL_RECORDING,
TRACK_COLOR TRACK_COLOR
} from './styles'; } from './styles';
@@ -41,6 +44,11 @@ type Props = {
*/ */
_dialogStyles: StyleType, _dialogStyles: StyleType,
/**
* Whether local recording is enabled or not.
*/
_localRecordingEnabled: boolean,
/** /**
* The color-schemed stylesheet of this component. * The color-schemed stylesheet of this component.
*/ */
@@ -126,6 +134,8 @@ type Props = {
* @augments Component * @augments Component
*/ */
class StartRecordingDialogContent extends Component<Props> { class StartRecordingDialogContent extends Component<Props> {
_localRecordingAvailable: boolean;
/** /**
* Initializes a new {@code StartRecordingDialogContent} instance. * Initializes a new {@code StartRecordingDialogContent} instance.
* *
@@ -133,12 +143,29 @@ class StartRecordingDialogContent extends Component<Props> {
*/ */
constructor(props) { constructor(props) {
super(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. // Bind event handler so it is only bound once for every instance.
this._onSignIn = this._onSignIn.bind(this); this._onSignIn = this._onSignIn.bind(this);
this._onSignOut = this._onSignOut.bind(this); this._onSignOut = this._onSignOut.bind(this);
this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this); this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this);
this._onRecordingServiceSwitchChange = this._onRecordingServiceSwitchChange.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._renderFileSharingContent() }
{ this._renderUploadToTheCloudInfo() } { this._renderUploadToTheCloudInfo() }
{ this._renderIntegrationsContent() } { this._renderIntegrationsContent() }
{ this._renderLocalRecordingContent() }
</Container> </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. * Renders the file recording service sharing options, if enabled.
* *
* @returns {React$Component} * @returns {React$Component}
*/ */
_renderFileSharingContent() { _renderFileSharingContent() {
const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props; if (!this._shouldRenderFileSharingContent()) {
if (!fileRecordingsServiceSharingEnabled
|| isVpaas
|| selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
return null; 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. * Renders the content in case no integrations were enabled.
* *
* @returns {React$Component} * @returns {React$Component}
*/ */
_renderNoIntegrationsContent() { _renderNoIntegrationsContent() {
if (!this._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 null; return null;
} }
const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props; const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props;
const switchContent const switchContent
= this.props.integrationsEnabled = this.props.integrationsEnabled || this.props._localRecordingEnabled
? ( ? (
<Switch <Switch
className = 'recording-switch' className = 'recording-switch'
@@ -285,7 +338,7 @@ class StartRecordingDialogContent extends Component<Props> {
const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription'); const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription');
const jitsiContentRecordingIconContainer const jitsiContentRecordingIconContainer
= this.props.integrationsEnabled = this.props.integrationsEnabled || this.props._localRecordingEnabled
? 'jitsi-content-recording-icon-container-with-switch' ? 'jitsi-content-recording-icon-container-with-switch'
: 'jitsi-content-recording-icon-container-without-switch'; : 'jitsi-content-recording-icon-container-without-switch';
const contentRecordingClass = isVpaas 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. * Renders the content in case integrations were enabled.
* *
@@ -324,7 +390,7 @@ class StartRecordingDialogContent extends Component<Props> {
* @returns {React$Component} * @returns {React$Component}
*/ */
_renderIntegrationsContent() { _renderIntegrationsContent() {
if (!this.props.integrationsEnabled) { if (!this._shouldRenderIntegrationsContent()) {
return null; return null;
} }
@@ -376,7 +442,7 @@ class StartRecordingDialogContent extends Component<Props> {
return ( return (
<Container> <Container>
<Container <Container
className = 'recording-header recording-header-line' className = 'recording-header'
style = { styles.headerIntegrations }> style = { styles.headerIntegrations }>
<Container <Container
className = 'recording-icon-container'> className = 'recording-icon-container'>
@@ -405,6 +471,7 @@ class StartRecordingDialogContent extends Component<Props> {
_onDropboxSwitchChange: () => void; _onDropboxSwitchChange: () => void;
_onRecordingServiceSwitchChange: () => void; _onRecordingServiceSwitchChange: () => void;
_onLocalRecordingSwitchChange: () => void;
/** /**
* Handler for onValueChange events from the Switch component. * Handler for onValueChange events from the Switch component.
@@ -419,8 +486,7 @@ class StartRecordingDialogContent extends Component<Props> {
} = this.props; } = this.props;
// act like group, cannot toggle off // act like group, cannot toggle off
if (selectedRecordingService if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
=== RECORDING_TYPES.JITSI_REC_SERVICE) {
return; return;
} }
@@ -444,8 +510,7 @@ class StartRecordingDialogContent extends Component<Props> {
} = this.props; } = this.props;
// act like group, cannot toggle off // act like group, cannot toggle off
if (selectedRecordingService if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
=== RECORDING_TYPES.DROPBOX) {
return; 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. * 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; _onSignIn: () => void;
/** /**
@@ -546,6 +689,7 @@ function _mapStateToProps(state) {
return { return {
..._abstractMapStateToProps(state), ..._abstractMapStateToProps(state),
isVpaas: isVpaasMeeting(state), isVpaas: isVpaasMeeting(state),
_localRecordingEnabled: state['features/base/config'].enableLocalRecording,
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent') _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_CLOUD = require('../../../../../images/icon-cloud.png');
export const ICON_INFO = require('../../../../../images/icon-info.png'); export const ICON_INFO = require('../../../../../images/icon-info.png');
export const ICON_USERS = require('../../../../../images/icon-users.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; 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 DROPBOX_LOGO = 'images/dropboxLogo_square.png';
export const LOCAL_RECORDING = 'images/downloadLocalRecording.png';
export const ICON_CLOUD = 'images/icon-cloud.png'; export const ICON_CLOUD = 'images/icon-cloud.png';
export const ICON_INFO = 'images/icon-info.png'; export const ICON_INFO = 'images/icon-info.png';

View File

@@ -39,6 +39,8 @@ class StartRecordingDialog extends AbstractStartRecordingDialog {
return false; return false;
} else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) { } else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
return !isTokenValid; return !isTokenValid;
} else if (selectedRecordingService === RECORDING_TYPES.LOCAL) {
return false;
} }
return true; return true;

View File

@@ -45,7 +45,8 @@ export const RECORDING_ON_SOUND_ID = 'RECORDING_ON_SOUND';
*/ */
export const RECORDING_TYPES = { export const RECORDING_TYPES = {
JITSI_REC_SERVICE: 'recording-service', JITSI_REC_SERVICE: 'recording-service',
DROPBOX: 'dropbox' DROPBOX: 'dropbox',
LOCAL: 'local'
}; };
/** /**

View File

@@ -1,11 +1,12 @@
// @flow // @flow
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet'; 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 { isInBreakoutRoom } from '../breakout-rooms/functions';
import { isEnabled as isDropboxEnabled } from '../dropbox'; import { isEnabled as isDropboxEnabled } from '../dropbox';
import { extractFqnFromPath } from '../dynamic-branding/functions.any'; import { extractFqnFromPath } from '../dynamic-branding/functions.any';
import LocalRecordingManager from './components/Recording/LocalRecordingManager';
import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants'; import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants';
import logger from './logger'; 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; return status;
} }
@@ -241,3 +247,22 @@ export async function sendMeetingHighlight(state: Object) {
return false; 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, JitsiConferenceEvents,
JitsiRecordingConstants JitsiRecordingConstants
} from '../base/lib-jitsi-meet'; } 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 { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { import {
playSound, playSound,
@@ -19,8 +20,10 @@ import {
stopSound, stopSound,
unregisterSound unregisterSound
} from '../base/sounds'; } 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 { import {
clearRecordingSessions, clearRecordingSessions,
hidePendingRecordingNotification, hidePendingRecordingNotification,
@@ -32,13 +35,18 @@ import {
showStoppedRecordingNotification, showStoppedRecordingNotification,
updateRecordingSessionData updateRecordingSessionData
} from './actions'; } from './actions';
import LocalRecordingManager from './components/Recording/LocalRecordingManager';
import { import {
LIVE_STREAMING_OFF_SOUND_ID, LIVE_STREAMING_OFF_SOUND_ID,
LIVE_STREAMING_ON_SOUND_ID, LIVE_STREAMING_ON_SOUND_ID,
RECORDING_OFF_SOUND_ID, RECORDING_OFF_SOUND_ID,
RECORDING_ON_SOUND_ID RECORDING_ON_SOUND_ID
} from './constants'; } from './constants';
import { getSessionById, getResourceId } from './functions'; import {
getSessionById,
getResourceId
} from './functions';
import logger from './logger';
import { import {
LIVE_STREAMING_OFF_SOUND_FILE, LIVE_STREAMING_OFF_SOUND_FILE,
LIVE_STREAMING_ON_SOUND_FILE, LIVE_STREAMING_ON_SOUND_FILE,
@@ -68,7 +76,7 @@ StateListenerRegistry.register(
* @param {Store} store - The redux store. * @param {Store} store - The redux store.
* @returns {Function} * @returns {Function}
*/ */
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { MiddlewareRegistry.register(({ dispatch, getState }) => next => async action => {
let oldSessionData; let oldSessionData;
if (action.type === RECORDING_SESSION_UPDATED) { if (action.type === RECORDING_SESSION_UPDATED) {
@@ -123,6 +131,41 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break; 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: { case RECORDING_SESSION_UPDATED: {
// When in recorder mode no notifications are shown // When in recorder mode no notifications are shown
// or extra sounds are also not desired // or extra sounds are also not desired
@@ -211,6 +254,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break; 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; return result;