diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..24e6e9b8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+# Recording...
+app/rec
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 9df74517..536c2c51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ package-lock.json
# personal
config.js
docker-compose.yml
-docker-push.sh
\ No newline at end of file
+docker-push.sh
+rec
\ No newline at end of file
diff --git a/README.md b/README.md
index 94fb893a..757c82a5 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@
- Choose your audio input, output, and video source.
- Supports video quality up to 4K.
- Supports advance Picture-in-Picture (PiP) offering a more streamlined and flexible viewing experience.
-- Record your screen, audio, and video.
+- Record your screen, audio, and video locally or on your Server.
- Snapshot video frames and save them as PNG images.
- Chat with an Emoji Picker for expressing feelings, private messages, Markdown support, and conversation saving.
- ChatGPT (powered by OpenAI) for answering questions, providing information, and connecting users to relevant resources.
diff --git a/app/src/Room.js b/app/src/Room.js
index 2e193125..312d6cd3 100644
--- a/app/src/Room.js
+++ b/app/src/Room.js
@@ -19,6 +19,9 @@ module.exports = class Room {
this._isLobbyEnabled = false;
this._roomPassword = null;
this._hostOnlyRecording = false;
+ // ##########################
+ this._recSyncServerRecording = config?.server?.recording?.enabled || false;
+ // ##########################
this._moderator = {
audio_start_muted: false,
video_start_hidden: false,
@@ -155,6 +158,7 @@ module.exports = class Room {
return {
id: this.id,
broadcasting: this._isBroadcasting,
+ recSyncServerRecording: this._recSyncServerRecording,
config: {
isLocked: this._isLocked,
isLobbyEnabled: this._isLobbyEnabled,
diff --git a/app/src/Server.js b/app/src/Server.js
index 3b2305bd..df91f95b 100644
--- a/app/src/Server.js
+++ b/app/src/Server.js
@@ -41,7 +41,7 @@ dependencies: {
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
- * @version 1.3.67
+ * @version 1.3.68
*
*/
@@ -158,8 +158,17 @@ if (config.chatGPT.enabled) {
// directory
const dir = {
public: path.join(__dirname, '../../', 'public'),
+ rec: path.join(__dirname, '../', config?.server?.recording?.dir ? config.server.recording.dir + '/' : 'rec/'),
};
+// rec directory create
+const serverRecordingEnabled = config?.server?.recording?.enabled;
+if (serverRecordingEnabled) {
+ if (!fs.existsSync(dir.rec)) {
+ fs.mkdirSync(dir.rec, { recursive: true });
+ }
+}
+
// html views
const views = {
about: path.join(__dirname, '../../', 'public/views/about.html'),
@@ -434,6 +443,43 @@ function startServer() {
}
});
+ // ####################################################
+ // KEEP RECORDING ON SERVER DIR
+ // ####################################################
+
+ app.post(['/recSync'], (req, res) => {
+ // Store recording...
+ if (serverRecordingEnabled) {
+ if (!fs.existsSync(dir.rec)) fs.mkdirSync(dir.rec, { recursive: true });
+
+ const { fileName } = req.query;
+
+ if (!fileName) {
+ return res.status(400).send('Filename not provided');
+ }
+
+ try {
+ const filePath = dir.rec + fileName;
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a' });
+
+ req.pipe(writeStream);
+
+ writeStream.on('error', (err) => {
+ log.error('Error writing to file:', err.message);
+ res.status(500).send('Internal Server Error');
+ });
+
+ writeStream.on('finish', () => {
+ log.debug('File saved successfully:', fileName);
+ res.status(200).send('File uploaded successfully');
+ });
+ } catch (err) {
+ log.error('Error processing upload', err.message);
+ res.status(500).send('Internal Server Error');
+ }
+ }
+ });
+
// ####################################################
// API
// ####################################################
@@ -533,11 +579,8 @@ function startServer() {
await ngrok.authtoken(config.ngrok.authToken);
await ngrok.connect(config.server.listen.port);
const api = ngrok.getApi();
- // const data = JSON.parse(await api.get('api/tunnels')); // v3
- const data = await api.listTunnels(); // v4
- const pu0 = data.tunnels[0].public_url;
- const pu1 = data.tunnels[1].public_url;
- const tunnel = pu0.startsWith('https') ? pu0 : pu1;
+ const list = await api.listTunnels();
+ const tunnel = list.tunnels[0].public_url;
log.info('Listening on', {
app_version: packageJson.version,
node_version: process.versions.node,
@@ -559,9 +602,11 @@ function startServer() {
stats_enabled: config.stats.enabled,
chatGPT_enabled: config.chatGPT.enabled,
configUI: config.ui,
+ serverRec: config?.server?.recording,
});
} catch (err) {
log.error('Ngrok Start error: ', err.body);
+ await ngrok.kill();
process.exit(1);
}
}
@@ -608,6 +653,7 @@ function startServer() {
stats_enabled: config.stats.enabled,
chatGPT_enabled: config.chatGPT.enabled,
configUI: config.ui,
+ serverRec: config?.server?.recording,
});
});
diff --git a/app/src/config.template.js b/app/src/config.template.js
index 4160f415..12766427 100644
--- a/app/src/config.template.js
+++ b/app/src/config.template.js
@@ -35,6 +35,11 @@ module.exports = {
cert: '../ssl/cert.pem',
key: '../ssl/key.pem',
},
+ // The recording will be saved to the directory designated within your Server app/
+ recording: {
+ dir: 'rec',
+ enabled: false,
+ },
},
host: {
/*
@@ -105,8 +110,8 @@ module.exports = {
micOptionsButton: true, // presenter
tabModerator: true, // presenter
tabRecording: true,
- pushToTalk: true,
host_only_recording: true, // presenter
+ pushToTalk: true,
},
producerVideo: {
videoPictureInPicture: true,
diff --git a/package.json b/package.json
index 926bd8e3..efe1212c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mirotalksfu",
- "version": "1.3.67",
+ "version": "1.3.68",
"description": "WebRTC SFU browser-based video calls",
"main": "Server.js",
"scripts": {
@@ -38,8 +38,8 @@
"author": "Miroslav Pejic",
"license": "AGPL-3.0",
"dependencies": {
- "@sentry/integrations": "7.100.1",
- "@sentry/node": "7.100.1",
+ "@sentry/integrations": "7.101.0",
+ "@sentry/node": "7.101.0",
"axios": "^1.6.7",
"body-parser": "1.20.2",
"colors": "1.4.0",
@@ -51,8 +51,8 @@
"jsonwebtoken": "^9.0.2",
"mediasoup": "3.13.19",
"mediasoup-client": "3.7.2",
- "ngrok": "^4.3.3",
- "openai": "^4.27.0",
+ "ngrok": "^5.0.0-beta.2",
+ "openai": "^4.28.0",
"qs": "6.11.2",
"socket.io": "4.7.4",
"swagger-ui-express": "5.0.0",
diff --git a/public/js/LocalStorage.js b/public/js/LocalStorage.js
index 96698749..aa50edbb 100644
--- a/public/js/LocalStorage.js
+++ b/public/js/LocalStorage.js
@@ -43,7 +43,8 @@ class LocalStorage {
pitch_bar: true, // volume indicator
sounds: true, // room notify sounds
host_ony_recording: false, // presenter
- rec_prioritize_h264: false, // Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs.
+ rec_prioritize_h264: false, // Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs
+ rec_server: false, // The recording will be stored on the server rather than locally
video_obj_fit: 2, // cover
video_controls: 0, // off
theme: 0, // dark
diff --git a/public/js/Room.js b/public/js/Room.js
index ca5429a0..6686c706 100644
--- a/public/js/Room.js
+++ b/public/js/Room.js
@@ -11,7 +11,7 @@ if (location.href.substr(0, 5) !== 'https') location.href = 'https' + location.h
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
- * @version 1.3.67
+ * @version 1.3.68
*
*/
@@ -281,6 +281,7 @@ function initClient() {
'Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs',
'right',
);
+ setTippy('switchServerRecording', 'The recording will be stored on the server rather than locally', 'right');
setTippy('whiteboardGhostButton', 'Toggle transparent background', 'bottom');
setTippy('wbBackgroundColorEl', 'Background color', 'bottom');
setTippy('wbDrawingColorEl', 'Drawing color', 'bottom');
@@ -1189,10 +1190,11 @@ function roomIsReady() {
BUTTONS.settings.broadcastingButton && show(broadcastingButton);
BUTTONS.settings.lobbyButton && show(lobbyButton);
if (BUTTONS.settings.host_only_recording) {
- show(roomRecording);
show(recordingImage);
+ show(roomHostOnlyRecording);
show(roomRecordingOptions);
}
+ if (rc.recSyncServerRecording) show(roomRecordingServer);
BUTTONS.main.aboutButton && show(aboutButton);
if (!DetectRTC.isMobileDevice) show(pinUnpinGridDiv);
if (!isSpeechSynthesisSupported) hide(speechMsgDiv);
@@ -1973,6 +1975,13 @@ function handleSelects() {
lS.setSettings(localStorageSettings);
e.target.blur();
};
+ switchServerRecording.onchange = (e) => {
+ rc.recSyncServerRecording = e.currentTarget.checked;
+ rc.roomMessage('recSyncServer', rc.recSyncServerRecording);
+ localStorageSettings.rec_server = rc.recSyncServerRecording;
+ lS.setSettings(localStorageSettings);
+ e.target.blur();
+ };
// styling
keepCustomTheme.onchange = (e) => {
themeCustom.keep = e.currentTarget.checked;
@@ -2277,6 +2286,8 @@ function loadSettingsFromLocalStorage() {
recPrioritizeH264 = localStorageSettings.rec_prioritize_h264;
switchH264Recording.checked = recPrioritizeH264;
+ switchServerRecording.checked = localStorageSettings.rec_server;
+
keepCustomTheme.checked = themeCustom.keep;
selectTheme.disabled = themeCustom.keep;
themeCustom.input.value = themeCustom.color;
@@ -2456,9 +2467,10 @@ function handleRoomClientEvents() {
rc.saveRecording('Room event: host only recording enabled, going to stop recording');
}
hide(startRecButton);
- hide(roomRecording);
hide(recordingImage);
+ hide(roomHostOnlyRecording);
hide(roomRecordingOptions);
+ hide(roomRecordingServer);
show(recordingMessage);
hostOnlyRecording = true;
}
@@ -2468,8 +2480,7 @@ function handleRoomClientEvents() {
console.log('Room event: host only recording disabled');
show(startRecButton);
show(recordingImage);
- show(roomRecordingOptions);
- hide(roomRecording);
+ hide(roomHostOnlyRecording);
hide(recordingMessage);
hostOnlyRecording = false;
}
@@ -2569,6 +2580,13 @@ function getDataTimeString() {
return `${date}-${time}`;
}
+function getDataTimeStringFormat() {
+ const d = new Date();
+ const date = d.toISOString().split('T')[0].replace(/-/g, '_');
+ const time = d.toTimeString().split(' ')[0].replace(/:/g, '_');
+ return `${date}_${time}`;
+}
+
function getUUID() {
const uuid4 = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
diff --git a/public/js/RoomClient.js b/public/js/RoomClient.js
index c2bfdb68..41390969 100644
--- a/public/js/RoomClient.js
+++ b/public/js/RoomClient.js
@@ -9,7 +9,7 @@
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
- * @version 1.3.67
+ * @version 1.3.68
*
*/
@@ -63,6 +63,7 @@ const icons = {
broadcaster: '',
codecs: '',
theme: '',
+ recSync: '',
};
const image = {
@@ -243,9 +244,6 @@ class RoomClient {
this.localVideoStream = null;
this.localAudioStream = null;
this.localScreenStream = null;
- this.mediaRecorder = null;
- this.recScreenStream = null;
- this._isRecording = false;
this.RoomPassword = false;
@@ -264,7 +262,13 @@ class RoomClient {
this.chunkSize = 1024 * 16; // 16kb/s
// Recording
+ this._isRecording = false;
+ this.mediaRecorder = null;
this.audioRecorder = null;
+ this.recScreenStream = null;
+ this.recSyncServerRecording = false;
+ this.recSyncTime = 4000; // 4 sec
+ this.recSyncChunkSize = 1000000; // 1MB
// Encodings
this.forceVP8 = false; // Force VP8 codec for webcam and screen sharing
@@ -423,6 +427,7 @@ class RoomClient {
this.getId('isUserPresenter').innerText = isPresenter;
window.localStorage.isReconnected = false;
handleRules(isPresenter);
+
// ###################################################################################################
isBroadcastingEnabled = isPresenter && !room.broadcasting ? isBroadcastingEnabled : room.broadcasting;
console.log('07.1 ----> ROOM BROADCASTING', isBroadcastingEnabled);
@@ -435,6 +440,18 @@ class RoomClient {
: this.event(_EVENTS.hostOnlyRecordingOff);
}
+ // ###################################################################################################
+ if (room.recSyncServerRecording) {
+ console.log('07.1 WARNING ----> SERVER SYNC RECORDING ENABLED!');
+ this.recSyncServerRecording = localStorageSettings.rec_server;
+ if (BUTTONS.settings.tabRecording && !room.config.hostOnlyRecording) {
+ show(roomRecordingServer);
+ }
+ switchServerRecording.checked = this.recSyncServerRecording;
+ }
+ console.log('07.1 ----> SERVER SYNC RECORDING', this.recSyncServerRecording);
+ // ###################################################################################################
+
// Handle Room moderator rules
if (room.moderator && (!isRulesActive || !isPresenter)) {
console.log('07.2 ----> MODERATOR', room.moderator);
@@ -4123,89 +4140,170 @@ class RoomClient {
handleMediaRecorder() {
if (this.mediaRecorder) {
- this.mediaRecorder.start();
+ this.recServerFileName = this.getServerRecFileName();
+ rc.recSyncServerRecording ? this.mediaRecorder.start(this.recSyncTime) : this.mediaRecorder.start();
this.mediaRecorder.addEventListener('start', this.handleMediaRecorderStart);
this.mediaRecorder.addEventListener('dataavailable', this.handleMediaRecorderData);
this.mediaRecorder.addEventListener('stop', this.handleMediaRecorderStop);
}
}
+ getServerRecFileName() {
+ const dateTime = getDataTimeStringFormat();
+ return `Rec_${dateTime}.webm`;
+ }
+
handleMediaRecorderStart(evt) {
console.log('MediaRecorder started: ', evt);
+ rc.cleanLastRecordingInfo();
+ rc.disableRecordingOptions();
}
handleMediaRecorderData(evt) {
- console.log('MediaRecorder data: ', evt);
- if (evt.data && evt.data.size > 0) recordedBlobs.push(evt.data);
+ // console.log('MediaRecorder data: ', evt);
+ if (evt.data && evt.data.size > 0) {
+ rc.recSyncServerRecording ? rc.syncRecordingInCloud(evt.data) : recordedBlobs.push(evt.data);
+ }
+ }
+
+ async syncRecordingInCloud(data) {
+ const arrayBuffer = data;
+ const chunkSize = rc.recSyncChunkSize;
+ const fileReader = new FileReader();
+ fileReader.readAsArrayBuffer(arrayBuffer);
+ fileReader.onload = async (event) => {
+ const arrayBuffer = event.target.result;
+ const totalChunks = Math.ceil(arrayBuffer.byteLength / chunkSize);
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const chunk = arrayBuffer.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize);
+ try {
+ await axios.post('/recSync?fileName=' + rc.recServerFileName, chunk, {
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'Content-Length': chunk.length,
+ },
+ });
+ } catch (error) {
+ console.error('Error syncing chunk:', error.message);
+ }
+ }
+ };
}
handleMediaRecorderStop(evt) {
try {
console.log('MediaRecorder stopped: ', evt);
- console.log('MediaRecorder Blobs: ', recordedBlobs);
-
- const dateTime = getDataTimeString();
- const type = recordedBlobs[0].type.includes('mp4') ? 'mp4' : 'webm';
- const blob = new Blob(recordedBlobs, { type: 'video/' + type });
- const recFileName = `${dateTime}-REC.${type}`;
- const currentDevice = DetectRTC.isMobileDevice ? 'MOBILE' : 'PC';
- const blobFileSize = bytesToSize(blob.size);
- const recTime = document.getElementById('recordingStatus');
-
- const recordingInfo = `
-
-
- - Time: ${recTime.innerText}
- - File: ${recFileName}
- - Codecs: ${recCodecs}
- - Size: ${blobFileSize}
-
-
- `;
-
- const lastRecordingInfo = document.getElementById('lastRecordingInfo');
- lastRecordingInfo.style.color = '#FFFFFF';
- lastRecordingInfo.innerHTML = `Last Recording Info: ${recordingInfo}`;
- show(lastRecordingInfo);
-
- if (window.localStorage.isReconnected === 'false') {
- Swal.fire({
- background: swalBackground,
- position: 'center',
- icon: 'success',
- title: 'Recording',
- html: `
- 🔴 Recording Info:
- ${recordingInfo}
- Please wait to be processed, then will be downloaded to your ${currentDevice} device.
-
`,
- showClass: { popup: 'animate__animated animate__fadeInDown' },
- hideClass: { popup: 'animate__animated animate__fadeOutUp' },
- });
- }
-
- console.log('MediaRecorder Download Blobs');
- const url = window.URL.createObjectURL(blob);
-
- const downloadLink = document.createElement('a');
- downloadLink.style.display = 'none';
- downloadLink.href = url;
- downloadLink.download = recFileName;
- document.body.appendChild(downloadLink);
- downloadLink.click();
-
- setTimeout(() => {
- document.body.removeChild(downloadLink);
- window.URL.revokeObjectURL(url);
- console.log(`🔴 Recording FILE: ${recFileName} done 👍`);
- recordedBlobs = [];
- recTime.innerText = '0s';
- }, 100);
+ rc.recSyncServerRecording ? rc.handleServerRecordingStop() : rc.handleLocalRecordingStop();
+ rc.disableRecordingOptions(false);
} catch (err) {
console.error('Recording save failed', err);
}
}
+ disableRecordingOptions(disabled = true) {
+ switchH264Recording.disabled = disabled;
+ switchServerRecording.disabled = disabled;
+ switchHostOnlyRecording.disabled = disabled;
+ }
+
+ handleLocalRecordingStop() {
+ console.log('MediaRecorder Blobs: ', recordedBlobs);
+
+ const dateTime = getDataTimeString();
+ const type = recordedBlobs[0].type.includes('mp4') ? 'mp4' : 'webm';
+ const blob = new Blob(recordedBlobs, { type: 'video/' + type });
+ const recFileName = `Rec_${dateTime}.${type}`;
+ const currentDevice = DetectRTC.isMobileDevice ? 'MOBILE' : 'PC';
+ const blobFileSize = bytesToSize(blob.size);
+ const recTime = document.getElementById('recordingStatus');
+ const recType = 'Locally';
+ const recordingInfo = `
+
+
+ - Stored: ${recType}
+ - Time: ${recTime.innerText}
+ - File: ${recFileName}
+ - Codecs: ${recCodecs}
+ - Size: ${blobFileSize}
+
+
+ `;
+ const recordingMsg = `Please wait to be processed, then will be downloaded to your ${currentDevice} device.`;
+
+ this.saveLastRecordingInfo(recordingInfo);
+ this.showRecordingInfo(recType, recordingInfo, recordingMsg);
+ this.saveRecordingInLocalDevice(blob, recFileName, recTime);
+ }
+
+ handleServerRecordingStop() {
+ console.log('MediaRecorder Stop');
+ const recTime = document.getElementById('recordingStatus');
+ const recType = 'Server';
+ const recordingInfo = `
+
+
+ - Stored: ${recType}
+ - Time: ${recTime.innerText}
+ - File: ${this.recServerFileName}
+ - Codecs: ${recCodecs}
+
+
+ `;
+ this.saveLastRecordingInfo(recordingInfo);
+ this.showRecordingInfo(recType, recordingInfo);
+ }
+
+ saveLastRecordingInfo(recordingInfo) {
+ const lastRecordingInfo = document.getElementById('lastRecordingInfo');
+ lastRecordingInfo.style.color = '#FFFFFF';
+ lastRecordingInfo.innerHTML = `Last Recording Info: ${recordingInfo}`;
+ show(lastRecordingInfo);
+ }
+
+ cleanLastRecordingInfo() {
+ const lastRecordingInfo = document.getElementById('lastRecordingInfo');
+ lastRecordingInfo.innerHTML = '';
+ hide(lastRecordingInfo);
+ }
+
+ showRecordingInfo(recType, recordingInfo, recordingMsg = '') {
+ if (window.localStorage.isReconnected === 'false') {
+ Swal.fire({
+ background: swalBackground,
+ position: 'center',
+ icon: 'success',
+ title: 'Recording',
+ html: `
+ 🔴 ${recType} Recording Info:
+ ${recordingInfo}
+ ${recordingMsg}
+
`,
+ showClass: { popup: 'animate__animated animate__fadeInDown' },
+ hideClass: { popup: 'animate__animated animate__fadeOutUp' },
+ });
+ }
+ }
+
+ saveRecordingInLocalDevice(blob, recFileName, recTime) {
+ console.log('MediaRecorder Download Blobs');
+ const url = window.URL.createObjectURL(blob);
+
+ const downloadLink = document.createElement('a');
+ downloadLink.style.display = 'none';
+ downloadLink.href = url;
+ downloadLink.download = recFileName;
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+
+ setTimeout(() => {
+ document.body.removeChild(downloadLink);
+ window.URL.revokeObjectURL(url);
+ console.log(`🔴 Recording FILE: ${recFileName} done 👍`);
+ recordedBlobs = [];
+ recTime.innerText = '0s';
+ }, 100);
+ }
+
pauseRecording() {
if (this.mediaRecorder) {
this._isRecording = false;
@@ -5038,6 +5136,9 @@ class RoomClient {
case 'recPrioritizeH264':
this.userLog('info', `${icons.codecs} Recording prioritize h.264 ${status}`, 'top-end');
break;
+ case 'recSyncServer':
+ this.userLog('info', `${icons.recSync} Server Sync Recording ${status}`, 'top-end');
+ break;
case 'customThemeKeep':
this.userLog('info', `${icons.theme} Custom theme keep ${status}`, 'top-end');
break;
diff --git a/public/js/Rules.js b/public/js/Rules.js
index 79c73df9..dd241151 100644
--- a/public/js/Rules.js
+++ b/public/js/Rules.js
@@ -39,8 +39,8 @@ let BUTTONS = {
micOptionsButton: true, // presenter
tabModerator: true, // presenter
tabRecording: true,
- pushToTalk: true,
host_only_recording: true, // presenter
+ pushToTalk: true,
},
producerVideo: {
videoPictureInPicture: true,
diff --git a/public/views/Room.html b/public/views/Room.html
index 25513e5d..68e81458 100644
--- a/public/views/Room.html
+++ b/public/views/Room.html
@@ -772,7 +772,8 @@ access to use this app.
-
+
+
|
@@ -792,6 +793,27 @@ access to use this app.
|
+
+