No need for apps, simply capture the QR code with your mobile camera Or Invite someone else to join by sending them the following URL
} - The mixed audio track or null if none available
*/
async function mixScreenAndMicAudio(screenAudioTrack, micAudioTrack) {
if (screenAudioTrack && micAudioTrack) {
try {
screenShareAudioContext = new (window.AudioContext || window.webkitAudioContext)();
const destination = screenShareAudioContext.createMediaStreamDestination();
const screenSource = screenShareAudioContext.createMediaStreamSource(new MediaStream([screenAudioTrack]));
screenSource.connect(destination);
const micSource = screenShareAudioContext.createMediaStreamSource(new MediaStream([micAudioTrack]));
micSource.connect(destination);
try {
await screenShareAudioContext.resume();
} catch (_) {}
return destination.stream.getAudioTracks()[0] || null;
} catch (err) {
console.warn('[ScreenShare] Unable to mix screen+mic audio, falling back to screen audio only:', err);
return screenAudioTrack;
}
} else if (screenAudioTrack) {
return screenAudioTrack;
} else if (micAudioTrack) {
return micAudioTrack;
}
return null;
}
/**
* Update Screen Sharing UI
* @param {boolean} isScreenStreaming - Indicates if screen sharing is active
* @param {boolean} init - Indicates if it's the initial screen share
*/
function updateScreenSharingUI(isScreenStreaming, init) {
setScreenSharingStatus(isScreenStreaming);
if (!init && myVideoAvatarImage && !useVideo) {
elemDisplay(myVideo, false);
elemDisplay(myVideoAvatarImage, true, 'block');
}
isScreenStreaming
? setColor(init ? initScreenShareBtn : screenShareBtn, 'orange')
: setColor(init ? initScreenShareBtn : screenShareBtn, 'white');
screenReaderAccessibility.announceMessage(isScreenStreaming ? 'Screen sharing started' : 'Screen sharing stopped');
}
/**
* Get local screen extras for deterministic routing
*/
function getLocalScreenExtras() {
try {
const track = getVideoTrack(localScreenMediaStream);
return track ? { screen_track_id: track.id, screen_stream_id: localScreenMediaStream.id } : undefined;
} catch (e) {
return undefined;
}
}
/**
* Handle exception and actions when toggling screen sharing
* @param {string} reason - The reason message
* @param {boolean} init - Indicates whether it's an initial state
*/
async function handleToggleScreenException(reason, init) {
try {
console.warn('handleToggleScreenException', reason);
// Update video privacy status
isVideoPrivacyActive = false;
emitPeerStatus('privacy', isVideoPrivacyActive);
// Inform peers about screen sharing stop
emitPeersAction('screenStop');
// Turn off your video
setMyVideoOff(myPeerName);
// Toggle screen streaming status
isScreenStreaming = !isScreenStreaming;
myScreenStatus = isScreenStreaming;
// Update screen sharing status
setScreenSharingStatus(isScreenStreaming);
// Emit screen status to peers
peerInfo.extras = {};
await emitPeerStatus('screen', false, {});
// Stop the local video track
await stopLocalVideoTrack();
// Toggle the 'mirror' class on myVideo (guard if not yet created)
if (typeof myVideo !== 'undefined' && myVideo) {
myVideo.classList.toggle('mirror');
}
// Handle video avatar image and privacy button visibility
if (myVideoAvatarImage && !useVideo) {
isScreenStreaming ? elemDisplay(myVideoAvatarImage, false) : elemDisplay(myVideoAvatarImage, true, 'block');
}
// Automatically pin the video if screen sharing or video is pinned
if ((isScreenStreaming || isVideoPinned) && typeof myScreenPinBtn !== 'undefined' && myScreenPinBtn) {
myScreenPinBtn.click();
}
} catch (error) {
console.error('[Error] An unexpected error occurred', error);
}
}
/**
* Set Screen Sharing Status
* @param {boolean} status of screen sharing
*/
function setScreenSharingStatus(status) {
setMediaButtonsClass([
{ element: initScreenShareBtn, status, mediaType: 'screen' },
{ element: screenShareBtn, status, mediaType: 'screen' },
]);
setTippy(screenShareBtn, status ? 'Stop screen sharing' : 'Start screen sharing', placement);
}
/**
* Set myVideoStatus true
*/
async function setMyVideoStatusTrue() {
if (myVideoStatus || !useVideo) return;
// Enable video track
const videoTrack = getVideoTrack(localVideoMediaStream);
if (videoTrack) {
videoTrack.enabled = true;
}
myVideoStatus = true;
// Update multiple buttons
setMediaButtonsClass([
{ element: initVideoBtn, status: true, mediaType: 'video' },
{ element: videoBtn, status: true, mediaType: 'video' },
{ element: myVideoStatusIcon, status: true, mediaType: 'video' },
]);
// Update display elements
displayElements([
{ element: myVideoAvatarImage, display: false },
{ element: myVideo, display: true, mode: 'block' },
]);
// Update tooltips
setTippy(videoBtn, 'Stop the video', placement);
setTippy(initVideoBtn, 'Stop the video', 'top');
emitPeerStatus('video', myVideoStatus);
}
/**
* Enter - esc on full screen mode
* https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API
*/
function toggleFullScreen() {
const fullScreenIcon = fullScreenBtn.querySelector('i');
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
fullScreenIcon.className = className.fsOn;
isDocumentOnFullScreen = true;
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
fullScreenIcon.className = className.fsOff;
isDocumentOnFullScreen = false;
}
}
const fullScreenLabel = isDocumentOnFullScreen ? 'Exit full screen' : 'View full screen';
screenReaderAccessibility.announceMessage(fullScreenLabel);
}
/**
* Refresh my stream changes to connected peers in the room
* https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/replaceTrack
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getSenders
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addTrack
*
* @param {MediaStream} stream - Media stream (audio/video) to refresh to peers.
* @param {boolean} localAudioTrackChange - Indicates whether there's a change in the local audio track (default false).
*/
async function refreshMyStreamToPeers(stream, localAudioTrackChange = false) {
if (!thereArePeerConnections()) return;
// Enable/disable local audio as requested by caller
if (useAudio && localAudioTrackChange && localAudioMediaStream) {
const audioTrack = getAudioTrack(localAudioMediaStream);
if (audioTrack) {
audioTrack.enabled = myAudioStatus;
}
}
// Current local tracks
const cameraTrack = getVideoTrack(localVideoMediaStream);
const screenTrack = getVideoTrack(localScreenMediaStream);
// Determine which audio track to use.
// While screen sharing, prefer the screen-share audio track (which may be mixed screen+mic).
// Always prefer mic audio when not screen sharing
let audioTrack, audioStream;
if (isScreenStreaming && hasAudioTrack(localScreenMediaStream)) {
audioTrack = getAudioTrack(localScreenMediaStream);
audioStream = localScreenMediaStream;
} else {
audioTrack = getAudioTrack(localAudioMediaStream);
audioStream = localAudioMediaStream;
}
// Push tracks to every peer
for (const peer_id in peerConnections) {
const pc = peerConnections[peer_id];
const peer_name = allPeers[peer_id]['peer_name'];
const senders = pc.getSenders();
const videoSenders = senders.filter((s) => s.track && s.track.kind === 'video');
const audioSender = senders.find((s) => s.track && s.track.kind === 'audio');
// Camera track management (sender index 0)
if (cameraTrack) {
if (videoSenders.length >= 1) {
await videoSenders[0].replaceTrack(cameraTrack);
console.log('REPLACE CAMERA TRACK TO', { peer_id, peer_name, cameraTrack });
} else {
pc.addTrack(cameraTrack, localVideoMediaStream);
await handleRtcOffer(peer_id);
console.log('ADD CAMERA TRACK TO', { peer_id, peer_name, cameraTrack });
}
} else {
if (videoSenders.length >= 1 && !screenTrack) {
try {
await videoSenders[0].replaceTrack(null);
console.log('REMOVE CAMERA TRACK FROM', { peer_id, peer_name });
} catch (e) {
console.warn('REMOVE CAMERA TRACK FAILED', e);
}
}
}
// Screen track management (sender index 1)
if (screenTrack) {
if (videoSenders.length >= 2) {
await videoSenders[1].replaceTrack(screenTrack);
console.log('REPLACE SCREEN TRACK TO', { peer_id, peer_name, screenTrack });
} else {
pc.addTrack(screenTrack, localScreenMediaStream);
await handleRtcOffer(peer_id);
console.log('ADD SCREEN TRACK TO', { peer_id, peer_name, screenTrack });
}
} else {
if (videoSenders.length >= 2) {
try {
pc.removeTrack(videoSenders[1]);
await handleRtcOffer(peer_id);
console.log('REMOVE SCREEN SENDER FROM', { peer_id, peer_name });
} catch (e) {
console.warn('REMOVE SCREEN SENDER FAILED', e);
}
}
}
// Audio track management
if (audioTrack) {
if (audioSender) {
await audioSender.replaceTrack(audioTrack);
console.log('REPLACE AUDIO TRACK TO', { peer_id, peer_name, audioTrack });
} else {
pc.addTrack(audioTrack, audioStream || new MediaStream([audioTrack]));
await handleRtcOffer(peer_id);
console.log('ADD AUDIO TRACK TO', { peer_id, peer_name, audioTrack });
}
}
}
}
/**
* Refresh my local stream
* @param {object} stream media stream audio - video
* @param {boolean} localAudioTrackChange default false
*/
async function refreshMyLocalStream(stream, localAudioTrackChange = false) {
// enable video
if (stream && (useVideo || isScreenStreaming)) {
const videoTrack = getVideoTrack(stream);
if (videoTrack) {
videoTrack.enabled = true;
}
}
const tracksToInclude = [];
const videoTrack = stream && hasVideoTrack(stream) ? getVideoTrack(stream) : getVideoTrack(localVideoMediaStream);
const audioTrack =
hasAudioTrack(stream) && localAudioTrackChange ? getAudioTrack(stream) : getAudioTrack(localAudioMediaStream);
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStream
if (useVideo || isScreenStreaming) {
console.log('Refresh my local media stream VIDEO - AUDIO', { isScreenStreaming: isScreenStreaming });
if (videoTrack) {
tracksToInclude.push(videoTrack);
// Avoid overwriting camera when screen sharing uses a separate tile
if (!isScreenStreaming) {
localVideoMediaStream = new MediaStream([videoTrack]);
attachMediaStream(myVideo, localVideoMediaStream);
logStreamSettingsInfo('refreshMyLocalStream-localVideoMediaStream', localVideoMediaStream);
}
}
if (audioTrack) {
tracksToInclude.push(audioTrack);
localAudioMediaStream = new MediaStream([audioTrack]);
attachMediaStream(myAudio, localAudioMediaStream);
getMicrophoneVolumeIndicator(localAudioMediaStream);
logStreamSettingsInfo('refreshMyLocalStream-localAudioMediaStream', localAudioMediaStream);
}
} else {
console.log('Refresh my local media stream AUDIO');
if (useAudio && audioTrack) {
tracksToInclude.push(audioTrack);
localAudioMediaStream = new MediaStream([audioTrack]);
getMicrophoneVolumeIndicator(localAudioMediaStream);
logStreamSettingsInfo('refreshMyLocalStream-localAudioMediaStream', localAudioMediaStream);
}
}
// Keep camera tile object-fit consistent with the selected theme setting
myVideo.style.objectFit = 'var(--video-object-fit)';
}
/**
* Check if MediaStream has audio track
* @param {MediaStream} mediaStream
* @returns boolean
*/
function hasAudioTrack(mediaStream) {
if (!mediaStream) return false;
const audioTracks = mediaStream.getAudioTracks();
return audioTracks.length > 0;
}
/**
* Check if MediaStream has video track
* @param {MediaStream} mediaStream
* @returns boolean
*/
function hasVideoTrack(mediaStream) {
if (!mediaStream) return false;
const videoTracks = mediaStream.getVideoTracks();
return videoTracks.length > 0;
}
/**
* Safely get first video track from MediaStream
* @param {MediaStream} mediaStream
* @returns {MediaStreamTrack|null}
*/
function getVideoTrack(mediaStream) {
if (!mediaStream) return null;
const tracks = mediaStream.getVideoTracks();
return tracks.length > 0 ? tracks[0] : null;
}
/**
* Safely get first audio track from MediaStream
* @param {MediaStream} mediaStream
* @returns {MediaStreamTrack|null}
*/
function getAudioTrack(mediaStream) {
if (!mediaStream) return null;
const tracks = mediaStream.getAudioTracks();
return tracks.length > 0 ? tracks[0] : null;
}
/**
* Check if recording is active, if yes,
* on disconnect, remove peer, kick out or leave room, we going to save it
*/
function checkRecording() {
if (isStreamRecording || myVideoPeerName.innerText.includes('REC')) {
console.log('Going to save recording');
stopStreamRecording();
}
}
/**
* Handle recording errors
* @param {string} error
*/
function handleRecordingError(error, popupLog = true) {
console.error('Recording error', error);
if (popupLog) userLog('error', error);
}
/**
* Get time to string HH:MM:SS
* @param {number} time in milliseconds
* @return {string} format HH:MM:SS
*/
function getTimeToString(time) {
let diffInHrs = time / 3600000;
let hh = Math.floor(diffInHrs);
let diffInMin = (diffInHrs - hh) * 60;
let mm = Math.floor(diffInMin);
let diffInSec = (diffInMin - mm) * 60;
let ss = Math.floor(diffInSec);
let formattedHH = hh.toString().padStart(2, '0');
let formattedMM = mm.toString().padStart(2, '0');
let formattedSS = ss.toString().padStart(2, '0');
return `${formattedHH}:${formattedMM}:${formattedSS}`;
}
/**
* Seconds to HMS
* @param {number} d
* @return {string} format HH:MM:SS
*/
function secondsToHms(d) {
d = Number(d);
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
const hDisplay = h > 0 ? h + 'h' : '';
const mDisplay = m > 0 ? m + 'm' : '';
const sDisplay = s > 0 ? s + 's' : '';
return hDisplay + ' ' + mDisplay + ' ' + sDisplay;
}
/**
* Start/Stop recording timer
*/
function startRecordingTimer() {
resumeRecButtons();
recElapsedTime = 0;
recTimer = setInterval(function printTime() {
if (!isStreamRecordingPaused) {
recElapsedTime++;
let recTimeElapsed = secondsToHms(recElapsedTime);
myVideoPeerName.innerText = myPeerName + ' 🔴 REC ' + recTimeElapsed;
recordingTime.innerText = '🔴 REC ' + recTimeElapsed;
}
}, 1000);
}
function stopRecordingTimer() {
clearInterval(recTimer);
resetRecButtons();
}
/**
* Get MediaRecorder MimeTypes
* @returns {boolean} is mimeType supported by media recorder
*/
function getSupportedMimeTypes() {
const possibleTypes = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/mp4'];
console.log('POSSIBLE CODECS', possibleTypes);
return possibleTypes.filter((mimeType) => {
return MediaRecorder.isTypeSupported(mimeType);
});
}
/**
* Start Recording
* https://github.com/webrtc/samples/tree/gh-pages/src/content/getusermedia/record
* https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
* https://developer.mozilla.org/en-US/docs/Web/API/MediaStream
*/
function startStreamRecording() {
recordedBlobs = [];
// Get supported MIME types and set options
const supportedMimeTypes = getSupportedMimeTypes();
console.log('MediaRecorder supported options', supportedMimeTypes);
const options = { mimeType: supportedMimeTypes[0] };
recCodecs = supportedMimeTypes[0];
try {
audioRecorder = new MixedAudioRecorder();
const audioStreams = getAudioStreamFromAudioElements();
console.log('Audio streams tracks --->', audioStreams.getTracks());
const audioMixerStreams = audioRecorder.getMixedAudioStream(
audioStreams
.getTracks()
.filter((track) => track.kind === 'audio')
.map((track) => new MediaStream([track]))
);
const audioMixerTracks = audioMixerStreams.getTracks();
console.log('Audio mixer tracks --->', audioMixerTracks);
isMobileDevice ? startMobileRecording(options, audioMixerTracks) : recordingOptions(options, audioMixerTracks);
} catch (err) {
handleRecordingError('Exception while creating MediaRecorder: ' + err);
}
}
/**
* Recording options Camera or Screen/Window for Desktop devices
* @param {MediaRecorderOptions} options - MediaRecorder options.
* @param {array} audioMixerTracks - Array of audio tracks from the audio mixer.
*/
function recordingOptions(options, audioMixerTracks) {
Swal.fire({
background: swBg,
position: 'top',
imageUrl: images.recording,
title: 'Recording options',
text: 'Select the recording type you want to start. Audio will be recorded from all participants.',
showDenyButton: true,
showCancelButton: true,
cancelButtonColor: 'red',
denyButtonColor: 'green',
confirmButtonText: `Camera`,
denyButtonText: `Screen/Window`,
cancelButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
startMobileRecording(options, audioMixerTracks);
} else if (result.isDenied) {
startDesktopRecording(options, audioMixerTracks);
}
});
}
/**
* Starts mobile recording with the specified options and audio mixer tracks.
* @param {MediaRecorderOptions} options - MediaRecorder options.
* @param {array} audioMixerTracks - Array of audio tracks from the audio mixer.
*/
function startMobileRecording(options, audioMixerTracks) {
try {
// Combine audioMixerTracks and videoTracks into a single array
const combinedTracks = [];
// Add audio mixer tracks to the combinedTracks array if available
if (Array.isArray(audioMixerTracks)) {
combinedTracks.push(...audioMixerTracks);
}
// Check if there's a local media stream (presumably for the camera)
if (useVideo && localVideoMediaStream !== null) {
const videoTracks = localVideoMediaStream.getVideoTracks();
console.log('Cam video tracks --->', videoTracks);
// Add video tracks from the local media stream to combinedTracks if available
if (Array.isArray(videoTracks)) {
combinedTracks.push(...videoTracks);
}
}
// Create a new MediaStream using the combinedTracks
const recCamStream = new MediaStream(combinedTracks);
console.log('New Cam Media Stream tracks --->', recCamStream.getTracks());
// Create a MediaRecorder instance with the combined stream and specified options
mediaRecorder = new MediaRecorder(recCamStream, options);
console.log('Created MediaRecorder', mediaRecorder, 'with options', options);
// Call a function to handle the MediaRecorder
handleMediaRecorder(mediaRecorder);
} catch (err) {
// Handle any errors that occur during the recording setup
handleRecordingError('Unable to record the camera + audio: ' + err, false);
}
}
/**
* Starts desktop recording with the specified options and audio mixer tracks.
* On desktop devices, it records the screen or window along with all audio tracks.
* @param {MediaRecorderOptions} options - MediaRecorder options.
* @param {array} audioMixerTracks - Array of audio tracks from the audio mixer.
*/
function startDesktopRecording(options, audioMixerTracks) {
// Get the desired frame rate for screen recording
// screenMaxFrameRate = parseInt(screenFpsSelect.value, 10);
// Define constraints for capturing the screen
const constraints = {
video: { frameRate: { max: 30 } }, // Recording max 30fps
};
// Request access to screen capture using the specified constraints
navigator.mediaDevices
.getDisplayMedia(constraints)
.then((screenStream) => {
// Get video tracks from the screen capture stream
const screenTracks = screenStream.getVideoTracks();
console.log('Screen video tracks --->', screenTracks);
// Create an array to combine screen tracks and audio mixer tracks
const combinedTracks = [];
// Add screen video tracks to combinedTracks if available
if (Array.isArray(screenTracks)) {
combinedTracks.push(...screenTracks);
}
// Add audio mixer tracks to combinedTracks if available
if (useAudio && Array.isArray(audioMixerTracks)) {
combinedTracks.push(...audioMixerTracks);
}
// Create a new MediaStream using the combinedTracks
recScreenStream = new MediaStream(combinedTracks);
console.log('New Screen/Window Media Stream tracks --->', recScreenStream.getTracks());
// Create a MediaRecorder instance with the combined stream and specified options
mediaRecorder = new MediaRecorder(recScreenStream, options);
console.log('Created MediaRecorder', mediaRecorder, 'with options', options);
// Set a flag to indicate that screen recording is active
isRecScreenStream = true;
// Call a function to handle the MediaRecorder
handleMediaRecorder(mediaRecorder);
})
.catch((err) => {
// Handle any errors that occur during screen recording setup
handleRecordingError('Unable to record the screen + audio: ' + err, false);
});
}
/**
* Get a MediaStream containing audio tracks from audio elements on the page.
* @returns {MediaStream} A MediaStream containing audio tracks.
*/
function getAudioStreamFromAudioElements() {
const audioElements = getSlALL('audio');
const audioStream = new MediaStream();
audioElements.forEach((audio) => {
if (audio.srcObject) {
const audioTrack = getAudioTrack(audio.srcObject);
if (audioTrack) {
audioStream.addTrack(audioTrack);
}
}
});
return audioStream;
}
/**
* Notify me if someone start to recording they camera/screen/window + audio
* @param {string} fromId peer_id
* @param {string} from peer_name
* @param {string} fromAvatar peer_avatar
* @param {string} action recording action
*/
function notifyRecording(fromId, from, fromAvatar, action) {
const msg = '🔴 ' + action + ' conference recording';
const chatMessage = {
from: from,
fromAvatar: fromAvatar,
fromId: fromId,
to: myPeerName,
msg: msg,
privateMsg: false,
};
handleDataChannelChat(chatMessage);
if (!showChatOnMessage) {
const recAgree = action != 'Stop' ? 'Your presence implies you agree to being recorded' : '';
toastMessage(
null,
null,
`${from}
${msg}
${recAgree}`,
'top-end',
6000
);
}
}
/**
* Toggle Video and Audio tabs
* @param {boolean} disabled - If true, disable the tabs; otherwise, enable them
*/
function toggleVideoAudioTabs(disabled = false) {
tabVideoBtn.disabled = disabled;
tabAudioBtn.disabled = disabled;
}
/**
* Handle Media Recorder
* @param {object} mediaRecorder
*/
function handleMediaRecorder(mediaRecorder) {
mediaRecorder.start();
mediaRecorder.addEventListener('start', handleMediaRecorderStart);
mediaRecorder.addEventListener('dataavailable', handleMediaRecorderData);
mediaRecorder.addEventListener('stop', handleMediaRecorderStop);
}
/**
* Handle Media Recorder onstart event
* @param {object} event of media recorder
*/
function handleMediaRecorderStart(event) {
toggleVideoAudioTabs(true);
startRecordingTimer();
emitPeersAction('recStart');
emitPeerStatus('rec', true);
console.log('MediaRecorder started: ', event);
isStreamRecording = true;
const recordStreamIcon = recordStreamBtn.querySelector('i');
recordStreamIcon.style.setProperty('color', '#ff4500');
if (isMobileDevice) elemDisplay(swapCameraBtn, false);
recStartTs = performance.now();
playSound('recStart');
screenReaderAccessibility.announceMessage('Recording started');
}
/**
* Handle Media Recorder ondata event
* @param {object} event of media recorder
*/
function handleMediaRecorderData(event) {
console.log('MediaRecorder data: ', event);
if (event.data && event.data.size > 0) recordedBlobs.push(event.data);
}
/**
* Handle Media Recorder onstop event
* @param {object} event of media recorder
*/
function handleMediaRecorderStop(event) {
toggleVideoAudioTabs(false);
console.log('MediaRecorder stopped: ', event);
console.log('MediaRecorder Blobs: ', recordedBlobs);
stopRecordingTimer();
emitPeersAction('recStop');
emitPeerStatus('rec', false);
isStreamRecording = false;
myVideoPeerName.innerText = myPeerName + ' (me)';
if (isRecScreenStream) {
recScreenStream.getTracks().forEach((track) => {
if (track.kind === 'video') track.stop();
});
isRecScreenStream = false;
}
const recordStreamIcon = recordStreamBtn.querySelector('i');
recordStreamIcon.style.setProperty('color', '#ffffff');
downloadRecordedStream();
if (isMobileDevice) elemDisplay(swapCameraBtn, true, 'block');
playSound('recStop');
screenReaderAccessibility.announceMessage('Recording stopped');
}
/**
* Stop recording
*/
function stopStreamRecording() {
mediaRecorder.stop();
audioRecorder.stopMixedAudioStream();
}
/**
* Pause recording display buttons
*/
function pauseRecButtons() {
displayElements([
{ element: pauseRecBtn, display: false },
{ element: resumeRecBtn, display: true },
]);
}
/**
* Resume recording display buttons
*/
function resumeRecButtons() {
displayElements([
{ element: resumeRecBtn, display: false },
{ element: pauseRecBtn, display: true },
]);
}
/**
* Reset recording display buttons
*/
function resetRecButtons() {
displayElements([
{ element: pauseRecBtn, display: false },
{ element: resumeRecBtn, display: false },
]);
}
/**
* Pause recording
*/
function pauseRecording() {
if (mediaRecorder) {
isStreamRecordingPaused = true;
mediaRecorder.pause();
pauseRecButtons();
console.log('Pause recording');
}
}
/**
* Resume recording
*/
function resumeRecording() {
if (mediaRecorder) {
mediaRecorder.resume();
isStreamRecordingPaused = false;
resumeRecButtons();
console.log('Resume recording');
}
}
/**
* Get WebM duration fixer function
* @returns {Function|null}
*/
function getWebmFixerFn() {
const fn = window.FixWebmDuration;
return typeof fn === 'function' ? fn : null;
}
/**
* Download recorded stream
*/
async function downloadRecordedStream() {
try {
// Check if we have recorded data
if (!recordedBlobs || recordedBlobs.length === 0) {
console.error('No recorded data available');
userLog('error', 'Recording failed: No data was recorded', 6000);
return;
}
const type = recordedBlobs[0].type.includes('mp4') ? 'mp4' : 'webm';
const rawBlob = new Blob(recordedBlobs, { type: 'video/' + type });
const recFileName = getDataTimeString() + '-REC.' + type;
const currentDevice = isMobileDevice ? 'MOBILE' : 'PC';
const blobFileSize = bytesToSize(rawBlob.size);
const recordingInfo = `
- Time: ${recordingTime.innerText}
- File: ${recFileName}
- Codecs: ${recCodecs}
- Size: ${blobFileSize}
`;
lastRecordingInfo.innerHTML = `
Last recording info: ${recordingInfo}`;
recordingTime.innerText = '';
msgHTML(
null,
null,
'Recording',
`
🔴 Recording Info:
${recordingInfo}
Please wait to be processed, then will be downloaded to your ${currentDevice} device.
`,
'top'
);
// Fix WebM duration to make it seekable
const fixWebmDuration = async (blob) => {
if (type !== 'webm') return blob;
try {
const fix = getWebmFixerFn();
const durationMs = recStartTs ? performance.now() - recStartTs : undefined;
const fixed = await fix(blob, durationMs);
return fixed || blob;
} catch (e) {
console.warn('WEBM duration fix failed, saving original blob:', e);
return blob;
} finally {
recStartTs = null;
}
};
(async () => {
const finalBlob = await fixWebmDuration(rawBlob);
saveBlobToFile(finalBlob, recFileName);
})();
} catch (err) {
userLog('error', 'Recording save failed: ' + err);
}
}
/**
* Create Chat Room Data Channel
* @param {string} peer_id socket.id
*/
function createChatDataChannel(peer_id) {
chatDataChannels[peer_id] = peerConnections[peer_id].createDataChannel('mirotalk_chat_channel');
chatDataChannels[peer_id].onopen = (event) => {
console.log('chatDataChannels created', event);
};
}
/**
* Set the chat room & caption on full screen mode for mobile
*/
function setChatRoomAndCaptionForMobile() {
if (isMobileDevice) {
// chat full screen
setSP('--msger-height', '99%');
setSP('--msger-width', '99%');
// caption full screen
setSP('--caption-height', '99%');
setSP('--caption-width', '99%');
} else {
// make chat room draggable for desktop
dragElement(msgerDraggable, msgerHeader);
// make chat room participants draggable for desktop
dragElement(msgerDraggable, msgerCPHeader);
// make caption draggable for desktop
dragElement(captionDraggable, captionHeader);
}
}
/**
* Show msger draggable on center screen position
*/
function showChatRoomDraggable() {
playSound('newMessage');
if (isMobileDevice) {
elemDisplay(bottomButtons, false);
isButtonsVisible = false;
if (isChatPinned) {
chatUnpin();
}
setSP('--msger-width', '99%');
setSP('--msger-height', '99%');
}
//chatLeftCenter();
chatCenter();
chatRoomBtn.className = className.chatOff;
isChatRoomVisible = true;
if (!isMobileDevice && canBePinned() && pinChatByDefault && !isChatPinned && !isCaptionPinned) {
chatPin();
}
syncParticipantsPanelVisibility();
setTippy(chatRoomBtn, 'Close the chat', bottomButtonsPlacement);
screenReaderAccessibility.announceMessage('Chat opened');
}
function shouldDockParticipantsPanel() {
return !isMobileDevice && window.innerWidth > 1200;
}
function syncParticipantsListContainer(showParticipantsPanel = false) {
if (!msgerCPList || !msgerCPChat || !msgerPrivateChatsEmpty?.parentElement) {
return;
}
const sidebarContainer = msgerPrivateChatsEmpty.parentElement;
const useMobilePanel = isChatRoomVisible && (isMobileDevice || window.innerWidth <= 820) && showParticipantsPanel;
if (useMobilePanel) {
if (msgerCPList.parentElement !== msgerCPChat) {
msgerCPChat.appendChild(msgerCPList);
}
return;
}
if (msgerCPList.parentElement !== sidebarContainer) {
msgerPrivateChatsEmpty.insertAdjacentElement('afterend', msgerCPList);
}
}
function syncParticipantsPanelVisibility(forceVisible = null) {
if (!msgerCP || !msgerMain) {
return;
}
const canShowParticipantsPanel = isChatRoomVisible && (isMobileDevice || window.innerWidth <= 820);
const shouldShow = forceVisible === null ? isParticipantsVisible : forceVisible;
if (!canShowParticipantsPanel) {
syncParticipantsListContainer(false);
elemDisplay(msgerMain, true, 'flex');
elemDisplay(msgerCP, false);
msgerCP.setAttribute('aria-hidden', 'true');
msgerDraggable.classList.remove('msger-mobile-participants-open');
msgerCPBtn.classList.remove('active');
closeAllMsgerParticipantDropdownMenus();
isParticipantsVisible = false;
return;
}
syncParticipantsListContainer(shouldShow);
elemDisplay(msgerMain, !shouldShow, 'flex');
elemDisplay(msgerCP, shouldShow, 'flex');
msgerCP.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
msgerDraggable.classList.toggle('msger-mobile-participants-open', shouldShow);
msgerCPBtn.classList.toggle('active', shouldShow);
if (shouldShow) {
if (isMobileDevice || window.innerWidth <= 820) {
msgerCPCloseBtn?.focus();
} else {
searchPeerBarName?.focus();
}
} else {
closeAllMsgerParticipantDropdownMenus();
}
isParticipantsVisible = shouldShow;
toggleMsgerParticipantsEmptyNotice();
}
/**
* Show caption box draggable on center screen position
*/
function showCaptionDraggable() {
playSound('newMessage');
if (isMobileDevice) {
elemDisplay(bottomButtons, false);
isButtonsVisible = false;
}
captionCenter();
const captionIcon = captionBtn.querySelector('i');
captionIcon.className = 'far fa-closed-captioning';
isCaptionBoxVisible = true;
if (isDesktopDevice && canBePinned() && !isChatPinned && !isCaptionPinned) {
captionPin();
}
screenReaderAccessibility.announceMessage('Caption opened');
}
/**
* Toggle Chat dropdown menu
*/
function toggleChatDropDownMenu() {
msgerDropDownContent.style.display === 'block'
? (msgerDropDownContent.style.display = 'none')
: (msgerDropDownContent.style.display = 'block');
}
function toggleCaptionDropDownMenu() {
captionDropDownContent.style.display === 'block'
? (captionDropDownContent.style.display = 'none')
: (captionDropDownContent.style.display = 'block');
}
function closeMsgerDropdownMenus() {
[msgerDropDownContent, msgerCPDropDownContent, msgerSidebarDropDownContent, captionDropDownContent].forEach(
(menuEl) => {
if (menuEl) {
elemDisplay(menuEl, false);
}
}
);
}
function isEventInsideElements(target, ...elements) {
return elements.some((element) => element && (element === target || element.contains(target)));
}
function handleMsgerDropdownOutsidePress(event) {
if (
isEventInsideElements(
event.target,
msgerDropDownMenuBtn,
msgerDropDownContent,
msgerCPDropDownMenuBtn,
msgerCPDropDownContent,
msgerSidebarDropDownMenuBtn,
msgerSidebarDropDownContent,
captionDropDownMenuBtn,
captionDropDownContent
)
) {
return;
}
closeMsgerDropdownMenus();
}
function toggleParticipantsDropDownMenu(activeMenu, siblingMenu = null) {
if (!activeMenu) {
return;
}
if (siblingMenu) {
siblingMenu.style.display = 'none';
}
activeMenu.style.display === 'block' ? (activeMenu.style.display = 'none') : (activeMenu.style.display = 'block');
}
function syncCaptionEveryoneButtons(isActive) {
elemDisplay(captionEveryoneBtn, !isActive, 'inline');
elemDisplay(captionEveryoneStopBtn, isActive, 'inline');
elemDisplay(captionEveryoneBtnDesktop, !isActive, 'inline');
elemDisplay(captionEveryoneStopBtnDesktop, isActive, 'inline');
elemDisplay(captionEveryoneBtn?.closest('li'), !isActive);
elemDisplay(captionEveryoneStopBtn?.closest('li'), isActive);
elemDisplay(captionEveryoneBtnDesktop?.closest('li'), !isActive);
elemDisplay(captionEveryoneStopBtnDesktop?.closest('li'), isActive);
}
/**
* Chat maximize
*/
function chatMaximize() {
elemDisplay(msgerMaxBtn, false);
elemDisplay(msgerMinBtn, true);
chatCenter();
setSP('--msger-width', '100%');
setSP('--msger-height', '100%');
}
/**
* Chat minimize
*/
function chatMinimize() {
elemDisplay(msgerMinBtn, false);
elemDisplay(msgerMaxBtn, true);
chatCenter();
if (!isChatPinned) {
if (isMobileDevice) {
setSP('--msger-width', '99%');
setSP('--msger-height', '99%');
} else {
setSP('--msger-width', 'min(1120px, 92vw)');
setSP('--msger-height', 'min(760px, 92vh)');
}
} else {
setSP('--msger-width', '25%');
setSP('--msger-height', '100%');
}
}
function setChatPinnedLayout(isPinned) {
msgerDraggable.classList.toggle('msger-pinned', isPinned);
if (!isPinned) {
msgerDraggable.classList.remove('msger-pinned-sidebar-open');
}
msgerCPBtn.classList.toggle('active', false);
msgerTogglePin.classList.toggle('active', isPinned);
}
/**
* Set chat position
*/
function chatCenter() {
if (!isChatPinned) {
msgerDraggable.style.position = 'fixed';
msgerDraggable.style.display = 'flex';
msgerDraggable.style.top = '50%';
msgerDraggable.style.left = '50%';
msgerDraggable.style.transform = 'translate(-50%, -50%)';
msgerDraggable.style.webkitTransform = 'translate(-50%, -50%)';
msgerDraggable.style.mozTransform = 'translate(-50%, -50%)';
}
}
/**
* Check if the element can be pinned based of viewport size
* @returns boolean
*/
function canBePinned() {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
return viewportWidth >= 1024 && viewportHeight >= 768;
}
/**
* Toggle Chat Pin
*/
function toggleChatPin() {
if (isCaptionPinned) {
return userLog('toast', 'Please unpin the Caption that appears to be currently pinned');
}
isChatPinned ? chatUnpin() : chatPin();
playSound('click');
}
/**
* Handle chat pin
*/
function chatPin() {
videoMediaContainerPin();
chatPinned();
isChatPinned = true;
setChatPinnedLayout(true);
setColor(msgerTogglePin, 'lime');
resizeVideoMedia();
if (!isMobileDevice) {
undragElement(msgerDraggable, msgerHeader);
}
}
/**
* Handle chat unpin
*/
function chatUnpin() {
videoMediaContainerUnpin();
setSP('--msger-width', 'min(1120px, 92vw)');
setSP('--msger-height', 'min(760px, 92vh)');
elemDisplay(msgerMinBtn, false);
buttons.chat.showMaxBtn && elemDisplay(msgerMaxBtn, true);
isChatPinned = false;
setChatPinnedLayout(false);
//chatLeftCenter();
chatCenter();
setColor(msgerTogglePin, 'white');
resizeVideoMedia();
if (!isMobileDevice) {
dragElement(msgerDraggable, msgerHeader);
}
}
/**
* Move Chat center left
*/
function chatLeftCenter() {
msgerDraggable.style.position = 'fixed';
msgerDraggable.style.display = 'flex';
msgerDraggable.style.top = '50%';
msgerDraggable.style.left = isMobileDevice ? '50%' : '25%';
msgerDraggable.style.transform = 'translate(-50%, -50%)';
}
/**
* Chat is pinned
*/
function chatPinned() {
msgerDraggable.style.position = 'absolute';
msgerDraggable.style.top = 0;
msgerDraggable.style.right = 0;
msgerDraggable.style.left = null;
msgerDraggable.style.transform = null;
setSP('--msger-width', '25%');
setSP('--msger-height', '100%');
}
/**
* Caption maximize
*/
function captionMaximize() {
elemDisplay(captionMaxBtn, false);
elemDisplay(captionMinBtn, true);
captionCenter();
setSP('--caption-width', '100%');
setSP('--caption-height', '100%');
}
/**
* Caption minimize
*/
function captionMinimize() {
elemDisplay(captionMinBtn, false);
elemDisplay(captionMaxBtn, true);
captionCenter();
if (!isCaptionPinned) {
if (isMobileDevice) {
setSP('--caption-width', '99%');
setSP('--caption-height', '99%');
} else {
setSP('--caption-width', '420px');
setSP('--caption-height', '680px');
}
} else {
setSP('--caption-width', '25%');
setSP('--caption-height', '100%');
}
}
/**
* Set chat position
*/
function captionCenter() {
if (!isCaptionPinned) {
captionDraggable.style.position = 'fixed';
captionDraggable.style.display = 'flex';
captionDraggable.style.top = '50%';
captionDraggable.style.left = '50%';
captionDraggable.style.transform = 'translate(-50%, -50%)';
captionDraggable.style.webkitTransform = 'translate(-50%, -50%)';
captionDraggable.style.mozTransform = 'translate(-50%, -50%)';
}
}
/**
* Toggle Caption Pin
*/
function toggleCaptionPin() {
if (isChatPinned) {
return userLog('toast', 'Please unpin the Chat that appears to be currently pinned');
}
isCaptionPinned ? captionUnpin() : captionPin();
playSound('click');
}
/**
* Handle caption pin
*/
function captionPin() {
videoMediaContainerPin();
captionPinned();
isCaptionPinned = true;
captionDraggable.classList.add('caption-pinned');
setColor(captionTogglePin, 'lime');
resizeVideoMedia();
if (!isMobileDevice) undragElement(captionDraggable, captionHeader);
}
/**
* Handle caption unpin
*/
function captionUnpin() {
videoMediaContainerUnpin();
setSP('--caption-width', '420px');
setSP('--caption-height', '680px');
elemDisplay(captionMinBtn, false);
buttons.caption.showMaxBtn && elemDisplay(captionMaxBtn, true);
isCaptionPinned = false;
captionDraggable.classList.remove('caption-pinned');
//captionRightCenter();
captionCenter();
setColor(captionTogglePin, 'white');
resizeVideoMedia();
if (!isMobileDevice) dragElement(captionDraggable, captionHeader);
}
/**
* Move Caption center right
*/
function captionRightCenter() {
captionDraggable.style.position = 'fixed';
captionDraggable.style.display = 'flex';
captionDraggable.style.top = '50%';
captionDraggable.style.left = isMobileDevice ? '50%' : '75%';
captionDraggable.style.transform = 'translate(-50%, -50%)';
}
/**
* Caption is pinned
*/
function captionPinned() {
captionDraggable.style.position = 'absolute';
captionDraggable.style.top = 0;
captionDraggable.style.right = 0;
captionDraggable.style.left = null;
captionDraggable.style.transform = null;
setSP('--caption-width', '25%');
setSP('--caption-height', '100%');
}
/**
* Clean chat messages
*/
function cleanMessages() {
playSound('newMessage');
Swal.fire({
background: swBg,
position: 'top',
title: 'Chat',
text: 'Clean up chat messages?',
imageUrl: images.delete,
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
// clean chat messages
if (result.isConfirmed) {
// Remove only elements with class 'msg' inside msgerChat
const messages = msgerChat.querySelectorAll('.msg');
messages.forEach((msg) => msgerChat.removeChild(msg));
// clean chat messages
chatMessages = [];
// clean chatGPT context
chatGPTcontext = [];
// show empty messages
toggleMsgerEmptyNotice();
playSound('delete');
}
});
}
/**
* Clean captions
*/
function cleanCaptions() {
playSound('newMessage');
Swal.fire({
background: swBg,
position: 'top',
title: 'Clean up all caption transcripts?',
imageUrl: images.delete,
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
// clean chat messages
if (result.isConfirmed) {
// Remove only elements with class 'msg' inside captionChat
const captions = Array.from(captionChat.querySelectorAll('.msg'));
captions.forEach((caption) => captionChat.removeChild(caption));
// clean object
transcripts = [];
// show empty caption
showCaptionEmptyNoticeIfNoCaptions();
playSound('delete');
}
});
}
/**
* Hide chat room and emoji picker
*/
function hideChatRoomAndEmojiPicker() {
if (isChatPinned) {
chatUnpin();
}
elemDisplay(msgerDraggable, false);
elemDisplay(msgerCP, false);
elemDisplay(msgerEmojiPicker, false);
setColor(msgerEmojiBtn, '#FFFFFF');
chatRoomBtn.className = className.chatOn;
isChatRoomVisible = false;
isParticipantsVisible = false;
isChatEmojiVisible = false;
setTippy(chatRoomBtn, 'Open the chat', bottomButtonsPlacement);
screenReaderAccessibility.announceMessage('Chat closed');
}
/**
* Hide chat room and emoji picker
*/
function hideCaptionBox() {
if (isCaptionPinned) {
captionUnpin();
}
elemDisplay(captionDraggable, false);
const captionIcon = captionBtn.querySelector('i');
captionIcon.className = 'fas fa-closed-captioning';
isCaptionBoxVisible = false;
}
/**
* Send Chat messages to peers in the room
*/
async function sendChatMessage() {
if (!thereArePeerConnections() && !isChatGPTConversationActive()) {
cleanMessageInput();
isChatPasteTxt = false;
return userLog('info', "Can't send message, no participants in the room");
}
msgerInput.value = filterXSS(msgerInput.value.trim());
const msg = checkMsg(msgerInput.value);
// empty msg or
if (!msg) {
isChatPasteTxt = false;
return cleanMessageInput();
}
if (activeConversation.type === 'private' && activeConversation.peerId === CHAT_GPT_PEER_ID) {
appendMessage(myPeerName, rightChatAvatar, 'right', msg, true, null, CHAT_GPT_NAME);
await getChatGPTmessage(msg);
} else if (activeConversation.type === 'private' && activeConversation.peerName) {
emitMsg(myPeerName, myPeerAvatar, activeConversation.peerName, msg, true, myPeerId);
appendMessage(myPeerName, rightChatAvatar, 'right', msg, true, null, activeConversation.peerName);
} else {
emitMsg(myPeerName, myPeerAvatar, 'toAll', msg, false, myPeerId);
appendMessage(myPeerName, rightChatAvatar, 'right', msg, false);
}
cleanMessageInput();
}
/**
* handle Incoming Data Channel Chat Messages
* @param {object} dataMessage chat messages
*/
function handleDataChannelChat(dataMessage) {
if (!dataMessage) return;
// sanitize all params
const msgFrom = filterXSS(dataMessage.from);
const msgFromAvatar = filterXSS(dataMessage.fromAvatar);
const msgFromId = filterXSS(dataMessage.fromId);
const msgTo = filterXSS(dataMessage.to);
const msg = filterXSS(dataMessage.msg);
const msgPrivate = filterXSS(dataMessage.privateMsg);
const msgId = filterXSS(dataMessage.id);
// We check if the message is from real peer
const from_peer_name = allPeers[msgFromId]['peer_name'];
if (from_peer_name != msgFrom) {
console.log('Fake message detected', { realFrom: from_peer_name, fakeFrom: msgFrom, msg: msg });
return;
}
// private message but not for me return
if (msgPrivate && msgTo != myPeerName) return;
console.log('handleDataChannelChat', dataMessage);
// chat message for me also
if (!isChatRoomVisible && showChatOnMessage) {
showChatRoomDraggable();
chatRoomBtn.className = className.chatOff;
}
// show message from
if (!showChatOnMessage) {
userLog('toast', `New message from: ${msgFrom}`);
}
if (msgPrivate) {
if (!isConversationCurrentlyVisible('private', msgFrom, msgFromId)) {
addUnreadMessage('private', msgFromId);
if (isChatRoomVisible && isChatPinned) {
openPinnedParticipantsSidebar();
}
}
} else if (!isConversationCurrentlyVisible('public')) {
addUnreadMessage('public');
}
setPeerChatAvatarImgName('left', msgFrom, msgFromAvatar);
appendMessage(msgFrom, leftChatAvatar, 'left', msg, msgPrivate, msgId, msgFrom);
speechInMessages ? speechMessage(true, msgFrom, msg) : playSound('chatMessage');
// Screen reader announcement for incoming chat message
if (!speechInMessages) {
screenReaderAccessibility.announceMessage(`New message from ${msgFrom}`);
}
}
/**
* Clean input txt message
*/
function cleanMessageInput() {
msgerInput.value = '';
checkLineBreaks();
}
/**
* Paste from clipboard to input txt message
*/
function pasteToMessageInput() {
navigator.clipboard
.readText()
.then((text) => {
msgerInput.value += text;
isChatPasteTxt = true;
checkLineBreaks();
})
.catch((err) => {
console.error('Failed to read clipboard contents: ', err);
});
}
/**
* Handle text transcript getting from peers
* @param {object} config data
*/
function handleDataChannelSpeechTranscript(config) {
handleSpeechTranscript(config);
}
/**
* Handle text transcript getting from peers
* @param {object} config data
*/
function handleSpeechTranscript(config) {
if (!config) return;
console.log('Handle speech transcript', config);
config.text_data = filterXSS(config.text_data);
config.peer_name = filterXSS(config.peer_name);
config.peer_avatar = filterXSS(config.peer_avatar);
const { peer_name, peer_avatar, text_data } = config;
const time_stamp = getFormatDate(new Date());
const avatar_image =
peer_avatar && isImageURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
: genAvatarSvg(peer_name, 32);
if (!isCaptionBoxVisible && transcriptShowOnMsg) showCaptionDraggable();
const msgHTML = `
${peer_name} : ${time_stamp}
${text_data}
`;
captionChat.insertAdjacentHTML('beforeend', msgHTML);
captionChat.scrollTop += 500;
transcripts.push({
time: time_stamp,
name: peer_name,
caption: text_data,
});
showCaptionEmptyNoticeIfNoCaptions();
playSound('speech');
}
/**
* Hide empty caption notice
*/
function showCaptionEmptyNoticeIfNoCaptions() {
const captions = captionChat.querySelectorAll('.msg');
captions.length === 0 ? captionEmptyNotice.classList.remove('hidden') : captionEmptyNotice.classList.add('hidden');
}
/**
* Escape Special Chars
* @param {string} regex string to replace
*/
function escapeSpecialChars(regex) {
return regex.replace(/([()[{*+.$^\\|?])/g, '\\$1');
}
/**
* Append Message to msger chat room
* @param {string} from peer name
* @param {string} img images url
* @param {string} side left/right
* @param {string} msg message to append
* @param {boolean} privateMsg if is private message
* @param {string} msgId peer id
* @param {string} to peer name
*/
function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '') {
let time = getFormatDate(new Date());
// sanitize all params
const getFrom = filterXSS(from);
const getTo = filterXSS(to);
const getSide = filterXSS(side);
const getImg = getFrom === CHAT_GPT_NAME && getSide === 'left' ? images.chatgpt : filterXSS(img);
const getMsg = filterXSS(msg);
const getPrivateMsg = filterXSS(privateMsg);
// collect chat messages to save it later
const conversationPeer = getPrivateMsg ? (getSide === 'left' ? getFrom : getTo) : '';
chatMessages.push({
time: time,
from: getFrom,
to: getTo,
msg: getMsg,
privateMsg: getPrivateMsg,
conversationPeer: conversationPeer,
});
// check if i receive a private message
let msgBubble = getPrivateMsg ? 'private-msg-bubble' : 'msg-bubble';
let msgHTML = `
`;
msgHTML += `
`;
if (isSpeechSynthesisSupported) {
msgHTML += `
`;
}
msgHTML += `
`;
msgerChat.insertAdjacentHTML('beforeend', msgHTML);
const message = getId(`message-${chatMessagesId}`);
if (message) {
if (getFrom === 'ChatGPT') {
// Stream the message for ChatGPT
streamMessage(message, getMsg, 100);
} else {
// Process the message for other senders
message.innerHTML = processMessage(getMsg);
hljs.highlightAll();
}
}
msgerChat.scrollTop += 500;
filterMessagesByConversation();
refreshMessageGrouping();
if (!isMobileDevice) {
setTippy(getId('msg-delete-' + chatMessagesId), 'Delete', 'top');
setTippy(getId('msg-copy-' + chatMessagesId), 'Copy', 'top');
setTippy(getId('msg-speech-' + chatMessagesId), 'Speech', 'top');
}
chatMessagesId++;
toggleMsgerEmptyNotice();
}
/**
* Toggle empty chat notice
*/
function toggleMsgerEmptyNotice() {
const messages = Array.from(msgerChat.querySelectorAll('.msg')).filter(
(message) => message.style.display !== 'none'
);
messages.length === 0 ? msgerEmptyNotice.classList.remove('hidden') : msgerEmptyNotice.classList.add('hidden');
}
function refreshMessageGrouping() {
const messages = Array.from(msgerChat.querySelectorAll('.msg')).filter(
(message) => message.style.display !== 'none'
);
let previousKey = '';
messages.forEach((message) => {
const sender = message.dataset.sender || '';
const chatType = message.dataset.chatType || 'public';
const chatPeer = message.dataset.chatPeer || '';
const side = message.classList.contains('right-msg') ? 'right' : 'left';
const currentKey = `${side}:${sender}:${chatType}:${chatPeer}`;
const isGrouped = currentKey === previousKey;
message.classList.toggle('msg-grouped', isGrouped);
previousKey = currentKey;
});
}
function formatUnreadCount(count) {
return count > 99 ? '99+' : String(count);
}
function updateUnreadBadge(element, count) {
if (!element) return;
element.textContent = formatUnreadCount(count);
element.classList.toggle('hidden', count <= 0);
}
function refreshUnreadBadges() {
updateUnreadBadge(msgerRoomChatBadge, unreadMessages.public);
msgerCPList.querySelectorAll('.msger-chat-item').forEach((item) => {
const peerId = item.dataset.peerId;
if (!peerId) return;
const badge = getId(peerId + '_pMsgBadge');
updateUnreadBadge(badge, unreadMessages.private[peerId] || 0);
});
}
function clearUnreadMessages(type = 'public', peerId = '') {
if (type === 'private' && peerId) {
unreadMessages.private[peerId] = 0;
} else {
unreadMessages.public = 0;
}
refreshUnreadBadges();
}
function addUnreadMessage(type = 'public', peerId = '') {
if (type === 'private' && peerId) {
unreadMessages.private[peerId] = (unreadMessages.private[peerId] || 0) + 1;
} else {
unreadMessages.public += 1;
}
refreshUnreadBadges();
}
function isConversationCurrentlyVisible(type = 'public', peerName = '', peerId = '') {
if (!isChatRoomVisible) return false;
if (type === 'private') {
return (
activeConversation.type === 'private' &&
((peerId && activeConversation.peerId === peerId) ||
(peerName && activeConversation.peerName.toLowerCase() === peerName.toLowerCase()))
);
}
return activeConversation.type === 'public';
}
function getConversationMeta() {
if (activeConversation.type === 'private' && activeConversation.peerId === CHAT_GPT_PEER_ID) {
return {
label: 'AI assistant',
title: CHAT_GPT_NAME,
meta: `Direct messages with ${CHAT_GPT_NAME}.`,
placeholder: `Ask ${CHAT_GPT_NAME}...`,
};
}
if (activeConversation.type === 'private' && activeConversation.peerName) {
return {
label: 'Private chat',
title: activeConversation.peerName,
meta: `Direct messages with ${activeConversation.peerName}.`,
placeholder: `Message ${activeConversation.peerName}...`,
};
}
return {
label: 'Current view',
title: 'All messages',
meta: 'Public messages appear here.',
placeholder: 'Write a message...',
};
}
function updateConversationUi() {
const conversation = getConversationMeta();
if (msgerConversationLabel) msgerConversationLabel.textContent = conversation.label;
if (msgerConversationTitle) msgerConversationTitle.textContent = conversation.title;
if (msgerConversationMeta) msgerConversationMeta.textContent = conversation.meta;
if (msgerInput) msgerInput.placeholder = conversation.placeholder;
if (msgerRoomChatItem) {
msgerRoomChatItem.classList.toggle('active', activeConversation.type === 'public');
}
msgerCPList.querySelectorAll('.msger-chat-item').forEach((item) => {
const isActive =
activeConversation.type === 'private' &&
item.value &&
item.value.toLowerCase() === activeConversation.peerName.toLowerCase();
item.classList.toggle('active', isActive);
});
}
function filterMessagesByConversation() {
const conversationPeer = activeConversation.peerName.toLowerCase();
msgerChat.querySelectorAll('.msg').forEach((message) => {
const chatType = message.dataset.chatType || 'public';
const chatPeer = (message.dataset.chatPeer || '').toLowerCase();
const shouldShow =
activeConversation.type === 'private'
? chatType === 'private' && chatPeer === conversationPeer
: chatType === 'public';
elemDisplay(message, shouldShow, 'flex');
});
refreshMessageGrouping();
toggleMsgerEmptyNotice();
msgerChat.scrollTop = msgerChat.scrollHeight;
}
function setActiveConversation(type = 'public', peerName = '', peerId = '') {
activeConversation = {
type,
peerName: filterXSS(peerName || ''),
peerId: peerId || '',
};
if (type === 'private' && peerId) {
clearUnreadMessages('private', peerId);
} else {
clearUnreadMessages('public');
}
updateConversationUi();
filterMessagesByConversation();
}
function isChatGPTConversationActive() {
return activeConversation.type === 'private' && activeConversation.peerId === CHAT_GPT_PEER_ID;
}
function resolvePeerNameById(peerId = '') {
if (!peerId) return '';
if (peerId === CHAT_GPT_PEER_ID) return CHAT_GPT_NAME;
const privateChatButton = getId(peerId + '_pMsgBtn');
if (privateChatButton?.value) {
return privateChatButton.value;
}
return allPeers[peerId]?.peer_name || '';
}
function getConversationShareTarget(actionLabel = 'this item') {
if (activeConversation.type !== 'private') {
return {
broadcast: true,
peerId: myPeerId,
videoPeerId: null,
peerName: '',
};
}
if (!activeConversation.peerId || activeConversation.peerId === CHAT_GPT_PEER_ID) {
userLog('info', `Switch to a participant chat to share ${actionLabel}`);
return null;
}
return {
broadcast: false,
peerId: activeConversation.peerId,
videoPeerId: activeConversation.peerId,
peerName: activeConversation.peerName || resolvePeerNameById(activeConversation.peerId),
};
}
function ensureChatGPTConversationEntry() {
if (!msgerCPList || !buttons.chat.showChatGPTBtn || getId(CHAT_GPT_PEER_ID + '_pMsgDiv')) {
return;
}
const chatGPTEntry = `
${CHAT_GPT_NAME}
Ask anything
0
`;
msgerCPList.insertAdjacentHTML('afterbegin', chatGPTEntry);
const msgerPrivateBtn = getId(CHAT_GPT_PEER_ID + '_pMsgBtn');
addMsgerPrivateBtn(msgerPrivateBtn, null, null, null, null, null, null, null, null, myPeerId, CHAT_GPT_PEER_ID);
toggleMsgerParticipantsEmptyNotice();
refreshUnreadBadges();
updateConversationUi();
}
/**
* Toggle empty participants notice
*/
function toggleMsgerParticipantsEmptyNotice() {
const privateChats = msgerCPList.querySelectorAll('.msger-private-chat-entry');
const hasPrivateChats = privateChats.length !== 0;
const isMobileParticipantsView = msgerCPList.parentElement === msgerCPChat;
msgerEmptyParticipantsNotice?.classList.toggle('hidden', hasPrivateChats || !isMobileParticipantsView);
msgerPrivateChatsEmpty?.classList.toggle('hidden', hasPrivateChats || isMobileParticipantsView);
elemDisplay(msgerCPList, hasPrivateChats, 'flex');
elemDisplay(msgerParticipantsList, false);
}
/**
* Process Messages
* @param {string} message
* @returns string message processed
*/
function processMessage(message) {
const codeBlockRegex = /```([a-zA-Z0-9]+)?\n([\s\S]*?)```/g;
let parts = [];
let lastIndex = 0;
message.replace(codeBlockRegex, (match, lang, code, offset) => {
if (offset > lastIndex) {
parts.push({ type: 'text', value: message.slice(lastIndex, offset) });
}
parts.push({ type: 'code', lang, value: code });
lastIndex = offset + match.length;
});
if (lastIndex < message.length) {
parts.push({ type: 'text', value: message.slice(lastIndex) });
}
return parts
.map((part) => {
if (part.type === 'text') {
return part.value;
} else if (part.type === 'code') {
return `${part.value}
`;
}
})
.join('');
}
/**
* Stream message
* @param {string} element
* @param {string} message
* @param {integer} speed
*/
function streamMessage(element, message, speed = 100) {
const parts = processMessage(message);
const words = parts.split(' ');
let textBuffer = '';
let wordIndex = 0;
const interval = setInterval(() => {
if (wordIndex < words.length) {
textBuffer += words[wordIndex] + ' ';
element.innerHTML = textBuffer;
wordIndex++;
} else {
clearInterval(interval);
highlightCodeBlocks(element);
}
}, speed);
function highlightCodeBlocks(element) {
const codeBlocks = element.querySelectorAll('pre code');
codeBlocks.forEach((block) => {
hljs.highlightElement(block);
});
}
}
/**
* Speech message
* https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance
*
* @param {boolean} newMsg true/false
* @param {string} from peer_name
* @param {string} msg message
*/
function speechMessage(newMsg = true, from, msg) {
const speech = new SpeechSynthesisUtterance();
speech.text = (newMsg ? 'New' : '') + ' message from:' + from + '. The message is:' + msg;
speech.rate = 0.9;
window.speechSynthesis.speak(speech);
}
/**
* Speech element text
* @param {boolean} newMsg true/false
* @param {string} from peer_name
* @param {string} elemId
*/
function speechElementText(newMsg = true, from, elemId) {
const element = getId(elemId);
speechMessage(newMsg, from, element.innerText);
}
/**
* Delete message
* @param {string} id msg id
*/
function deleteMessage(id) {
playSound('newMessage');
Swal.fire({
background: swBg,
position: 'top',
title: 'Chat',
text: 'Delete this messages?',
imageUrl: images.delete,
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
// clean this message
if (result.isConfirmed) {
getId(id).remove();
refreshMessageGrouping();
toggleMsgerEmptyNotice();
playSound('delete');
}
});
}
/**
* Copy the element innerText on clipboard
* @param {string} id
*/
function copyToClipboard(id) {
const text = getId(id).innerText;
navigator.clipboard
.writeText(text)
.then(() => {
msgPopup('success', 'Message copied!', 'top-end', 1000);
})
.catch((err) => {
msgPopup('error', err, 'top', 2000);
});
}
function closeAllMsgerParticipantDropdownMenus() {
document
.querySelectorAll('.msger-private-chat-entry .dropdown-menu-custom-list, .dropdown-menu-custom-list.floating')
.forEach((menu) => {
const placeholder = menu._msgerDropdownPlaceholder;
if (placeholder?.parentNode) {
placeholder.parentNode.insertBefore(menu, placeholder);
placeholder.remove();
menu._msgerDropdownPlaceholder = null;
}
menu.classList.remove('show', 'floating');
menu.style.left = '';
menu.style.top = '';
menu.style.visibility = '';
const toggle = menu._msgerDropdownToggle || menu.parentElement?.querySelector('.dropdown-toggle');
toggle?.setAttribute('aria-expanded', 'false');
});
activeMsgerParticipantDropdown = null;
}
function positionMsgerParticipantDropdownMenu(toggleEl, menuEl) {
if (!toggleEl || !menuEl) return;
const gap = 8;
const viewportPadding = 12;
if (!menuEl._msgerDropdownPlaceholder && menuEl.parentNode) {
const placeholder = document.createElement('span');
placeholder.style.display = 'none';
menuEl.parentNode.insertBefore(placeholder, menuEl);
menuEl._msgerDropdownPlaceholder = placeholder;
}
menuEl._msgerDropdownToggle = toggleEl;
document.body.appendChild(menuEl);
menuEl.classList.add('show', 'floating');
menuEl.style.visibility = 'hidden';
menuEl.style.left = '0px';
menuEl.style.top = '0px';
menuEl.style.maxHeight = `${Math.max(260, window.innerHeight - viewportPadding * 2)}px`;
const toggleRect = toggleEl.getBoundingClientRect();
const menuWidth = Math.max(menuEl.offsetWidth, 220);
const menuHeight = menuEl.offsetHeight;
const maxLeft = window.innerWidth - menuWidth - viewportPadding;
const preferredLeft = toggleRect.right - menuWidth;
const left = Math.max(viewportPadding, Math.min(preferredLeft, maxLeft));
const fitsBelow = toggleRect.bottom + gap + menuHeight <= window.innerHeight - viewportPadding;
const top = fitsBelow ? toggleRect.bottom + gap : Math.max(viewportPadding, toggleRect.top - menuHeight - gap);
menuEl.style.left = `${left}px`;
menuEl.style.top = `${top}px`;
menuEl.style.visibility = '';
}
function supportsHoverPointer() {
return window.matchMedia('(hover: hover) and (pointer: fine)').matches;
}
function openMsgerParticipantDropdownMenu(toggleEl, menuEl) {
if (!toggleEl || !menuEl) return;
if (activeMsgerParticipantDropdown?.menuEl === menuEl) {
return;
}
closeAllMsgerParticipantDropdownMenus();
positionMsgerParticipantDropdownMenu(toggleEl, menuEl);
toggleEl.setAttribute('aria-expanded', 'true');
activeMsgerParticipantDropdown = { toggleEl, menuEl };
}
function toggleMsgerParticipantDropdownMenu(toggleEl, menuEl) {
if (!toggleEl || !menuEl) return;
const isOpen = menuEl.classList.contains('show');
closeAllMsgerParticipantDropdownMenus();
if (isOpen) return;
openMsgerParticipantDropdownMenu(toggleEl, menuEl);
}
function handleMsgerParticipantDropdownDocumentClick(event) {
if (!activeMsgerParticipantDropdown) return;
const { toggleEl, menuEl } = activeMsgerParticipantDropdown;
if (toggleEl?.contains(event.target) || menuEl?.contains(event.target)) {
return;
}
closeAllMsgerParticipantDropdownMenus();
}
function getMsgerParticipantDropdownActionMarkup(buttonId, iconClass, label, variant = 'default') {
const actionClass =
variant === 'danger'
? 'dropdown-item app-dropdown-action msger-participant-action msger-participant-action-danger'
: 'dropdown-item app-dropdown-action msger-participant-action';
return `
`;
}
function getMsgerParticipantDropdownDividerMarkup() {
return ``;
}
function getMsgerRoomActionsDropdownMarkup(idSuffix = '') {
return `
${getMsgerParticipantDropdownActionMarkup(`captionEveryoneBtn${idSuffix}`, 'fas fa-play', 'Start captions')}
${getMsgerParticipantDropdownActionMarkup(`captionEveryoneStopBtn${idSuffix}`, 'fas fa-stop', 'Stop captions')}
${getMsgerParticipantDropdownDividerMarkup()}
${getMsgerParticipantDropdownActionMarkup(`muteEveryoneBtn${idSuffix}`, 'fas fa-microphone', 'Mute everyone', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`hideEveryoneBtn${idSuffix}`, 'fas fa-video', 'Hide everyone', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`ejectEveryoneBtn${idSuffix}`, 'fas fa-right-from-bracket', 'Eject everyone', 'danger')}
`;
}
function renderMsgerRoomActionsDropdown(dropdownContent, idSuffix = '') {
if (!dropdownContent) return;
dropdownContent.innerHTML = getMsgerRoomActionsDropdownMarkup(idSuffix);
}
/**
* Add participants in the chat room lists
* @param {object} peers all peers info connected to the same room
*/
async function msgerAddPeers(peers) {
// console.log("peers", peers);
// Check if I am a presenter
const peer_presenter = peers[myPeerId] && peers[myPeerId]['peer_presenter'];
// add all current Participants
for (const peer_id in peers) {
const peer_name = peers[peer_id]['peer_name'];
const peer_avatar = peers[peer_id]['peer_avatar'];
// bypass insert to myself in the list :)
if (peer_id != myPeerId && peer_name) {
const exsistMsgerPrivateDiv = getId(peer_id + '_pMsgDiv');
// if there isn't add it....
if (!exsistMsgerPrivateDiv) {
const chatAvatar =
peer_avatar && isImageURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
: genAvatarSvg(peer_name, 24);
// Dropdown menu options based on isPresenter
let dropdownOptions = '';
if (peer_presenter) {
dropdownOptions = `
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pKickOut`, 'fas fa-user-slash', 'Eject participant', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pToggleAudio`, 'fas fa-microphone', 'Mute microphone', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pToggleVideo`, 'fas fa-video', 'Stop video', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pToggleScreen`, 'fas fa-desktop', 'Stop screen', 'danger')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pSelectFile`, 'fas fa-upload', 'Send file')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pSendVideoUrl`, 'fab fa-youtube', 'Share video or audio')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pRequestGeo`, 'fas fa-location-dot', 'Request geolocation')}
`;
} else {
elemDisplay(msgerCPDropDownMenuBtn, false);
elemDisplay(msgerSidebarDropDownMenuBtn, false);
dropdownOptions = `
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pSelectFile`, 'fas fa-upload', 'Send file')}
${getMsgerParticipantDropdownActionMarkup(`${peer_id}_pSendVideoUrl`, 'fab fa-youtube', 'Share video or audio')}
`;
}
const msgerPrivateDiv = `
${peer_name}
Open private conversation
0
`;
msgerCPList.insertAdjacentHTML('beforeend', msgerPrivateDiv);
msgerCPList.scrollTop += 500;
const msgerPrivateBtn = getId(peer_id + '_pMsgBtn');
const msgerPrivateKickOutBtn = getId(peer_id + '_pKickOut');
const msgerPrivateToggleAudioBtn = getId(peer_id + '_pToggleAudio');
const msgerPrivateToggleVideoBtn = getId(peer_id + '_pToggleVideo');
const msgerPrivateToggleScreenBtn = getId(peer_id + '_pToggleScreen');
const msgerPrivateSelectFileBtn = getId(peer_id + '_pSelectFile');
const msgerPrivateSendVideoUrlBtn = getId(peer_id + '_pSendVideoUrl');
const msgerPrivateRequestGeoBtn = getId(peer_id + '_pRequestGeo');
addMsgerPrivateBtn(
msgerPrivateBtn,
null,
msgerPrivateKickOutBtn,
msgerPrivateToggleAudioBtn,
msgerPrivateToggleVideoBtn,
msgerPrivateToggleScreenBtn,
msgerPrivateSelectFileBtn,
msgerPrivateSendVideoUrlBtn,
msgerPrivateRequestGeoBtn,
myPeerId,
peer_id
);
updateConversationUi();
// Dropdown toggle logic
const dropdownDiv = getId(peer_id + '_pMsgDiv').querySelector('.dropdown-menu-custom');
const dropdownToggle = dropdownDiv.querySelector('.dropdown-toggle');
const dropdownContent = dropdownDiv.querySelector('.dropdown-menu-custom-list');
if (dropdownToggle && dropdownContent) {
let hideTimeoutId;
const showDropdown = () => {
if (!supportsHoverPointer()) return;
clearTimeout(hideTimeoutId);
openMsgerParticipantDropdownMenu(dropdownToggle, dropdownContent);
};
const hideDropdown = () => {
if (!supportsHoverPointer()) return;
hideTimeoutId = setTimeout(() => {
if (activeMsgerParticipantDropdown?.menuEl === dropdownContent) {
closeAllMsgerParticipantDropdownMenus();
}
}, 180);
};
dropdownToggle.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (supportsHoverPointer()) return;
toggleMsgerParticipantDropdownMenu(dropdownToggle, dropdownContent);
});
dropdownToggle.addEventListener('mouseenter', showDropdown);
dropdownToggle.addEventListener('mouseleave', hideDropdown);
dropdownContent.addEventListener('mouseenter', () => clearTimeout(hideTimeoutId));
dropdownContent.addEventListener('mouseleave', hideDropdown);
}
refreshUnreadBadges();
}
}
}
toggleMsgerParticipantsEmptyNotice();
}
/**
* Search peer by name in chat room lists to send the private messages
*/
function searchPeer() {
const peerSearchValue = getId('searchPeerBarName').value.toLowerCase().trim();
const privateEntries = msgerCPList.querySelectorAll('.msger-private-chat-entry');
privateEntries.forEach((entry) => {
const peerName = entry.dataset.peerName || '';
elemDisplay(entry, peerName.includes(peerSearchValue), 'flex');
});
}
/**
* Remove participant from chat room lists
* @param {string} peer_id socket.id
*/
function msgerRemovePeer(peer_id) {
const msgerPrivateDiv = getId(peer_id + '_pMsgDiv');
const isActiveConversation = activeConversation.type === 'private' && activeConversation.peerId === peer_id;
if (msgerPrivateDiv) {
let peerToRemove = msgerPrivateDiv.firstChild;
while (peerToRemove) {
msgerPrivateDiv.removeChild(peerToRemove);
peerToRemove = msgerPrivateDiv.firstChild;
}
msgerPrivateDiv.remove();
}
delete unreadMessages.private[peer_id];
refreshUnreadBadges();
if (isActiveConversation) setActiveConversation('public');
toggleMsgerParticipantsEmptyNotice();
}
/**
* Setup msger buttons to send private messages
* @param {object} msgerPrivateBtn chat private message send button
* @param {object} msgerPrivateMsgInput chat private message text input
* @param {string} peerId chat peer_id
*/
function addMsgerPrivateBtn(
msgerPrivateBtn,
msgerPrivateMsgInput,
msgerPrivateKickOutBtn,
msgerPrivateToggleAudioBtn,
msgerPrivateToggleVideoBtn,
msgerPrivateToggleScreenBtn,
msgerPrivateSelectFileBtn,
msgerPrivateSendVideoUrlBtn,
msgerPrivateRequestGeoBtn,
myPeerId,
peerId
) {
// Send private message button
msgerPrivateBtn.addEventListener('click', (e) => {
e.preventDefault();
if (e.target.closest('.dropdown-menu-custom')) return;
if (msgerPrivateMsgInput) {
sendPrivateMessage();
return;
}
const selectedPeerId = msgerPrivateBtn.dataset.peerId || peerId;
setActiveConversation('private', msgerPrivateBtn.dataset.value, selectedPeerId);
msgerDraggable.classList.remove('msger-pinned-sidebar-open');
if (shouldDockParticipantsPanel()) {
msgerCPBtn.classList.remove('active');
isParticipantsVisible = false;
} else {
syncParticipantsPanelVisibility(false);
}
msgerInput.focus();
});
// Enter key to send private message
if (msgerPrivateMsgInput) {
msgerPrivateMsgInput.addEventListener('keyup', (e) => {
if (e.keyCode === 13) {
e.preventDefault();
sendPrivateMessage();
}
});
msgerPrivateMsgInput.onpaste = () => {
isChatPasteTxt = true;
};
}
function sendPrivateMessage() {
msgerPrivateMsgInput.value = filterXSS(msgerPrivateMsgInput.value.trim());
const pMsg = checkMsg(msgerPrivateMsgInput.value);
if (!pMsg) {
msgerPrivateMsgInput.value = '';
isChatPasteTxt = false;
return;
}
// sanitization to prevent XSS
msgerPrivateBtn.dataset.value = filterXSS(msgerPrivateBtn.dataset.value);
myPeerName = filterXSS(myPeerName);
if (isHtml(myPeerName) && isHtml(msgerPrivateBtn.dataset.value)) {
msgerPrivateMsgInput.value = '';
isChatPasteTxt = false;
return;
}
const toPeerName = msgerPrivateBtn.dataset.value;
emitMsg(myPeerName, myPeerAvatar, toPeerName, pMsg, true, myPeerId);
appendMessage(myPeerName, rightChatAvatar, 'right', pMsg, true, null, toPeerName);
msgerPrivateMsgInput.value = '';
if (!shouldDockParticipantsPanel()) {
syncParticipantsPanelVisibility(false);
}
}
// Dropdown actions
if (msgerPrivateKickOutBtn) {
msgerPrivateKickOutBtn.addEventListener('click', (e) => {
e.preventDefault();
kickOut(peerId);
});
}
if (msgerPrivateToggleAudioBtn) {
msgerPrivateToggleAudioBtn.addEventListener('click', (e) => {
e.preventDefault();
disablePeer(peerId, 'audio');
});
}
if (msgerPrivateToggleVideoBtn) {
msgerPrivateToggleVideoBtn.addEventListener('click', (e) => {
e.preventDefault();
disablePeer(peerId, 'video');
});
}
if (msgerPrivateToggleScreenBtn) {
msgerPrivateToggleScreenBtn.addEventListener('click', (e) => {
e.preventDefault();
disablePeer(peerId, 'screen');
});
}
if (msgerPrivateSelectFileBtn) {
msgerPrivateSelectFileBtn.addEventListener('click', (e) => {
e.preventDefault();
selectFileToShare(peerId);
});
}
if (msgerPrivateSendVideoUrlBtn) {
msgerPrivateSendVideoUrlBtn.addEventListener('click', (e) => {
e.preventDefault();
sendVideoUrl(peerId);
});
}
if (msgerPrivateRequestGeoBtn) {
msgerPrivateRequestGeoBtn.addEventListener('click', (e) => {
e.preventDefault();
geo.askPeerGeoLocation(peerId);
});
}
}
/**
* Check Message
* @param {string} txt passed text
* @returns {string} html format
*/
function checkMsg(txt) {
const text = filterXSS(txt);
if (text.trim().length == 0) return;
if (isHtml(text)) return sanitizeHtml(text);
if (isValidHttpURL(text)) {
if (isImageURL(text)) return getImage(text);
//if (isVideoTypeSupported(text)) return getIframe(text);
return getLink(text);
}
if (isChatMarkdownOn) return marked.parse(text);
if (isChatPasteTxt && getLineBreaks(text) > 1) {
isChatPasteTxt = false;
return getPre(text);
}
if (getLineBreaks(text) > 1) return getPre(text);
console.log('CheckMsg', text);
return text;
}
/**
* Sanitize Html
* @param {string} input code
* @returns Html as string
*/
function sanitizeHtml(input) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
};
return input.replace(/[&<>"'/]/g, (m) => map[m]);
}
/**
* Check if string contain html
* @param {string} str
* @returns
*/
function isHtml(str) {
let a = document.createElement('div');
a.innerHTML = str;
for (let c = a.childNodes, i = c.length; i--; ) {
if (c[i].nodeType == 1) return true;
}
return false;
}
/**
* Check if valid URL
* @param {string} str to check
* @returns boolean true/false
*/
function isValidHttpURL(input) {
try {
new URL(input);
return true;
} catch (_) {
return false;
}
}
/**
* Check if url passed is a image
* @param {string} url to check
* @returns {boolean} true/false
*/
function isImageURL(input) {
if (!input || typeof input !== 'string') return false;
try {
const url = new URL(input);
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg'].some((ext) =>
url.pathname.toLowerCase().endsWith(ext)
);
} catch (e) {
return false;
}
}
/**
* Check if Image File
* @return boolean
*/
function isImageFile(filename) {
return /(\.jpg|\.jpeg|\.png|\.gif|\.webp|\.bmp|\.tiff|\.svg)$/i.test(filename);
}
/**
* Get image
* @param {string} text
* @returns img
*/
function getImage(text) {
const url = filterXSS(text);
const div = document.createElement('div');
const img = document.createElement('img');
img.setAttribute('src', url);
img.setAttribute('width', '200px');
img.setAttribute('height', 'auto');
div.appendChild(img);
console.log('GetImg', div.firstChild.outerHTML);
return div.firstChild.outerHTML;
}
/**
* Get Link
* @param {string} text
* @returns a href
*/
function getLink(text) {
const url = filterXSS(text);
const a = document.createElement('a');
const div = document.createElement('div');
const linkText = document.createTextNode(url);
a.setAttribute('href', url);
a.setAttribute('target', '_blank');
a.appendChild(linkText);
div.appendChild(a);
console.log('GetLink', div.firstChild.outerHTML);
return div.firstChild.outerHTML;
}
/**
* Get pre
* @param {string} txt
* @returns pre
*/
function getPre(txt) {
const text = filterXSS(txt);
const pre = document.createElement('pre');
const div = document.createElement('div');
pre.textContent = text;
div.appendChild(pre);
console.log('GetPre', div.firstChild.outerHTML);
return div.firstChild.outerHTML;
}
/**
* Get IFrame from URL
* @param {string} text
* @returns html iframe
*/
function getIframe(text) {
const url = filterXSS(text);
const iframe = document.createElement('iframe');
const div = document.createElement('div');
const is_youtube = getVideoType(url) == 'na' ? true : false;
const video_audio_url = is_youtube ? getYoutubeEmbed(url) : url;
iframe.setAttribute('title', 'Chat-IFrame');
iframe.setAttribute('src', video_audio_url);
iframe.setAttribute('width', 'auto');
iframe.setAttribute('frameborder', '0');
iframe.setAttribute(
'allow',
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
);
iframe.setAttribute('allowfullscreen', 'allowfullscreen');
div.appendChild(iframe);
console.log('GetIFrame', div.firstChild.outerHTML);
return div.firstChild.outerHTML;
}
/**
* Get text Line breaks
* @param {string} text
* @returns integer lines
*/
function getLineBreaks(text) {
return (text.match(/\n/g) || []).length;
}
/**
* Check chat input line breaks and value length
*/
function checkLineBreaks() {
if (!msgerInput) return;
msgerInput.style.height = 'auto';
const minHeight = 52;
const maxHeight = 160;
const nextHeight = Math.min(Math.max(msgerInput.scrollHeight, minHeight), maxHeight);
msgerInput.style.height = `${nextHeight}px`;
}
/**
* Format date
* @param {object} date
* @returns {string} date format h:m:s
*/
function getFormatDate(date) {
const time = date.toTimeString().split(' ')[0];
return `${time}`;
}
/**
* Send message over Secure dataChannels
* @param {string} from peer name
* @param {string} fromAvatar peer avatar
* @param {string} to peer name
* @param {string} msg message to send
* @param {boolean} privateMsg if is a private message
* @param {string} id peer_id
*/
function emitMsg(from, fromAvatar, to, msg, privateMsg, id) {
if (!msg) return;
// sanitize all params
const getFrom = filterXSS(from);
const getFromAvatar = filterXSS(fromAvatar);
const getFromId = filterXSS(myPeerId);
const getTo = filterXSS(to);
const getMsg = filterXSS(msg);
const getPrivateMsg = filterXSS(privateMsg);
const getId = filterXSS(id);
const chatMessage = {
type: 'chat',
from: getFrom,
fromAvatar: getFromAvatar,
fromId: getFromId,
id: getId,
to: getTo,
msg: getMsg,
privateMsg: getPrivateMsg,
};
console.log('Send msg', chatMessage);
sendToDataChannel(chatMessage);
}
/**
* Read ChatGPT incoming message
* https://platform.openai.com/docs/introduction
* @param {string} msg
*/
async function getChatGPTmessage(msg) {
console.log('Send ChatGPT message:', msg);
signalingSocket
.request('data', {
room_id: roomId,
peer_id: myPeerId,
peer_name: myPeerName,
method: 'getChatGPT',
params: {
time: getDataTimeString(),
prompt: msg,
context: chatGPTcontext,
},
})
.then(
function (completion) {
if (!completion) return;
const { message, context } = completion;
chatGPTcontext = context ? context : [];
ensureChatGPTConversationEntry();
setPeerChatAvatarImgName('left', CHAT_GPT_NAME);
appendMessage(CHAT_GPT_NAME, images.chatgpt, 'left', message, true, null, myPeerName);
cleanMessageInput();
speechInMessages ? speechMessage(true, CHAT_GPT_NAME, message) : playSound('message');
}.bind(this)
)
.catch((err) => {
console.log('ChatGPT error:', err);
});
}
/**
* Hide - Show emoji picker div
*/
function hideShowEmojiPicker() {
if (!isChatEmojiVisible) {
elemDisplay(msgerEmojiPicker, true, 'block');
setColor(msgerEmojiBtn, '#FFFF00');
isChatEmojiVisible = true;
return;
}
elemDisplay(msgerEmojiPicker, false);
setColor(msgerEmojiBtn, '#FFFFFF');
isChatEmojiVisible = false;
}
/**
* Download chat messages grouped by conversation and participant in JSON format
*/
function downloadChatMsgs() {
const groupedConversations = [];
const groupedMessages = new Map();
chatMessages.forEach((message) => {
const isPrivate =
message.privateMsg === true ||
message.privateMsg === 'true' ||
message.privateMsg === 1 ||
message.privateMsg === '1';
const conversationPeer = message.conversationPeer || '';
const conversationKey = isPrivate && conversationPeer ? `private:${conversationPeer}` : 'public:room';
if (!groupedMessages.has(conversationKey)) {
groupedMessages.set(conversationKey, {
type: isPrivate ? 'private' : 'public',
title: isPrivate && conversationPeer ? `Private chat with ${conversationPeer}` : 'Room chat',
peer: conversationPeer || null,
participants: new Map(),
});
}
const conversation = groupedMessages.get(conversationKey);
if (!conversation.participants.has(message.from)) {
conversation.participants.set(message.from, []);
}
conversation.participants.get(message.from).push({
time: message.time,
from: message.from,
to: message.to || '',
msg: message.msg,
privateMsg: isPrivate,
});
});
groupedMessages.forEach((conversation) => {
const participants = [];
conversation.participants.forEach((messages, sender) => {
participants.push({
name: sender,
messages,
});
});
groupedConversations.push({
type: conversation.type,
title: conversation.title,
peer: conversation.peer,
participants,
});
});
const exportContent = JSON.stringify(
{
exportedAt: new Date().toISOString(),
roomId,
conversations: groupedConversations,
},
null,
2
);
let a = document.createElement('a');
a.href = 'data:text/json;charset=utf-8,' + encodeURIComponent(exportContent);
a.download = getDataTimeString() + '-CHAT.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
playSound('download');
}
/**
* Download Captions in json format
* https://developer.mozilla.org/it/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
*/
function downloadCaptions() {
let a = document.createElement('a');
a.href = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(transcripts, null, 1));
a.download = getDataTimeString() + roomId + '-CAPTIONS.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
playSound('download');
}
/**
* Hide - show my settings
*/
function hideShowMySettings() {
if (!isMySettingsVisible) {
playSound('newMessage');
// adapt it for mobile
if (isMobileDevice) {
mySettings.style.setProperty('width', '100%');
mySettings.style.setProperty('height', '100%');
setSP('--mySettings-select-w', '99%');
}
// my current peer name
myPeerNameSet.placeholder = myPeerName;
// center screen on show
mySettings.style.top = '50%';
mySettings.style.left = '50%';
elemDisplay(mySettings, true, 'block');
setTippy(mySettingsBtn, 'Close the settings', bottomButtonsPlacement);
isMySettingsVisible = true;
videoMediaContainer.style.opacity = 0.3;
screenReaderAccessibility.announceMessage('Settings opened');
return;
}
elemDisplay(mySettings, false);
setTippy(mySettingsBtn, 'Open the settings', bottomButtonsPlacement);
isMySettingsVisible = false;
videoMediaContainer.style.opacity = 1;
screenReaderAccessibility.announceMessage('Settings closed');
}
/**
* Handle html tab settings
* https://www.w3schools.com/howto/howto_js_tabs.asp
* @param {object} evt event
* @param {string} tabName name of the tab to open
*/
function openTab(evt, tabName) {
const tabN = getId(tabName);
const tabContent = getEcN('tabcontent');
const tabLinks = getEcN('tablinks');
let i;
for (i = 0; i < tabContent.length; i++) {
elemDisplay(tabContent[i], false);
}
for (i = 0; i < tabLinks.length; i++) {
tabLinks[i].className = tabLinks[i].className.replace(' active', '');
}
elemDisplay(tabN, true, 'block');
evt.currentTarget.className += ' active';
}
/**
* Update myPeerName to other peers in the room
*/
async function updateMyPeerName() {
// myNewPeerName empty
if (!myPeerNameSet.value) return;
// check if peer name is already in use in the room
if (await checkUserName(myPeerNameSet.value)) {
myPeerNameSet.value = '';
return userLog('warning', 'Username is already in use!');
}
// prevent xss execution itself
myPeerNameSet.value = filterXSS(myPeerNameSet.value);
// prevent XSS injection to remote peer
if (isHtml(myPeerNameSet.value)) {
myPeerNameSet.value = '';
return userLog('warning', 'Invalid name!');
}
const myNewPeerName = myPeerNameSet.value;
const myOldPeerName = myPeerName;
myPeerName = myNewPeerName;
myVideoPeerName.innerText = myPeerName + ' (me)';
myScreenPeerName = getId('myScreenPeerName');
if (myScreenPeerName) myScreenPeerName.innerText = myPeerName + ' (me)';
sendToServer('peerName', {
room_id: roomId,
peer_name_old: myOldPeerName,
peer_name_new: myPeerName,
peer_avatar: myPeerAvatar,
});
myPeerNameSet.value = '';
myPeerNameSet.placeholder = myPeerName;
window.localStorage.peer_name = myPeerName;
setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar);
setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar);
setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar);
userLog('toast', 'My name changed to ' + myPeerName);
}
/**
* Append updated peer name to video player
* @param {object} config data
*/
function handlePeerName(config) {
const { peer_id, peer_name, peer_avatar } = config;
const videoName = getId(peer_id + '_name');
const screenName = getId(peer_id + '_screen_name');
if (videoName) videoName.innerText = peer_name;
if (screenName) screenName.innerText = peer_name + ' (screen)';
// change also avatar and btn value - name on chat lists....
const msgerPeerName = getId(peer_id + '_pMsgBtn');
const msgerPeerAvatar = getId(peer_id + '_pMsgAvatar');
if (msgerPeerName) msgerPeerName.value = peer_name;
if (msgerPeerAvatar) {
msgerPeerAvatar.src =
peer_avatar && isImageURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
: genAvatarSvg(peer_name, 32);
}
// refresh also peer video avatar name
setPeerAvatarImgName(peer_id + '_avatar', peer_name, peer_avatar);
}
/**
* Send my Video-Audio-Hand... status
* @param {string} element typo
* @param {boolean} status true/false
*/
async function emitPeerStatus(element, status, extras = {}) {
sendToServer('peerStatus', {
room_id: roomId,
peer_name: myPeerName,
peer_id: myPeerId,
element: element,
status: status,
extras: extras,
});
}
/**
* Handle hide myself from room view
* @param {boolean} isHideMeActive
*/
function handleHideMe(isHideMeActive) {
const hideMeIcon = hideMeBtn.querySelector('i');
if (isHideMeActive) {
if (isVideoPinned) myVideoPinBtn.click();
elemDisplay(myVideoWrap, false);
setColor(hideMeIcon, 'red');
hideMeIcon.className = className.hideMeOn;
playSound('off');
} else {
elemDisplay(myVideoWrap, true, 'inline-block');
hideMeIcon.className = className.hideMeOff;
setColor(hideMeIcon, 'var(--btn-bar-bg-color)');
playSound('on');
}
resizeVideoMedia();
screenReaderAccessibility.announceMessage(
isHideMeActive ? 'You are hidden from the room' : 'You are visible in the room'
);
}
/**
* Set my Hand Status and Icon
*/
function setMyHandStatus() {
myHandStatus = !myHandStatus;
if (myHandStatus) {
// Raise hand
setColor(myHandBtn, '#FFD700');
elemDisplay(myHandStatusIcon, true);
setTippy(myHandBtn, 'Raise your hand', bottomButtonsPlacement);
playSound('raiseHand');
} else {
// Lower hand
setColor(myHandBtn, 'var(--btn-bar-bg-color)');
elemDisplay(myHandStatusIcon, false);
setTippy(myHandBtn, 'Lower your hand', bottomButtonsPlacement);
}
emitPeerStatus('hand', myHandStatus);
}
/**
* Set My Audio Status Icon and Title
* @param {boolean} status of my audio
*/
function setMyAudioStatus(status) {
console.log('My audio status', status);
const audioClassName = status ? className.audioOn : className.audioOff;
audioBtn.className = audioClassName;
myAudioStatusIcon.className = audioClassName;
// send my audio status to all peers in the room
emitPeerStatus('audio', status);
const audioStatusLabel = status ? 'My audio is on' : 'My audio is off';
setTippy(myAudioStatusIcon, audioStatusLabel, 'bottom');
setTippy(audioBtn, status ? 'Stop the audio' : 'Start the audio', bottomButtonsPlacement);
status ? playSound('on') : playSound('off');
screenReaderAccessibility.announceMessage(audioStatusLabel);
}
/**
* Set My Video Status Icon and Title
* @param {boolean} status of my video
*/
function setMyVideoStatus(status) {
console.log('My video status', status);
// On video OFF display my video avatar name
if (myVideoAvatarImage) {
elemDisplay(myVideoAvatarImage, status ? false : true, status ? undefined : 'block');
}
if (myVideoStatusIcon) {
setMediaButtonsClass([{ element: myVideoStatusIcon, status, mediaType: 'video' }]);
}
// send my video status to all peers in the room
emitPeerStatus('video', status);
const videoStatusLabel = status ? 'My video is on' : 'My video is off';
if (!isMobileDevice) {
if (myVideoStatusIcon) setTippy(myVideoStatusIcon, videoStatusLabel, 'bottom');
setTippy(videoBtn, status ? 'Stop the video' : 'Start the video', bottomButtonsPlacement);
}
if (status) {
displayElements([
{ element: myVideo, display: true, mode: 'block' },
{ element: initVideo, display: true, mode: 'block' },
]);
playSound('on');
} else {
displayElements([
{ element: myVideo, display: false },
{ element: initVideo, display: false },
]);
const myVideoWrap = getId('myVideoWrap');
const spinner = myVideoWrap ? myVideoWrap.querySelector('.video-loading-spinner') : null;
if (spinner) elemDisplay(spinner, false);
playSound('off');
}
screenReaderAccessibility.announceMessage(videoStatusLabel);
}
/**
* Handle peer audio - video - hand - privacy status
* @param {object} config data
*/
function handlePeerStatus(config) {
//
const { peer_id, peer_name, element, status, extras } = config;
switch (element) {
case 'video':
setPeerVideoStatus(peer_id, status);
break;
case 'screen':
setPeerScreenStatus(peer_id, status, extras);
break;
case 'audio':
setPeerAudioStatus(peer_id, status);
break;
case 'hand':
setPeerHandStatus(peer_id, peer_name, status);
break;
case 'privacy':
setVideoPrivacyStatus(peer_id + '___video', status);
break;
default:
break;
}
}
/**
* Set Participant Hand Status Icon and Title
* @param {string} peer_id socket.id
* @param {string} peer_name peer name
* @param {boolean} status of the hand
*/
function setPeerHandStatus(peer_id, peer_name, status) {
const peerHandStatus = getId(peer_id + '_handStatus');
if (status) {
elemDisplay(peerHandStatus, true);
userLog('toast', `${icons.user} ${peer_name} \n has raised the hand!`);
playSound('raiseHand');
} else {
elemDisplay(peerHandStatus, false);
}
}
/**
* Set Participant Audio Status and toggle Audio Volume
* @param {string} peer_id socket.id
* @param {boolean} status of peer audio
*/
function setPeerAudioStatus(peer_id, status) {
const peerAudioStatus = getId(peer_id + '_audioStatus');
const peerAudioVolume = getId(peer_id + '_audioVolume');
if (peerAudioStatus) {
setMediaButtonsClass([{ element: peerAudioStatus, status, mediaType: 'audio' }]);
setTippy(peerAudioStatus, status ? 'Participant audio is on' : 'Participant audio is off', 'bottom');
status ? playSound('on') : playSound('off');
}
if (peerAudioVolume) {
elemDisplay(peerAudioVolume, status);
}
}
/**
* Handle Peer audio volume 0/100
* @param {string} audioVolumeId audio volume input id
* @param {string} mediaId peer audio id
*/
function handleAudioVolume(audioVolumeId, mediaId) {
const media = getId(mediaId);
const audioVolume = getId(audioVolumeId);
if (audioVolume && media) {
audioVolume.style.maxWidth = '40px';
audioVolume.style.display = 'inline';
audioVolume.style.cursor = 'pointer';
audioVolume.value = 100;
audioVolume.addEventListener('input', () => {
media.volume = audioVolume.value / 100;
});
} else {
if (audioVolume) elemDisplay(audioVolume, false);
}
}
/**
* Mute Audio to specific user in the room
* @param {string} peer_id socket.id
*/
function handlePeerAudioBtn(peer_id) {
if (!buttons.remote.audioBtnClickAllowed) return;
const peerAudioBtn = getId(peer_id + '_audioStatus');
peerAudioBtn.onclick = () => {
if (peerAudioBtn.className === className.audioOn) {
isPresenter
? disablePeer(peer_id, 'audio')
: msgPopup('warning', 'Only the presenter can mute participants', 'top-end', 4000);
}
};
}
/**
* Hide Video to specified peer in the room
* @param {string} peer_id socket.id
*/
function handlePeerVideoBtn(peer_id) {
if (!useVideo || !buttons.remote.videoBtnClickAllowed) return;
const peerVideoBtn = getId(peer_id + '_videoStatus');
peerVideoBtn.onclick = () => {
if (peerVideoBtn.className === className.videoOn) {
isPresenter
? disablePeer(peer_id, 'video')
: msgPopup('warning', 'Only the presenter can hide participants', 'top-end', 4000);
}
};
}
function handlePeerGeoLocation(peer_id) {
const remoteGeoLocationBtn = getId(peer_id + '_geoLocation');
remoteGeoLocationBtn.onclick = () => {
isPresenter
? geo.askPeerGeoLocation(peer_id)
: msgPopup('warning', 'Only the presenter can ask geolocation to the participants', 'top-end', 4000);
};
}
/**
* Send Private Message to specific peer
* @param {string} peer_id socket.id
* @param {string} toPeerName peer name to send message
* @param {string} privateMsgBtnId private message button id
*/
function handlePeerPrivateMsg(peer_id, toPeerName, privateMsgBtnId) {
const peerPrivateMsg = getId(privateMsgBtnId);
peerPrivateMsg.onclick = (e) => {
e.preventDefault();
sendPrivateMsgToPeer(peer_id, toPeerName);
};
}
/**
* Send Private messages to peers
* @param {string} toPeerId
* @param {string} toPeerName
*/
function sendPrivateMsgToPeer(toPeerId, toPeerName) {
if (!isChatRoomVisible) {
showChatRoomDraggable();
}
setActiveConversation('private', toPeerName, toPeerId);
if (!isMobileDevice && canBePinned() && !isCaptionPinned) {
if (!isChatPinned) {
chatPin();
}
msgerDraggable.classList.remove('msger-pinned-sidebar-open');
msgerCPBtn.classList.remove('active');
isParticipantsVisible = false;
} else {
syncParticipantsPanelVisibility(false);
}
msgerInput.focus();
}
/**
* Handle peer send file
* @param {string} peer_id
* @param {string} fileShareBtnId
*/
function handlePeerSendFile(peer_id, fileShareBtnId) {
const peerFileSendBtn = getId(fileShareBtnId);
peerFileSendBtn.onclick = () => {
selectFileToShare(peer_id);
};
}
/**
* Send video - audio URL to specific peer
* @param {string} peer_id socket.id
* @param {string} peerYoutubeBtnId youtube button id
*/
function handlePeerVideoAudioUrl(peer_id, peerYoutubeBtnId) {
const peerYoutubeBtn = getId(peerYoutubeBtnId);
peerYoutubeBtn.onclick = () => {
sendVideoUrl(peer_id);
};
}
/**
* Set Participant Video Status Icon and Title
* @param {string} peer_id socket.id
* @param {boolean} status of peer video
*/
function setPeerVideoStatus(peer_id, status) {
const peerVideoPlayer = getId(peer_id + '___video');
const peerVideoAvatarImage = getId(peer_id + '_avatar');
const peerVideoStatus = getId(peer_id + '_videoStatus');
const peerVideoWrap = getId(peer_id + '_videoWrap');
if (status) {
displayElements([
{ element: peerVideoPlayer, display: true, mode: 'block' },
{ element: peerVideoAvatarImage, display: false },
]);
if (peerVideoStatus) {
setMediaButtonsClass([{ element: peerVideoStatus, status: true, mediaType: 'video' }]);
setTippy(peerVideoStatus, 'Participant video is on', 'bottom');
playSound('on');
}
} else {
displayElements([
{ element: peerVideoPlayer, display: false },
{ element: peerVideoAvatarImage, display: true, mode: 'block' },
]);
const spinner = peerVideoWrap ? peerVideoWrap.querySelector('.video-loading-spinner') : null;
if (spinner) elemDisplay(spinner, false);
if (peerVideoStatus) {
setMediaButtonsClass([{ element: peerVideoStatus, status: false, mediaType: 'video' }]);
setTippy(peerVideoStatus, 'Participant video is off', 'bottom');
playSound('off');
}
}
}
function setPeerScreenStatus(peer_id, status, extras) {
// Track screen status on the peer model
if (!allPeers[peer_id]) allPeers[peer_id] = {};
allPeers[peer_id]['peer_screen_status'] = !!status;
// Initialize extras object if not already present
if (!allPeers[peer_id]['extras']) {
allPeers[peer_id]['extras'] = {};
}
// Merge provided extras if any
if (extras && (extras.screen_track_id || extras.screen_stream_id)) {
allPeers[peer_id]['extras'].screen_track_id = extras.screen_track_id;
allPeers[peer_id]['extras'].screen_stream_id = extras.screen_stream_id;
}
}
/**
* Emit actions to all peers in the same room except yourself
* @param {object} peerAction to all peers
* @param {object} extras additional data
*/
async function emitPeersAction(peerAction, extras = {}) {
if (!thereArePeerConnections()) return;
sendToServer('peerAction', {
room_id: roomId,
peer_name: myPeerName,
peer_avatar: myPeerAvatar,
peer_id: myPeerId,
peer_uuid: myPeerUUID,
peer_use_video: useVideo,
peer_action: peerAction,
extras: extras,
send_to_all: true,
});
}
/**
* Emit actions to specified peer in the same room
* @param {string} peer_id socket.id
* @param {object} peerAction to specified peer
* @param {object} extras additional data
*/
async function emitPeerAction(peer_id, peerAction, extras = {}) {
if (!thereArePeerConnections()) return;
sendToServer('peerAction', {
room_id: roomId,
peer_id: peer_id,
peer_avatar: myPeerAvatar,
peer_use_video: useVideo,
peer_name: myPeerName,
peer_action: peerAction,
extras: extras,
send_to_all: false,
});
}
/**
* Handle received peer actions
* @param {object} config data
*/
function handlePeerAction(config) {
console.log('Handle peer action: ', config);
const { peer_id, peer_name, peer_avatar, peer_use_video, peer_action, extras } = config;
switch (peer_action) {
case 'muteAudio':
setMyAudioOff(peer_name);
break;
case 'hideVideo':
setMyVideoOff(peer_name);
break;
case 'stopScreen':
setMyScreenOff(peer_name);
break;
case 'recStart':
notifyRecording(peer_id, peer_name, peer_avatar, 'Start');
break;
case 'recStop':
notifyRecording(peer_id, peer_name, peer_avatar, 'Stop');
break;
case 'screenStart':
handleScreenStart(peer_id, extras);
break;
case 'screenStop':
handleScreenStop(peer_id, peer_use_video);
break;
case 'ejectAll':
handleKickedOut(config);
break;
default:
break;
}
}
/**
* Handle commands from the server
* @param {object} config data
*/
function handleCmd(config) {
console.log('Handle cmd: ', config);
const { action, data } = config;
switch (action) {
case 'geoLocation':
// Peer is requesting your location
geo.confirmPeerGeoLocation(data);
break;
case 'geoLocationOK':
case 'geoLocationKO':
// Peer responded with their location or an error/denial
geo.handleGeoPeerLocation(config);
break;
//....
default:
break;
}
}
/**
* Handle incoming message
* @param {object} message
*/
function handleMessage(message) {
console.log('Got message', message);
switch (message.type) {
case 'roomEmoji':
handleEmoji(message);
break;
//....
default:
break;
}
}
/**
* Handle room emoji reaction
* @param {object} message
* @param {integer} duration time in ms
*/
function handleEmoji(message, duration = 5000) {
if (userEmoji) {
const emojiDisplay = document.createElement('div');
emojiDisplay.className = 'animate__animated animate__backInUp';
emojiDisplay.style.padding = '10px';
emojiDisplay.style.fontSize = '2vh';
emojiDisplay.style.color = '#FFF';
emojiDisplay.style.backgroundColor = 'rgba(0, 0, 0, 0.2)';
emojiDisplay.style.borderRadius = '10px';
emojiDisplay.style.marginBottom = '5px';
emojiDisplay.innerText = `${message.emoji} ${message.peer_name}`;
userEmoji.appendChild(emojiDisplay);
setTimeout(() => {
emojiDisplay.remove();
}, duration);
handleEmojiSound(message);
}
}
/**
* Play emoji sound
* https://freesound.org/
* https://cloudconvert.com
* @param {object} message
*/
function handleEmojiSound(message) {
const path = '../sounds/emoji/';
const force = true; // play even if sound effects are off
switch (message.shortcodes) {
case ':+1:':
case ':ok_hand:':
playSound('ok', force, path);
break;
case ':-1:':
playSound('boo', force, path);
break;
case ':clap:':
playSound('applause', force, path);
break;
case ':smiley:':
case ':grinning:':
playSound('smile', force, path);
break;
case ':joy:':
playSound('laughs', force, path);
break;
case ':tada:':
playSound('congrats', force, path);
break;
case ':open_mouth:':
playSound('woah', force, path);
break;
case ':trumpet:':
playSound('trombone', force, path);
break;
case ':kissing_heart:':
playSound('kiss', force, path);
break;
case ':heart:':
case ':hearts:':
playSound('heart', force, path);
break;
case ':rocket:':
playSound('rocket', force, path);
break;
case ':sparkles:':
case ':star:':
case ':star2:':
case ':dizzy:':
playSound('tinkerbell', force, path);
break;
// ...
default:
break;
}
}
/**
* Handle Screen Start
* @param {string} peer_id
* @param {object} extras
*/
function handleScreenStart(peer_id, extras) {
const remoteScreenAvatarImage = getId(peer_id + '_screen_avatar');
const remoteScreenStatusBtn = getId(peer_id + '_screenStatus');
if (extras) {
// Initialize extras object if not already present
if (!allPeers[peer_id]) allPeers[peer_id] = {};
if (!allPeers[peer_id]['extras']) {
allPeers[peer_id]['extras'] = {};
}
allPeers[peer_id]['extras']['screen_track_id'] = extras.screen_track_id;
allPeers[peer_id]['extras']['screen_stream_id'] = extras.screen_stream_id;
// Also update peer screen status flag for fallback classification
allPeers[peer_id]['peer_screen_status'] = true;
console.log('[HANDLE SCREEN START] Stored screen IDs for', peer_id, extras);
}
if (remoteScreenStatusBtn) {
remoteScreenStatusBtn.className = className.videoOn;
setTippy(remoteScreenStatusBtn, 'Participant screen share is on', 'bottom');
}
if (remoteScreenAvatarImage) elemDisplay(remoteScreenAvatarImage, false);
}
/**
* Handle Screen Stop
* @param {string} peer_id
* @param {boolean} peer_use_video
*/
function handleScreenStop(peer_id, peer_use_video) {
const remoteScreenStream = getId(peer_id + '___screen');
const remoteScreenWrap = getId(peer_id + '_screenWrap');
const remoteScreenAvatarImage = getId(peer_id + '_screen_avatar');
const remoteScreenStatusBtn = getId(peer_id + '_screenStatus');
const remoteScreenPinUnpin = getId(peer_id + '_screen_pinUnpin');
if (remoteScreenStatusBtn) {
remoteScreenStatusBtn.className = className.videoOff;
setTippy(remoteScreenStatusBtn, 'Participant screen share is off', 'bottom');
}
// If the screen is pinned, unpin it first to restore grid layout
if (
remoteScreenWrap &&
isVideoPinned &&
pinnedVideoPlayerId === (remoteScreenStream ? remoteScreenStream.id : null)
) {
console.log('[STOP SCREEN] Unpinning remote screen before removal', peer_id);
if (remoteScreenPinUnpin) remoteScreenPinUnpin.click();
}
// Remove dedicated remote screen tile if present
if (remoteScreenWrap) {
remoteScreenWrap.remove();
adaptAspectRatio();
}
if (remoteScreenAvatarImage && remoteScreenStream && !peer_use_video) {
elemDisplay(remoteScreenAvatarImage, true, 'block');
remoteScreenStream.srcObject.getVideoTracks().forEach((track) => {
track.stop();
// track.enabled = false;
});
elemDisplay(remoteScreenStream, false);
} else {
if (remoteScreenAvatarImage) elemDisplay(remoteScreenAvatarImage, false);
}
// Clean up screen extras from allPeers
if (allPeers[peer_id]) {
if (allPeers[peer_id]['extras']) {
delete allPeers[peer_id]['extras']['screen_track_id'];
delete allPeers[peer_id]['extras']['screen_stream_id'];
}
// Update screen status flag
allPeers[peer_id]['peer_screen_status'] = false;
console.log('[HANDLE SCREEN STOP] Cleared screen IDs for', peer_id);
}
}
function confirmAudioOn(config) {
const { peer_name } = config;
}
function confirmVideoOn(config) {
const { peer_name } = config;
}
function confirmScreenOn(config) {
const { peer_name } = config;
}
/**
* Set my Audio off and Popup the peer name that performed this action
* @param {string} peer_name peer name
*/
function setMyAudioOff(peer_name) {
if (myAudioStatus === false || !useAudio) return;
const audioTrack = getAudioTrack(localAudioMediaStream);
if (audioTrack) {
audioTrack.enabled = false;
myAudioStatus = audioTrack.enabled;
} else {
myAudioStatus = false;
}
audioBtn.className = className.audioOff;
setMyAudioStatus(myAudioStatus);
userLog('toast', `${icons.user} ${peer_name} \n has disabled your audio`);
playSound('off');
}
/**
* Set my Audio on and Popup the peer name that performed this action
* @param {string} peer_name peer name
*/
function setMyAudioOn(peer_name) {
if (myAudioStatus === true || !useAudio) return;
const audioTrack = getAudioTrack(localAudioMediaStream);
if (audioTrack) {
audioTrack.enabled = true;
myAudioStatus = audioTrack.enabled;
} else {
myAudioStatus = false;
}
audioBtn.className = className.audioOn;
setMyAudioStatus(myAudioStatus);
userLog('toast', `${icons.user} ${peer_name} \n has enabled your audio`);
playSound('on');
}
/**
* Set my Video off and Popup the peer name that performed this action
* @param {string} peer_name peer name
*/
function setMyVideoOff(peer_name) {
if (!useVideo) return;
//if (myVideoStatus === false || !useVideo) return;
const videoTrack = getVideoTrack(localVideoMediaStream);
if (videoTrack) {
videoTrack.enabled = false;
myVideoStatus = videoTrack.enabled;
} else {
myVideoStatus = false;
}
videoBtn.className = className.videoOff;
setMyVideoStatus(myVideoStatus);
userLog('toast', `${icons.user} ${peer_name} \n has disabled your video`);
playSound('off');
}
/**
* Set my Screen off and Popup the peer name that performed this action
* @param {string} peer_name peer name
*/
function setMyScreenOff(peer_name) {
if (isScreenStreaming) {
toggleScreenSharing();
userLog('toast', `${icons.user} ${peer_name} \n has stopped your screen sharing`);
playSound('off');
}
}
/**
* Mute or Hide everyone except yourself
* @param {string} element type audio/video
*/
function disableAllPeers(element) {
if (!thereArePeerConnections()) {
return toastMessage('info', 'No participants detected', '', 'top');
}
Swal.fire({
background: swBg,
position: 'top',
imageUrl: element == 'audio' ? images.audioOff : images.videoOff,
title: element == 'audio' ? 'Mute everyone except yourself?' : 'Hide everyone except yourself?',
text:
element == 'audio'
? "Once muted, you won't be able to unmute them, but they can unmute themselves at any time."
: "Once hided, you won't be able to unhide them, but they can unhide themselves at any time.",
showDenyButton: true,
confirmButtonText: element == 'audio' ? `Mute` : `Hide`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
switch (element) {
case 'audio':
userLog('toast', 'Mute everyone 👍');
emitPeersAction('muteAudio');
break;
case 'video':
userLog('toast', 'Hide everyone 👍');
emitPeersAction('hideVideo');
break;
default:
break;
}
}
});
}
/**
* Eject all participants in the room expect yourself
*/
function ejectEveryone() {
if (!thereArePeerConnections()) {
return toastMessage('info', 'No participants detected', '', 'top');
}
Swal.fire({
background: swBg,
imageUrl: images.leave,
position: 'center',
title: 'Eject everyone except yourself?',
text: 'Are you sure to want eject all participants from the room?',
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
emitPeersAction('ejectAll');
}
});
}
/**
* Get active rooms from the server
*/
function getActiveRooms() {
openURL('/activeRooms', true);
}
/**
* Mute or Hide specific peer
* @param {string} peer_id socket.id
* @param {string} element type audio/video/screen
*/
function disablePeer(peer_id, element) {
if (!thereArePeerConnections()) {
return toastMessage('info', 'No participants detected', '', 'top');
}
let text, imageUrl, title, confirmButtonText;
switch (element) {
case 'audio':
imageUrl = images.audioOff;
title = 'Mute this participant?';
text = "Once muted, you won't be able to unmute them, but they can unmute themselves at any time.";
confirmButtonText = 'Mute';
break;
case 'video':
title = 'Hide this participant?';
imageUrl = images.videoOff;
text = "Once hided, you won't be able to unhide them, but they can unhide themselves at any time.";
confirmButtonText = 'Hide';
break;
case 'screen':
title = 'Stop screen sharing?';
imageUrl = images.screenOff;
text = "Once stopped, you wan't be able to start then, but they can start screen themselves at any time.";
confirmButtonText = 'Stop';
break;
default:
break;
}
Swal.fire({
background: swBg,
position: 'top',
imageUrl: imageUrl,
title: title,
text: text,
showDenyButton: true,
confirmButtonText: confirmButtonText,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
switch (element) {
case 'audio':
userLog('toast', 'Mute audio 👍');
emitPeerAction(peer_id, 'muteAudio');
break;
case 'video':
userLog('toast', 'Hide video 👍');
emitPeerAction(peer_id, 'hideVideo');
break;
case 'screen':
userLog('toast', 'Stop screen 👍');
emitPeerAction(peer_id, 'stopScreen');
break;
default:
break;
}
}
});
}
/**
* Handle Room action
* @param {object} config data
* @param {boolean} emit data to signaling server
*/
function handleRoomAction(config, emit = false) {
const { action } = config;
if (emit) {
const thisConfig = {
room_id: roomId,
peer_id: myPeerId,
peer_name: myPeerName,
peer_uuid: myPeerUUID,
action: action,
password: null,
};
switch (action) {
case 'lock':
playSound('newMessage');
Swal.fire({
allowOutsideClick: false,
allowEscapeKey: false,
showDenyButton: true,
background: swBg,
imageUrl: images.locked,
input: 'text',
inputPlaceholder: 'Set Room password',
confirmButtonText: `OK`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
inputValidator: (pwd) => {
if (!pwd) return 'Please enter the Room password';
thisRoomPassword = pwd;
},
}).then((result) => {
if (result.isConfirmed) {
thisConfig.password = thisRoomPassword;
sendToServer('roomAction', thisConfig);
handleRoomStatus(thisConfig);
}
});
break;
case 'unlock':
sendToServer('roomAction', thisConfig);
handleRoomStatus(thisConfig);
break;
default:
break;
}
} else {
// data coming from signaling server
handleRoomStatus(config);
}
}
/**
* Handle room status
* @param {object} config data
*/
function handleRoomStatus(config) {
const { action, peer_name, password } = config;
switch (action) {
case 'lock':
playSound('locked');
userLog('toast', `${icons.user} ${peer_name} \n has 🔒 LOCKED the room by password`, 'top-end');
elemDisplay(lockRoomBtn, false);
elemDisplay(unlockRoomBtn, true);
isRoomLocked = true;
screenReaderAccessibility.announceMessage(`${peer_name} locked the room`);
break;
case 'unlock':
userLog('toast', `${icons.user} ${peer_name} \n has 🔓 UNLOCKED the room`, 'top-end');
elemDisplay(unlockRoomBtn, false);
elemDisplay(lockRoomBtn, true);
isRoomLocked = false;
screenReaderAccessibility.announceMessage(`${peer_name} unlocked the room`);
break;
case 'checkPassword':
isRoomLocked = true;
password == 'OK' ? joinToChannel() : handleRoomLocked();
break;
default:
break;
}
}
/**
* Room is locked you provide a wrong password, can't access!
*/
function handleRoomLocked() {
playSound('eject');
console.log('Room is Locked, try with another one');
Swal.fire({
allowOutsideClick: false,
background: swBg,
position: 'center',
imageUrl: images.locked,
title: 'Oops, Wrong Room Password',
text: 'The room is locked, try with another one.',
showDenyButton: false,
confirmButtonText: `Ok`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) openURL('/newcall');
});
}
/**
* Try to unlock the room by providing a valid password
*/
function handleUnlockTheRoom() {
playSound('alert');
Swal.fire({
allowOutsideClick: false,
allowEscapeKey: false,
background: swBg,
imageUrl: images.locked,
title: 'Oops, Room is Locked',
input: 'text',
inputPlaceholder: 'Enter the Room password',
confirmButtonText: `OK`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
inputValidator: (pwd) => {
if (!pwd) return 'Please enter the Room password';
thisRoomPassword = pwd;
},
}).then(() => {
const config = {
room_id: roomId,
peer_name: myPeerName,
action: 'checkPassword',
password: thisRoomPassword,
};
sendToServer('roomAction', config);
elemDisplay(lockRoomBtn, false);
elemDisplay(unlockRoomBtn, true);
});
}
/**
* Handle whiteboard toogle
*/
function handleWhiteboardToggle() {
thereArePeerConnections() ? whiteboardAction(getWhiteboardAction('toggle')) : toggleWhiteboard();
}
/**
* Toggle Lock/Unlock whiteboard
*/
function toggleLockUnlockWhiteboard() {
wbIsLock = !wbIsLock;
const btnToShow = wbIsLock ? whiteboardUnlockBtn : whiteboardLockBtn;
const btnToHide = wbIsLock ? whiteboardLockBtn : whiteboardUnlockBtn;
const btnColor = wbIsLock ? 'red' : 'white';
const action = wbIsLock ? 'lock' : 'unlock';
elemDisplay(btnToShow, true, 'flex');
elemDisplay(btnToHide, false);
setColor(whiteboardUnlockBtn, btnColor);
whiteboardAction(getWhiteboardAction(action));
if (wbIsLock) {
userLog('toast', 'The whiteboard is locked. \n The participants cannot interact with it.');
playSound('locked');
}
}
/**
* Whiteboard: Show-Hide
*/
function toggleWhiteboard() {
if (!wbIsOpen) {
playSound('newMessage');
}
if (wbIsBgTransparent) setTheme();
whiteboard.classList.toggle('show');
centerWhiteboard();
wbIsOpen = !wbIsOpen;
screenReaderAccessibility.announceMessage(wbIsOpen ? 'Whiteboard opened' : 'Whiteboard closed');
}
/**
* Whiteboard: setup
*/
function setupWhiteboard() {
setupWhiteboardCanvas();
setupWhiteboardCanvasSize();
setupWhiteboardLocalListeners();
setupWhiteboardShortcuts();
setupWhiteboardDragAndDrop();
setupWhiteboardResizeListener();
}
/**
* Whiteboard: setup resize listener for responsive behavior
*/
function setupWhiteboardResizeListener() {
let resizeFrame;
window.addEventListener('resize', () => {
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
if (wbCanvas && wbIsOpen) {
setupWhiteboardCanvasSize();
}
});
});
// Also handle orientation change for mobile devices
window.addEventListener('orientationchange', () => {
setTimeout(() => {
if (wbCanvas && wbIsOpen) {
setupWhiteboardCanvasSize();
}
}, 300);
});
}
/**
* Whiteboard: draw grid on canvas
*/
function drawCanvasGrid() {
// Use reference dimensions for grid, zoom will handle scaling
const width = wbReferenceWidth;
const height = wbReferenceHeight;
removeCanvasGrid();
// Draw vertical lines
for (let i = 0; i <= width; i += wbGridSize) {
wbGridLines.push(createGridLine(i, 0, i, height));
}
// Draw horizontal lines
for (let i = 0; i <= height; i += wbGridSize) {
wbGridLines.push(createGridLine(0, i, width, i));
}
// Create a group for grid lines and send it to the back
const gridGroup = new fabric.Group(wbGridLines, { selectable: false, evented: false });
wbCanvas.add(gridGroup);
gridGroup.sendToBack();
wbCanvas.renderAll();
setColor(whiteboardGridBtn, 'green');
}
/**
* Create a grid line
*/
function createGridLine(x1, y1, x2, y2) {
return new fabric.Line([x1, y1, x2, y2], {
stroke: wbStroke,
selectable: false,
evented: false,
});
}
/**
* Whiteboard: remove grid lines from canvas
*/
function removeCanvasGrid() {
wbGridLines.forEach((line) => {
line.set({ stroke: wbGridVisible ? wbStroke : 'rgba(255, 255, 255, 0)' });
wbCanvas.remove(line);
});
wbGridLines = [];
wbCanvas.renderAll();
setColor(whiteboardGridBtn, 'white');
}
/**
* Whiteboard: toggle grid
*/
function toggleCanvasGrid() {
wbGridVisible = !wbGridVisible;
wbGridVisible ? drawCanvasGrid() : removeCanvasGrid();
wbCanvasToJson();
}
/**
* Whiteboard: setup canvas
*/
function setupWhiteboardCanvas() {
wbCanvas = new fabric.Canvas('wbCanvas');
wbCanvas.freeDrawingBrush.color = '#FFFFFF';
wbCanvas.freeDrawingBrush.width = 3;
whiteboardIsPencilMode(true);
}
/**
* Whiteboard: setup canvas size to always fit full screen with proper scaling
*/
function setupWhiteboardCanvasSize() {
// Get available viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Account for whiteboard container padding
const containerPadding = isMobileDevice ? 10 : 20; // 5px * 2 for mobile, 10px * 2 for desktop
// Header height varies by device
const headerHeight = isMobileDevice ? 40 : 60; // Smaller header on mobile
const extraMargin = 20; // Small margin to avoid any overflow
const availableWidth = viewportWidth - containerPadding - extraMargin;
const availableHeight = viewportHeight - containerPadding - headerHeight - extraMargin;
// Calculate scale factor to fit the viewport while maintaining aspect ratio
const scaleX = availableWidth / wbReferenceWidth;
const scaleY = availableHeight / wbReferenceHeight;
const scale = Math.min(scaleX, scaleY);
// Set canvas dimensions to scaled reference size
const canvasWidth = wbReferenceWidth * scale;
const canvasHeight = wbReferenceHeight * scale;
// Update canvas dimensions and zoom
wbCanvas.setWidth(canvasWidth);
wbCanvas.setHeight(canvasHeight);
wbCanvas.setZoom(scale);
// Update CSS variables for whiteboard container
// Add padding and header to get total container size
setWhiteboardSize(canvasWidth + containerPadding, canvasHeight + headerHeight + containerPadding);
// Recenter whiteboard on screen
centerWhiteboard();
// Recalculate offsets and render
wbCanvas.calcOffset();
wbCanvas.renderAll();
}
/**
* Whiteboard: center on screen
*/
function centerWhiteboard() {
if (whiteboard) {
// Force reflow to ensure centering is applied
whiteboard.style.top = '50%';
whiteboard.style.left = '50%';
whiteboard.style.transform = 'translate(-50%, -50%)';
}
}
/**
* Whiteboard: setup size
* @param {string} w width
* @param {string} h height
*/
function setWhiteboardSize(w, h) {
setSP('--wb-width', w);
setSP('--wb-height', h);
}
/**
* Set whiteboard background color
* @param {string} color whiteboard bg
*/
function setWhiteboardBgColor(color) {
const config = {
room_id: roomId,
peer_name: myPeerName,
action: 'bgcolor',
color: color,
};
whiteboardAction(config);
}
/**
* Reset all whiteboard mode
*/
function whiteboardResetAllMode() {
whiteboardIsPencilMode(false);
whiteboardIsVanishingMode(false);
whiteboardIsObjectMode(false);
whiteboardIsEraserMode(false);
}
/**
* Set whiteboard Pencil mode
*/
function whiteboardIsPencilMode(status) {
wbCanvas.isDrawingMode = status;
wbIsPencil = status;
setColor(whiteboardPencilBtn, wbIsPencil ? 'green' : 'white');
}
/**
* Set whiteboard Vanishing mode
*/
function whiteboardIsVanishingMode(status) {
wbCanvas.isDrawingMode = status;
wbIsVanishing = status;
wbCanvas.freeDrawingBrush.color = wbIsVanishing ? 'yellow' : wbDrawingColorEl.value;
setColor(whiteboardVanishingBtn, wbIsVanishing ? 'green' : 'white');
}
/**
* Set whiteboard Object mode
*/
function whiteboardIsObjectMode(status) {
wbIsObject = status;
setColor(whiteboardObjectBtn, status ? 'green' : 'white');
}
/**
* Set whiteboard Eraser mode
*/
function whiteboardIsEraserMode(status) {
wbIsEraser = status;
setColor(whiteboardEraserBtn, wbIsEraser ? 'green' : 'white');
}
/**
* Set color to specific element
* @param {object} elem element
* @param {string} color to set
*/
function setColor(elem, color) {
elem.style.color = color;
}
/**
* Whiteboard: Add object to canvas
* @param {string} type of object to add
*/
function whiteboardAddObj(type) {
wbCanvas.freeDrawingBrush.color = wbDrawingColorEl.value;
switch (type) {
case 'imgUrl':
Swal.fire({
background: swBg,
title: 'Image URL',
input: 'text',
showCancelButton: true,
confirmButtonText: 'OK',
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
let wbCanvasImgURL = result.value;
if (isImageURL(wbCanvasImgURL)) {
fabric.Image.fromURL(wbCanvasImgURL, function (myImg) {
addWbCanvasObj(myImg);
});
} else {
userLog('error', 'The URL is not a valid image');
}
}
});
break;
case 'imgFile':
setupFileSelection('Select the image', wbImageInput, renderImageToCanvas);
break;
case 'pdfFile':
setupFileSelection('Select the PDF', wbPdfInput, renderPdfToCanvas);
break;
case 'stickyNote':
createStickyNote();
break;
case 'text':
const text = new fabric.IText('Lorem Ipsum', {
top: 0,
left: 0,
fontFamily: 'Montserrat',
fill: wbCanvas.freeDrawingBrush.color,
strokeWidth: wbCanvas.freeDrawingBrush.width,
stroke: wbCanvas.freeDrawingBrush.color,
});
addWbCanvasObj(text);
break;
case 'line':
const line = new fabric.Line([50, 100, 200, 200], {
top: 0,
left: 0,
fill: wbCanvas.freeDrawingBrush.color,
strokeWidth: wbCanvas.freeDrawingBrush.width,
stroke: wbCanvas.freeDrawingBrush.color,
});
addWbCanvasObj(line);
break;
case 'circle':
const circle = new fabric.Circle({
radius: 50,
fill: 'transparent',
stroke: wbCanvas.freeDrawingBrush.color,
strokeWidth: wbCanvas.freeDrawingBrush.width,
});
addWbCanvasObj(circle);
break;
case 'rect':
const rect = new fabric.Rect({
top: 0,
left: 0,
width: 150,
height: 100,
fill: 'transparent',
stroke: wbCanvas.freeDrawingBrush.color,
strokeWidth: wbCanvas.freeDrawingBrush.width,
});
addWbCanvasObj(rect);
break;
case 'triangle':
const triangle = new fabric.Triangle({
top: 0,
left: 0,
width: 150,
height: 100,
fill: 'transparent',
stroke: wbCanvas.freeDrawingBrush.color,
strokeWidth: wbCanvas.freeDrawingBrush.width,
});
addWbCanvasObj(triangle);
break;
default:
break;
}
}
/**
* Whiteboard delte object
*/
function whiteboardDeleteObject() {
const obj = wbCanvas?.getActiveObject?.();
if (!obj) return;
// Ignore if typing in input (unless editing Fabric text)
const tag = document.activeElement?.tagName;
if ((tag === 'INPUT' || tag === 'TEXTAREA') && !obj.isEditing) return;
if (obj.isEditing && obj.exitEditing) obj.exitEditing();
whiteboardEraseObject();
return;
}
/**
* Whiteboard erase object
*/
function whiteboardEraseObject() {
if (wbCanvas && typeof wbCanvas.getActiveObjects === 'function') {
const activeObjects = wbCanvas.getActiveObjects();
if (activeObjects && activeObjects.length > 0) {
// Remove all selected objects
activeObjects.forEach((obj) => {
wbCanvas.remove(obj);
});
wbCanvas.discardActiveObject();
wbCanvas.requestRenderAll();
wbCanvasToJson();
}
}
}
/**
* Whoteboard clone object
*/
function whiteboardCloneObject() {
if (wbCanvas && typeof wbCanvas.getActiveObjects === 'function') {
const activeObjects = wbCanvas.getActiveObjects();
if (activeObjects && activeObjects.length > 0) {
activeObjects.forEach((obj, idx) => {
obj.clone((cloned) => {
// Offset each clone for visibility
cloned.set({
left: obj.left + 30 + idx * 10,
top: obj.top + 30 + idx * 10,
evented: true,
});
wbCanvas.add(cloned);
wbCanvas.setActiveObject(cloned);
wbCanvasToJson();
});
});
wbCanvas.requestRenderAll();
}
}
}
/**
* Whiteboard vanishong objects
*/
function wbHandleVanishingObjects() {
if (wbIsVanishing && wbCanvas._objects.length > 0) {
const obj = wbCanvas._objects[wbCanvas._objects.length - 1];
if (obj && obj.type === 'path') {
wbVanishingObjects.push(obj);
const fadeDuration = 1000,
vanishTimeout = 5000;
setTimeout(() => {
const start = performance.now();
function fade(ts) {
const p = Math.min((ts - start) / fadeDuration, 1);
obj.set('opacity', 1 - p);
wbCanvas.requestRenderAll();
if (p < 1) requestAnimationFrame(fade);
}
requestAnimationFrame(fade);
}, vanishTimeout - fadeDuration);
setTimeout(() => {
wbCanvas.remove(obj);
wbCanvas.renderAll();
wbCanvasToJson();
wbVanishingObjects.splice(wbVanishingObjects.indexOf(obj), 1);
}, vanishTimeout);
}
}
}
/**
* Whoteboard create sticky note
*/
function createStickyNote() {
Swal.fire({
background: swBg,
title: 'Create Sticky Note',
html: `
`,
showCancelButton: true,
confirmButtonText: 'Create',
cancelButtonText: 'Cancel',
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
preConfirm: () => {
return {
text: getId('stickyNoteText').value,
color: getId('stickyNoteColor').value,
textColor: getId('stickyNoteTextColor').value,
};
},
didOpen: () => {
// Focus textarea for quick typing
setTimeout(() => {
getId('stickyNoteText').focus();
}, 100);
},
}).then((result) => {
if (result.isConfirmed) {
const noteData = result.value;
// Create sticky note background (rectangle)
const noteRect = new fabric.Rect({
left: 100,
top: 100,
width: 220,
height: 160,
fill: noteData.color,
shadow: 'rgba(0,0,0,0.18) 0px 4px 12px',
rx: 14,
ry: 14,
});
// Create text for sticky note
const noteText = new fabric.Textbox(noteData.text, {
left: 110,
top: 110,
width: 200,
fontSize: 18,
fontFamily: 'Segoe UI, Arial, sans-serif',
fill: noteData.textColor,
textAlign: 'left',
editable: true,
fontWeight: 'bold',
shadow: new fabric.Shadow({
color: 'rgba(255,255,255,0.18)',
blur: 2,
offsetX: 1,
offsetY: 1,
}),
padding: 8,
cornerSize: 8,
});
// Group rectangle and text together
const stickyNoteGroup = new fabric.Group([noteRect, noteText], {
left: 100,
top: 100,
selectable: true,
hasControls: true,
hoverCursor: 'pointer',
});
// Make the text editable by handling double-click events
stickyNoteGroup.on('mousedblclick', function () {
noteText.enterEditing();
noteText.hiddenTextarea && noteText.hiddenTextarea.focus();
});
// Exit editing when clicking outside the noteText
wbCanvas.on('mouse:down', function (e) {
if (noteText.isEditing && e.target !== noteText) {
noteText.exitEditing();
}
});
addWbCanvasObj(stickyNoteGroup);
}
});
}
/**
* Setup Canvas file selections
* @param {string} title
* @param {string} accept
* @param {object} renderToCanvas
*/
function setupFileSelection(title, accept, renderToCanvas) {
Swal.fire({
allowOutsideClick: false,
background: swBg,
position: 'center',
title: title,
input: 'file',
html: `
Drag and drop your file here
`,
inputAttributes: {
accept: accept,
'aria-label': title,
},
didOpen: () => {
const dropArea = document.getElementById('dropArea');
dropArea.addEventListener('dragenter', handleDragEnter);
dropArea.addEventListener('dragover', handleDragOver);
dropArea.addEventListener('dragleave', handleDragLeave);
dropArea.addEventListener('drop', handleDrop);
},
showDenyButton: true,
confirmButtonText: `OK`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
renderToCanvas(result.value);
}
});
function handleDragEnter(e) {
e.preventDefault();
e.stopPropagation();
e.target.style.background = 'var(--body-bg)';
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}
function handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
e.target.style.background = '';
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
e.target.style.background = '';
}
function handleFiles(files) {
if (files.length > 0) {
const file = files[0];
console.log('Selected file:', file);
Swal.close();
renderToCanvas(file);
}
}
}
/**
* Render Image file to Canvas
* @param {object} wbCanvasImg
*/
function renderImageToCanvas(wbCanvasImg) {
if (wbCanvasImg && wbCanvasImg.size > 0) {
let reader = new FileReader();
reader.onload = function (event) {
let imgObj = new Image();
imgObj.src = event.target.result;
imgObj.onload = function () {
let image = new fabric.Image(imgObj);
image.set({ top: 0, left: 0 }).scale(0.3);
addWbCanvasObj(image);
};
};
reader.readAsDataURL(wbCanvasImg);
}
}
/**
* Render PDF file to Canvas
* @param {object} wbCanvasPdf
*/
async function renderPdfToCanvas(wbCanvasPdf) {
if (wbCanvasPdf && wbCanvasPdf.size > 0) {
let reader = new FileReader();
reader.onload = async function (event) {
wbCanvas.requestRenderAll();
await pdfToImage(event.target.result, wbCanvas);
whiteboardResetAllMode();
whiteboardIsObjectMode(false);
wbCanvasToJson();
};
reader.readAsDataURL(wbCanvasPdf);
}
}
/**
* Promisify the FileReader
* @param {object} blob
* @returns object Data URL
*/
function readBlob(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', reject);
reader.readAsDataURL(blob);
});
}
/**
* Load PDF and return an array of canvases
* @param {object} pdfData
* @param {object} pages
* @returns canvas object
*/
async function loadPDF(pdfData, pages) {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
pdfData = pdfData instanceof Blob ? await readBlob(pdfData) : pdfData;
const data = atob(pdfData.startsWith(Base64Prefix) ? pdfData.substring(Base64Prefix.length) : pdfData);
try {
const pdf = await pdfjsLib.getDocument({ data }).promise;
const numPages = pdf.numPages;
const canvases = await Promise.all(
Array.from({ length: numPages }, (_, i) => {
const pageNumber = i + 1;
if (pages && pages.indexOf(pageNumber) === -1) return null;
return pdf.getPage(pageNumber).then(async (page) => {
const viewport = page.getViewport({ scale: window.devicePixelRatio });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext).promise;
return canvas;
});
})
);
return canvases.filter((canvas) => canvas !== null);
} catch (error) {
console.error('Error loading PDF:', error);
throw error;
}
}
/**
* Convert PDF to fabric.js images and add to canvas
* @param {object} pdfData
* @param {object} canvas
*/
async function pdfToImage(pdfData, canvas) {
const scale = 1 / window.devicePixelRatio;
try {
const canvases = await loadPDF(pdfData);
canvases.forEach(async (c) => {
canvas.add(
new fabric.Image(await c, {
scaleX: scale,
scaleY: scale,
})
);
});
} catch (error) {
console.error('Error converting PDF to images:', error);
throw error;
}
}
/**
* Whiteboard: add object
* @param {object} obj to add
*/
function addWbCanvasObj(obj) {
if (obj) {
wbCanvas.add(obj).setActiveObject(obj);
whiteboardResetAllMode();
whiteboardIsObjectMode(true);
wbCanvasToJson();
} else {
console.error('Invalid input. Expected an obj of canvas elements');
}
}
/**
* Whiteboard: Local listners
*/
function setupWhiteboardLocalListeners() {
wbCanvas.on('mouse:down', function (e) {
mouseDown(e);
});
wbCanvas.on('mouse:up', function () {
mouseUp();
});
wbCanvas.on('mouse:move', function () {
mouseMove();
});
wbCanvas.on('object:added', function () {
objectAdded();
});
}
/**
* Whiteboard: mouse down
* @param {object} e event
* @returns
*/
function mouseDown(e) {
wbIsDrawing = true;
if (wbIsEraser && e.target) {
// Don't add vanishing objects to redo stack
if (!wbVanishingObjects.includes(e.target)) {
wbPop.push(e.target); // To allow redo
}
wbCanvas.remove(e.target);
return;
}
}
/**
* Whiteboard: mouse up
*/
function mouseUp() {
wbIsDrawing = false;
wbCanvasToJson();
}
/**
* Whiteboard: mouse move
* @returns
*/
function mouseMove() {
if (wbIsEraser) {
wbCanvas.hoverCursor = 'not-allowed';
return;
} else {
wbCanvas.hoverCursor = 'move';
}
if (!wbIsDrawing) return;
}
/**
* Whiteboard: tmp objects
*/
function objectAdded() {
if (!wbIsRedoing) wbPop = [];
wbIsRedoing = false;
wbHandleVanishingObjects();
}
/**
* Whiteboard: set background color
* @param {string} color to set
*/
function wbCanvasBackgroundColor(color) {
setSP('--wb-bg', color);
wbBackgroundColorEl.value = color;
wbCanvas.setBackgroundColor(color);
wbCanvas.renderAll();
}
/**
* Whiteboard: undo
*/
function wbCanvasUndo() {
if (wbCanvas._objects.length > 0) {
const obj = wbCanvas._objects.pop();
// Don't add vanishing objects to redo stack
if (!wbVanishingObjects.includes(obj)) {
wbPop.push(obj);
}
wbCanvas.renderAll();
}
}
/**
* Whiteboard: redo
*/
function wbCanvasRedo() {
if (wbPop.length > 0) {
wbIsRedoing = true;
wbCanvas.add(wbPop.pop());
}
}
/**
* Whiteboard: clear
*/
function wbCanvasClear() {
wbCanvas.clear();
wbCanvas.renderAll();
}
/**
* Whiteboard: save as images png
*/
function wbCanvasSaveImg() {
const dataURL = wbCanvas.toDataURL({
width: wbCanvas.getWidth(),
height: wbCanvas.getHeight(),
left: 0,
top: 0,
format: 'png',
});
const dataNow = getDataTimeString();
const fileName = `whiteboard-${dataNow}.png`;
saveDataToFile(dataURL, fileName);
playSound('ok');
}
/**
* Whiteboard: save data to file
* @param {object} dataURL to download
* @param {string} fileName to save
*/
function saveDataToFile(dataURL, fileName) {
const a = document.createElement('a');
elemDisplay(a, false);
a.href = dataURL;
a.download = fileName;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(dataURL);
}, 100);
}
/**
* Whiteboard: canvas objects to json
*/
function wbCanvasToJson() {
if (!isPresenter && wbIsLock) return;
if (thereArePeerConnections()) {
const config = {
room_id: roomId,
wbCanvasJson: JSON.stringify(wbCanvas.toJSON()),
};
sendToServer('wbCanvasToJson', config);
}
}
/**
* If whiteboard opened, update canvas to all peers connected
*/
async function wbUpdate() {
if (wbIsOpen && thereArePeerConnections()) {
wbCanvasToJson();
whiteboardAction(getWhiteboardAction(wbIsLock ? 'lock' : 'unlock'));
}
}
/**
* Whiteboard: json to canvas objects
* @param {object} config data
*/
function handleJsonToWbCanvas(config) {
if (!wbIsOpen) toggleWhiteboard();
wbIsRedoing = true;
// Parse the JSON and load it
wbCanvas.loadFromJSON(config.wbCanvasJson, function () {
// After loading, ensure proper scaling is maintained
setupWhiteboardCanvasSize();
wbIsRedoing = false;
});
if (!isPresenter && !wbCanvas.isDrawingMode && wbIsLock) {
wbDrawing(false);
}
}
/**
* Whiteboard: actions
* @param {string} action whiteboard action
* @returns {object} data
*/
function getWhiteboardAction(action) {
return {
room_id: roomId,
peer_name: myPeerName,
action: action,
};
}
/**
* Whiteboard: Clean content
*/
function confirmCleanBoard() {
playSound('newMessage');
Swal.fire({
background: swBg,
imageUrl: images.delete,
position: 'top',
title: 'Clean the board',
text: 'Are you sure you want to clean the board?',
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
whiteboardAction(getWhiteboardAction('clear'));
playSound('delete');
}
});
}
/**
* Whiteboard: actions
* @param {object} config data
*/
function whiteboardAction(config) {
if (thereArePeerConnections()) {
sendToServer('whiteboardAction', config);
}
handleWhiteboardAction(config, false);
}
/**
* Whiteboard: handle actions
* @param {object} config data
* @param {boolean} logMe popup action
*/
function handleWhiteboardAction(config, logMe = true) {
const { peer_name, action, color } = config;
if (logMe) {
userLog('toast', `${icons.user} ${peer_name} \n whiteboard action: ${action}`);
}
switch (action) {
case 'bgcolor':
wbCanvasBackgroundColor(color);
break;
case 'undo':
wbCanvasUndo();
break;
case 'redo':
wbCanvasRedo();
break;
case 'clear':
wbCanvasClear();
removeCanvasGrid();
break;
case 'toggle':
toggleWhiteboard();
break;
case 'lock':
if (!isPresenter) {
elemDisplay(whiteboardTitle, false);
elemDisplay(whiteboardOptions, false);
elemDisplay(whiteboardBtn, false);
wbDrawing(false);
wbIsLock = true;
}
break;
case 'unlock':
if (!isPresenter) {
elemDisplay(whiteboardTitle, true, 'flex');
elemDisplay(whiteboardOptions, true, 'flex');
elemDisplay(whiteboardBtn, true);
wbDrawing(true);
wbIsLock = false;
}
break;
//...
default:
break;
}
}
/**
* Toggle whiteboard drawing mode
* @param {boolean} status
*/
function wbDrawing(status) {
wbCanvas.isDrawingMode = status; // Disable free drawing
wbCanvas.selection = status; // Disable object selection
wbCanvas.forEachObject(function (obj) {
obj.selectable = status; // Make all objects unselectable
});
}
/**
* Show whiteboard shortcuts
*/
function showWhiteboardShortcuts() {
if (!whiteboardShortcutsContent) {
console.error('Whiteboard shortcuts content not found');
return;
}
Swal.fire({
background: swBg,
position: 'center',
title: 'Whiteboard Shortcuts',
html: whiteboardShortcutsContent.innerHTML,
confirmButtonText: 'Got it!',
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
}
/**
* Setup whiteboard drag and drop
* @returns void
*/
function setupWhiteboardDragAndDrop() {
if (!wbCanvas) return;
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
wbCanvas.upperCanvasEl.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop area
['dragenter', 'dragover'].forEach((eventName) => {
wbCanvas.upperCanvasEl.addEventListener(
eventName,
() => {
wbCanvas.upperCanvasEl.style.border = '1px dashed #fff';
},
false
);
});
['dragleave', 'drop'].forEach((eventName) => {
wbCanvas.upperCanvasEl.addEventListener(
eventName,
() => {
wbCanvas.upperCanvasEl.style.border = '';
},
false
);
});
// Handle dropped files
wbCanvas.upperCanvasEl.addEventListener('drop', handleWhiteboardDrop, false);
}
/**
* Handle whiteboard drop
* @param {object} e event
*/
function handleWhiteboardDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length === 0) return;
const file = files[0];
const fileType = file.type;
switch (true) {
case fileType.startsWith('image/'):
renderImageToCanvas(file);
break;
case fileType === 'application/pdf':
renderPdfToCanvas(file);
break;
default:
userLog('warning', `Unsupported file type: ${fileType}. Please drop an image or PDF file.`, 6000);
break;
}
}
/**
* Setup whiteboard keyboard shortcuts
*/
function setupWhiteboardShortcuts() {
document.addEventListener('keydown', (event) => {
if (!wbIsOpen) return;
// Whiteboard clone shortcut: Cmd+C/Ctrl+C
if ((event.key === 'c' || event.key === 'C') && (event.ctrlKey || event.metaKey)) {
whiteboardCloneObject();
event.preventDefault();
return;
}
// Whiteboard erase shortcut: Cmd+X/Ctrl+X
if ((event.key === 'x' || event.key === 'X') && (event.ctrlKey || event.metaKey)) {
whiteboardEraseObject();
event.preventDefault();
return;
}
// Whiteboard undo shortcuts: Cmd+Z/Ctrl+Z
if ((event.key === 'z' || event.key === 'Z') && (event.ctrlKey || event.metaKey) && !event.shiftKey) {
whiteboardAction(getWhiteboardAction('undo'));
event.preventDefault();
return;
}
// Whiteboard Redo shortcuts: Cmd+Shift+Z/Ctrl+Shift+Z or Cmd+Y/Ctrl+Y
if (
((event.key === 'z' || event.key === 'Z') && (event.ctrlKey || event.metaKey) && event.shiftKey) ||
((event.key === 'y' || event.key === 'Y') && (event.ctrlKey || event.metaKey))
) {
whiteboardAction(getWhiteboardAction('redo'));
event.preventDefault();
return;
}
// Whiteboard delete shortcut: Delete / Backspace
if (event.key === 'Delete' || event.key === 'Backspace') {
whiteboardDeleteObject();
event.preventDefault();
return;
}
// Use event.code and check for Alt+Meta (Mac) or Alt+Ctrl (Windows/Linux)
if (event.code && event.altKey && (event.ctrlKey || event.metaKey) && !event.shiftKey) {
switch (event.code) {
case 'KeyT': // Text
whiteboardAddObj('text');
event.preventDefault();
break;
case 'KeyL': // Line
whiteboardAddObj('line');
event.preventDefault();
break;
case 'KeyC': // Circle
whiteboardAddObj('circle');
event.preventDefault();
break;
case 'KeyR': // Rectangle
whiteboardAddObj('rect');
event.preventDefault();
break;
case 'KeyG': // Triangle (G for Geometry)
whiteboardAddObj('triangle');
event.preventDefault();
break;
case 'KeyN': // Sticky Note
whiteboardAddObj('stickyNote');
event.preventDefault();
break;
case 'KeyU': // Image (from URL)
whiteboardAddObj('imgUrl');
event.preventDefault();
break;
case 'KeyV': // Vanishing Pen
whiteboardResetAllMode();
whiteboardIsVanishingMode(!wbIsVanishing);
event.preventDefault();
break;
case 'KeyI': // Image (from file)
whiteboardAddObj('imgFile');
event.preventDefault();
break;
case 'KeyP': // PDF (from file)
whiteboardAddObj('pdfFile');
event.preventDefault();
break;
case 'KeyQ': // Clear Board
confirmCleanBoard();
event.preventDefault();
break;
default:
break;
}
}
});
}
/**
* Create File Sharing Data Channel
* @param {string} peer_id socket.id
*/
function createFileSharingDataChannel(peer_id) {
fileDataChannels[peer_id] = peerConnections[peer_id].createDataChannel('mirotalk_file_sharing_channel');
fileDataChannels[peer_id].binaryType = 'arraybuffer';
fileDataChannels[peer_id].onopen = (event) => {
console.log('fileDataChannels created', event);
};
}
/**
* Handle File Sharing
* @param {object} data received
*/
function handleDataChannelFileSharing(data) {
if (!receiveInProgress) return;
receiveBuffer.push(data);
receivedSize += data.byteLength;
receiveProgress.value = receivedSize;
receiveFilePercentage.innerText =
'Receive progress: ' + ((receivedSize / incomingFileInfo.file.fileSize) * 100).toFixed(2) + '%';
if (receivedSize === incomingFileInfo.file.fileSize) {
elemDisplay(receiveFileDiv, false);
incomingFileData = receiveBuffer;
receiveBuffer = [];
endDownload();
}
}
/**
* Send File Data trought datachannel
* https://webrtc.github.io/samples/src/content/datachannel/filetransfer/
* https://github.com/webrtc/samples/blob/gh-pages/src/content/datachannel/filetransfer/js/main.js
*
* @param {string} peer_id peer id
* @param {boolean} broadcast sent to all or not
*/
function sendFileData(peer_id, broadcast) {
console.log('Send file ' + fileToSend.name + ' size ' + bytesToSize(fileToSend.size) + ' type ' + fileToSend.type);
sendInProgress = true;
sendFileInfo.innerText =
'File name: ' +
fileToSend.name +
'\n' +
'File type: ' +
fileToSend.type +
'\n' +
'File size: ' +
bytesToSize(fileToSend.size) +
'\n';
elemDisplay(sendFileDiv, true);
sendProgress.max = fileToSend.size;
fileReader = new FileReader();
let offset = 0;
fileReader.addEventListener('error', (err) => console.error('fileReader error', err));
fileReader.addEventListener('abort', (e) => console.log('fileReader aborted', e));
fileReader.addEventListener('load', (e) => {
if (!sendInProgress) return;
// peer to peer over DataChannels
const data = {
peer_id: peer_id,
broadcast: broadcast,
fileData: e.target.result,
};
sendFSData(data);
offset += data.fileData.byteLength;
sendProgress.value = offset;
sendFilePercentage.innerText = 'Send progress: ' + ((offset / fileToSend.size) * 100).toFixed(2) + '%';
// send file completed
if (offset === fileToSend.size) {
sendInProgress = false;
elemDisplay(sendFileDiv, false);
userLog('success', 'The file ' + fileToSend.name + ' was sent successfully.');
}
if (offset < fileToSend.size) readSlice(offset);
});
const readSlice = (o) => {
for (const peer_id in fileDataChannels) {
// https://stackoverflow.com/questions/71285807/i-am-trying-to-share-a-file-over-webrtc-but-after-some-time-it-stops-and-log-rt
if (fileDataChannels[peer_id].bufferedAmount > fileDataChannels[peer_id].bufferedAmountLowThreshold) {
fileDataChannels[peer_id].onbufferedamountlow = () => {
fileDataChannels[peer_id].onbufferedamountlow = null;
readSlice(0);
};
return;
}
}
const slice = fileToSend.slice(offset, o + chunkSize);
fileReader.readAsArrayBuffer(slice);
};
readSlice(0);
}
/**
* Send File through RTC Data Channels
* @param {object} data to sent
*/
function sendFSData(data) {
const broadcast = data.broadcast;
const peer_id_to_send = data.peer_id;
if (broadcast) {
// send to all peers
for (const peer_id in fileDataChannels) {
if (fileDataChannels[peer_id].readyState === 'open') fileDataChannels[peer_id].send(data.fileData);
}
} else {
// send to peer
for (const peer_id in fileDataChannels) {
if (peer_id_to_send == peer_id && fileDataChannels[peer_id].readyState === 'open') {
fileDataChannels[peer_id].send(data.fileData);
}
}
}
}
/**
* Abort the file transfer
*/
function abortFileTransfer() {
if (fileReader && fileReader.readyState === 1) {
fileReader.abort();
elemDisplay(sendFileDiv, false);
sendInProgress = false;
sendToServer('fileAbort', {
room_id: roomId,
peer_name: myPeerName,
});
}
}
/**
* Abort file transfer
*/
function abortReceiveFileTransfer() {
sendToServer('fileReceiveAbort', {
room_id: roomId,
peer_name: myPeerName,
});
}
/**
* Handle abort file transfer
* @param object config - peer info that abort the file transfer
*/
function handleAbortFileTransfer(config) {
console.log(`File transfer aborted by ${config.peer_name}`);
userLog('toast', `⚠️ File transfer aborted by ${config.peer_name}`);
abortFileTransfer();
}
/**
* File Transfer aborted by peer
*/
function handleFileAbort() {
receiveBuffer = [];
incomingFileData = [];
receivedSize = 0;
receiveInProgress = false;
elemDisplay(receiveFileDiv, false);
console.log('File transfer aborted');
userLog('toast', '⚠️ File transfer aborted');
}
/**
* Hide incoming file transfer
*/
function hideFileTransfer() {
elemDisplay(receiveFileDiv, false);
}
/**
* Select or Drag and Drop the File to Share
* @param {string} peer_id
* @param {boolean} broadcast send to all (default false)
*/
function selectFileToShare(peer_id, broadcast = false, peerName = '') {
playSound('newMessage');
const targetLabel = !broadcast && peerName ? ` with ${peerName}` : '';
Swal.fire({
allowOutsideClick: false,
background: swBg,
imageAlt: 'mirotalk-file-sharing',
imageUrl: images.share,
position: 'center',
title: `Share file${targetLabel}`,
input: 'file',
html: `
Drag and drop your file here
`,
inputAttributes: {
accept: fileSharingInput,
'aria-label': 'Select file',
},
didOpen: () => {
const dropArea = getId('dropArea');
dropArea.addEventListener('dragenter', handleDragEnter);
dropArea.addEventListener('dragover', handleDragOver);
dropArea.addEventListener('dragleave', handleDragLeave);
dropArea.addEventListener('drop', handleDrop);
},
showDenyButton: true,
confirmButtonText: `Send`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
sendFileInformations(result.value, peer_id, broadcast, peerName);
}
});
function handleDragEnter(e) {
e.preventDefault();
e.stopPropagation();
e.target.style.background = 'var(--body-bg)';
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}
function handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
e.target.style.background = '';
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
e.target.style.background = '';
}
function handleFiles(files) {
if (files.length > 0) {
const file = files[0];
console.log('Selected file:', file);
Swal.close();
sendFileInformations(file, peer_id, broadcast, peerName);
}
}
}
/**
* Send file informations
* @param {object} file data
* @param {string} peer_id
* @param {boolean} broadcast send to all (default false)
* @returns
*/
function sendFileInformations(file, peer_id, broadcast = false, peerName = '') {
fileToSend = file;
// check if valid
if (fileToSend && fileToSend.size > 0) {
// no peers in the room
if (!thereArePeerConnections()) {
return toastMessage('info', 'No participants detected', '', 'top');
}
// prevent XSS injection to remote peer (fileToSend.name is read only)
if (isHtml(fileToSend.name) || !isValidFileName(fileToSend.name))
return userLog('warning', 'Invalid file name!');
const targetPeerName = !broadcast ? filterXSS(peerName || resolvePeerNameById(peer_id) || 'Participant') : '';
const fileInfo = {
room_id: roomId,
broadcast: broadcast,
peer_name: myPeerName,
peer_avatar: myPeerAvatar,
peer_id: peer_id,
file: {
fileName: fileToSend.name,
fileSize: fileToSend.size,
fileType: fileToSend.type,
},
};
// keep trace of sent file in chat
appendMessage(
myPeerName,
rightChatAvatar,
'right',
`${icons.fileSend} File send:
- Name: ${fileToSend.name}
- Size: ${bytesToSize(fileToSend.size)}
`,
!broadcast,
null,
targetPeerName
);
// send some metadata about our file to peers in the room
sendToServer('fileInfo', fileInfo);
// send the File
setTimeout(() => {
sendFileData(peer_id, broadcast);
}, 1000);
// Screen reader announcement for file sharing
screenReaderAccessibility.announceMessage(`Sending file ${fileToSend.name}`);
} else {
userLog('error', 'File dragged not valid or empty.');
}
}
/**
* Html Json pretty print
* @param {object} obj
* @returns html pre json
*/
function toHtmlJson(obj) {
return '' + JSON.stringify(obj, null, 4) + '
';
}
/**
* Get remote file info
* @param {object} config data
*/
function handleFileInfo(config) {
incomingFileInfo = config;
incomingFileData = [];
receiveBuffer = [];
receivedSize = 0;
let fileToReceiveInfo =
'From: ' +
incomingFileInfo.peer_name +
'\n' +
'Incoming file: ' +
incomingFileInfo.file.fileName +
'\n' +
'File size: ' +
bytesToSize(incomingFileInfo.file.fileSize) +
'\n' +
'File type: ' +
incomingFileInfo.file.fileType;
console.log(fileToReceiveInfo);
// generate chat avatar by peer_name
setPeerChatAvatarImgName('left', incomingFileInfo.peer_name, incomingFileInfo.peer_avatar);
// keep track of received file on chat
appendMessage(
incomingFileInfo.peer_name,
leftChatAvatar,
'left',
`${icons.fileReceive} File receive:
- From: ${incomingFileInfo.peer_name}
- Name: ${incomingFileInfo.file.fileName}
- Size: ${bytesToSize(incomingFileInfo.file.fileSize)}
`,
!incomingFileInfo.broadcast,
incomingFileInfo.peer_id
);
receiveFileInfo.innerText = fileToReceiveInfo;
elemDisplay(receiveFileDiv, true);
receiveProgress.max = incomingFileInfo.file.fileSize;
receiveInProgress = true;
userLog('toast', fileToReceiveInfo);
}
/**
* The file will be saved in the Blob. You will be asked to confirm if you want to save it on your PC / Mobile device.
* https://developer.mozilla.org/en-US/docs/Web/API/Blob
*/
function endDownload() {
playSound('download');
// save received file into Blob
const blob = new Blob(incomingFileData);
const file = incomingFileInfo.file.fileName;
incomingFileData = [];
// if file is image, show the preview
if (isImageFile(incomingFileInfo.file.fileName)) {
const reader = new FileReader();
reader.onload = (e) => {
Swal.fire({
allowOutsideClick: false,
background: swBg,
position: 'center',
title: 'Received file',
text: incomingFileInfo.file.fileName + ' size ' + bytesToSize(incomingFileInfo.file.fileSize),
imageUrl: e.target.result,
imageAlt: 'mirotalk-file-img-download',
showDenyButton: true,
confirmButtonText: `Save`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) saveBlobToFile(blob, file);
});
};
// blob where is stored downloaded file
reader.readAsDataURL(blob);
} else {
// not img file
Swal.fire({
allowOutsideClick: false,
background: swBg,
imageAlt: 'mirotalk-file-download',
imageUrl: images.share,
position: 'center',
title: 'Received file',
text: incomingFileInfo.file.fileName + ' size ' + bytesToSize(incomingFileInfo.file.fileSize),
showDenyButton: true,
confirmButtonText: `Save`,
denyButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) saveBlobToFile(blob, file);
});
}
}
/**
* Save to PC / Mobile devices
* https://developer.mozilla.org/en-US/docs/Web/API/Blob
* @param {object} blob content
* @param {string} file to save
*/
function saveBlobToFile(blob, file) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
elemDisplay(a, false);
a.href = url;
a.download = file;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
}
/**
* Opend and send Video URL to all peers in the room
* @param {string} peer_id socket.id
*/
function sendVideoUrl(peer_id = null, peer_name = '', broadcast = !peer_id) {
playSound('newMessage');
const targetPeerName = !broadcast ? filterXSS(peer_name || resolvePeerNameById(peer_id) || 'Participant') : '';
const targetLabel = !broadcast && targetPeerName ? ` with ${targetPeerName}` : '';
Swal.fire({
background: swBg,
position: 'center',
imageUrl: images.vaShare,
title: `Share a Video or Audio${targetLabel}`,
text: `Paste a Video or audio URL${targetLabel}`,
input: 'text',
showCancelButton: true,
confirmButtonText: `Share`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.value) {
result.value = filterXSS(result.value);
if (!thereArePeerConnections()) {
return toastMessage('info', 'No participants detected', '', 'top');
}
console.log('Video URL: ' + result.value);
/*
https://www.youtube.com/watch?v=RT6_Id5-7-s
http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
https://www.learningcontainer.com/wp-content/uploads/2020/02/Kalimba.mp3
*/
if (!isVideoTypeSupported(result.value)) {
return userLog('warning', 'Something wrong, try with another Video or audio URL');
}
const is_youtube = getVideoType(result.value) == 'na' ? true : false;
const video_url = is_youtube ? getYoutubeEmbed(result.value) : result.value;
const config = {
peer_id: peer_id,
video_src: video_url,
broadcast: broadcast,
};
openVideoUrlPlayer(config);
emitVideoPlayer('open', config);
appendMessage(
myPeerName,
rightChatAvatar,
'right',
`${icons.share} Shared media:
${video_url}`,
!broadcast,
null,
targetPeerName
);
}
});
// Take URL from clipboard ex:
// https://www.youtube.com/watch?v=1ZYbU82GVz4
navigator.clipboard
.readText()
.then((clipboardText) => {
if (!clipboardText) return false;
const sanitizedText = filterXSS(clipboardText);
const inputElement = Swal.getInput();
if (isVideoTypeSupported(sanitizedText) && inputElement) {
inputElement.value = sanitizedText;
}
return false;
})
.catch(() => {
return false;
});
}
/**
* Open video url Player
*/
function openVideoUrlPlayer(config) {
console.log('Open video Player', config);
const videoSrc = config.video_src;
const videoType = getVideoType(videoSrc);
const videoEmbed = getYoutubeEmbed(videoSrc);
console.log('Video embed', videoEmbed);
//
if (!isVideoUrlPlayerOpen) {
if (videoEmbed) {
playSound('newMessage');
console.log('Load element type: iframe');
videoUrlIframe.src = videoEmbed;
elemDisplay(videoUrlCont, true, 'flex');
isVideoUrlPlayerOpen = true;
} else {
playSound('newMessage');
console.log('Load element type: Video');
elemDisplay(videoAudioUrlCont, true, 'flex');
videoAudioUrlElement.setAttribute('src', videoSrc);
videoAudioUrlElement.type = videoType;
if (videoAudioUrlElement.type == 'video/mp3') {
videoAudioUrlElement.poster = images.audioGif;
}
isVideoUrlPlayerOpen = true;
}
} else {
// video player seems open
if (videoEmbed) {
videoUrlIframe.src = videoEmbed;
} else {
videoAudioUrlElement.src = videoSrc;
}
}
}
/**
* Get video type
* @param {string} url
* @returns string video type
*/
function getVideoType(url) {
if (url.endsWith('.mp4')) return 'video/mp4';
if (url.endsWith('.mp3')) return 'video/mp3';
if (url.endsWith('.webm')) return 'video/webm';
if (url.endsWith('.ogg')) return 'video/ogg';
return 'na';
}
/**
* Check if video URL is supported
* @param {string} url
* @returns boolean true/false
*/
function isVideoTypeSupported(url) {
if (
url.endsWith('.mp4') ||
url.endsWith('.mp3') ||
url.endsWith('.webm') ||
url.endsWith('.ogg') ||
url.includes('youtube.com')
)
return true;
return false;
}
/**
* Get youtube embed URL
* @param {string} url of YouTube video
* @returns {string} YouTube Embed URL
*/
function getYoutubeEmbed(url) {
const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return match && match[7].length == 11 ? 'https://www.youtube.com/embed/' + match[7] + '?autoplay=1' : false;
}
/**
* Close Video Url Player
*/
function closeVideoUrlPlayer() {
console.log('Close video Player', {
videoUrlIframe: videoUrlIframe.src,
videoAudioUrlElement: videoAudioUrlElement.src,
});
if (videoUrlIframe.src != '') videoUrlIframe.setAttribute('src', '');
if (videoAudioUrlElement.src != '') videoAudioUrlElement.setAttribute('src', '');
elemDisplay(videoUrlCont, false);
elemDisplay(videoAudioUrlCont, false);
isVideoUrlPlayerOpen = false;
}
/**
* Emit video palyer to peers
* @param {string} video_action type
* @param {object} config data
*/
function emitVideoPlayer(video_action, config = {}) {
sendToServer('videoPlayer', {
room_id: roomId,
peer_name: myPeerName,
video_action: video_action,
video_src: config.video_src,
peer_id: config.peer_id,
broadcast: config.broadcast,
});
}
/**
* Handle Video Player
* @param {object} config data
*/
function handleVideoPlayer(config) {
const { peer_name, video_action, video_src, broadcast } = config;
//
switch (video_action) {
case 'open':
userLog('toast', `${icons.user} ${peer_name} \n open video player`);
openVideoUrlPlayer(config);
appendMessage(
peer_name,
leftChatAvatar,
'left',
`${icons.share} Shared media:
${video_src}`,
!broadcast,
null,
peer_name
);
break;
case 'close':
userLog('toast', `${icons.user} ${peer_name} \n close video player`);
closeVideoUrlPlayer();
break;
default:
break;
}
}
/**
* Handle peer kick out event button
* @param {string} peer_id socket.id
*/
function handlePeerKickOutBtn(peer_id) {
if (!buttons.remote.showKickOutBtn) return;
const peerKickOutBtn = getId(peer_id + '_kickOut');
peerKickOutBtn.addEventListener('click', (e) => {
isPresenter
? kickOut(peer_id)
: msgPopup('warning', 'Only the presenter can eject participants', 'top-end', 4000);
});
}
/**
* Eject peer, confirm before
* @param {string} peer_id socket.id
*/
function kickOut(peer_id) {
const pName = getId(peer_id + '_name').innerText;
Swal.fire({
background: swBg,
position: 'top',
imageUrl: images.leave,
title: 'Kick out',
text: `Are you sure you want to kick out ${pName}?`,
showDenyButton: true,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
// send peer to kick out from room
sendToServer('kickOut', {
room_id: roomId,
peer_id: peer_id,
peer_uuid: myPeerUUID,
peer_name: myPeerName,
});
}
});
}
/**
* Start caption if not already started
* @param {object} config data
*/
function handleCaptionActions(config) {
const { peer_name, action } = config;
switch (action) {
case 'start':
if (!speechRecognition) {
userLog(
'info',
`${peer_name} wants to start captions for this session, but your browser does not support it. Please use a Chromium-based browser like Google Chrome, Microsoft Edge, or Brave.`
);
return;
}
if (recognitionRunning || !buttons.main.showCaptionRoomBtn) return;
Swal.fire({
allowOutsideClick: false,
allowEscapeKey: false,
showDenyButton: true,
background: swBg,
imageUrl: images.caption,
title: 'Start Captions',
text: `${peer_name} wants to start the captions for this session. Would you like to enable them?`,
confirmButtonText: `Yes`,
denyButtonText: `No`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
if (!isCaptionBoxVisible) {
captionBtn.click();
}
if (!recognitionRunning) {
const { recognitionLanguageIndex, recognitionDialectIndex } = config.data;
recognitionLanguage.selectedIndex = recognitionLanguageIndex;
updateCountry();
recognitionDialect.selectedIndex = recognitionDialectIndex;
speechRecognitionStart.click();
}
}
});
break;
case 'stop':
if (!recognitionRunning || !buttons.main.showCaptionRoomBtn) return;
toastMessage(
'warning',
'Stop captions',
`${peer_name} has stopped the captions for this session`,
'top-end',
6000
);
if (recognitionRunning) {
speechRecognitionStop.click();
}
break;
default:
break;
}
}
/**
* You will be kicked out from the room and popup the peer name that performed this action
* @param {object} config data
*/
function handleKickedOut(config) {
signalingSocket.disconnect();
const { peer_name } = config;
playSound('eject');
let timerInterval;
Swal.fire({
allowOutsideClick: false,
background: swBg,
position: 'center',
imageUrl: images.leave,
title: 'Kicked out!',
html:
`` +
`User ` +
peer_name +
`
will kick out you after milliseconds.`,
timer: 5000,
timerProgressBar: true,
didOpen: () => {
Swal.showLoading();
timerInterval = setInterval(() => {
const content = Swal.getHtmlContainer();
if (content) {
const b = content.querySelector('b');
if (b) b.textContent = Swal.getTimerLeft();
}
}, 100);
},
willClose: () => {
clearInterval(timerInterval);
},
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then(() => {
checkRecording();
openURL('/newcall');
});
}
/**
* MiroTalk about info
*/
function showAbout() {
playSound('newMessage');
Swal.fire({
background: swBg,
position: 'center',
title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.7.97',
imageUrl: brand.about?.imageUrl && brand.about.imageUrl.trim() !== '' ? brand.about.imageUrl : images.about,
customClass: { image: 'img-about' },
html: `
`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
}
/**
* Init Exit Meeting
*/
function initExitMeeting() {
openURL('/newcall');
}
/**
* Leave the Room and create a new one
*/
function leaveRoom() {
checkRecording();
surveyActive ? leaveFeedback() : redirectOnLeave();
}
/**
* Exit the Room
*/
function exitRoom() {
checkRecording();
redirectOnLeave();
}
/**
* Ask for feedback when room exit
*/
function leaveFeedback() {
Swal.fire({
allowOutsideClick: false,
allowEscapeKey: false,
showDenyButton: true,
showCancelButton: true,
confirmButtonColor: 'green',
denyButtonColor: 'red',
cancelButtonColor: 'gray',
background: swBg,
imageUrl: images.feedback,
position: 'top',
title: 'Leave a feedback',
text: 'Do you want to rate your MiroTalk experience?',
confirmButtonText: `Yes`,
denyButtonText: `No`,
cancelButtonText: `Cancel`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed) {
openURL(surveyURL);
} else if (result.isDenied) {
redirectOnLeave();
}
});
}
function redirectOnLeave() {
redirectActive ? openURL(redirectURL) : openURL('/newcall');
}
/**
* Make Obj draggable: https://www.w3schools.com/howto/howto_js_draggable.asp
* @param {object} elmnt father element
* @param {object} dragObj children element to make father draggable (click + mouse move)
*/
function dragElement(elmnt, dragObj) {
let pos1 = 0,
pos2 = 0,
pos3 = 0,
pos4 = 0;
if (dragObj) {
// if present, the header is where you move the DIV from:
dragObj.onmousedown = dragMouseDown;
} else {
// otherwise, move the DIV from anywhere inside the DIV:
elmnt.onmousedown = dragMouseDown;
}
function dragMouseDown(e) {
e = e || window.event;
e.preventDefault();
// get the mouse cursor position at startup:
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
// calculate the new cursor position:
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
// set the element's new position:
elmnt.style.top = elmnt.offsetTop - pos2 + 'px';
elmnt.style.left = elmnt.offsetLeft - pos1 + 'px';
}
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
/**
* Make Obj Undraggable
* @param {object} elmnt father element
* @param {object} dragObj children element to make father undraggable
*/
function undragElement(elmnt, dragObj) {
if (dragObj) {
dragObj.onmousedown = null;
} else {
elmnt.onmousedown = null;
}
elmnt.style.top = '';
elmnt.style.left = '';
}
/**
* Date Format: https://convertio.co/it/
* @returns {string} date string format: DD-MM-YYYY-H_M_S
*/
function getDataTimeString() {
const d = new Date();
const date = d.toISOString().split('T')[0];
const time = d.toTimeString().split(' ')[0];
return `${date}-${time}`;
}
/**
* Convert bytes to KB-MB-GB-TB
* @param {object} bytes to convert
* @returns {string} converted size
*/
function bytesToSize(bytes) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes == 0) return '0 Byte';
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
}
/**
* Handle peer audio volume
* @param {object} data peer audio
*/
function handlePeerVolume(data) {
const { peer_id, volume } = data;
let audioColorTmp = '#19bb5c';
if ([50, 60, 70].includes(volume)) audioColorTmp = '#FFA500'; // Orange
if ([80, 90, 100].includes(volume)) audioColorTmp = '#FF0000'; // Red
if (!isAudioPitchBar) {
const remotePeerAvatarImg = getId(peer_id + '_avatar');
if (remotePeerAvatarImg) {
applyBoxShadowEffect(remotePeerAvatarImg, audioColorTmp, 100);
}
const remotePeerVideo = getId(peer_id + '___video');
if (remotePeerVideo && remotePeerVideo.classList.contains('videoCircle')) {
applyBoxShadowEffect(remotePeerVideo, audioColorTmp, 100);
}
return;
}
const remotePitchBar = getId(peer_id + '_pitch_bar');
//let remoteVideoWrap = getId(peer_id + '_videoWrap');
if (!remotePitchBar) return;
remotePitchBar.style.backgroundColor = audioColorTmp;
remotePitchBar.style.height = volume + '%';
//remoteVideoWrap.classList.toggle('speaking');
setTimeout(function () {
remotePitchBar.style.backgroundColor = '#19bb5c';
remotePitchBar.style.height = '0%';
//remoteVideoWrap.classList.toggle('speaking');
}, 100);
}
/**
* Handle my audio volume
* @param {object} data my audio
*/
function handleMyVolume(data) {
const { volume } = data;
let audioColorTmp = '#19bb5c';
if ([50, 60, 70].includes(volume)) audioColorTmp = '#FFA500'; // Orange
if ([80, 90, 100].includes(volume)) audioColorTmp = '#FF0000'; // Red
if (!isAudioPitchBar || !myPitchBar) {
const localPeerAvatarImg = getId('myVideoAvatarImage');
if (localPeerAvatarImg) {
applyBoxShadowEffect(localPeerAvatarImg, audioColorTmp, 100);
}
if (myVideo && myVideo.classList.contains('videoCircle')) {
applyBoxShadowEffect(myVideo, audioColorTmp, 100);
}
return;
}
myPitchBar.style.backgroundColor = audioColorTmp;
myPitchBar.style.height = volume + '%';
//myVideoWrap.classList.toggle('speaking');
setTimeout(function () {
myPitchBar.style.backgroundColor = '#19bb5c';
myPitchBar.style.height = '0%';
//myVideoWrap.classList.toggle('speaking');
}, 100);
}
/**
* Apply Box Shadow effect to element
* @param {object} element
* @param {string} color
* @param {integer} delay ms
*/
function applyBoxShadowEffect(element, color, delay = 200) {
if (element) {
element.style.boxShadow = `0 0 20px ${color}`;
setTimeout(() => {
element.style.boxShadow = 'none';
}, delay);
}
}
/**
* Basic user logging using https://sweetalert2.github.io & https://animate.style/
* @param {string} type of popup
* @param {string} message to popup
* @param {integer} timer toast duration ms
*/
function userLog(type, message, timer = 3000) {
switch (type) {
case 'warning':
case 'error':
Swal.fire({
background: swBg,
position: 'center',
icon: type,
title: type,
text: message,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
playSound('alert');
break;
case 'info':
case 'success':
Swal.fire({
background: swBg,
position: 'center',
icon: type,
title: type,
text: message,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
break;
case 'success-html':
Swal.fire({
background: swBg,
position: 'center',
icon: 'success',
title: 'Success',
html: message,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
break;
case 'toast':
const Toast = Swal.mixin({
background: swBg,
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: timer,
timerProgressBar: true,
});
Toast.fire({
html: message,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
break;
// ......
default:
alert(message);
break;
}
}
/**
* Popup Toast message
* @param {string} icon info, success, alert, warning
* @param {string} title message title
* @param {string} html message in html format
* @param {string} position message position
* @param {integer} duration time popup in ms
*/
function toastMessage(icon, title, html, position = 'top-end', duration = 3000) {
if (['warning', 'error'].includes(icon)) playSound('alert');
const Toast = Swal.mixin({
background: swBg,
position: position,
icon: icon,
showConfirmButton: false,
timerProgressBar: true,
toast: true,
timer: duration,
});
Toast.fire({
title: title,
html: html,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
}
/**
* Popup html message
* @param {string} icon info, success, alert, warning
* @param {string} imageUrl image path
* @param {string} title message title
* @param {string} html message in html format
* @param {string} position message position
* @param {string} redirectURL if set on press ok will be redirected to the URL
*/
function msgHTML(icon, imageUrl, title, html, position = 'center', redirectURL = false) {
if (['warning', 'error'].includes(icon)) playSound('alert');
Swal.fire({
allowOutsideClick: false,
allowEscapeKey: false,
background: swBg,
position: position,
icon: icon,
imageUrl: imageUrl,
title: title,
html: html,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
}).then((result) => {
if (result.isConfirmed && redirectURL) {
openURL(redirectURL);
}
});
}
/**
* Message popup
* @param {string} icon info, success, warning, error
* @param {string} message to show
* @param {string} position of the toast
* @param {integer} timer ms before to hide
*/
function msgPopup(icon, message, position, timer = 1000) {
if (['warning', 'error'].includes(icon)) playSound('alert');
const Toast = Swal.mixin({
background: swBg,
toast: true,
position: position,
showConfirmButton: false,
timer: timer,
timerProgressBar: true,
});
Toast.fire({
icon: icon,
title: message,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
}
/**
* https://notificationsounds.com/notification-sounds
* @param {string} name audio to play
* @param {boolean} force audio
* @param {string} path of sound files
*/
async function playSound(name, force = false, path = '../sounds/') {
if (!notifyBySound && !force) return;
const sound = path + name + '.mp3';
const audioToPlay = new Audio(sound);
try {
audioToPlay.volume = 0.5;
await audioToPlay.play();
} catch (err) {
// console.error("Cannot play sound", err);
// Automatic playback failed. (safari)
return;
}
}
/**
* Test speaker by playing a sound through the selected audio output device
* @param {string} deviceId - Optional audio output device ID. If not provided, uses the currently selected speaker
* @param {string} name audio to play
* @param {string} path od sound files
*/
async function playSpeaker(deviceId = null, name, path = '../sounds/') {
const selectedDeviceId = deviceId || audioOutputSelect?.value;
if (selectedDeviceId) {
const sound = path + name + '.mp3';
const audioToPlay = new Audio(sound);
try {
if (typeof audioToPlay.setSinkId === 'function') {
await audioToPlay.setSinkId(selectedDeviceId);
}
audioToPlay.volume = 0.5;
await audioToPlay.play();
} catch (err) {
console.error('Cannot play test sound:', err);
}
} else {
playSound(name, true);
}
}
/**
* Open specified URL
* @param {string} url to open
* @param {boolean} blank if true opne url in the new tab
*/
function openURL(url, blank = false) {
blank ? window.open(url, '_blank') : (window.location.href = url);
}
/**
* Show-Hide all elements grp by class name
* @param {string} className to toggle
* @param {string} displayState of the element
*/
function toggleClassElements(className, displayState) {
const elements = getEcN(className);
for (let i = 0; i < elements.length; i++) {
elements[i].style.display = displayState;
}
}
/**
* Check if valid filename
* @param {string} fileName
* @returns boolean
*/
function isValidFileName(fileName) {
const invalidChars = /[\\\/\?\*\|:"<>]/;
return !invalidChars.test(fileName);
}
/**
* Check if WebRTC supported
* @return {boolean} true/false
*/
function checkWebRTCSupported() {
return !!(navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function');
}
/**
* Get Html element by Id
* @param {string} id of the element
* @returns {object} element
*/
function getId(id) {
return document.getElementById(id);
}
/**
* Get all element descendants of node
* @param {string} selectors
* @returns all element descendants of node that match selectors.
*/
function getQsA(selectors) {
return document.querySelectorAll(selectors);
}
/**
* Get element by selector
* @param {string} selector
* @returns element
*/
function getQs(selector) {
return document.querySelector(selector);
}
/**
* Set document style property
* @param {string} key
* @param {string} value
* @returns {objects} element
*/
function setSP(key, value) {
return document.documentElement.style.setProperty(key, value);
}
/**
* Get Html element by selector
* @param {string} selector of the element
* @returns {object} element
*/
function getSl(selector) {
return document.querySelector(selector);
}
/**
* Get ALL Html elements by selector
* @param {string} selector of the element
* @returns {object} element
*/
function getSlALL(selector) {
return document.querySelectorAll(selector);
}
/**
* Get Html element by class name
* @param {string} className of the element
* @returns {object} element
*/
function getEcN(className) {
return document.getElementsByClassName(className);
}
/**
* Get html element by name
* @param {string} name
* @returns element
*/
function getName(name) {
return document.getElementsByName(name);
}
/**
* Element style display
* @param {object} elem
* @param {boolean} yes true/false
*/
function elemDisplay(element, display, mode = 'inline') {
element.style.display = display ? mode : 'none';
}
/**
* Sanitize XSS scripts
* @param {object} src object
* @returns sanitized object
*/
function sanitizeXSS(src) {
return JSON.parse(filterXSS(JSON.stringify(src)));
}
/**
* Disable element
* @param {object} elem
* @param {boolean} disabled
*/
function disable(elem, disabled) {
elem.disabled = disabled;
}
/**
* Remove Border Radius
*/
function restoreSplitButtonsBorderRadius() {
// On mobile we skip dropdown behavior, but ensure split buttons still look rounded.
document.querySelectorAll('#bottomButtons .split-btn').forEach((group) => {
group.querySelectorAll('button').forEach((button) => {
// Hack: Exclude settingsExtraToggle extra buttons...
if (button.id != 'settingsExtraToggle' && button.id != 'mySettingsBtn') {
button.style.setProperty('border-radius', '10px', 'important');
}
});
const toggle = group.querySelector('.device-dropdown-toggle');
if (toggle) toggle.style.setProperty('border-left', 'none', 'important');
});
}
/**
* Setup Quick audio/video device switch dropdowns
*/
function setupQuickDeviceSwitchDropdowns() {
// For now keep this feature only for desktop devices
if (!isDesktopDevice) {
restoreSplitButtonsBorderRadius();
return;
}
if (!videoBtn || !audioBtn || !videoDropdown || !audioDropdown || !videoToggle || !audioToggle) {
return;
}
function syncVisibility() {
// Keep dropdown visible while the corresponding button is visible
const showVideo = !!videoBtn && window.getComputedStyle(videoBtn).display !== 'none';
const showAudio = !!audioBtn && window.getComputedStyle(audioBtn).display !== 'none';
videoDropdown.classList.toggle('hidden', !showVideo);
audioDropdown.classList.toggle('hidden', !showAudio);
}
function isMenuOpen(menuEl) {
return !!menuEl && menuEl.classList.contains('show');
}
function closeMenu(toggleEl, menuEl) {
if (!toggleEl || !menuEl) return;
menuEl.classList.remove('show');
toggleEl.setAttribute('aria-expanded', 'false');
}
function openMenu(toggleEl, menuEl, rebuildFn) {
if (!toggleEl || !menuEl) return;
if (typeof rebuildFn === 'function') rebuildFn();
menuEl.classList.add('show');
toggleEl.setAttribute('aria-expanded', 'true');
}
function toggleMenu(toggleEl, menuEl, rebuildFn) {
const open = isMenuOpen(menuEl);
// only one open at a time
closeMenu(videoToggle, videoMenu);
closeMenu(audioToggle, audioMenu);
if (!open) openMenu(toggleEl, menuEl, rebuildFn);
}
function appendMenuHeader(menuEl, iconClass, title) {
if (!menuEl) return;
const header = document.createElement('div');
header.className = 'device-menu-header';
const icon = document.createElement('i');
icon.className = iconClass;
const text = document.createElement('span');
text.textContent = title;
header.appendChild(icon);
header.appendChild(text);
menuEl.appendChild(header);
}
function appendMenuDivider(menuEl) {
if (!menuEl) return;
const divider = document.createElement('div');
divider.className = 'device-menu-divider';
menuEl.appendChild(divider);
}
function appendSelectOptions(menuEl, selectEl, emptyLabel, rebuildFn) {
if (!menuEl || !selectEl) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'app-dropdown-action';
btn.disabled = true;
btn.textContent = emptyLabel;
menuEl.appendChild(btn);
return;
}
const options = Array.from(selectEl.options || []).filter((o) => o && o.value);
if (options.length === 0) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'app-dropdown-action';
btn.disabled = true;
btn.textContent = emptyLabel;
menuEl.appendChild(btn);
return;
}
options.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'app-dropdown-action';
const isSelected = opt.value === selectEl.value;
const label = opt.textContent || opt.label || opt.value;
btn.replaceChildren();
if (isSelected) {
const icon = document.createElement('i');
icon.className = 'fas fa-check';
icon.style.marginRight = '0.5em';
btn.appendChild(icon);
btn.appendChild(document.createTextNode(` ${label}`));
} else {
const spacer = document.createElement('span');
spacer.style.display = 'inline-block';
spacer.style.width = '1.25em';
btn.appendChild(spacer);
btn.appendChild(document.createTextNode(label));
}
btn.addEventListener('click', () => {
if (selectEl.value === opt.value) return;
selectEl.value = opt.value;
selectEl.dispatchEvent(new Event('change'));
if (typeof rebuildFn === 'function') rebuildFn();
});
menuEl.appendChild(btn);
});
}
function rebuildVideoMenu() {
if (!videoMenu) return;
videoMenu.innerHTML = '';
appendMenuHeader(videoMenu, 'fas fa-video', 'Cameras');
appendSelectOptions(videoMenu, videoSelect, 'No cameras found', rebuildVideoMenu);
// Add settings button
appendMenuDivider(videoMenu);
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'app-dropdown-action device-menu-action-btn';
const settingsIcon = document.createElement('i');
settingsIcon.className = 'fas fa-cog';
settingsBtn.appendChild(settingsIcon);
settingsBtn.appendChild(document.createTextNode(' Open Video Settings'));
settingsBtn.addEventListener('click', () => {
hideShowMySettings();
// Simulate tab click to open video devices tab
setTimeout(() => {
tabVideoBtn.click();
}, 100);
});
videoMenu.appendChild(settingsBtn);
}
function rebuildAudioMenu() {
if (!audioMenu) return;
audioMenu.innerHTML = '';
appendMenuHeader(audioMenu, 'fas fa-microphone', 'Microphones');
appendSelectOptions(audioMenu, audioInputSelect, 'No microphones found', rebuildAudioMenu);
appendMenuDivider(audioMenu);
appendMenuHeader(audioMenu, 'fas fa-volume-high', 'Speakers');
if (!audioOutputSelect || audioOutputSelect.disabled) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'app-dropdown-action';
btn.disabled = true;
btn.textContent = 'Speaker selection not supported';
audioMenu.appendChild(btn);
return;
}
appendSelectOptions(audioMenu, audioOutputSelect, 'No speakers found', rebuildAudioMenu);
// Add action buttons
appendMenuDivider(audioMenu);
// Test speaker button
const testBtn = document.createElement('button');
testBtn.type = 'button';
testBtn.className = 'app-dropdown-action device-menu-action-btn';
const testIcon = document.createElement('i');
testIcon.className = 'fa-solid fa-circle-play';
testBtn.appendChild(testIcon);
testBtn.appendChild(document.createTextNode(' Test Speaker'));
testBtn.addEventListener('click', () => playSpeaker(audioOutputSelect?.value, 'ring'));
audioMenu.appendChild(testBtn);
// Settings button
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'app-dropdown-action device-menu-action-btn';
const settingsIcon = document.createElement('i');
settingsIcon.className = 'fas fa-cog';
settingsBtn.appendChild(settingsIcon);
settingsBtn.appendChild(document.createTextNode(' Open Audio Settings'));
settingsBtn.addEventListener('click', () => {
hideShowMySettings();
// Simulate tab click to open audio devices tab
setTimeout(() => {
tabAudioBtn.click();
}, 100);
});
audioMenu.appendChild(settingsBtn);
}
// Hover behavior (desktop only). Note: rebuilding alone is invisible if the menu isn't opened.
const supportsHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (supportsHover) {
const attachHoverDropdown = (toggleEl, menuEl, rebuildFn, closeOtherFn) => {
if (!toggleEl || !menuEl) return;
let closeTimeout;
const cancelClose = () => {
if (!closeTimeout) return;
clearTimeout(closeTimeout);
closeTimeout = null;
};
const scheduleClose = () => {
cancelClose();
closeTimeout = setTimeout(() => closeMenu(toggleEl, menuEl), 180);
};
toggleEl.addEventListener('mouseenter', () => {
cancelClose();
if (typeof closeOtherFn === 'function') closeOtherFn();
openMenu(toggleEl, menuEl, rebuildFn);
});
toggleEl.addEventListener('mouseleave', scheduleClose);
menuEl.addEventListener('mouseenter', cancelClose);
menuEl.addEventListener('mouseleave', scheduleClose);
};
attachHoverDropdown(videoToggle, videoMenu, rebuildVideoMenu, () => closeMenu(audioToggle, audioMenu));
attachHoverDropdown(audioToggle, audioMenu, rebuildAudioMenu, () => closeMenu(videoToggle, videoMenu));
}
videoToggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleMenu(videoToggle, videoMenu, rebuildVideoMenu);
});
audioToggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleMenu(audioToggle, audioMenu, rebuildAudioMenu);
});
// Close on outside click
document.addEventListener('click', (e) => {
const t = e.target;
const inVideo = videoDropdown && (videoDropdown === t || videoDropdown.contains(t));
const inAudio = audioDropdown && (audioDropdown === t || audioDropdown.contains(t));
if (!inVideo) closeMenu(videoToggle, videoMenu);
if (!inAudio) closeMenu(audioToggle, audioMenu);
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
closeMenu(videoToggle, videoMenu);
closeMenu(audioToggle, audioMenu);
});
// Close after selecting an item
if (videoMenu) videoMenu.addEventListener('click', () => closeMenu(videoToggle, videoMenu));
if (audioMenu) audioMenu.addEventListener('click', () => closeMenu(audioToggle, audioMenu));
// Keep UI synced when settings panel changes device
if (videoSelect) videoSelect.addEventListener('change', rebuildVideoMenu);
if (audioInputSelect) audioInputSelect.addEventListener('change', rebuildAudioMenu);
if (audioOutputSelect) audioOutputSelect.addEventListener('change', rebuildAudioMenu);
// Keep arrow buttons visible only when Start buttons are visible
syncVisibility();
const observer = new MutationObserver(syncVisibility);
observer.observe(videoBtn, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(audioBtn, { attributes: true, attributeFilter: ['class', 'style'] });
// Re-enumerate & refresh lists on hardware changes
if (navigator.mediaDevices) {
let deviceChangeFrame;
let lastChangeTime = 0;
navigator.mediaDevices.addEventListener('devicechange', async () => {
const now = Date.now();
// Debounce: ignore rapid-fire changes
if (now - lastChangeTime < 1000) return;
lastChangeTime = now;
if (deviceChangeFrame) cancelAnimationFrame(deviceChangeFrame);
deviceChangeFrame = requestAnimationFrame(async () => {
console.log('🔄 Audio devices changed - refreshing...');
// Give OS time to finish routing (especially important on mobile)
await new Promise((resolve) => setTimeout(resolve, isMobileDevice ? 1500 : 500));
try {
await refreshMyAudioVideoDevices();
} catch (err) {
console.warn('Device refresh failed:', err);
}
setTimeout(() => {
rebuildVideoMenu();
rebuildAudioMenu();
}, 50);
});
});
}
}
/**
* Handle dropdown menus on hover (for non-touch devices)
*/
function handleDropdownHover() {
// Detect if device supports hover (pointer: fine) - works on desktop, tablets with mouse, etc.
const supportsHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (!supportsHover) {
// Touch-only devices - use click behavior only (already handled elsewhere)
return;
}
// Handle Chat dropdown menu hover
if (msgerDropDownMenuBtn && msgerDropDownContent) {
let chatTimeoutId;
const showChatDropdown = () => {
clearTimeout(chatTimeoutId);
elemDisplay(msgerDropDownContent, true, 'block');
};
const hideChatDropdown = () => {
chatTimeoutId = setTimeout(() => {
elemDisplay(msgerDropDownContent, false);
}, 200);
};
msgerDropDownMenuBtn.addEventListener('mouseenter', showChatDropdown);
msgerDropDownMenuBtn.addEventListener('mouseleave', hideChatDropdown);
msgerDropDownContent.addEventListener('mouseenter', () => clearTimeout(chatTimeoutId));
msgerDropDownContent.addEventListener('mouseleave', hideChatDropdown);
}
// Handle MsgerCP dropdown menu hover
if (msgerCPDropDownMenuBtn && msgerCPDropDownContent) {
let msgerCPTimeoutId;
const showMsgerCPDropdown = () => {
clearTimeout(msgerCPTimeoutId);
elemDisplay(msgerCPDropDownContent, true, 'block');
elemDisplay(msgerSidebarDropDownContent, false);
};
const hideMsgerCPDropdown = () => {
msgerCPTimeoutId = setTimeout(() => {
elemDisplay(msgerCPDropDownContent, false);
}, 200);
};
msgerCPDropDownMenuBtn.addEventListener('mouseenter', showMsgerCPDropdown);
msgerCPDropDownMenuBtn.addEventListener('mouseleave', hideMsgerCPDropdown);
msgerCPDropDownContent.addEventListener('mouseenter', () => clearTimeout(msgerCPTimeoutId));
msgerCPDropDownContent.addEventListener('mouseleave', hideMsgerCPDropdown);
}
if (msgerSidebarDropDownMenuBtn && msgerSidebarDropDownContent) {
let msgerSidebarTimeoutId;
const showMsgerSidebarDropdown = () => {
clearTimeout(msgerSidebarTimeoutId);
elemDisplay(msgerSidebarDropDownContent, true, 'block');
elemDisplay(msgerCPDropDownContent, false);
};
const hideMsgerSidebarDropdown = () => {
msgerSidebarTimeoutId = setTimeout(() => {
elemDisplay(msgerSidebarDropDownContent, false);
}, 200);
};
msgerSidebarDropDownMenuBtn.addEventListener('mouseenter', showMsgerSidebarDropdown);
msgerSidebarDropDownMenuBtn.addEventListener('mouseleave', hideMsgerSidebarDropdown);
msgerSidebarDropDownContent.addEventListener('mouseenter', () => clearTimeout(msgerSidebarTimeoutId));
msgerSidebarDropDownContent.addEventListener('mouseleave', hideMsgerSidebarDropdown);
}
// Handle Whiteboard dropdown menu hover
if (whiteboardDropDownMenuBtn && whiteboardDropdownMenu) {
let wbTimeoutId;
const showWhiteboardDropdown = () => {
clearTimeout(wbTimeoutId);
elemDisplay(whiteboardDropdownMenu, true, 'block');
};
const hideWhiteboardDropdown = () => {
wbTimeoutId = setTimeout(() => {
elemDisplay(whiteboardDropdownMenu, false);
}, 200);
};
whiteboardDropDownMenuBtn.addEventListener('mouseenter', showWhiteboardDropdown);
whiteboardDropDownMenuBtn.addEventListener('mouseleave', hideWhiteboardDropdown);
whiteboardDropdownMenu.addEventListener('mouseenter', () => clearTimeout(wbTimeoutId));
whiteboardDropdownMenu.addEventListener('mouseleave', hideWhiteboardDropdown);
}
// Handle Caption dropdown menu hover
if (captionDropDownMenuBtn && captionDropDownContent) {
let captionTimeoutId;
const showCaptionDropdown = () => {
clearTimeout(captionTimeoutId);
elemDisplay(captionDropDownContent, true, 'block');
};
const hideCaptionDropdown = () => {
captionTimeoutId = setTimeout(() => {
elemDisplay(captionDropDownContent, false);
}, 200);
};
captionDropDownMenuBtn.addEventListener('mouseenter', showCaptionDropdown);
captionDropDownMenuBtn.addEventListener('mouseleave', hideCaptionDropdown);
captionDropDownContent.addEventListener('mouseenter', () => clearTimeout(captionTimeoutId));
captionDropDownContent.addEventListener('mouseleave', hideCaptionDropdown);
}
}
/**
* Handle click outside of an element
* @param {object} targetElement
* @param {object} triggerElement
* @param {function} callback
* @param {number} minWidth
*/
function handleClickOutside(targetElement, triggerElement, callback, minWidth = 0) {
document.addEventListener('click', (e) => {
if (minWidth && window.innerWidth > minWidth) return;
let el = e.target;
let shouldExclude = false;
while (el) {
if (el instanceof HTMLElement && (el === targetElement || el === triggerElement)) {
shouldExclude = true;
break;
}
el = el.parentElement;
}
if (!shouldExclude) callback();
});
}
/**
* Set media button class based on status
* @param {object} button - Button element
* @param {boolean} status - Media status (on/off)
* @param {string} mediaType - 'audio', 'video', or 'screen'
*/
function setMediaButtonClass(button, status, mediaType) {
if (!button) return;
const classMap = {
audio: status ? className.audioOn : className.audioOff,
video: status ? className.videoOn : className.videoOff,
screen: status ? className.screenOff : className.screenOn,
};
button.className = classMap[mediaType] || button.className;
}
/**
* Set multiple media button classes at once
* @param {Array} buttons - Array of {element, status, mediaType}
*/
function setMediaButtonsClass(buttons) {
buttons.forEach(({ element, status, mediaType }) => {
setMediaButtonClass(element, status, mediaType);
});
}
/**
* Display multiple elements at once
* @param {Array} elements - Array of {element, display, mode}
*/
function displayElements(elements) {
elements.forEach(({ element, display, mode = 'inline' }) => {
if (element) elemDisplay(element, display, mode);
});
}
/**
* Sleep in ms
* @param {integer} ms milleseconds
* @returns Promise
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}