Browse Source

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

master
Robert Pintilii 3 years ago
parent
commit
e27069447b
No account linked to committer's email address

+ 3
- 18
config.js View File

295
     // Whether to enable live streaming or not.
295
     // Whether to enable live streaming or not.
296
     // liveStreamingEnabled: false,
296
     // liveStreamingEnabled: false,
297
 
297
 
298
+    // Whether to enable local recording or not.
299
+    // enableLocalRecording: false,
300
+
298
     // Transcription (in interface_config,
301
     // Transcription (in interface_config,
299
     // subtitles and buttons can be configured)
302
     // subtitles and buttons can be configured)
300
     // transcribingEnabled: false,
303
     // transcribingEnabled: false,
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
     // e2ee: {
959
     // e2ee: {
974
     //   labels,
960
     //   labels,
975
     //   externallyManagedKey: false
961
     //   externallyManagedKey: false
1305
     //     'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable
1291
     //     'liveStreaming.unavailableTitle', // shown when livestreaming service is not reachable
1306
     //     'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected
1292
     //     'lobby.joinRejectedMessage', // shown when while in a lobby, user's request to join is rejected
1307
     //     'lobby.notificationTitle', // shown when lobby is toggled and when join requests are allowed / denied
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
     //     'notify.chatMessages', // shown when receiving chat messages while the chat window is closed
1294
     //     'notify.chatMessages', // shown when receiving chat messages while the chat window is closed
1310
     //     'notify.disconnected', // shown when a participant has left
1295
     //     'notify.disconnected', // shown when a participant has left
1311
     //     'notify.connectedOneMember', // show when a participant joined
1296
     //     'notify.connectedOneMember', // show when a participant joined

+ 7
- 1
css/_recording.scss View File

23
 
23
 
24
     .recording-header-line {
24
     .recording-header-line {
25
         border-top: 1px solid #5e6d7a;
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
     .recording-switch-disabled {
35
     .recording-switch-disabled {

BIN
images/downloadLocalRecording.png View File


+ 5
- 0
lang/main.json View File

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

+ 100
- 7
package-lock.json View File

128
         "util": "0.12.1",
128
         "util": "0.12.1",
129
         "uuid": "8.3.2",
129
         "uuid": "8.3.2",
130
         "wasm-check": "2.0.1",
130
         "wasm-check": "2.0.1",
131
+        "webm-duration-fix": "1.0.4",
131
         "windows-iana": "^3.1.0",
132
         "windows-iana": "^3.1.0",
132
         "zxcvbn": "4.4.2"
133
         "zxcvbn": "4.4.2"
133
       },
134
       },
141
         "@babel/runtime": "7.16.0",
142
         "@babel/runtime": "7.16.0",
142
         "@jitsi/eslint-config": "4.0.0",
143
         "@jitsi/eslint-config": "4.0.0",
143
         "@types/react-native": "0.67.6",
144
         "@types/react-native": "0.67.6",
145
+        "@types/uuid": "8.3.4",
144
         "babel-loader": "8.2.3",
146
         "babel-loader": "8.2.3",
145
         "babel-plugin-optional-require": "0.3.1",
147
         "babel-plugin-optional-require": "0.3.1",
146
         "circular-dependency-plugin": "5.2.0",
148
         "circular-dependency-plugin": "5.2.0",
163
         "style-loader": "0.19.0",
165
         "style-loader": "0.19.0",
164
         "traverse": "0.6.6",
166
         "traverse": "0.6.6",
165
         "ts-loader": "9.2.6",
167
         "ts-loader": "9.2.6",
166
-        "typescript": "4.3.5",
168
+        "typescript": "4.6.4",
167
         "unorm": "1.6.0",
169
         "unorm": "1.6.0",
168
         "webpack": "5.57.1",
170
         "webpack": "5.57.1",
169
         "webpack-bundle-analyzer": "4.4.2",
171
         "webpack-bundle-analyzer": "4.4.2",
5561
         "@types/node": "*"
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
     "node_modules/@types/webgl-ext": {
5572
     "node_modules/@types/webgl-ext": {
5565
       "version": "0.0.30",
5573
       "version": "0.0.30",
5566
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
5574
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
8323
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
8331
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
8324
       "dev": true
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
     "node_modules/ee-first": {
8339
     "node_modules/ee-first": {
8327
       "version": "1.1.1",
8340
       "version": "1.1.1",
8328
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
8341
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
10657
         "css-in-js-utils": "^2.0.0"
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
     "node_modules/internal-slot": {
10681
     "node_modules/internal-slot": {
10661
       "version": "1.0.3",
10682
       "version": "1.0.3",
10662
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
10683
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
18643
       }
18664
       }
18644
     },
18665
     },
18645
     "node_modules/typescript": {
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
       "dev": true,
18670
       "dev": true,
18650
       "bin": {
18671
       "bin": {
18651
         "tsc": "bin/tsc",
18672
         "tsc": "bin/tsc",
19123
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
19144
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
19124
       "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
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
     "node_modules/webpack": {
19181
     "node_modules/webpack": {
19127
       "version": "5.57.1",
19182
       "version": "5.57.1",
19128
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",
19183
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",
24162
         "@types/node": "*"
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
     "@types/webgl-ext": {
24226
     "@types/webgl-ext": {
24166
       "version": "0.0.30",
24227
       "version": "0.0.30",
24167
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
24228
       "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
26348
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
26409
       "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
26349
       "dev": true
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
     "ee-first": {
26417
     "ee-first": {
26352
       "version": "1.1.1",
26418
       "version": "1.1.1",
26353
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
26419
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
28173
         "css-in-js-utils": "^2.0.0"
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
     "internal-slot": {
28247
     "internal-slot": {
28177
       "version": "1.0.3",
28248
       "version": "1.0.3",
28178
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
28249
       "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
34274
       }
34345
       }
34275
     },
34346
     },
34276
     "typescript": {
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
       "dev": true
34351
       "dev": true
34281
     },
34352
     },
34282
     "ua-parser-js": {
34353
     "ua-parser-js": {
34621
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
34692
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
34622
       "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
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
     "webpack": {
34717
     "webpack": {
34625
       "version": "5.57.1",
34718
       "version": "5.57.1",
34626
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",
34719
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.57.1.tgz",

+ 3
- 1
package.json View File

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

+ 1
- 1
react/features/base/config/configWhitelist.js View File

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

+ 9
- 0
react/features/base/participants/actionTypes.ts View File

231
  * }
231
  * }
232
  */
232
  */
233
 export const OVERWRITE_PARTICIPANTS_NAMES = 'OVERWRITE_PARTICIPANTS_NAMES';
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 View File

10
     LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
10
     LOCAL_PARTICIPANT_AUDIO_LEVEL_CHANGED,
11
     LOCAL_PARTICIPANT_RAISE_HAND,
11
     LOCAL_PARTICIPANT_RAISE_HAND,
12
     MUTE_REMOTE_PARTICIPANT,
12
     MUTE_REMOTE_PARTICIPANT,
13
+    OVERWRITE_PARTICIPANT_NAME,
14
+    OVERWRITE_PARTICIPANTS_NAMES,
13
     PARTICIPANT_ID_CHANGED,
15
     PARTICIPANT_ID_CHANGED,
14
     PARTICIPANT_JOINED,
16
     PARTICIPANT_JOINED,
15
     PARTICIPANT_KICKED,
17
     PARTICIPANT_KICKED,
16
     PARTICIPANT_LEFT,
18
     PARTICIPANT_LEFT,
17
     PARTICIPANT_UPDATED,
19
     PARTICIPANT_UPDATED,
18
     PIN_PARTICIPANT,
20
     PIN_PARTICIPANT,
21
+    RAISE_HAND_UPDATED,
19
     SCREENSHARE_PARTICIPANT_NAME_CHANGED,
22
     SCREENSHARE_PARTICIPANT_NAME_CHANGED,
20
     SET_LOADABLE_AVATAR_URL,
23
     SET_LOADABLE_AVATAR_URL,
21
-    RAISE_HAND_UPDATED,
22
-    OVERWRITE_PARTICIPANT_NAME,
23
-    OVERWRITE_PARTICIPANTS_NAMES
24
+    SET_LOCAL_PARTICIPANT_RECORDING_STATUS
24
 } from './actionTypes';
25
 } from './actionTypes';
25
 import {
26
 import {
26
     DISCO_REMOTE_CONTROL_FEATURE
27
     DISCO_REMOTE_CONTROL_FEATURE
683
         participantList
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 View File

10
 import { toggleE2EE } from '../../e2ee/actions';
10
 import { toggleE2EE } from '../../e2ee/actions';
11
 import { MAX_MODE } from '../../e2ee/constants';
11
 import { MAX_MODE } from '../../e2ee/constants';
12
 import {
12
 import {
13
+    LOCAL_RECORDING_NOTIFICATION_ID,
13
     NOTIFICATION_TIMEOUT_TYPE,
14
     NOTIFICATION_TIMEOUT_TYPE,
14
     RAISE_HAND_NOTIFICATION_ID,
15
     RAISE_HAND_NOTIFICATION_ID,
15
     showNotification
16
     showNotification
17
 import { isForceMuted } from '../../participants-pane/functions';
18
 import { isForceMuted } from '../../participants-pane/functions';
18
 import { CALLING, INVITED } from '../../presence-status';
19
 import { CALLING, INVITED } from '../../presence-status';
19
 import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
20
 import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
21
+import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../../recording';
20
 import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
22
 import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
21
 import {
23
 import {
22
     CONFERENCE_WILL_JOIN,
24
     CONFERENCE_WILL_JOIN,
42
     PARTICIPANT_JOINED,
44
     PARTICIPANT_JOINED,
43
     PARTICIPANT_LEFT,
45
     PARTICIPANT_LEFT,
44
     PARTICIPANT_UPDATED,
46
     PARTICIPANT_UPDATED,
45
-    RAISE_HAND_UPDATED
47
+    RAISE_HAND_UPDATED,
48
+    SET_LOCAL_PARTICIPANT_RECORDING_STATUS
46
 } from './actionTypes';
49
 } from './actionTypes';
47
 import {
50
 import {
48
     localParticipantIdChanged,
51
     localParticipantIdChanged,
174
         break;
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
     case MUTE_REMOTE_PARTICIPANT: {
199
     case MUTE_REMOTE_PARTICIPANT: {
178
         const { conference } = store.getState()['features/base/conference'];
200
         const { conference } = store.getState()['features/base/conference'];
179
 
201
 
389
                         id: participant.getId(),
411
                         id: participant.getId(),
390
                         features: { 'screen-sharing': true }
412
                         features: { 'screen-sharing': true }
391
                     })),
413
                     })),
414
+                'localRecording': (participant, value) =>
415
+                    _localRecordingUpdated(store, conference, participant.getId(), value),
392
                 'raisedHand': (participant, value) =>
416
                 'raisedHand': (participant, value) =>
393
                     _raiseHandUpdated(store, conference, participant.getId(), value),
417
                     _raiseHandUpdated(store, conference, participant.getId(), value),
394
                 'region': (participant, value) =>
418
                 'region': (participant, value) =>
566
 function _participantJoinedOrUpdated(store, next, action) {
590
 function _participantJoinedOrUpdated(store, next, action) {
567
     const { dispatch, getState } = store;
591
     const { dispatch, getState } = store;
568
     const { overwrittenNameList } = store.getState()['features/base/participants'];
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
     // Send an external update of the local participant's raised hand state
603
     // Send an external update of the local participant's raised hand state
572
     // if a new raised hand state is defined in the action.
604
     // if a new raised hand state is defined in the action.
587
         action.participant.name = overwrittenNameList[id];
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
     // Allow the redux update to go through and compare the old avatar
636
     // Allow the redux update to go through and compare the old avatar
591
     // to the new avatar and emit out change events if necessary.
637
     // to the new avatar and emit out change events if necessary.
592
     const result = next(action);
638
     const result = next(action);
618
     return result;
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
  * Handles a raise hand status update.
697
  * Handles a raise hand status update.
623
  *
698
  *

+ 11
- 4
react/features/notifications/constants.js View File

59
 };
59
 };
60
 
60
 
61
 /**
61
 /**
62
- * The identifier of the salesforce link notification.
62
+ * The identifier of the lobby notification.
63
  *
63
  *
64
  * @type {string}
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
  * @type {string}
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
  * The identifier of the raise hand notification.
76
  * The identifier of the raise hand notification.
79
  */
79
  */
80
 export const RAISE_HAND_NOTIFICATION_ID = 'RAISE_HAND_NOTIFICATION';
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
  * Amount of participants beyond which no join notification will be emitted.
90
  * Amount of participants beyond which no join notification will be emitted.
84
  */
91
  */

+ 18
- 0
react/features/recording/actionTypes.ts View File

66
  * }
66
  * }
67
  */
67
  */
68
 export const SET_MEETING_HIGHLIGHT_BUTTON_STATE = 'SET_MEETING_HIGHLIGHT_BUTTON_STATE';
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 View File

19
     SET_MEETING_HIGHLIGHT_BUTTON_STATE,
19
     SET_MEETING_HIGHLIGHT_BUTTON_STATE,
20
     SET_PENDING_RECORDING_NOTIFICATION_UID,
20
     SET_PENDING_RECORDING_NOTIFICATION_UID,
21
     SET_SELECTED_RECORDING_SERVICE,
21
     SET_SELECTED_RECORDING_SERVICE,
22
-    SET_STREAM_KEY
22
+    SET_STREAM_KEY,
23
+    START_LOCAL_RECORDING,
24
+    STOP_LOCAL_RECORDING
23
 } from './actionTypes';
25
 } from './actionTypes';
24
 import {
26
 import {
25
     getRecordingLink,
27
     getRecordingLink,
332
         uid
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 View File

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

+ 14
- 4
react/features/recording/components/Recording/AbstractStartRecordingDialog.js View File

15
 } from '../../../dropbox';
15
 } from '../../../dropbox';
16
 import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications';
16
 import { NOTIFICATION_TIMEOUT_TYPE, showErrorNotification } from '../../../notifications';
17
 import { toggleRequestingSubtitles } from '../../../subtitles';
17
 import { toggleRequestingSubtitles } from '../../../subtitles';
18
-import { setSelectedRecordingService } from '../../actions';
18
+import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions';
19
 import { RECORDING_TYPES } from '../../constants';
19
 import { RECORDING_TYPES } from '../../constants';
20
 
20
 
21
 export type Props = {
21
 export type Props = {
293
         let appData;
293
         let appData;
294
         const attributes = {};
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
                 appData = JSON.stringify({
299
                 appData = JSON.stringify({
299
                     'file_recording_metadata': {
300
                     'file_recording_metadata': {
300
                         'upload_credentials': {
301
                         'upload_credentials': {
313
 
314
 
314
                 return;
315
                 return;
315
             }
316
             }
316
-        } else {
317
+            break;
318
+        }
319
+        case RECORDING_TYPES.JITSI_REC_SERVICE: {
317
             appData = JSON.stringify({
320
             appData = JSON.stringify({
318
                 'file_recording_metadata': {
321
                 'file_recording_metadata': {
319
                     'share': this.state.sharingEnabled
322
                     'share': this.state.sharingEnabled
320
                 }
323
                 }
321
             });
324
             });
322
             attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
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
         sendAnalytics(
335
         sendAnalytics(

+ 18
- 5
react/features/recording/components/Recording/AbstractStopRecordingDialog.js View File

7
     sendAnalytics
7
     sendAnalytics
8
 } from '../../../analytics';
8
 } from '../../../analytics';
9
 import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
9
 import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
10
+import { stopLocalVideoRecording } from '../../actions';
10
 import { getActiveSession } from '../../functions';
11
 import { getActiveSession } from '../../functions';
11
 
12
 
13
+import LocalRecordingManager from './LocalRecordingManager';
14
+
12
 /**
15
 /**
13
  * The type of the React {@code Component} props of
16
  * The type of the React {@code Component} props of
14
  * {@link AbstractStopRecordingDialog}.
17
  * {@link AbstractStopRecordingDialog}.
25
      */
28
      */
26
     _fileRecordingSession: Object,
29
     _fileRecordingSession: Object,
27
 
30
 
31
+    /**
32
+     * Whether the recording is a local recording or not.
33
+     */
34
+    _localRecording: boolean,
35
+
28
     /**
36
     /**
29
      * The redux dispatch function.
37
      * The redux dispatch function.
30
      */
38
      */
68
     _onSubmit() {
76
     _onSubmit() {
69
         sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
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
         return true;
90
         return true;
105
     return {
117
     return {
106
         _conference: state['features/base/conference'].conference,
118
         _conference: state['features/base/conference'].conference,
107
         _fileRecordingSession:
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 View File

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 View File

10
 import {
10
 import {
11
     _abstractMapStateToProps
11
     _abstractMapStateToProps
12
 } from '../../../base/dialog';
12
 } from '../../../base/dialog';
13
+import { isMobileBrowser } from '../../../base/environment/utils';
13
 import { translate } from '../../../base/i18n';
14
 import { translate } from '../../../base/i18n';
15
+import { browser } from '../../../base/lib-jitsi-meet';
14
 import {
16
 import {
15
     Button,
17
     Button,
16
     Container,
18
     Container,
31
     ICON_CLOUD,
33
     ICON_CLOUD,
32
     ICON_INFO,
34
     ICON_INFO,
33
     ICON_USERS,
35
     ICON_USERS,
36
+    LOCAL_RECORDING,
34
     TRACK_COLOR
37
     TRACK_COLOR
35
 } from './styles';
38
 } from './styles';
36
 
39
 
41
      */
44
      */
42
     _dialogStyles: StyleType,
45
     _dialogStyles: StyleType,
43
 
46
 
47
+    /**
48
+     * Whether local recording is enabled or not.
49
+     */
50
+    _localRecordingEnabled: boolean,
51
+
44
     /**
52
     /**
45
      * The color-schemed stylesheet of this component.
53
      * The color-schemed stylesheet of this component.
46
      */
54
      */
126
  * @augments Component
134
  * @augments Component
127
  */
135
  */
128
 class StartRecordingDialogContent extends Component<Props> {
136
 class StartRecordingDialogContent extends Component<Props> {
137
+    _localRecordingAvailable: boolean;
138
+
129
     /**
139
     /**
130
      * Initializes a new {@code StartRecordingDialogContent} instance.
140
      * Initializes a new {@code StartRecordingDialogContent} instance.
131
      *
141
      *
133
      */
143
      */
134
     constructor(props) {
144
     constructor(props) {
135
         super(props);
145
         super(props);
146
+        const supportsLocalRecording = browser.isChromiumBased() && !browser.isElectron() && !isMobileBrowser();
147
+
148
+        this._localRecordingAvailable = props._localRecordingEnabled && supportsLocalRecording;
136
 
149
 
137
         // Bind event handler so it is only bound once for every instance.
150
         // Bind event handler so it is only bound once for every instance.
138
         this._onSignIn = this._onSignIn.bind(this);
151
         this._onSignIn = this._onSignIn.bind(this);
139
         this._onSignOut = this._onSignOut.bind(this);
152
         this._onSignOut = this._onSignOut.bind(this);
140
         this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this);
153
         this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this);
141
         this._onRecordingServiceSwitchChange = this._onRecordingServiceSwitchChange.bind(this);
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
                 { this._renderFileSharingContent() }
185
                 { this._renderFileSharingContent() }
159
                 { this._renderUploadToTheCloudInfo() }
186
                 { this._renderUploadToTheCloudInfo() }
160
                 { this._renderIntegrationsContent() }
187
                 { this._renderIntegrationsContent() }
188
+                { this._renderLocalRecordingContent() }
161
             </Container>
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
         const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props;
199
         const { fileRecordingsServiceSharingEnabled, isVpaas, selectedRecordingService } = this.props;
172
 
200
 
173
         if (!fileRecordingsServiceSharingEnabled
201
         if (!fileRecordingsServiceSharingEnabled
174
             || isVpaas
202
             || isVpaas
175
             || selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
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
             return null;
217
             return null;
177
         }
218
         }
178
 
219
 
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
         // show the non integrations part only if fileRecordingsServiceEnabled
305
         // show the non integrations part only if fileRecordingsServiceEnabled
266
         // is enabled or when there are no integrations enabled
306
         // is enabled or when there are no integrations enabled
267
         if (!(this.props.fileRecordingsServiceEnabled
307
         if (!(this.props.fileRecordingsServiceEnabled
268
             || !this.props.integrationsEnabled)) {
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
             return null;
322
             return null;
270
         }
323
         }
271
 
324
 
272
         const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props;
325
         const { _dialogStyles, _styles: styles, isValidating, isVpaas, t } = this.props;
273
 
326
 
274
         const switchContent
327
         const switchContent
275
-            = this.props.integrationsEnabled
328
+            = this.props.integrationsEnabled || this.props._localRecordingEnabled
276
                 ? (
329
                 ? (
277
                     <Switch
330
                     <Switch
278
                         className = 'recording-switch'
331
                         className = 'recording-switch'
285
 
338
 
286
         const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription');
339
         const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription');
287
         const jitsiContentRecordingIconContainer
340
         const jitsiContentRecordingIconContainer
288
-            = this.props.integrationsEnabled
341
+            = this.props.integrationsEnabled || this.props._localRecordingEnabled
289
                 ? 'jitsi-content-recording-icon-container-with-switch'
342
                 ? 'jitsi-content-recording-icon-container-with-switch'
290
                 : 'jitsi-content-recording-icon-container-without-switch';
343
                 : 'jitsi-content-recording-icon-container-without-switch';
291
         const contentRecordingClass = isVpaas
344
         const contentRecordingClass = isVpaas
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
      * Renders the content in case integrations were enabled.
387
      * Renders the content in case integrations were enabled.
322
      *
388
      *
324
      * @returns {React$Component}
390
      * @returns {React$Component}
325
      */
391
      */
326
     _renderIntegrationsContent() {
392
     _renderIntegrationsContent() {
327
-        if (!this.props.integrationsEnabled) {
393
+        if (!this._shouldRenderIntegrationsContent()) {
328
             return null;
394
             return null;
329
         }
395
         }
330
 
396
 
376
         return (
442
         return (
377
             <Container>
443
             <Container>
378
                 <Container
444
                 <Container
379
-                    className = 'recording-header recording-header-line'
445
+                    className = 'recording-header'
380
                     style = { styles.headerIntegrations }>
446
                     style = { styles.headerIntegrations }>
381
                     <Container
447
                     <Container
382
                         className = 'recording-icon-container'>
448
                         className = 'recording-icon-container'>
405
 
471
 
406
     _onDropboxSwitchChange: () => void;
472
     _onDropboxSwitchChange: () => void;
407
     _onRecordingServiceSwitchChange: () => void;
473
     _onRecordingServiceSwitchChange: () => void;
474
+    _onLocalRecordingSwitchChange: () => void;
408
 
475
 
409
     /**
476
     /**
410
      * Handler for onValueChange events from the Switch component.
477
      * Handler for onValueChange events from the Switch component.
419
         } = this.props;
486
         } = this.props;
420
 
487
 
421
         // act like group, cannot toggle off
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
             return;
490
             return;
425
         }
491
         }
426
 
492
 
444
         } = this.props;
510
         } = this.props;
445
 
511
 
446
         // act like group, cannot toggle off
512
         // act like group, cannot toggle off
447
-        if (selectedRecordingService
448
-                === RECORDING_TYPES.DROPBOX) {
513
+        if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
449
             return;
514
             return;
450
         }
515
         }
451
 
516
 
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
      * Renders a spinner component.
549
      * Renders a spinner component.
461
      *
550
      *
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
     _onSignIn: () => void;
657
     _onSignIn: () => void;
515
 
658
 
516
     /**
659
     /**
546
     return {
689
     return {
547
         ..._abstractMapStateToProps(state),
690
         ..._abstractMapStateToProps(state),
548
         isVpaas: isVpaasMeeting(state),
691
         isVpaas: isVpaasMeeting(state),
692
+        _localRecordingEnabled: state['features/base/config'].enableLocalRecording,
549
         _styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
693
         _styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
550
     };
694
     };
551
 }
695
 }

+ 1
- 0
react/features/recording/components/Recording/styles.native.js View File

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

+ 2
- 0
react/features/recording/components/Recording/styles.web.js View File

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

+ 2
- 0
react/features/recording/components/Recording/web/StartRecordingDialog.js View File

39
             return false;
39
             return false;
40
         } else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
40
         } else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
41
             return !isTokenValid;
41
             return !isTokenValid;
42
+        } else if (selectedRecordingService === RECORDING_TYPES.LOCAL) {
43
+            return false;
42
         }
44
         }
43
 
45
 
44
         return true;
46
         return true;

+ 2
- 1
react/features/recording/constants.js View File

45
  */
45
  */
46
 export const RECORDING_TYPES = {
46
 export const RECORDING_TYPES = {
47
     JITSI_REC_SERVICE: 'recording-service',
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 View File

1
 // @flow
1
 // @flow
2
 
2
 
3
 import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
3
 import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
4
-import { getLocalParticipant, isLocalParticipantModerator } from '../base/participants';
4
+import { getLocalParticipant, getRemoteParticipants, isLocalParticipantModerator } from '../base/participants';
5
 import { isInBreakoutRoom } from '../breakout-rooms/functions';
5
 import { isInBreakoutRoom } from '../breakout-rooms/functions';
6
 import { isEnabled as isDropboxEnabled } from '../dropbox';
6
 import { isEnabled as isDropboxEnabled } from '../dropbox';
7
 import { extractFqnFromPath } from '../dynamic-branding/functions.any';
7
 import { extractFqnFromPath } from '../dynamic-branding/functions.any';
8
 
8
 
9
+import LocalRecordingManager from './components/Recording/LocalRecordingManager';
9
 import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants';
10
 import { RECORDING_STATUS_PRIORITIES, RECORDING_TYPES } from './constants';
10
 import logger from './logger';
11
 import logger from './logger';
11
 
12
 
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
     return status;
126
     return status;
121
 }
127
 }
241
 
247
 
242
     return false;
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 View File

11
     JitsiConferenceEvents,
11
     JitsiConferenceEvents,
12
     JitsiRecordingConstants
12
     JitsiRecordingConstants
13
 } from '../base/lib-jitsi-meet';
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
 import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
16
 import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
16
 import {
17
 import {
17
     playSound,
18
     playSound,
19
     stopSound,
20
     stopSound,
20
     unregisterSound
21
     unregisterSound
21
 } from '../base/sounds';
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
 import {
27
 import {
25
     clearRecordingSessions,
28
     clearRecordingSessions,
26
     hidePendingRecordingNotification,
29
     hidePendingRecordingNotification,
32
     showStoppedRecordingNotification,
35
     showStoppedRecordingNotification,
33
     updateRecordingSessionData
36
     updateRecordingSessionData
34
 } from './actions';
37
 } from './actions';
38
+import LocalRecordingManager from './components/Recording/LocalRecordingManager';
35
 import {
39
 import {
36
     LIVE_STREAMING_OFF_SOUND_ID,
40
     LIVE_STREAMING_OFF_SOUND_ID,
37
     LIVE_STREAMING_ON_SOUND_ID,
41
     LIVE_STREAMING_ON_SOUND_ID,
38
     RECORDING_OFF_SOUND_ID,
42
     RECORDING_OFF_SOUND_ID,
39
     RECORDING_ON_SOUND_ID
43
     RECORDING_ON_SOUND_ID
40
 } from './constants';
44
 } from './constants';
41
-import { getSessionById, getResourceId } from './functions';
45
+import {
46
+    getSessionById,
47
+    getResourceId
48
+} from './functions';
49
+import logger from './logger';
42
 import {
50
 import {
43
     LIVE_STREAMING_OFF_SOUND_FILE,
51
     LIVE_STREAMING_OFF_SOUND_FILE,
44
     LIVE_STREAMING_ON_SOUND_FILE,
52
     LIVE_STREAMING_ON_SOUND_FILE,
68
  * @param {Store} store - The redux store.
76
  * @param {Store} store - The redux store.
69
  * @returns {Function}
77
  * @returns {Function}
70
  */
78
  */
71
-MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
79
+MiddlewareRegistry.register(({ dispatch, getState }) => next => async action => {
72
     let oldSessionData;
80
     let oldSessionData;
73
 
81
 
74
     if (action.type === RECORDING_SESSION_UPDATED) {
82
     if (action.type === RECORDING_SESSION_UPDATED) {
123
         break;
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
     case RECORDING_SESSION_UPDATED: {
169
     case RECORDING_SESSION_UPDATED: {
127
         // When in recorder mode no notifications are shown
170
         // When in recorder mode no notifications are shown
128
         // or extra sounds are also not desired
171
         // or extra sounds are also not desired
211
 
254
 
212
         break;
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
     return result;
269
     return result;

Loading…
Cancel
Save