feat(local-video-recording) Allow users to record the meeting locally (#11338)
This commit is contained in:
parent
7ac573d628
commit
e27069447b
21
config.js
21
config.js
|
@ -295,6 +295,9 @@ var config = {
|
|||
// Whether to enable live streaming or not.
|
||||
// liveStreamingEnabled: false,
|
||||
|
||||
// Whether to enable local recording or not.
|
||||
// enableLocalRecording: false,
|
||||
|
||||
// Transcription (in interface_config,
|
||||
// subtitles and buttons can be configured)
|
||||
// transcribingEnabled: false,
|
||||
|
@ -953,23 +956,6 @@ var config = {
|
|||
// ]
|
||||
// },
|
||||
|
||||
// Local Recording
|
||||
//
|
||||
|
||||
// localRecording: {
|
||||
// Enables local recording.
|
||||
// Additionally, 'localrecording' (all lowercase) needs to be added to
|
||||
// the `toolbarButtons`-array for the Local Recording button to show up
|
||||
// on the toolbar.
|
||||
//
|
||||
// enabled: true,
|
||||
//
|
||||
|
||||
// The recording format, can be one of 'ogg', 'flac' or 'wav'.
|
||||
// format: 'flac'
|
||||
//
|
||||
|
||||
// },
|
||||
// e2ee: {
|
||||
// labels,
|
||||
// externallyManagedKey: false
|
||||
|
@ -1305,7 +1291,6 @@ var config = {
|
|||
// 'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable
|
||||
// 'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected
|
||||
// 'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied
|
||||
// 'localRecording.localRecording', // shown when a local recording is started
|
||||
// 'notify.chatMessages', // shown when receiving chat messages while the chat window is closed
|
||||
// 'notify.disconnected', // shown when a participant has left
|
||||
// 'notify.connectedOneMember', // show when a participant joined
|
||||
|
|
|
@ -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 |
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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')
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue