Bladeren bron

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

master
Robert Pintilii 3 jaren geleden
bovenliggende
commit
e27069447b
No account linked to committer's email address

+ 3
- 18
config.js Bestand weergeven

@@ -295,6 +295,9 @@ var config = {
295 295
     // Whether to enable live streaming or not.
296 296
     // liveStreamingEnabled: false,
297 297
 
298
+    // Whether to enable local recording or not.
299
+    // enableLocalRecording: false,
300
+
298 301
     // Transcription (in interface_config,
299 302
     // subtitles and buttons can be configured)
300 303
     // transcribingEnabled: false,
@@ -953,23 +956,6 @@ var config = {
953 956
     //     ]
954 957
     // },
955 958
 
956
-    // Local Recording
957
-    //
958
-
959
-    // localRecording: {
960
-    // Enables local recording.
961
-    // Additionally, 'localrecording' (all lowercase) needs to be added to
962
-    // the `toolbarButtons`-array for the Local Recording button to show up
963
-    // on the toolbar.
964
-    //
965
-    //     enabled: true,
966
-    //
967
-
968
-    // The recording format, can be one of 'ogg', 'flac' or 'wav'.
969
-    //     format: 'flac'
970
-    //
971
-
972
-    // },
973 959
     // e2ee: {
974 960
     //   labels,
975 961
     //   externallyManagedKey: false
@@ -1305,7 +1291,6 @@ var config = {
1305 1291
     //     'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable
1306 1292
     //     'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected
1307 1293
     //     'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied
1308
-    //     'localRecording.localRecording', // shown when a local recording is started
1309 1294
     //     'notify.chatMessages', // shown when receiving chat messages while the chat window is closed
1310 1295
     //     'notify.disconnected', // shown when a participant has left
1311 1296
     //     'notify.connectedOneMember', // show when a participant joined

+ 7
- 1
css/_recording.scss Bestand weergeven

@@ -23,7 +23,13 @@
23 23
 
24 24
     .recording-header-line {
25 25
         border-top: 1px solid #5e6d7a;
26
-        padding-top: 32px;
26
+        padding-top: 16px;
27
+        margin-top: 16px;
28
+    }
29
+
30
+    .local-recording-warning {
31
+        margin-top: 4px;
32
+        display: block;
27 33
     }
28 34
 
29 35
     .recording-switch-disabled {

BIN
images/downloadLocalRecording.png Bestand weergeven


+ 5
- 0
lang/main.json Bestand weergeven

@@ -657,6 +657,8 @@
657 657
         "linkToSalesforceKey": "Link this meeting",
658 658
         "linkToSalesforceProgress": "Linking meeting to Salesforce...",
659 659
         "linkToSalesforceSuccess": "The meeting was linked to Salesforce",
660
+        "localRecordingStarted": "{{name}} has started a local recording.",
661
+        "localRecordingStopped": "{{name}} has stopped a local recording.",
660 662
         "me": "Me",
661 663
         "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.",
662 664
         "moderationInEffectCSTitle": "Screen sharing is blocked by the moderator",
@@ -887,6 +889,7 @@
887 889
         "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>.",
888 890
         "linkGenerated": "We have generated a link to your recording.",
889 891
         "live": "LIVE",
892
+        "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.",
890 893
         "loggedIn": "Logged in as {{userName}}",
891 894
         "off": "Recording stopped",
892 895
         "offBy": "{{name}} stopped the recording",
@@ -894,6 +897,7 @@
894 897
         "onBy": "{{name}} started the recording",
895 898
         "pending": "Preparing to record the meeting...",
896 899
         "rec": "REC",
900
+        "saveLocalRecording": "Save recording file locally",
897 901
         "serviceDescription": "Your recording will be saved by the recording service",
898 902
         "serviceDescriptionCloud": "Cloud recording",
899 903
         "serviceDescriptionCloudInfo": "Recorded meetings are automatically cleared 24h after their recording time.",
@@ -901,6 +905,7 @@
901 905
         "sessionAlreadyActive": "This session is already being recorded or live streamed.",
902 906
         "signIn": "Sign in",
903 907
         "signOut": "Sign out",
908
+        "surfaceError": "Please select the current tab.",
904 909
         "unavailable": "Oops! The {{serviceName}} is currently unavailable. We're working on resolving the issue. Please try again later.",
905 910
         "unavailableTitle": "Recording unavailable",
906 911
         "uploadToCloud": "Upload to the cloud"

+ 100
- 7
package-lock.json Bestand weergeven

@@ -128,6 +128,7 @@
128 128
         "util": "0.12.1",
129 129
         "uuid": "8.3.2",
130 130
         "wasm-check": "2.0.1",
131
+        "webm-duration-fix": "1.0.4",
131 132
         "windows-iana": "^3.1.0",
132 133
         "zxcvbn": "4.4.2"
133 134
       },
@@ -141,6 +142,7 @@
141 142
         "@babel/runtime": "7.16.0",
142 143
         "@jitsi/eslint-config": "4.0.0",
143 144
         "@types/react-native": "0.67.6",
145
+        "@types/uuid": "8.3.4",
144 146
         "babel-loader": "8.2.3",
145 147
         "babel-plugin-optional-require": "0.3.1",
146 148
         "circular-dependency-plugin": "5.2.0",
@@ -163,7 +165,7 @@
163 165
         "style-loader": "0.19.0",
164 166
         "traverse": "0.6.6",
165 167
         "ts-loader": "9.2.6",
166
-        "typescript": "4.3.5",
168
+        "typescript": "4.6.4",
167 169
         "unorm": "1.6.0",
168 170
         "webpack": "5.57.1",
169 171
         "webpack-bundle-analyzer": "4.4.2",
@@ -5561,6 +5563,12 @@
5561 5563
         "@types/node": "*"
5562 5564
       }
5563 5565
     },
5566
+    "node_modules/@types/uuid": {
5567
+      "version": "8.3.4",
5568
+      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
5569
+      "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
5570
+      "dev": true
5571
+    },
5564 5572
     "node_modules/@types/webgl-ext": {
5565 5573
       "version": "0.0.30",
5566 5574
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
@@ -8323,6 +8331,11 @@
8323 8331
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
8324 8332
       "dev": true
8325 8333
     },
8334
+    "node_modules/ebml-block": {
8335
+      "version": "1.1.2",
8336
+      "resolved": "https://registry.npmjs.org/ebml-block/-/ebml-block-1.1.2.tgz",
8337
+      "integrity": "sha512-HgNlIsRFP6D9VKU5atCeHRJY7XkJP8bOe8yEhd8NB7B3b4++VWTyauz6g650iiPmLfPLGlVpoJmGSgMfXDYusg=="
8338
+    },
8326 8339
     "node_modules/ee-first": {
8327 8340
       "version": "1.1.1",
8328 8341
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -10657,6 +10670,14 @@
10657 10670
         "css-in-js-utils": "^2.0.0"
10658 10671
       }
10659 10672
     },
10673
+    "node_modules/int64-buffer": {
10674
+      "version": "1.0.1",
10675
+      "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.0.1.tgz",
10676
+      "integrity": "sha512-+3azY4pXrjAupJHU1V9uGERWlhoqNswJNji6aD/02xac7oxol508AsMC5lxKhEqyZeDFy3enq5OGWXF4u75hiw==",
10677
+      "engines": {
10678
+        "node": ">= 4.5.0"
10679
+      }
10680
+    },
10660 10681
     "node_modules/internal-slot": {
10661 10682
       "version": "1.0.3",
10662 10683
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -18643,9 +18664,9 @@
18643 18664
       }
18644 18665
     },
18645 18666
     "node_modules/typescript": {
18646
-      "version": "4.3.5",
18647
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
18648
-      "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
18667
+      "version": "4.6.4",
18668
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
18669
+      "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
18649 18670
       "dev": true,
18650 18671
       "bin": {
18651 18672
         "tsc": "bin/tsc",
@@ -19123,6 +19144,40 @@
19123 19144
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
19124 19145
       "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
19125 19146
     },
19147
+    "node_modules/webm-duration-fix": {
19148
+      "version": "1.0.4",
19149
+      "resolved": "https://registry.npmjs.org/webm-duration-fix/-/webm-duration-fix-1.0.4.tgz",
19150
+      "integrity": "sha512-kvhmSmEnuohtK+j+mJswqCCM2ViKb9W8Ch0oAxcaeUvpok5CsMORQLnea+CYKDXPG6JH12H0CbRK85qhfeZLew==",
19151
+      "dependencies": {
19152
+        "buffer": "^6.0.3",
19153
+        "ebml-block": "^1.1.2",
19154
+        "events": "^3.3.0",
19155
+        "int64-buffer": "^1.0.1"
19156
+      }
19157
+    },
19158
+    "node_modules/webm-duration-fix/node_modules/buffer": {
19159
+      "version": "6.0.3",
19160
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
19161
+      "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
19162
+      "funding": [
19163
+        {
19164
+          "type": "github",
19165
+          "url": "https://github.com/sponsors/feross"
19166
+        },
19167
+        {
19168
+          "type": "patreon",
19169
+          "url": "https://www.patreon.com/feross"
19170
+        },
19171
+        {
19172
+          "type": "consulting",
19173
+          "url": "https://feross.org/support"
19174
+        }
19175
+      ],
19176
+      "dependencies": {
19177
+        "base64-js": "^1.3.1",
19178
+        "ieee754": "^1.2.1"
19179
+      }
19180
+    },
19126 19181
     "node_modules/webpack": {
19127 19182
       "version": "5.57.1",
19128 19183
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",
@@ -24162,6 +24217,12 @@
24162 24217
         "@types/node": "*"
24163 24218
       }
24164 24219
     },
24220
+    "@types/uuid": {
24221
+      "version": "8.3.4",
24222
+      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
24223
+      "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
24224
+      "dev": true
24225
+    },
24165 24226
     "@types/webgl-ext": {
24166 24227
       "version": "0.0.30",
24167 24228
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
@@ -26348,6 +26409,11 @@
26348 26409
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
26349 26410
       "dev": true
26350 26411
     },
26412
+    "ebml-block": {
26413
+      "version": "1.1.2",
26414
+      "resolved": "https://registry.npmjs.org/ebml-block/-/ebml-block-1.1.2.tgz",
26415
+      "integrity": "sha512-HgNlIsRFP6D9VKU5atCeHRJY7XkJP8bOe8yEhd8NB7B3b4++VWTyauz6g650iiPmLfPLGlVpoJmGSgMfXDYusg=="
26416
+    },
26351 26417
     "ee-first": {
26352 26418
       "version": "1.1.1",
26353 26419
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -28173,6 +28239,11 @@
28173 28239
         "css-in-js-utils": "^2.0.0"
28174 28240
       }
28175 28241
     },
28242
+    "int64-buffer": {
28243
+      "version": "1.0.1",
28244
+      "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.0.1.tgz",
28245
+      "integrity": "sha512-+3azY4pXrjAupJHU1V9uGERWlhoqNswJNji6aD/02xac7oxol508AsMC5lxKhEqyZeDFy3enq5OGWXF4u75hiw=="
28246
+    },
28176 28247
     "internal-slot": {
28177 28248
       "version": "1.0.3",
28178 28249
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
@@ -34274,9 +34345,9 @@
34274 34345
       }
34275 34346
     },
34276 34347
     "typescript": {
34277
-      "version": "4.3.5",
34278
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
34279
-      "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
34348
+      "version": "4.6.4",
34349
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz",
34350
+      "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==",
34280 34351
       "dev": true
34281 34352
     },
34282 34353
     "ua-parser-js": {
@@ -34621,6 +34692,28 @@
34621 34692
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
34622 34693
       "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
34623 34694
     },
34695
+    "webm-duration-fix": {
34696
+      "version": "1.0.4",
34697
+      "resolved": "https://registry.npmjs.org/webm-duration-fix/-/webm-duration-fix-1.0.4.tgz",
34698
+      "integrity": "sha512-kvhmSmEnuohtK+j+mJswqCCM2ViKb9W8Ch0oAxcaeUvpok5CsMORQLnea+CYKDXPG6JH12H0CbRK85qhfeZLew==",
34699
+      "requires": {
34700
+        "buffer": "^6.0.3",
34701
+        "ebml-block": "^1.1.2",
34702
+        "events": "^3.3.0",
34703
+        "int64-buffer": "^1.0.1"
34704
+      },
34705
+      "dependencies": {
34706
+        "buffer": {
34707
+          "version": "6.0.3",
34708
+          "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
34709
+          "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
34710
+          "requires": {
34711
+            "base64-js": "^1.3.1",
34712
+            "ieee754": "^1.2.1"
34713
+          }
34714
+        }
34715
+      }
34716
+    },
34624 34717
     "webpack": {
34625 34718
       "version": "5.57.1",
34626 34719
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",

+ 3
- 1
package.json Bestand weergeven

@@ -133,6 +133,7 @@
133 133
     "util": "0.12.1",
134 134
     "uuid": "8.3.2",
135 135
     "wasm-check": "2.0.1",
136
+    "webm-duration-fix": "1.0.4",
136 137
     "windows-iana": "^3.1.0",
137 138
     "zxcvbn": "4.4.2"
138 139
   },
@@ -146,6 +147,7 @@
146 147
     "@babel/runtime": "7.16.0",
147 148
     "@jitsi/eslint-config": "4.0.0",
148 149
     "@types/react-native": "0.67.6",
150
+    "@types/uuid": "8.3.4",
149 151
     "babel-loader": "8.2.3",
150 152
     "babel-plugin-optional-require": "0.3.1",
151 153
     "circular-dependency-plugin": "5.2.0",
@@ -168,7 +170,7 @@
168 170
     "style-loader": "0.19.0",
169 171
     "traverse": "0.6.6",
170 172
     "ts-loader": "9.2.6",
171
-    "typescript": "4.3.5",
173
+    "typescript": "4.6.4",
172 174
     "unorm": "1.6.0",
173 175
     "webpack": "5.57.1",
174 176
     "webpack-bundle-analyzer": "4.4.2",

+ 1
- 1
react/features/base/config/configWhitelist.js Bestand weergeven

@@ -142,6 +142,7 @@ export default [
142 142
     'enableLayerSuspension',
143 143
     'enableLipSync',
144 144
     'enableLobbyChat',
145
+    'enableLocalRecording',
145 146
     'enableOpusRed',
146 147
     'enableRemb',
147 148
     'enableSaveLogs',
@@ -183,7 +184,6 @@ export default [
183 184
     'ignoreStartMuted',
184 185
     'inviteAppName',
185 186
     'liveStreamingEnabled',
186
-    'localRecording',
187 187
     'localSubject',
188 188
     'maxFullResolutionParticipants',
189 189
     'mouseMoveCallbackInterval',

+ 9
- 0
react/features/base/participants/actionTypes.ts Bestand weergeven

@@ -231,3 +231,12 @@ export const OVERWRITE_PARTICIPANT_NAME = 'OVERWRITE_PARTICIPANT_NAME';
231 231
  * }
232 232
  */
233 233
 export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES';
234
+
235
+/**
236
+ * Updates participants local recording status.
237
+ * {
238
+ *     type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
239
+ *     recording: boolean
240
+ * }
241
+ */
242
+export const SET_LOCAL_PARTICIPANT_RECORDING_STATUS = 'SET_LOCAL_PARTICIPANT_RECORDING_STATUS';

+ 20
- 3
react/features/base/participants/actions.js Bestand weergeven

@@ -10,17 +10,18 @@ import {
10 10
     LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
11 11
     LOCAL_PARTICIPANT_RAISE_HAND,
12 12
     MUTE_REMOTE_PARTICIPANT,
13
+    OVERWRITE_PARTICIPANT_NAME,
14
+    OVERWRITE_PARTICIPANTS_NAMES,
13 15
     PARTICIPANT_ID_CHANGED,
14 16
     PARTICIPANT_JOINED,
15 17
     PARTICIPANT_KICKED,
16 18
     PARTICIPANT_LEFT,
17 19
     PARTICIPANT_UPDATED,
18 20
     PIN_PARTICIPANT,
21
+    RAISE_HAND_UPDATED,
19 22
     SCREENSHARE_PARTICIPANT_NAME_CHANGED,
20 23
     SET_LOADABLE_AVATAR_URL,
21
-    RAISE_HAND_UPDATED,
22
-    OVERWRITE_PARTICIPANT_NAME,
23
-    OVERWRITE_PARTICIPANTS_NAMES
24
+    SET_LOCAL_PARTICIPANT_RECORDING_STATUS
24 25
 } from './actionTypes';
25 26
 import {
26 27
     DISCO_REMOTE_CONTROL_FEATURE
@@ -683,3 +684,19 @@ export function overwriteParticipantsNames(participantList) {
683 684
         participantList
684 685
     };
685 686
 }
687
+
688
+/**
689
+ * Local video recording status for the local participant.
690
+ *
691
+ * @param {boolean} recording - If local recording is ongoing.
692
+ * @returns {{
693
+ *     type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
694
+ *     recording: boolean
695
+ * }}
696
+ */
697
+export function updateLocalRecordingStatus(recording) {
698
+    return {
699
+        type: SET_LOCAL_PARTICIPANT_RECORDING_STATUS,
700
+        recording
701
+    };
702
+}

+ 77
- 2
react/features/base/participants/middleware.js Bestand weergeven

@@ -10,6 +10,7 @@ import { getBreakoutRooms } from '../../breakout-rooms/functions';
10 10
 import { toggleE2EE } from '../../e2ee/actions';
11 11
 import { MAX_MODE } from '../../e2ee/constants';
12 12
 import {
13
+    LOCAL_RECORDING_NOTIFICATION_ID,
13 14
     NOTIFICATION_TIMEOUT_TYPE,
14 15
     RAISE_HAND_NOTIFICATION_ID,
15 16
     showNotification
@@ -17,6 +18,7 @@ import {
17 18
 import { isForceMuted } from '../../participants-pane/functions';
18 19
 import { CALLING, INVITED } from '../../presence-status';
19 20
 import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
21
+import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../../recording';
20 22
 import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
21 23
 import {
22 24
     CONFERENCE_WILL_JOIN,
@@ -42,7 +44,8 @@ import {
42 44
     PARTICIPANT_JOINED,
43 45
     PARTICIPANT_LEFT,
44 46
     PARTICIPANT_UPDATED,
45
-    RAISE_HAND_UPDATED
47
+    RAISE_HAND_UPDATED,
48
+    SET_LOCAL_PARTICIPANT_RECORDING_STATUS
46 49
 } from './actionTypes';
47 50
 import {
48 51
     localParticipantIdChanged,
@@ -174,6 +177,25 @@ MiddlewareRegistry.register(store => next => action => {
174 177
         break;
175 178
     }
176 179
 
180
+    case SET_LOCAL_PARTICIPANT_RECORDING_STATUS: {
181
+        const { recording } = action;
182
+        const localId = getLocalParticipant(store.getState())?.id;
183
+
184
+        store.dispatch(participantUpdated({
185
+            // XXX Only the local participant is allowed to update without
186
+            // stating the JitsiConference instance (i.e. participant property
187
+            // `conference` for a remote participant) because the local
188
+            // participant is uniquely identified by the very fact that there is
189
+            // only one local participant.
190
+
191
+            id: localId,
192
+            local: true,
193
+            localRecording: recording
194
+        }));
195
+
196
+        break;
197
+    }
198
+
177 199
     case MUTE_REMOTE_PARTICIPANT: {
178 200
         const { conference } = store.getState()['features/base/conference'];
179 201
 
@@ -389,6 +411,8 @@ StateListenerRegistry.register(
389 411
                         id: participant.getId(),
390 412
                         features: { 'screen-sharing': true }
391 413
                     })),
414
+                'localRecording': (participant, value) =>
415
+                    _localRecordingUpdated(store, conference, participant.getId(), value),
392 416
                 'raisedHand': (participant, value) =>
393 417
                     _raiseHandUpdated(store, conference, participant.getId(), value),
394 418
                 'region': (participant, value) =>
@@ -566,7 +590,15 @@ function _maybePlaySounds({ getState, dispatch }, action) {
566 590
 function _participantJoinedOrUpdated(store, next, action) {
567 591
     const { dispatch, getState } = store;
568 592
     const { overwrittenNameList } = store.getState()['features/base/participants'];
569
-    const { participant: { avatarURL, email, id, local, name, raisedHandTimestamp } } = action;
593
+    const { participant: {
594
+        avatarURL,
595
+        email,
596
+        id,
597
+        local,
598
+        localRecording,
599
+        name,
600
+        raisedHandTimestamp
601
+    } } = action;
570 602
 
571 603
     // Send an external update of the local participant's raised hand state
572 604
     // if a new raised hand state is defined in the action.
@@ -587,6 +619,20 @@ function _participantJoinedOrUpdated(store, next, action) {
587 619
         action.participant.name = overwrittenNameList[id];
588 620
     }
589 621
 
622
+    // Send an external update of the local participant's local recording state
623
+    // if a new local recording state is defined in the action.
624
+    if (typeof localRecording !== 'undefined') {
625
+        if (local) {
626
+            const conference = getCurrentConference(getState);
627
+
628
+            // Send localRecording signalling only if there is a change
629
+            if (conference
630
+                && localRecording !== getLocalParticipant(getState()).localRecording) {
631
+                conference.setLocalParticipantProperty('localRecording', localRecording);
632
+            }
633
+        }
634
+    }
635
+
590 636
     // Allow the redux update to go through and compare the old avatar
591 637
     // to the new avatar and emit out change events if necessary.
592 638
     const result = next(action);
@@ -618,6 +664,35 @@ function _participantJoinedOrUpdated(store, next, action) {
618 664
     return result;
619 665
 }
620 666
 
667
+/**
668
+ * Handles a local recording status update.
669
+ *
670
+ * @param {Function} dispatch - The Redux dispatch function.
671
+ * @param {Object} conference - The conference for which we got an update.
672
+ * @param {string} participantId - The ID of the participant from which we got an update.
673
+ * @param {boolean} newValue - The new value of the local recording status.
674
+ * @returns {void}
675
+ */
676
+function _localRecordingUpdated({ dispatch, getState }, conference, participantId, newValue) {
677
+    const state = getState();
678
+
679
+    dispatch(participantUpdated({
680
+        conference,
681
+        id: participantId,
682
+        localRecording: newValue
683
+    }));
684
+    const participantName = getParticipantDisplayName(state, participantId);
685
+
686
+    dispatch(showNotification({
687
+        titleKey: 'notify.somebody',
688
+        title: participantName,
689
+        descriptionKey: newValue ? 'notify.localRecordingStarted' : 'notify.localRecordingStopped',
690
+        uid: LOCAL_RECORDING_NOTIFICATION_ID
691
+    }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
692
+    dispatch(playSound(newValue ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
693
+}
694
+
695
+
621 696
 /**
622 697
  * Handles a raise hand status update.
623 698
  *

+ 11
- 4
react/features/notifications/constants.js Bestand weergeven

@@ -59,18 +59,18 @@ export const NOTIFICATION_ICON = {
59 59
 };
60 60
 
61 61
 /**
62
- * The identifier of the salesforce link notification.
62
+ * The identifier of the lobby notification.
63 63
  *
64 64
  * @type {string}
65 65
  */
66
-export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
66
+export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION';
67 67
 
68 68
 /**
69
- * The identifier of the lobby notification.
69
+ * The identifier of the local recording notification.
70 70
  *
71 71
  * @type {string}
72 72
  */
73
-export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION';
73
+export const LOCAL_RECORDING_NOTIFICATION_ID = 'LOCAL_RECORDING_NOTIFICATION_ID';
74 74
 
75 75
 /**
76 76
  * The identifier of the raise hand notification.
@@ -79,6 +79,13 @@ export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION';
79 79
  */
80 80
 export const RAISE_HAND_NOTIFICATION_ID = 'RAISE_HAND_NOTIFICATION';
81 81
 
82
+/**
83
+ * The identifier of the salesforce link notification.
84
+ *
85
+ * @type {string}
86
+ */
87
+export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
88
+
82 89
 /**
83 90
  * Amount of participants beyond which no join notification will be emitted.
84 91
  */

+ 18
- 0
react/features/recording/actionTypes.ts Bestand weergeven

@@ -66,3 +66,21 @@ export const SET_STREAM_KEY = 'SET_STREAM_KEY';
66 66
  * }
67 67
  */
68 68
 export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_STATE';
69
+
70
+/**
71
+ * Attempts to start the local recording.
72
+ *
73
+ * {
74
+ *     type: START_LOCAL_RECORDING
75
+ * }
76
+ */
77
+export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING';
78
+
79
+/**
80
+ * Stops local recording.
81
+ *
82
+ * {
83
+ *     type: STOP_LOCAL_RECORDING
84
+ * }
85
+ */
86
+export const STOP_LOCAL_RECORDING = 'STOP_LOCAL_RECORDING';

+ 25
- 1
react/features/recording/actions.any.js Bestand weergeven

@@ -19,7 +19,9 @@ import {
19 19
     SET_MEETING_HIGHLIGHT_BUTTON_STATE,
20 20
     SET_PENDING_RECORDING_NOTIFICATION_UID,
21 21
     SET_SELECTED_RECORDING_SERVICE,
22
-    SET_STREAM_KEY
22
+    SET_STREAM_KEY,
23
+    START_LOCAL_RECORDING,
24
+    STOP_LOCAL_RECORDING
23 25
 } from './actionTypes';
24 26
 import {
25 27
     getRecordingLink,
@@ -332,3 +334,25 @@ function _setPendingRecordingNotificationUid(uid: ?number, streamType: string) {
332 334
         uid
333 335
     };
334 336
 }
337
+
338
+/**
339
+ * Starts local recording.
340
+ *
341
+ * @returns {Object}
342
+ */
343
+export function startLocalVideoRecording() {
344
+    return {
345
+        type: START_LOCAL_RECORDING
346
+    };
347
+}
348
+
349
+/**
350
+ * Stops local recording.
351
+ *
352
+ * @returns {Object}
353
+ */
354
+export function stopLocalVideoRecording() {
355
+    return {
356
+        type: STOP_LOCAL_RECORDING
357
+    };
358
+}

+ 4
- 1
react/features/recording/components/Recording/AbstractRecordButton.js Bestand weergeven

@@ -11,6 +11,8 @@ import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
11 11
 import { FEATURES } from '../../../jaas/constants';
12 12
 import { getActiveSession, getRecordButtonProps } from '../../functions';
13 13
 
14
+import LocalRecordingManager from './LocalRecordingManager';
15
+
14 16
 /**
15 17
  * The type of the React {@code Component} props of
16 18
  * {@link AbstractRecordButton}.
@@ -142,7 +144,8 @@ export function _mapStateToProps(state: Object): Object {
142 144
 
143 145
     return {
144 146
         _disabled,
145
-        _isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE)),
147
+        _isRecordingRunning: Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE))
148
+            || LocalRecordingManager.isRecordingLocally(),
146 149
         _tooltip,
147 150
         visible
148 151
     };

+ 14
- 4
react/features/recording/components/Recording/AbstractStartRecordingDialog.js Bestand weergeven

@@ -15,7 +15,7 @@ import {
15 15
 } from '../../../dropbox';
16 16
 import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications';
17 17
 import { toggleRequestingSubtitles } from '../../../subtitles';
18
-import { setSelectedRecordingService } from '../../actions';
18
+import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions';
19 19
 import { RECORDING_TYPES } from '../../constants';
20 20
 
21 21
 export type Props = {
@@ -293,8 +293,9 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
293 293
         let appData;
294 294
         const attributes = {};
295 295
 
296
-        if (_isDropboxEnabled && this.state.selectedRecordingService === RECORDING_TYPES.DROPBOX) {
297
-            if (_token) {
296
+        switch (this.state.selectedRecordingService) {
297
+        case RECORDING_TYPES.DROPBOX: {
298
+            if (_isDropboxEnabled && _token) {
298 299
                 appData = JSON.stringify({
299 300
                     'file_recording_metadata': {
300 301
                         'upload_credentials': {
@@ -313,13 +314,22 @@ class AbstractStartRecordingDialog extends Component<Props, State> {
313 314
 
314 315
                 return;
315 316
             }
316
-        } else {
317
+            break;
318
+        }
319
+        case RECORDING_TYPES.JITSI_REC_SERVICE: {
317 320
             appData = JSON.stringify({
318 321
                 'file_recording_metadata': {
319 322
                     'share': this.state.sharingEnabled
320 323
                 }
321 324
             });
322 325
             attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
326
+            break;
327
+        }
328
+        case RECORDING_TYPES.LOCAL: {
329
+            dispatch(startLocalVideoRecording());
330
+
331
+            return true;
332
+        }
323 333
         }
324 334
 
325 335
         sendAnalytics(

+ 18
- 5
react/features/recording/components/Recording/AbstractStopRecordingDialog.js Bestand weergeven

@@ -7,8 +7,11 @@ import {
7 7
     sendAnalytics
8 8
 } from '../../../analytics';
9 9
 import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
10
+import { stopLocalVideoRecording } from '../../actions';
10 11
 import { getActiveSession } from '../../functions';
11 12
 
13
+import LocalRecordingManager from './LocalRecordingManager';
14
+
12 15
 /**
13 16
  * The type of the React {@code Component} props of
14 17
  * {@link AbstractStopRecordingDialog}.
@@ -25,6 +28,11 @@ export type Props = {
25 28
      */
26 29
     _fileRecordingSession: Object,
27 30
 
31
+    /**
32
+     * Whether the recording is a local recording or not.
33
+     */
34
+    _localRecording: boolean,
35
+
28 36
     /**
29 37
      * The redux dispatch function.
30 38
      */
@@ -68,11 +76,15 @@ export default class AbstractStopRecordingDialog<P: Props>
68 76
     _onSubmit() {
69 77
         sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
70 78
 
71
-        const { _fileRecordingSession } = this.props;
79
+        if (this.props._localRecording) {
80
+            this.props.dispatch(stopLocalVideoRecording());
81
+        } else {
82
+            const { _fileRecordingSession } = this.props;
72 83
 
73
-        if (_fileRecordingSession) {
74
-            this.props._conference.stopRecording(_fileRecordingSession.id);
75
-            this._toggleScreenshotCapture();
84
+            if (_fileRecordingSession) {
85
+                this.props._conference.stopRecording(_fileRecordingSession.id);
86
+                this._toggleScreenshotCapture();
87
+            }
76 88
         }
77 89
 
78 90
         return true;
@@ -105,6 +117,7 @@ export function _mapStateToProps(state: Object) {
105 117
     return {
106 118
         _conference: state['features/base/conference'].conference,
107 119
         _fileRecordingSession:
108
-            getActiveSession(state, JitsiRecordingConstants.mode.FILE)
120
+            getActiveSession(state, JitsiRecordingConstants.mode.FILE),
121
+        _localRecording: LocalRecordingManager.isRecordingLocally()
109 122
     };
110 123
 }

+ 221
- 0
react/features/recording/components/Recording/LocalRecordingManager.ts Bestand weergeven

@@ -0,0 +1,221 @@
1
+import { v4 as uuidV4 } from 'uuid';
2
+import fixWebmDuration from 'webm-duration-fix';
3
+
4
+// @ts-ignore
5
+import { getRoomName } from '../../../base/conference';
6
+// @ts-ignore
7
+import { MEDIA_TYPE } from '../../../base/media';
8
+// @ts-ignore
9
+import { getTrackState } from '../../../base/tracks';
10
+// @ts-ignore
11
+import { stopLocalVideoRecording } from '../../actions.any';
12
+
13
+interface IReduxStore {
14
+    dispatch: Function;
15
+    getState: Function;
16
+}
17
+
18
+interface ILocalRecordingManager {
19
+    recordingData: Blob[];
20
+    recorder: MediaRecorder|undefined;
21
+    stream: MediaStream|undefined;
22
+    audioContext: AudioContext|undefined;
23
+    audioDestination: MediaStreamAudioDestinationNode|undefined;
24
+    roomName: string;
25
+    mediaType: string;
26
+    initializeAudioMixer: () => void;
27
+    mixAudioStream: (stream: MediaStream) => void;
28
+    addAudioTrackToLocalRecording: (track: MediaStreamTrack) => void;
29
+    getFilename: () => string;
30
+    saveRecording: (recordingData: Blob[], filename: string) => void;
31
+    stopLocalRecording: () => void;
32
+    startLocalRecording: (store: IReduxStore) => void;
33
+    isRecordingLocally: () => boolean;
34
+    totalSize: number;
35
+}
36
+
37
+const getMimeType = (): string => {
38
+    const possibleTypes = [
39
+        'video/mp4;codecs=h264',
40
+        'video/webm;codecs=h264',
41
+        'video/webm;codecs=vp9',
42
+        'video/webm;codecs=vp8',
43
+    ];
44
+    for(let type of possibleTypes) {
45
+        if(MediaRecorder.isTypeSupported(type)) {
46
+            return type;
47
+        }
48
+    }
49
+    throw new Error("No MIME Type supported by MediaRecorder");
50
+}
51
+
52
+const VIDEO_BIT_RATE = 2500000; // 2.5Mbps in bits
53
+
54
+const LocalRecordingManager: ILocalRecordingManager = {
55
+    recordingData: [],
56
+    recorder: undefined,
57
+    stream: undefined,
58
+    audioContext: undefined,
59
+    audioDestination: undefined,
60
+    roomName: '',
61
+    mediaType: getMimeType(),
62
+    totalSize: 1073741824, // 1GB in bytes
63
+
64
+    /**
65
+     * Initializes audio context used for mixing audio tracks.
66
+     */
67
+    initializeAudioMixer() {
68
+        this.audioContext = new AudioContext();
69
+        this.audioDestination = this.audioContext.createMediaStreamDestination();
70
+    },
71
+
72
+    /**
73
+     * Mixes multiple audio tracks to the destination media stream.
74
+     * */
75
+    mixAudioStream(stream) {
76
+        if (stream.getAudioTracks().length > 0 && this.audioDestination) {
77
+            this.audioContext?.createMediaStreamSource(stream).connect(this.audioDestination);
78
+        }
79
+    },
80
+
81
+    /**
82
+     * Adds audio track to the recording stream.
83
+     */
84
+    addAudioTrackToLocalRecording(track) {
85
+        if (track) {
86
+            const stream = new MediaStream([ track ]);
87
+
88
+            this.mixAudioStream(stream);
89
+        }
90
+    },
91
+
92
+    /**
93
+     * Returns a filename based ono the Jitsi room name in the URL and timestamp.
94
+     * */
95
+    getFilename() {
96
+        const now = new Date();
97
+        const timestamp = now.toISOString();
98
+
99
+        return `${this.roomName}_${timestamp}`;
100
+    },
101
+
102
+    /**
103
+     * Saves local recording to file.
104
+     * */
105
+    async saveRecording(recordingData, filename) {
106
+        // @ts-ignore
107
+        const blob = await fixWebmDuration(new Blob(recordingData, { type: this.mediaType }));
108
+        // @ts-ignore
109
+        const url = window.URL.createObjectURL(blob);
110
+        const a = document.createElement('a');
111
+
112
+        const extension = this.mediaType.slice(this.mediaType.indexOf('/') + 1, this.mediaType.indexOf(';'))
113
+        a.style.display = 'none';
114
+        a.href = url;
115
+        a.download = `${filename}.${extension}`;
116
+        a.click();
117
+    },
118
+
119
+    /**
120
+     * Stops local recording.
121
+     * */
122
+    stopLocalRecording() {
123
+        if (this.recorder) {
124
+            this.recorder.stop();
125
+            this.recorder = undefined;
126
+            this.audioContext = undefined;
127
+            this.audioDestination = undefined;
128
+            setTimeout(() => this.saveRecording(this.recordingData, this.getFilename()), 1000);
129
+        }
130
+    },
131
+
132
+    /**
133
+     * Starts a local recording.
134
+     */
135
+    async startLocalRecording(store) {
136
+        const { dispatch, getState } = store;
137
+        // @ts-ignore
138
+        const supportsCaptureHandle = Boolean(navigator.mediaDevices.setCaptureHandleConfig);
139
+        const tabId = uuidV4();
140
+
141
+        if (supportsCaptureHandle) {
142
+            // @ts-ignore
143
+            navigator.mediaDevices.setCaptureHandleConfig({
144
+                handle: `JitsiMeet-${tabId}`,
145
+                permittedOrigins: [ '*' ]
146
+            });
147
+        }
148
+
149
+        this.recordingData = [];
150
+        // @ts-ignore
151
+        const gdmStream = await navigator.mediaDevices.getDisplayMedia({
152
+            // @ts-ignore
153
+            video: { displaySurface: 'browser' },
154
+            audio: true
155
+        });
156
+        // @ts-ignore
157
+        const isBrowser = gdmStream.getVideoTracks()[0].getSettings().displaySurface === 'browser';
158
+
159
+        if (!isBrowser || (supportsCaptureHandle // @ts-ignore
160
+            && gdmStream.getVideoTracks()[0].getCaptureHandle()?.handle !== `JitsiMeet-${tabId}`)) {
161
+            gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
162
+            throw new Error('WrongSurfaceSelected');
163
+        }
164
+
165
+        this.initializeAudioMixer();
166
+        this.mixAudioStream(gdmStream);
167
+        this.roomName = getRoomName(getState());
168
+        const tracks = getTrackState(getState());
169
+
170
+        tracks.forEach((track: any) => {
171
+            if (track.mediaType === MEDIA_TYPE.AUDIO) {
172
+                const audioTrack = track?.jitsiTrack?.track;
173
+
174
+                this.addAudioTrackToLocalRecording(audioTrack);
175
+            }
176
+        });
177
+
178
+        this.stream = new MediaStream([
179
+            ...(this.audioDestination?.stream.getAudioTracks() || []),
180
+            gdmStream.getVideoTracks()[0]
181
+        ]);
182
+        this.recorder = new MediaRecorder(this.stream, {
183
+            mimeType: this.mediaType,
184
+            videoBitsPerSecond: VIDEO_BIT_RATE
185
+        });
186
+        this.recorder.addEventListener('dataavailable', e => {
187
+            if (e.data && e.data.size > 0) {
188
+                this.recordingData.push(e.data);
189
+                this.totalSize -= e.data.size;
190
+                if (this.totalSize <= 0) {
191
+                    this.stopLocalRecording();
192
+                }
193
+            }
194
+        });
195
+
196
+        this.recorder.addEventListener('stop', () => {
197
+            this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
198
+            gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
199
+        });
200
+
201
+        gdmStream.addEventListener('inactive', () => {
202
+            dispatch(stopLocalVideoRecording());
203
+        });
204
+
205
+        this.stream.addEventListener('inactive', () => {
206
+            dispatch(stopLocalVideoRecording());
207
+        });
208
+
209
+        this.recorder.start(5000);
210
+    },
211
+
212
+    /**
213
+     * Whether or not we're currently recording locally.
214
+     */
215
+    isRecordingLocally() {
216
+        return Boolean(this.recorder);
217
+    }
218
+
219
+};
220
+
221
+export default LocalRecordingManager;

+ 159
- 15
react/features/recording/components/Recording/StartRecordingDialogContent.js Bestand weergeven

@@ -10,7 +10,9 @@ import { ColorSchemeRegistry } from '../../../base/color-scheme';
10 10
 import {
11 11
     _abstractMapStateToProps
12 12
 } from '../../../base/dialog';
13
+import { isMobileBrowser } from '../../../base/environment/utils';
13 14
 import { translate } from '../../../base/i18n';
15
+import { browser } from '../../../base/lib-jitsi-meet';
14 16
 import {
15 17
     Button,
16 18
     Container,
@@ -31,6 +33,7 @@ import {
31 33
     ICON_CLOUD,
32 34
     ICON_INFO,
33 35
     ICON_USERS,
36
+    LOCAL_RECORDING,
34 37
     TRACK_COLOR
35 38
 } from './styles';
36 39
 
@@ -41,6 +44,11 @@ type Props = {
41 44
      */
42 45
     _dialogStyles: StyleType,
43 46
 
47
+    /**
48
+     * Whether local recording is enabled or not.
49
+     */
50
+    _localRecordingEnabled: boolean,
51
+
44 52
     /**
45 53
      * The color-schemed stylesheet of this component.
46 54
      */
@@ -126,6 +134,8 @@ type Props = {
126 134
  * @augments Component
127 135
  */
128 136
 class StartRecordingDialogContent extends Component<Props> {
137
+    _localRecordingAvailable: boolean;
138
+
129 139
     /**
130 140
      * Initializes a new {@code StartRecordingDialogContent} instance.
131 141
      *
@@ -133,12 +143,29 @@ class StartRecordingDialogContent extends Component<Props> {
133 143
      */
134 144
     constructor(props) {
135 145
         super(props);
146
+        const supportsLocalRecording = browser.isChromiumBased() && !browser.isElectron() && !isMobileBrowser();
147
+
148
+        this._localRecordingAvailable = props._localRecordingEnabled && supportsLocalRecording;
136 149
 
137 150
         // Bind event handler so it is only bound once for every instance.
138 151
         this._onSignIn = this._onSignIn.bind(this);
139 152
         this._onSignOut = this._onSignOut.bind(this);
140 153
         this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this);
141 154
         this._onRecordingServiceSwitchChange = this._onRecordingServiceSwitchChange.bind(this);
155
+        this._onLocalRecordingSwitchChange = this._onLocalRecordingSwitchChange.bind(this);
156
+    }
157
+
158
+    /**
159
+     * Implements the Component's componentDidMount method.
160
+     *
161
+     * @inheritdoc
162
+     */
163
+    componentDidMount() {
164
+        if (!this._shouldRenderNoIntegrationsContent()
165
+            && !this._shouldRenderIntegrationsContent()
166
+            && !this._shouldRenderFileSharingContent()) {
167
+            this._onLocalRecordingSwitchChange();
168
+        }
142 169
     }
143 170
 
144 171
     /**
@@ -158,21 +185,35 @@ class StartRecordingDialogContent extends Component<Props> {
158 185
                 { this._renderFileSharingContent() }
159 186
                 { this._renderUploadToTheCloudInfo() }
160 187
                 { this._renderIntegrationsContent() }
188
+                { this._renderLocalRecordingContent() }
161 189
             </Container>
162 190
         );
163 191
     }
164 192
 
165 193
     /**
166
-     * Renders the file recording service sharing options, if enabled.
194
+     * Whether the file sharing content should be rendered or not.
167 195
      *
168
-     * @returns {React$Component}
196
+     * @returns {boolean}
169 197
      */
170
-    _renderFileSharingContent() {
198
+    _shouldRenderFileSharingContent() {
171 199
         const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props;
172 200
 
173 201
         if (!fileRecordingsServiceSharingEnabled
174 202
             || isVpaas
175 203
             || selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
204
+            return false;
205
+        }
206
+
207
+        return true;
208
+    }
209
+
210
+    /**
211
+     * Renders the file recording service sharing options, if enabled.
212
+     *
213
+     * @returns {React$Component}
214
+     */
215
+    _renderFileSharingContent() {
216
+        if (!this._shouldRenderFileSharingContent()) {
176 217
             return null;
177 218
         }
178 219
 
@@ -256,23 +297,35 @@ class StartRecordingDialogContent extends Component<Props> {
256 297
     }
257 298
 
258 299
     /**
259
-     * Renders the content in case no integrations were enabled.
300
+     * Whether the no integrations content should be rendered or not.
260 301
      *
261
-     * @returns {React$Component}
302
+     * @returns {boolean}
262 303
      */
263
-    _renderNoIntegrationsContent() {
264
-
304
+    _shouldRenderNoIntegrationsContent() {
265 305
         // show the non integrations part only if fileRecordingsServiceEnabled
266 306
         // is enabled or when there are no integrations enabled
267 307
         if (!(this.props.fileRecordingsServiceEnabled
268 308
             || !this.props.integrationsEnabled)) {
309
+            return false;
310
+        }
311
+
312
+        return true;
313
+    }
314
+
315
+    /**
316
+     * Renders the content in case no integrations were enabled.
317
+     *
318
+     * @returns {React$Component}
319
+     */
320
+    _renderNoIntegrationsContent() {
321
+        if (!this._shouldRenderNoIntegrationsContent()) {
269 322
             return null;
270 323
         }
271 324
 
272 325
         const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props;
273 326
 
274 327
         const switchContent
275
-            = this.props.integrationsEnabled
328
+            = this.props.integrationsEnabled || this.props._localRecordingEnabled
276 329
                 ? (
277 330
                     <Switch
278 331
                         className = 'recording-switch'
@@ -285,7 +338,7 @@ class StartRecordingDialogContent extends Component<Props> {
285 338
 
286 339
         const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription');
287 340
         const jitsiContentRecordingIconContainer
288
-            = this.props.integrationsEnabled
341
+            = this.props.integrationsEnabled || this.props._localRecordingEnabled
289 342
                 ? 'jitsi-content-recording-icon-container-with-switch'
290 343
                 : 'jitsi-content-recording-icon-container-without-switch';
291 344
         const contentRecordingClass = isVpaas
@@ -317,6 +370,19 @@ class StartRecordingDialogContent extends Component<Props> {
317 370
         );
318 371
     }
319 372
 
373
+    /**
374
+     * Whether the integrations content should be rendered or not.
375
+     *
376
+     * @returns {boolean}
377
+     */
378
+    _shouldRenderIntegrationsContent() {
379
+        if (!this.props.integrationsEnabled) {
380
+            return false;
381
+        }
382
+
383
+        return true;
384
+    }
385
+
320 386
     /**
321 387
      * Renders the content in case integrations were enabled.
322 388
      *
@@ -324,7 +390,7 @@ class StartRecordingDialogContent extends Component<Props> {
324 390
      * @returns {React$Component}
325 391
      */
326 392
     _renderIntegrationsContent() {
327
-        if (!this.props.integrationsEnabled) {
393
+        if (!this._shouldRenderIntegrationsContent()) {
328 394
             return null;
329 395
         }
330 396
 
@@ -376,7 +442,7 @@ class StartRecordingDialogContent extends Component<Props> {
376 442
         return (
377 443
             <Container>
378 444
                 <Container
379
-                    className = 'recording-header recording-header-line'
445
+                    className = 'recording-header'
380 446
                     style = { styles.headerIntegrations }>
381 447
                     <Container
382 448
                         className = 'recording-icon-container'>
@@ -405,6 +471,7 @@ class StartRecordingDialogContent extends Component<Props> {
405 471
 
406 472
     _onDropboxSwitchChange: () => void;
407 473
     _onRecordingServiceSwitchChange: () => void;
474
+    _onLocalRecordingSwitchChange: () => void;
408 475
 
409 476
     /**
410 477
      * Handler for onValueChange events from the Switch component.
@@ -419,8 +486,7 @@ class StartRecordingDialogContent extends Component<Props> {
419 486
         } = this.props;
420 487
 
421 488
         // act like group, cannot toggle off
422
-        if (selectedRecordingService
423
-                === RECORDING_TYPES.JITSI_REC_SERVICE) {
489
+        if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
424 490
             return;
425 491
         }
426 492
 
@@ -444,8 +510,7 @@ class StartRecordingDialogContent extends Component<Props> {
444 510
         } = this.props;
445 511
 
446 512
         // act like group, cannot toggle off
447
-        if (selectedRecordingService
448
-                === RECORDING_TYPES.DROPBOX) {
513
+        if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
449 514
             return;
450 515
         }
451 516
 
@@ -456,6 +521,30 @@ class StartRecordingDialogContent extends Component<Props> {
456 521
         }
457 522
     }
458 523
 
524
+    /**
525
+     * Handler for onValueChange events from the Switch component.
526
+     *
527
+     * @returns {void}
528
+     */
529
+    _onLocalRecordingSwitchChange() {
530
+        const {
531
+            onChange,
532
+            selectedRecordingService
533
+        } = this.props;
534
+
535
+        if (!this._localRecordingAvailable) {
536
+            return;
537
+        }
538
+
539
+        // act like group, cannot toggle off
540
+        if (selectedRecordingService
541
+            === RECORDING_TYPES.LOCAL) {
542
+            return;
543
+        }
544
+
545
+        onChange(RECORDING_TYPES.LOCAL);
546
+    }
547
+
459 548
     /**
460 549
      * Renders a spinner component.
461 550
      *
@@ -511,6 +600,60 @@ class StartRecordingDialogContent extends Component<Props> {
511 600
         );
512 601
     }
513 602
 
603
+    _renderLocalRecordingContent: () => void;
604
+
605
+    /**
606
+     * Renders the content for local recordings.
607
+     *
608
+     * @protected
609
+     * @returns {React$Component}
610
+     */
611
+    _renderLocalRecordingContent() {
612
+        const { _styles: styles, isValidating, t, _dialogStyles, selectedRecordingService } = this.props;
613
+
614
+        if (!this._localRecordingAvailable) {
615
+            return null;
616
+        }
617
+
618
+        return (
619
+            <Container>
620
+                <Container
621
+                    className = 'recording-header recording-header-line'
622
+                    style = { styles.header }>
623
+                    <Container
624
+                        className = 'recording-icon-container'>
625
+                        <Image
626
+                            className = 'recording-icon'
627
+                            src = { LOCAL_RECORDING }
628
+                            style = { styles.recordingIcon } />
629
+                    </Container>
630
+                    <Text
631
+                        className = 'recording-title'
632
+                        style = {{
633
+                            ..._dialogStyles.text,
634
+                            ...styles.title
635
+                        }}>
636
+                        { t('recording.saveLocalRecording') }
637
+                    </Text>
638
+                    <Switch
639
+                        className = 'recording-switch'
640
+                        disabled = { isValidating }
641
+                        onValueChange = { this._onLocalRecordingSwitchChange }
642
+                        style = { styles.switch }
643
+                        trackColor = {{ false: TRACK_COLOR }}
644
+                        value = { this.props.selectedRecordingService
645
+                        === RECORDING_TYPES.LOCAL } />
646
+                </Container>
647
+                {selectedRecordingService === RECORDING_TYPES.LOCAL
648
+                    && <Text className = 'local-recording-warning'>
649
+                        {t('recording.localRecordingWarning')}
650
+                    </Text>
651
+                }
652
+            </Container>
653
+
654
+        );
655
+    }
656
+
514 657
     _onSignIn: () => void;
515 658
 
516 659
     /**
@@ -546,6 +689,7 @@ function _mapStateToProps(state) {
546 689
     return {
547 690
         ..._abstractMapStateToProps(state),
548 691
         isVpaas: isVpaasMeeting(state),
692
+        _localRecordingEnabled: state['features/base/config'].enableLocalRecording,
549 693
         _styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
550 694
     };
551 695
 }

+ 1
- 0
react/features/recording/components/Recording/styles.native.js Bestand weergeven

@@ -8,6 +8,7 @@ export const DROPBOX_LOGO = require('../../../../../images/dropboxLogo_square.pn
8 8
 export const ICON_CLOUD = require('../../../../../images/icon-cloud.png');
9 9
 export const ICON_INFO = require('../../../../../images/icon-info.png');
10 10
 export const ICON_USERS = require('../../../../../images/icon-users.png');
11
+export const LOCAL_RECORDING = require('../../../../../images/downloadLocalRecording.png');
11 12
 export const TRACK_COLOR = BaseTheme.palette.ui15;
12 13
 
13 14
 

+ 2
- 0
react/features/recording/components/Recording/styles.web.js Bestand weergeven

@@ -6,6 +6,8 @@ export default {};
6 6
 
7 7
 export const DROPBOX_LOGO = 'images/dropboxLogo_square.png';
8 8
 
9
+export const LOCAL_RECORDING = 'images/downloadLocalRecording.png';
10
+
9 11
 export const ICON_CLOUD = 'images/icon-cloud.png';
10 12
 
11 13
 export const ICON_INFO = 'images/icon-info.png';

+ 2
- 0
react/features/recording/components/Recording/web/StartRecordingDialog.js Bestand weergeven

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

+ 2
- 1
react/features/recording/constants.js Bestand weergeven

@@ -45,7 +45,8 @@ export const RECORDING_ON_SOUND_ID = 'RECORDING_ON_SOUND';
45 45
  */
46 46
 export const RECORDING_TYPES = {
47 47
     JITSI_REC_SERVICE: 'recording-service',
48
-    DROPBOX: 'dropbox'
48
+    DROPBOX: 'dropbox',
49
+    LOCAL: 'local'
49 50
 };
50 51
 
51 52
 /**

+ 26
- 1
react/features/recording/functions.js Bestand weergeven

@@ -1,11 +1,12 @@
1 1
 // @flow
2 2
 
3 3
 import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
4
-import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants';
4
+import { getLocalParticipant, getRemoteParticipants, isLocalParticipantModerator } from '../base/participants';
5 5
 import { isInBreakoutRoom } from '../breakout-rooms/functions';
6 6
 import { isEnabled as isDropboxEnabled } from '../dropbox';
7 7
 import { extractFqnFromPath } from '../dynamic-branding/functions.any';
8 8
 
9
+import LocalRecordingManager from './components/Recording/LocalRecordingManager';
9 10
 import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants';
10 11
 import logger from './logger';
11 12
 
@@ -116,6 +117,11 @@ export function getSessionStatusToShow(state: Object, mode: string): ?string {
116 117
             }
117 118
         }
118 119
     }
120
+    if ((!Array.isArray(recordingSessions) || recordingSessions.length === 0)
121
+        && mode === JitsiRecordingConstants.mode.FILE
122
+        && (LocalRecordingManager.isRecordingLocally() || isRemoteParticipantRecordingLocally(state))) {
123
+        status = JitsiRecordingConstants.status.ON;
124
+    }
119 125
 
120 126
     return status;
121 127
 }
@@ -241,3 +247,22 @@ export async function sendMeetingHighlight(state: Object) {
241 247
 
242 248
     return false;
243 249
 }
250
+
251
+/**
252
+ * Whether a remote participant is recording locally or not.
253
+ *
254
+ * @param {Object} state - Redux state.
255
+ * @returns {boolean}
256
+ */
257
+function isRemoteParticipantRecordingLocally(state) {
258
+    const participants = getRemoteParticipants(state);
259
+
260
+    // eslint-disable-next-line prefer-const
261
+    for (let value of participants.values()) {
262
+        if (value.localRecording) {
263
+            return true;
264
+        }
265
+    }
266
+
267
+    return false;
268
+}

+ 57
- 4
react/features/recording/middleware.js Bestand weergeven

@@ -11,7 +11,8 @@ import JitsiMeetJS, {
11 11
     JitsiConferenceEvents,
12 12
     JitsiRecordingConstants
13 13
 } from '../base/lib-jitsi-meet';
14
-import { getParticipantDisplayName } from '../base/participants';
14
+import { MEDIA_TYPE } from '../base/media';
15
+import { getParticipantDisplayName, updateLocalRecordingStatus } from '../base/participants';
15 16
 import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
16 17
 import {
17 18
     playSound,
@@ -19,8 +20,10 @@ import {
19 20
     stopSound,
20 21
     unregisterSound
21 22
 } from '../base/sounds';
23
+import { TRACK_ADDED } from '../base/tracks';
24
+import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification, showNotification } from '../notifications';
22 25
 
23
-import { RECORDING_SESSION_UPDATED } from './actionTypes';
26
+import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes';
24 27
 import {
25 28
     clearRecordingSessions,
26 29
     hidePendingRecordingNotification,
@@ -32,13 +35,18 @@ import {
32 35
     showStoppedRecordingNotification,
33 36
     updateRecordingSessionData
34 37
 } from './actions';
38
+import LocalRecordingManager from './components/Recording/LocalRecordingManager';
35 39
 import {
36 40
     LIVE_STREAMING_OFF_SOUND_ID,
37 41
     LIVE_STREAMING_ON_SOUND_ID,
38 42
     RECORDING_OFF_SOUND_ID,
39 43
     RECORDING_ON_SOUND_ID
40 44
 } from './constants';
41
-import { getSessionById, getResourceId } from './functions';
45
+import {
46
+    getSessionById,
47
+    getResourceId
48
+} from './functions';
49
+import logger from './logger';
42 50
 import {
43 51
     LIVE_STREAMING_OFF_SOUND_FILE,
44 52
     LIVE_STREAMING_ON_SOUND_FILE,
@@ -68,7 +76,7 @@ StateListenerRegistry.register(
68 76
  * @param {Store} store - The redux store.
69 77
  * @returns {Function}
70 78
  */
71
-MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
79
+MiddlewareRegistry.register(({ dispatch, getState }) => next => async action => {
72 80
     let oldSessionData;
73 81
 
74 82
     if (action.type === RECORDING_SESSION_UPDATED) {
@@ -123,6 +131,41 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
123 131
         break;
124 132
     }
125 133
 
134
+    case START_LOCAL_RECORDING: {
135
+        try {
136
+            await LocalRecordingManager.startLocalRecording({ dispatch,
137
+                getState });
138
+            const props = {
139
+                descriptionKey: 'recording.on',
140
+                titleKey: 'dialog.recording'
141
+            };
142
+
143
+            dispatch(playSound(RECORDING_ON_SOUND_ID));
144
+            dispatch(showNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
145
+            dispatch(updateLocalRecordingStatus(true));
146
+        } catch (err) {
147
+            logger.error('Capture failed', err);
148
+
149
+            const noTabError = err.message === 'WrongSurfaceSelected';
150
+            const props = {
151
+                descriptionKey: noTabError ? 'recording.surfaceError' : 'recording.error',
152
+                titleKey: 'recording.failedToStart'
153
+            };
154
+
155
+            dispatch(showErrorNotification(props, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
156
+        }
157
+        break;
158
+    }
159
+
160
+    case STOP_LOCAL_RECORDING: {
161
+        if (LocalRecordingManager.isRecordingLocally()) {
162
+            LocalRecordingManager.stopLocalRecording();
163
+            dispatch(playSound(RECORDING_OFF_SOUND_ID));
164
+            dispatch(updateLocalRecordingStatus(false));
165
+        }
166
+        break;
167
+    }
168
+
126 169
     case RECORDING_SESSION_UPDATED: {
127 170
         // When in recorder mode no notifications are shown
128 171
         // or extra sounds are also not desired
@@ -211,6 +254,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
211 254
 
212 255
         break;
213 256
     }
257
+    case TRACK_ADDED: {
258
+        const { track } = action;
259
+
260
+        if (LocalRecordingManager.isRecordingLocally() && track.mediaType === MEDIA_TYPE.AUDIO) {
261
+            const audioTrack = track.jitsiTrack.track;
262
+
263
+            LocalRecordingManager.addAudioTrackToLocalRecording(audioTrack);
264
+        }
265
+        break;
266
+    }
214 267
     }
215 268
 
216 269
     return result;

Laden…
Annuleren
Opslaan