14622 lines
496 KiB
JavaScript
14622 lines
496 KiB
JavaScript
/*
|
|
██████ ██ ██ ███████ ███ ██ ████████
|
|
██ ██ ██ ██ ████ ██ ██
|
|
██ ██ ██ █████ ██ ██ ██ ██
|
|
██ ██ ██ ██ ██ ██ ██ ██
|
|
██████ ███████ ██ ███████ ██ ████ ██
|
|
*/
|
|
|
|
/**
|
|
* MiroTalk P2P - Client component
|
|
*
|
|
* @link GitHub: https://github.com/miroslavpejic85/mirotalk
|
|
* @link Official Live demo: https://p2p.mirotalk.com
|
|
* @license For open source use: AGPLv3
|
|
* @license For commercial use or closed source, contact us at license.mirotalk@gmail.com or purchase directly from CodeCanyon
|
|
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-p2p-webrtc-realtime-video-conferences/38376661
|
|
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
|
|
* @version 1.7.13
|
|
*
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// https://www.w3schools.com/js/js_strict.asp
|
|
|
|
// Signaling server URL
|
|
const signalingServer = getSignalingServer();
|
|
|
|
// This room
|
|
const myRoomId = getId('myRoomId');
|
|
const roomId = getRoomId();
|
|
const myRoomUrl = window.location.origin + '/join/' + roomId; // share room url
|
|
|
|
// Images
|
|
const images = {
|
|
caption: '../images/caption.png',
|
|
chatgpt: '../images/chatgpt.png',
|
|
confirmation: '../images/image-placeholder.png',
|
|
share: '../images/share.png',
|
|
locked: '../images/locked.png',
|
|
videoOff: '../images/cam-off.png',
|
|
audioOff: '../images/audio-off.png',
|
|
audioGif: '../images/audio.gif',
|
|
screenOff: '../images/screen-off.png',
|
|
delete: '../images/delete.png',
|
|
message: '../images/message.png',
|
|
leave: '../images/leave-room.png',
|
|
vaShare: '../images/va-share.png',
|
|
about: '../images/mirotalk-logo.gif',
|
|
feedback: '../images/feedback.png',
|
|
forbidden: '../images/forbidden.png',
|
|
avatar: '../images/mirotalk-logo.png',
|
|
recording: '../images/recording.png',
|
|
poster: '../images/loader.gif',
|
|
geoLocation: '../images/geolocation.png',
|
|
}; // nice free icon: https://www.iconfinder.com
|
|
|
|
const className = {
|
|
user: 'fas fa-user',
|
|
clock: 'fas fa-clock',
|
|
hideMeOn: 'fas fa-user-slash',
|
|
hideMeOff: 'fas fa-user',
|
|
audioOn: 'fas fa-microphone',
|
|
audioOff: 'fas fa-microphone-slash',
|
|
videoOn: 'fas fa-video',
|
|
videoOff: 'fas fa-video-slash',
|
|
screenOn: 'fas fa-desktop',
|
|
screenOff: 'fas fa-stop-circle',
|
|
handPulsate: 'fas fa-hand-paper pulsate',
|
|
privacy: 'far fa-circle',
|
|
snapShot: 'fas fa-camera-retro',
|
|
pinUnpin: 'fas fa-map-pin',
|
|
mirror: 'fas fa-arrow-right-arrow-left',
|
|
zoomIn: 'fas fa-magnifying-glass-plus',
|
|
zoomOut: 'fas fa-magnifying-glass-minus',
|
|
fullScreen: 'fas fa-expand',
|
|
fsOn: 'fas fa-compress-alt',
|
|
fsOff: 'fas fa-expand-alt',
|
|
msgPrivate: 'fas fa-paper-plane',
|
|
geoLocation: 'fas fa-location-dot',
|
|
shareFile: 'fas fa-upload',
|
|
shareVideoAudio: 'fab fa-youtube',
|
|
kickOut: 'fas fa-sign-out-alt',
|
|
chatOn: 'fas fa-comment',
|
|
chatOff: 'fas fa-comment-slash',
|
|
ghost: 'fas fa-ghost',
|
|
undo: 'fas fa-undo',
|
|
captionOn: 'fas fa-closed-captioning',
|
|
trash: 'fas fa-trash',
|
|
copy: 'fas fa-copy',
|
|
speech: 'fas fa-volume-high',
|
|
heart: 'fas fa-heart',
|
|
pip: 'fas fa-images',
|
|
hideAll: 'fas fa-eye',
|
|
up: 'fas fa-chevron-up',
|
|
down: 'fas fa-chevron-down',
|
|
};
|
|
// https://fontawesome.com/search?o=r&m=free
|
|
|
|
const icons = {
|
|
lock: '<i class="fas fa-lock"></i>',
|
|
unlock: '<i class="fas fa-lock-open"></i>',
|
|
pitchBar: '<i class="fas fa-microphone-lines"></i>',
|
|
sounds: '<i class="fas fa-music"></i>',
|
|
share: '<i class="fas fa-share-alt"></i>',
|
|
user: '<i class="fas fa-user"></i>',
|
|
fileSend: '<i class="fas fa-file-export"></i>',
|
|
fileReceive: '<i class="fas fa-file-import"></i>',
|
|
codecs: '<i class="fa-solid fa-film"></i>',
|
|
theme: '<i class="fas fa-fill-drip"></i>',
|
|
close: '<i class="fas fa-times"></i>',
|
|
};
|
|
|
|
// Whiteboard and fileSharing
|
|
const fileSharingInput = '*'; // allow all file extensions
|
|
const Base64Prefix = 'data:application/pdf;base64,';
|
|
const wbPdfInput = 'application/pdf';
|
|
const wbImageInput = 'image/*';
|
|
|
|
// Reference dimensions for whiteboard (16:9 aspect ratio)
|
|
const wbReferenceWidth = 1920;
|
|
const wbReferenceHeight = 1080;
|
|
|
|
// Peer infos
|
|
const extraInfo = getId('extraInfo');
|
|
const isWebRTCSupported = checkWebRTCSupported();
|
|
const userAgent = navigator.userAgent;
|
|
const parser = new UAParser(userAgent);
|
|
const parserResult = parser.getResult();
|
|
const deviceType = parserResult.device.type || 'desktop';
|
|
const isMobileDevice = deviceType === 'mobile';
|
|
const isTabletDevice = deviceType === 'tablet';
|
|
const isIPadDevice = parserResult.device.model?.toLowerCase() === 'ipad';
|
|
const isDesktopDevice = deviceType === 'desktop';
|
|
const osName = parserResult.os.name;
|
|
const osVersion = parserResult.os.version;
|
|
const browserName = parserResult.browser.name;
|
|
const browserVersion = parserResult.browser.version;
|
|
const isFirefox = browserName.toLowerCase().includes('firefox');
|
|
const peerInfo = getPeerInfo();
|
|
const thisInfo = getInfo();
|
|
|
|
// Local Storage class
|
|
const lS = new LocalStorage();
|
|
const localStorageSettings = lS.getObjectLocalStorage('P2P_SETTINGS');
|
|
const lsSettings = localStorageSettings ? localStorageSettings : lS.P2P_SETTINGS;
|
|
console.log('LOCAL_STORAGE_SETTINGS', lsSettings);
|
|
|
|
// Check if embedded inside an iFrame
|
|
const isEmbedded = window.self !== window.top;
|
|
|
|
// Check if PIP is supported by this browser
|
|
const showVideoPipBtn = document.pictureInPictureEnabled;
|
|
|
|
// Check if Document PIP is supported by this browser
|
|
const showDocumentPipBtn = !isEmbedded && 'documentPictureInPicture' in window;
|
|
|
|
// Loading div
|
|
const loadingDiv = getId('loadingDiv');
|
|
|
|
// Video/Audio media container
|
|
const videoMediaContainer = getId('videoMediaContainer');
|
|
const videoPinMediaContainer = getId('videoPinMediaContainer');
|
|
const audioMediaContainer = getId('audioMediaContainer');
|
|
|
|
// Share Room QR popup
|
|
const qrRoomPopupContainer = getId('qrRoomPopupContainer');
|
|
|
|
// Init audio-video
|
|
const initUser = getId('initUser');
|
|
const initVideoContainer = getQs('.init-video-container');
|
|
const initVideo = getId('initVideo');
|
|
const initVideoBtn = getId('initVideoBtn');
|
|
const initAudioBtn = getId('initAudioBtn');
|
|
const initScreenShareBtn = getId('initScreenShareBtn');
|
|
const initVideoMirrorBtn = getId('initVideoMirrorBtn');
|
|
const initUsernameEmojiButton = getId('initUsernameEmojiButton');
|
|
const initExitBtn = getId('initExitBtn');
|
|
const initVideoSelect = getId('initVideoSelect');
|
|
const initMicrophoneSelect = getId('initMicrophoneSelect');
|
|
const initSpeakerSelect = getId('initSpeakerSelect');
|
|
const usernameEmoji = getId('usernameEmoji');
|
|
|
|
// Buttons bar
|
|
const shareRoomBtn = getId('shareRoomBtn');
|
|
const recordStreamBtn = getId('recordStreamBtn');
|
|
const fullScreenBtn = getId('fullScreenBtn');
|
|
const chatRoomBtn = getId('chatRoomBtn');
|
|
const participantsBtn = getId('participantsBtn');
|
|
const participantsCountBadge = getId('participantsCountBadge');
|
|
const captionBtn = getId('captionBtn');
|
|
const roomEmojiPickerBtn = getId('roomEmojiPickerBtn');
|
|
const whiteboardBtn = getId('whiteboardBtn');
|
|
const snapshotRoomBtn = getId('snapshotRoomBtn');
|
|
const fileShareBtn = getId('fileShareBtn');
|
|
const documentPiPBtn = getId('documentPiPBtn');
|
|
const aboutBtn = getId('aboutBtn');
|
|
|
|
// Buttons bottom
|
|
const bottomButtons = getId('bottomButtons');
|
|
const audioBtn = getId('audioBtn');
|
|
const videoBtn = getId('videoBtn');
|
|
const videoDropdown = getId('videoDropdown');
|
|
const audioDropdown = getId('audioDropdown');
|
|
const videoToggle = getId('videoToggle');
|
|
const audioToggle = getId('audioToggle');
|
|
const videoMenu = getId('videoMenu');
|
|
const audioMenu = getId('audioMenu');
|
|
const swapCameraBtn = getId('swapCameraBtn');
|
|
const hideMeBtn = getId('hideMeBtn');
|
|
const screenShareBtn = getId('screenShareBtn');
|
|
const myHandBtn = getId('myHandBtn');
|
|
const mySettingsBtn = getId('mySettingsBtn');
|
|
const settingsSplit = getId('settingsSplit');
|
|
const settingsExtraDropdown = getId('settingsExtraDropdown');
|
|
const settingsExtraToggle = getId('settingsExtraToggle');
|
|
const settingsExtraMenu = getId('settingsExtraMenu');
|
|
const leaveRoomBtn = getId('leaveRoomBtn');
|
|
|
|
// Room Emoji Picker
|
|
const closeEmojiPickerContainer = getId('closeEmojiPickerContainer');
|
|
const emojiPickerContainer = getId('emojiPickerContainer');
|
|
const userEmoji = getId(`userEmoji`);
|
|
|
|
// Chat room
|
|
const msgerDraggable = getId('msgerDraggable');
|
|
const msgerHeader = getId('msgerHeader');
|
|
const msgerTogglePin = getId('msgerTogglePin');
|
|
const msgerTheme = getId('msgerTheme');
|
|
const msgerCPBtn = getId('msgerCPBtn');
|
|
const msgerDropDownMenuBtn = getId('msgerDropDownMenuBtn');
|
|
const msgerDropDownContent = getId('msgerDropDownContent');
|
|
const msgerClean = getId('msgerClean');
|
|
const msgerSaveBtn = getId('msgerSaveBtn');
|
|
const msgerClose = getId('msgerClose');
|
|
const msgerMaxBtn = getId('msgerMaxBtn');
|
|
const msgerMinBtn = getId('msgerMinBtn');
|
|
const msgerChat = getId('msgerChat');
|
|
const msgerEmptyNotice = getId('msgerEmptyNotice');
|
|
const msgerEmptyParticipantsNotice = getId('msgerEmptyParticipantsNotice');
|
|
const msgerEmojiBtn = getId('msgerEmojiBtn');
|
|
const msgerMarkdownBtn = getId('msgerMarkdownBtn');
|
|
const msgerGPTBtn = getId('msgerGPTBtn');
|
|
const msgerShareFileBtn = getId('msgerShareFileBtn');
|
|
const msgerVideoUrlBtn = getId('msgerVideoUrlBtn');
|
|
const msgerInput = getId('msgerInput');
|
|
const msgerCleanTextBtn = getId('msgerCleanTextBtn');
|
|
const msgerPasteBtn = getId('msgerPasteBtn');
|
|
const msgerShowChatOnMsgDiv = getId('msgerShowChatOnMsgDiv');
|
|
const msgerShowChatOnMsg = getId('msgerShowChatOnMsg');
|
|
const msgerSpeechMsgDiv = getId('msgerSpeechMsgDiv');
|
|
const msgerSpeechMsg = getId('msgerSpeechMsg');
|
|
const msgerSendBtn = getId('msgerSendBtn');
|
|
|
|
const chatInputEmoji = {
|
|
'<3': '❤️',
|
|
'</3': '💔',
|
|
':D': '😀',
|
|
':)': '😃',
|
|
';)': '😉',
|
|
':(': '😒',
|
|
':p': '😛',
|
|
';p': '😜',
|
|
":'(": '😢',
|
|
':+1:': '👍',
|
|
':*': '😘',
|
|
':O': '😲',
|
|
':|': '😐',
|
|
':*(': '😭',
|
|
XD: '😆',
|
|
':B': '😎',
|
|
':P': '😜',
|
|
'<(': '👎',
|
|
'>:(': '😡',
|
|
':S': '😟',
|
|
':X': '🤐',
|
|
';(': '😥',
|
|
':T': '😖',
|
|
':@': '😠',
|
|
':$': '🤑',
|
|
':&': '🤗',
|
|
':#': '🤔',
|
|
':!': '😵',
|
|
':W': '😷',
|
|
':%': '🤒',
|
|
':*!': '🤩',
|
|
':G': '😬',
|
|
':R': '😋',
|
|
':M': '🤮',
|
|
':L': '🥴',
|
|
':C': '🥺',
|
|
':F': '🥳',
|
|
':Z': '🤢',
|
|
':^': '🤓',
|
|
':K': '🤫',
|
|
':D!': '🤯',
|
|
':H': '🧐',
|
|
':U': '🤥',
|
|
':V': '🤪',
|
|
':N': '🥶',
|
|
':J': '🥴',
|
|
}; // https://github.com/wooorm/gemoji/blob/main/support.md
|
|
|
|
// Chat room emoji picker
|
|
const msgerEmojiPicker = getId('msgerEmojiPicker');
|
|
|
|
// Chat room connected peers
|
|
const msgerCP = getId('msgerCP');
|
|
const msgerCPHeader = getId('msgerCPHeader');
|
|
const msgerCPCloseBtn = getId('msgerCPCloseBtn');
|
|
const msgerCPList = getId('msgerCPList');
|
|
const searchPeerBarName = getId('searchPeerBarName');
|
|
const msgerCPDropDownMenuBtn = getId('msgerCPDropDownMenuBtn');
|
|
const msgerCPDropDownContent = getId('msgerCPDropDownContent');
|
|
|
|
// Caption section
|
|
const captionDraggable = getId('captionDraggable');
|
|
const captionHeader = getId('captionHeader');
|
|
const captionTogglePin = getId('captionTogglePin');
|
|
const captionTheme = getId('captionTheme');
|
|
const captionMaxBtn = getId('captionMaxBtn');
|
|
const captionMinBtn = getId('captionMinBtn');
|
|
const captionClean = getId('captionClean');
|
|
const captionSaveBtn = getId('captionSaveBtn');
|
|
const captionClose = getId('captionClose');
|
|
const captionChat = getId('captionChat');
|
|
const captionEmptyNotice = getId('captionEmptyNotice');
|
|
const captionFooter = getId('captionFooter');
|
|
|
|
// My settings
|
|
const mySettings = getId('mySettings');
|
|
const mySettingsHeader = getId('mySettingsHeader');
|
|
const mySessionTime = getId('mySessionTime'); // conference session time
|
|
const tabVideoBtn = getId('tabVideoBtn');
|
|
const tabAudioBtn = getId('tabAudioBtn');
|
|
const tabVideoShareBtn = getId('tabVideoShareBtn');
|
|
const tabRecordingBtn = getId('tabRecordingBtn');
|
|
const tabProfileBtn = getId('tabProfileBtn');
|
|
const tabShortcutsBtn = getId('tabShortcutsBtn');
|
|
const tabNetworkBtn = getId('tabNetworkBtn');
|
|
const networkIP = getId('networkIP');
|
|
const networkHost = getId('networkHost');
|
|
const networkStun = getId('networkStun');
|
|
const networkTurn = getId('networkTurn');
|
|
const tabRoomBtn = getId('tabRoomBtn');
|
|
const roomSendEmailBtn = getId('roomSendEmailBtn');
|
|
const tabStylingBtn = getId('tabStylingBtn');
|
|
const tabLanguagesBtn = getId('tabLanguagesBtn');
|
|
const mySettingsCloseBtn = getId('mySettingsCloseBtn');
|
|
const myPeerNameSet = getId('myPeerNameSet');
|
|
const myPeerNameSetBtn = getId('myPeerNameSetBtn');
|
|
const switchSounds = getId('switchSounds');
|
|
const switchShare = getId('switchShare');
|
|
const switchKeepButtonsVisible = getId('switchKeepButtonsVisible');
|
|
const keepAwakeButton = getId('keepAwakeButton');
|
|
const switchKeepAwake = getId('switchKeepAwake');
|
|
const switchPushToTalk = getId('switchPushToTalk');
|
|
const switchAudioPitchBar = getId('switchAudioPitchBar');
|
|
const audioInputSelect = getId('audioSource');
|
|
const audioOutputSelect = getId('audioOutput');
|
|
const audioOutputDiv = getId('audioOutputDiv');
|
|
const speakerTestBtn = getId('speakerTestBtn');
|
|
const videoSelect = getId('videoSource');
|
|
const videoQualitySelect = getId('videoQuality');
|
|
const videoFpsSelect = getId('videoFps');
|
|
const videoFpsDiv = getId('videoFpsDiv');
|
|
const screenFpsSelect = getId('screenFps');
|
|
const pushToTalkDiv = getId('pushToTalkDiv');
|
|
const recImage = getId('recImage');
|
|
const pauseRecBtn = getId('pauseRecBtn');
|
|
const resumeRecBtn = getId('resumeRecBtn');
|
|
const recordingTime = getId('recordingTime');
|
|
const lastRecordingInfo = getId('lastRecordingInfo');
|
|
const themeSelect = getId('mirotalkTheme');
|
|
const videoObjFitSelect = getId('videoObjFitSelect');
|
|
const btnsBarSelect = getId('mainButtonsBarPosition');
|
|
const pinUnpinGridDiv = getId('pinUnpinGridDiv');
|
|
const pinVideoPositionSelect = getId('pinVideoPositionSelect');
|
|
const tabRoomPeerName = getId('tabRoomPeerName');
|
|
const tabRoomParticipants = getId('tabRoomParticipants');
|
|
const tabRoomSecurity = getId('tabRoomSecurity');
|
|
const tabEmailInvitation = getId('tabEmailInvitation');
|
|
const noiseSuppressionBtn = getId('noiseSuppressionBtn');
|
|
const isPeerPresenter = getId('isPeerPresenter');
|
|
const peersCount = getId('peersCount');
|
|
const screenFpsDiv = getId('screenFpsDiv');
|
|
const switchShortcuts = getId('switchShortcuts');
|
|
|
|
// Audio options
|
|
const micOptionsDiv = getId('micOptionsDiv');
|
|
const switchNoiseSuppression = getId('switchNoiseSuppression');
|
|
const labelNoiseSuppression = getId('labelNoiseSuppression');
|
|
|
|
// Tab Media
|
|
const shareMediaAudioVideoBtn = getId('shareMediaAudioVideoBtn');
|
|
|
|
// My whiteboard
|
|
const whiteboard = getId('whiteboard');
|
|
const whiteboardHeader = getId('whiteboardHeader');
|
|
const whiteboardTitle = getId('whiteboardTitle');
|
|
const whiteboardOptions = getId('whiteboardOptions');
|
|
const wbDrawingColorEl = getId('wbDrawingColorEl');
|
|
const whiteboardGhostButton = getId('whiteboardGhostButton');
|
|
const whiteboardGridBtn = getId('whiteboardGridBtn');
|
|
const wbBackgroundColorEl = getId('wbBackgroundColorEl');
|
|
const whiteboardPencilBtn = getId('whiteboardPencilBtn');
|
|
const whiteboardVanishingBtn = getId('whiteboardVanishingBtn');
|
|
const whiteboardObjectBtn = getId('whiteboardObjectBtn');
|
|
const whiteboardEraserBtn = getId('whiteboardEraserBtn');
|
|
const whiteboardUndoBtn = getId('whiteboardUndoBtn');
|
|
const whiteboardRedoBtn = getId('whiteboardRedoBtn');
|
|
const whiteboardDropDownMenuBtn = getId('whiteboardDropDownMenuBtn');
|
|
const whiteboardDropdownMenu = getId('whiteboardDropdownMenu');
|
|
const whiteboardImgFileBtn = getId('whiteboardImgFileBtn');
|
|
const whiteboardPdfFileBtn = getId('whiteboardPdfFileBtn');
|
|
const whiteboardImgUrlBtn = getId('whiteboardImgUrlBtn');
|
|
const whiteboardTextBtn = getId('whiteboardTextBtn');
|
|
const whiteboardStickyNoteBtn = getId('whiteboardStickyNoteBtn');
|
|
const whiteboardLineBtn = getId('whiteboardLineBtn');
|
|
const whiteboardRectBtn = getId('whiteboardRectBtn');
|
|
const whiteboardTriangleBtn = getId('whiteboardTriangleBtn');
|
|
const whiteboardCircleBtn = getId('whiteboardCircleBtn');
|
|
const whiteboardSaveBtn = getId('whiteboardSaveBtn');
|
|
const whiteboardCleanBtn = getId('whiteboardCleanBtn');
|
|
const whiteboardLockBtn = getId('whiteboardLockBtn');
|
|
const whiteboardUnlockBtn = getId('whiteboardUnlockBtn');
|
|
const whiteboardCloseBtn = getId('whiteboardCloseBtn');
|
|
const whiteboardShortcutsBtn = getId('whiteboardShortcutsBtn');
|
|
const whiteboardShortcutsContent = getId('whiteboardShortcutsContent');
|
|
|
|
// Room actions buttons
|
|
const captionEveryoneBtn = getId('captionEveryoneBtn');
|
|
const captionEveryoneStopBtn = getId('captionEveryoneStopBtn');
|
|
const muteEveryoneBtn = getId('muteEveryoneBtn');
|
|
const hideEveryoneBtn = getId('hideEveryoneBtn');
|
|
const ejectEveryoneBtn = getId('ejectEveryoneBtn');
|
|
const activeRoomsBtn = getId('activeRoomsBtn');
|
|
const lockRoomBtn = getId('lockRoomBtn');
|
|
const unlockRoomBtn = getId('unlockRoomBtn');
|
|
|
|
// File send progress
|
|
const sendFileDiv = getId('sendFileDiv');
|
|
const imgShareSend = getId('imgShareSend');
|
|
const sendFilePercentage = getId('sendFilePercentage');
|
|
const sendFileInfo = getId('sendFileInfo');
|
|
const sendProgress = getId('sendProgress');
|
|
const sendAbortBtn = getId('sendAbortBtn');
|
|
|
|
// File receive progress
|
|
const receiveFileDiv = getId('receiveFileDiv');
|
|
const imgShareReceive = getId('imgShareReceive');
|
|
const receiveFilePercentage = getId('receiveFilePercentage');
|
|
const receiveFileInfo = getId('receiveFileInfo');
|
|
const receiveProgress = getId('receiveProgress');
|
|
const receiveHideBtn = getId('receiveHideBtn');
|
|
const receiveAbortBtn = getId('receiveAbortBtn');
|
|
|
|
// Video/audio url player
|
|
const videoUrlCont = getId('videoUrlCont');
|
|
const videoAudioUrlCont = getId('videoAudioUrlCont');
|
|
const videoUrlHeader = getId('videoUrlHeader');
|
|
const videoAudioUrlHeader = getId('videoAudioUrlHeader');
|
|
const videoUrlCloseBtn = getId('videoUrlCloseBtn');
|
|
const videoAudioCloseBtn = getId('videoAudioCloseBtn');
|
|
const videoUrlIframe = getId('videoUrlIframe');
|
|
const videoAudioUrlElement = getId('videoAudioUrlElement');
|
|
|
|
// Speech recognition
|
|
const speechRecognitionIcon = getId('speechRecognitionIcon');
|
|
const speechRecognitionStart = getId('speechRecognitionStart');
|
|
const speechRecognitionStop = getId('speechRecognitionStop');
|
|
|
|
// Media
|
|
const sinkId = 'sinkId' in HTMLMediaElement.prototype;
|
|
|
|
//....
|
|
|
|
const isRulesActive = true; // Presenter can do anything, guest is slightly moderate, if false no Rules for the room.
|
|
const forceCamMaxResolutionAndFps = false; // This force the webCam to max resolution as default, up to 8k and 60fps (very high bandwidth are required) if false, you can set it from settings
|
|
const useAvatarSvg = true; // if false the cam-Off avatar = images.avatar
|
|
|
|
/**
|
|
* Determines the video zoom mode.
|
|
* If set to true, the video zooms at the center of the frame.
|
|
* If set to false, the video zooms at the cursor position.
|
|
*/
|
|
const ZOOM_CENTER_MODE = false;
|
|
const ZOOM_IN_OUT_ENABLED = true; // Video Zoom in/out default (true)
|
|
|
|
// Color Picker:
|
|
|
|
const keepCustomTheme = getId('keepCustomTheme');
|
|
|
|
const themeCustom = {
|
|
input: getId('themeColorPicker'),
|
|
color: lsSettings.theme_color ? lsSettings.theme_color : '#000000',
|
|
keep: lsSettings.theme_custom ? lsSettings.theme_custom : false,
|
|
};
|
|
|
|
const pickr = Pickr.create({
|
|
el: themeCustom.input,
|
|
theme: 'classic', // or 'monolith', or 'nano'
|
|
default: themeCustom.color,
|
|
useAsButton: true,
|
|
swatches: [
|
|
'rgba(244, 67, 54, 1)',
|
|
'rgba(233, 30, 99, 0.95)',
|
|
'rgba(156, 39, 176, 0.9)',
|
|
'rgba(103, 58, 183, 0.85)',
|
|
'rgba(63, 81, 181, 0.8)',
|
|
'rgba(33, 150, 243, 0.75)',
|
|
'rgba(3, 169, 244, 0.7)',
|
|
'rgba(0, 188, 212, 0.7)',
|
|
'rgba(0, 150, 136, 0.75)',
|
|
'rgba(76, 175, 80, 0.8)',
|
|
'rgba(139, 195, 74, 0.85)',
|
|
'rgba(205, 220, 57, 0.9)',
|
|
'rgba(255, 235, 59, 0.95)',
|
|
'rgba(255, 193, 7, 1)',
|
|
],
|
|
components: {
|
|
preview: true,
|
|
opacity: true,
|
|
hue: true,
|
|
},
|
|
})
|
|
.on('init', (pickr) => {
|
|
themeCustom.input.value = pickr.getSelectedColor().toHEXA().toString(0);
|
|
})
|
|
.on('change', (color) => {
|
|
themeCustom.color = color.toHEXA().toString();
|
|
themeCustom.input.value = themeCustom.color;
|
|
setCustomTheme();
|
|
})
|
|
.on('changestop', () => {
|
|
lsSettings.theme_color = themeCustom.color;
|
|
lS.setSettings(lsSettings);
|
|
});
|
|
|
|
// Room
|
|
let thisMaxRoomParticipants = 8;
|
|
|
|
// misc
|
|
let swBg = 'rgba(0, 0, 0, 0.7)'; // swAlert background color
|
|
let isDocumentOnFullScreen = false;
|
|
let isToggleExtraBtnClicked = false;
|
|
|
|
// peer
|
|
let myPeerId; // This socket.id
|
|
let myPeerUUID = getUUID(); // Unique peer id
|
|
let myPeerName = getPeerName();
|
|
let myPeerAvatar = getPeerAvatar();
|
|
let myToken = getPeerToken(); // peer JWT
|
|
let isPresenter = false; // True Who init the room (aka first peer joined)
|
|
let myHandStatus = false;
|
|
let myVideoStatus = false;
|
|
let myAudioStatus = false;
|
|
let myScreenStatus = false;
|
|
let isScreenEnabled = getScreenEnabled();
|
|
let notify = getNotify(); // popup room sharing on join
|
|
let chat = getChat(); // popup chat on join
|
|
let notifyBySound = true; // turn on - off sound notifications
|
|
let isPeerReconnected = false;
|
|
|
|
// media
|
|
let useAudio = true; // User allow for microphone usage
|
|
let useVideo = true; // User allow for camera usage
|
|
let isEnumerateVideoDevices = false;
|
|
let isEnumerateAudioDevices = false;
|
|
|
|
// video/audio player
|
|
let isVideoUrlPlayerOpen = false;
|
|
let pinnedVideoPlayerId = null;
|
|
|
|
// connection
|
|
let signalingSocket; // socket.io connection to our webserver
|
|
let peerConnections = {}; // keep track of our peer connections, indexed by peer_id == socket.io id
|
|
let chatDataChannels = {}; // keep track of our peer chat data channels
|
|
let fileDataChannels = {}; // keep track of our peer file sharing data channels
|
|
let allPeers = {}; // keep track of all peers in the room, indexed by peer_id == socket.io id
|
|
|
|
let lastStats = null;
|
|
|
|
// stream
|
|
let initStream; // initial webcam stream
|
|
let localVideoMediaStream; // my webcam
|
|
let localScreenMediaStream; // my screen share
|
|
let localScreenDisplayStream; // raw getDisplayMedia stream (may include audio)
|
|
let screenShareAudioContext; // AudioContext used to mix screen audio + microphone
|
|
let localAudioMediaStream; // my microphone
|
|
let noiseProcessor = null; // RNNoise audio processing
|
|
let peerScreenMediaElements = {}; // keep track of our peer <video> tags, indexed by peer_id_screen
|
|
let peerVideoMediaElements = {}; // keep track of our peer <video> tags, indexed by peer_id_video
|
|
let peerAudioMediaElements = {}; // keep track of our peer <audio> tags, indexed by peer_id_audio
|
|
|
|
// main and bottom buttons
|
|
let mainButtonsBarPosition = 'vertical'; // vertical - horizontal
|
|
let placement = 'right'; // https://atomiks.github.io/tippyjs/#placements
|
|
let bottomButtonsPlacement = 'right';
|
|
let isButtonsVisible = false;
|
|
let isButtonsBarOver = false;
|
|
|
|
// video
|
|
let myVideo;
|
|
let myScreen;
|
|
let myAudio;
|
|
let myVideoWrap;
|
|
let myVideoAvatarImage;
|
|
let myPrivacyBtn;
|
|
let myVideoPinBtn;
|
|
let myScreenPinBtn;
|
|
let myPitchBar;
|
|
let myVideoPeerName;
|
|
let myScreenPeerName;
|
|
let myHandStatusIcon;
|
|
let myVideoStatusIcon;
|
|
let myAudioStatusIcon;
|
|
let isVideoPrivacyActive = false; // Video circle for privacy
|
|
let isVideoPinned = false;
|
|
let isVideoFullScreenSupported = true;
|
|
let isVideoOnFullScreen = false;
|
|
let isScreenSharingSupported = false;
|
|
let isScreenStreaming = false;
|
|
let isHideMeActive = getHideMeActive();
|
|
let remoteMediaControls = false; // enable - disable peers video player controls (default false)
|
|
let camera = 'user'; // user = front-facing camera on a smartphone. | environment = the back camera on a smartphone.
|
|
|
|
// chat
|
|
let leftChatAvatar;
|
|
let rightChatAvatar;
|
|
let chatMessagesId = 0;
|
|
let showChatOnMessage = true;
|
|
let isChatPinned = false;
|
|
let isCaptionPinned = false;
|
|
let isChatRoomVisible = false;
|
|
let isParticipantsVisible = false;
|
|
let isCaptionBoxVisible = false;
|
|
let isChatEmojiVisible = false;
|
|
let isChatMarkdownOn = false;
|
|
let isChatGPTOn = false;
|
|
let isChatPasteTxt = false;
|
|
let speechInMessages = false;
|
|
let isSpeechSynthesisSupported = 'speechSynthesis' in window;
|
|
let transcripts = []; // collect all the transcripts to save it later if you need
|
|
let chatMessages = []; // collect chat messages to save it later if want
|
|
let chatGPTcontext = []; // keep chatGPT messages context
|
|
|
|
// settings
|
|
let videoMaxFrameRate = 30;
|
|
let screenMaxFrameRate = 30;
|
|
let videoQualitySelectedIndex = 0; // default HD and 30fps
|
|
let videoFpsSelectedIndex = 1; // 30 fps
|
|
let screenFpsSelectedIndex = 1; // 30 fps
|
|
let isMySettingsVisible = false;
|
|
let thisRoomPassword = null;
|
|
let isRoomLocked = false;
|
|
let isKeepButtonsVisible = false;
|
|
let isAudioPitchBar = true;
|
|
let isPushToTalkActive = false;
|
|
let isSpaceDown = false;
|
|
let isShortcutsEnabled = false;
|
|
|
|
// recording
|
|
let mediaRecorder;
|
|
let recordedBlobs;
|
|
let audioRecorder; // helpers.js
|
|
let recScreenStream; // screen media to recording
|
|
let recTimer;
|
|
let recCodecs;
|
|
let recElapsedTime;
|
|
let recStartTs = null;
|
|
let isStreamRecording = false;
|
|
let isStreamRecordingPaused = false;
|
|
let isRecScreenStream = false;
|
|
|
|
// whiteboard
|
|
let wbCanvas = null;
|
|
let wbIsLock = false;
|
|
let wbIsDrawing = false;
|
|
let wbIsOpen = false;
|
|
let wbIsRedoing = false;
|
|
let wbIsObject = false;
|
|
let wbIsEraser = false;
|
|
let wbIsPencil = false;
|
|
let wbIsVanishing = false;
|
|
let wbIsBgTransparent = false;
|
|
let wbPop = [];
|
|
let wbVanishingObjects = [];
|
|
let wbGridLines = [];
|
|
let wbGridSize = 20;
|
|
let wbStroke = '#cccccc63';
|
|
let wbGridVisible = false;
|
|
let isWhiteboardFs = false;
|
|
|
|
// file transfer
|
|
let fileToSend;
|
|
let fileReader;
|
|
let receiveBuffer = [];
|
|
let receivedSize = 0;
|
|
let incomingFileInfo;
|
|
let incomingFileData;
|
|
let sendInProgress = false;
|
|
let receiveInProgress = false;
|
|
/**
|
|
* MTU 1kb/s to prevent drop.
|
|
* Note: FireFox seems not supports chunkSize > 1024?
|
|
*/
|
|
const chunkSize = 1024; // 1024 * 16; // 16kb/s
|
|
|
|
// server
|
|
let isHostProtected = false; // Username and Password required to initialize room
|
|
let isPeerAuthEnabled = false; // Username and Password required in the URL params to join room
|
|
|
|
// survey
|
|
let surveyActive = true; // when leaving the room give a feedback, if false will be redirected to newcall page
|
|
let surveyURL = 'https://www.questionpro.com/t/AUs7VZq00L';
|
|
|
|
// Redirect on leave room
|
|
let redirectActive = false;
|
|
let redirectURL = '/newcall';
|
|
|
|
let needToCreateOfferByPeer = {};
|
|
|
|
// GeoLocation
|
|
const notificationService = new NotificationService({ Swal, swBg, images, playSound });
|
|
const geoService = GeoService;
|
|
let geo;
|
|
|
|
/**
|
|
* Load GeoLocation service
|
|
* @returns {void}
|
|
*/
|
|
function loadGeo() {
|
|
geo = new PeerGeoLocation({
|
|
room_id: roomId,
|
|
peer_name: myPeerName,
|
|
peer_id: myPeerId,
|
|
peer_uuid: myPeerUUID,
|
|
sendToServer,
|
|
msgPopup,
|
|
notificationService,
|
|
geoService,
|
|
openURL,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load all Html elements by Id
|
|
*/
|
|
function getHtmlElementsById() {
|
|
// My video elements
|
|
myVideo = getId('myVideo');
|
|
myScreen = getId('myScreen');
|
|
myAudio = getId('myAudio');
|
|
myVideoWrap = getId('myVideoWrap');
|
|
myVideoAvatarImage = getId('myVideoAvatarImage');
|
|
myPrivacyBtn = getId('myPrivacyBtn');
|
|
myVideoPinBtn = getId('myVideoPinBtn');
|
|
myScreenPinBtn = getId('myScreenPinBtn');
|
|
myPitchBar = getId('myPitchBar');
|
|
// My username, hand/video/audio status
|
|
myVideoPeerName = getId('myVideoPeerName');
|
|
myScreenPeerName = getId('myScreenPeerName');
|
|
myHandStatusIcon = getId('myHandStatusIcon');
|
|
myVideoStatusIcon = getId('myVideoStatusIcon');
|
|
myAudioStatusIcon = getId('myAudioStatusIcon');
|
|
}
|
|
|
|
/**
|
|
* Using tippy aka very nice tooltip!
|
|
* https://atomiks.github.io/tippyjs/
|
|
*/
|
|
function setButtonsToolTip() {
|
|
// Not need for mobile
|
|
if (isMobileDevice) return;
|
|
// Init buttons
|
|
setTippy(initScreenShareBtn, 'Toggle screen sharing', 'top');
|
|
setTippy(initVideoMirrorBtn, 'Toggle video mirror', 'top');
|
|
setTippy(initUsernameEmojiButton, 'Toggle username emoji', 'top');
|
|
setTippy(initExitBtn, 'Leave meeting', 'top');
|
|
|
|
// Main buttons
|
|
refreshMainButtonsToolTipPlacement();
|
|
// Chat room buttons
|
|
setTippy(msgerClose, 'Close', 'bottom');
|
|
setTippy(msgerShowChatOnMsgDiv, 'Show chat when you receive a new message', 'bottom');
|
|
setTippy(msgerSpeechMsgDiv, 'Speech the incoming messages', 'bottom');
|
|
setTippy(msgerTogglePin, 'Toggle chat pin', 'bottom');
|
|
setTippy(msgerTheme, 'Ghost theme', 'bottom');
|
|
setTippy(msgerMaxBtn, 'Maximize', 'bottom');
|
|
setTippy(msgerMinBtn, 'Minimize', 'bottom');
|
|
setTippy(msgerEmojiBtn, 'Emoji', 'top');
|
|
setTippy(msgerMarkdownBtn, 'Markdown', 'top');
|
|
setTippy(msgerGPTBtn, 'ChatGPT', 'top');
|
|
setTippy(msgerShareFileBtn, 'Share file', 'top');
|
|
setTippy(msgerCPBtn, 'Participants', 'top');
|
|
setTippy(msgerCleanTextBtn, 'Clean', 'top');
|
|
setTippy(msgerPasteBtn, 'Paste', 'top');
|
|
setTippy(msgerSendBtn, 'Send', 'top');
|
|
// Chat participants buttons
|
|
setTippy(msgerCPCloseBtn, 'Close', 'bottom');
|
|
// Caption buttons
|
|
setTippy(captionClose, 'Close', 'bottom');
|
|
setTippy(captionMaxBtn, 'Maximize', 'bottom');
|
|
setTippy(captionMinBtn, 'Minimize', 'bottom');
|
|
setTippy(captionTogglePin, 'Toggle caption pin', 'bottom');
|
|
setTippy(captionTheme, 'Ghost theme', 'bottom');
|
|
setTippy(captionClean, 'Clean the messages', 'bottom');
|
|
setTippy(captionSaveBtn, 'Save the messages', 'bottom');
|
|
setTippy(speechRecognitionIcon, 'Status', 'bottom');
|
|
setTippy(speechRecognitionStart, 'Start caption', 'top');
|
|
setTippy(speechRecognitionStop, 'Stop caption', 'top');
|
|
// Settings
|
|
setTippy(mySettingsCloseBtn, 'Close', 'bottom');
|
|
setTippy(myPeerNameSetBtn, 'Change name', 'top');
|
|
setTippy(myRoomId, 'Room name (click to copy/share)', 'right');
|
|
setTippy(mySessionTime, 'Session time', 'right');
|
|
setTippy(
|
|
switchNoiseSuppression,
|
|
'If Active, the audio will be processed to reduce background noise, making the voice clearer',
|
|
'right'
|
|
);
|
|
setTippy(
|
|
switchPushToTalk,
|
|
'If Active, When SpaceBar keydown the microphone will be activated, on keyup will be deactivated, like a walkie-talkie',
|
|
'right'
|
|
);
|
|
setTippy(switchSounds, 'Toggle room notify sounds', 'right');
|
|
setTippy(switchShare, "Show 'Share Room' popup on join.", 'right');
|
|
setTippy(switchKeepButtonsVisible, 'Keep buttons always visible', 'right');
|
|
setTippy(switchKeepAwake, 'Prevent the device from sleeping (if supported)', 'right');
|
|
setTippy(recImage, 'Toggle recording', 'right');
|
|
setTippy(networkIP, 'IP address associated with the ICE candidate', 'right');
|
|
setTippy(
|
|
networkHost,
|
|
'This type of ICE candidate represents a candidate that corresponds to an interface on the local device. Host candidates are typically generated based on the local IP addresses of the device and can be used for direct peer-to-peer communication within the same network',
|
|
'right'
|
|
);
|
|
setTippy(
|
|
networkStun,
|
|
'Server reflexive candidates are obtained by the ICE agent when it sends a request to a STUN (Session Traversal Utilities for NAT) server. These candidates reflect the public IP address and port of the client as observed by the STUN server. They are useful for traversing NATs (Network Address Translators) and establishing connectivity between peers across different networks',
|
|
'right'
|
|
);
|
|
setTippy(
|
|
networkTurn,
|
|
'Relay candidates are obtained when communication between peers cannot be established directly due to symmetric NATs or firewall restrictions. In such cases, communication is relayed through a TURN (Traversal Using Relays around NAT) server. TURN servers act as intermediaries, relaying data between peers, allowing them to communicate even when direct connections are not possible. This is typically the fallback mechanism for establishing connectivity when direct peer-to-peer communication fails',
|
|
'right'
|
|
);
|
|
// Whiteboard buttons
|
|
setTippy(whiteboardLockBtn, 'Toggle Lock whiteboard', 'right');
|
|
setTippy(whiteboardUnlockBtn, 'Toggle Lock whiteboard', 'right');
|
|
setTippy(whiteboardCloseBtn, 'Close', 'bottom');
|
|
setTippy(wbDrawingColorEl, 'Drawing color', 'bottom');
|
|
setTippy(whiteboardGhostButton, 'Toggle transparent background', 'bottom');
|
|
setTippy(whiteboardGridBtn, 'Toggle whiteboard grid', 'bottom');
|
|
setTippy(wbBackgroundColorEl, 'Background color', 'bottom');
|
|
setTippy(whiteboardPencilBtn, 'Drawing mode', 'bottom');
|
|
setTippy(whiteboardVanishingBtn, 'Vanishing pen (disappears in 5s)', 'bottom');
|
|
setTippy(whiteboardObjectBtn, 'Object mode', 'bottom');
|
|
setTippy(whiteboardEraserBtn, 'Eraser mode', 'bottom');
|
|
setTippy(whiteboardUndoBtn, 'Undo', 'bottom');
|
|
setTippy(whiteboardRedoBtn, 'Redo', 'bottom');
|
|
// Suspend/Hide File transfer buttons
|
|
setTippy(sendAbortBtn, 'Abort file transfer', 'bottom');
|
|
setTippy(receiveAbortBtn, 'Abort file transfer', 'bottom');
|
|
setTippy(receiveHideBtn, 'Hide file transfer', 'bottom');
|
|
// Video/audio URL player
|
|
setTippy(videoUrlCloseBtn, 'Close the video player', 'bottom');
|
|
setTippy(videoAudioCloseBtn, 'Close the video player', 'bottom');
|
|
setTippy(msgerVideoUrlBtn, 'Share a video or audio to all participants', 'top');
|
|
}
|
|
|
|
/**
|
|
* Refresh main buttons tooltips based of they position (vertical/horizontal)
|
|
* @returns void
|
|
*/
|
|
function refreshMainButtonsToolTipPlacement() {
|
|
// not need for mobile
|
|
if (isMobileDevice) return;
|
|
|
|
// ButtonsBar
|
|
placement = btnsBarSelect.options[btnsBarSelect.selectedIndex].value == 'vertical' ? 'right' : 'top';
|
|
|
|
// BottomButtons
|
|
bottomButtonsPlacement = btnsBarSelect.options[btnsBarSelect.selectedIndex].value == 'vertical' ? 'top' : 'right';
|
|
|
|
setTippy(audioBtn, useAudio ? 'Stop the audio' : 'My audio is disabled', bottomButtonsPlacement);
|
|
setTippy(videoBtn, useVideo ? 'Stop the video' : 'My video is disabled', bottomButtonsPlacement);
|
|
setTippy(screenShareBtn, 'Start screen sharing', bottomButtonsPlacement);
|
|
setTippy(myHandBtn, 'Raise your hand', bottomButtonsPlacement);
|
|
setTippy(chatRoomBtn, 'Open the chat', bottomButtonsPlacement);
|
|
setTippy(participantsBtn, 'Show participants', bottomButtonsPlacement);
|
|
setTippy(mySettingsBtn, 'Open the settings', bottomButtonsPlacement);
|
|
setTippy(leaveRoomBtn, 'Leave this room', bottomButtonsPlacement);
|
|
}
|
|
|
|
/**
|
|
* Set nice tooltip to element
|
|
* @param {object} element element
|
|
* @param {string} content message to popup
|
|
* @param {string} placement position
|
|
*/
|
|
function setTippy(element, content, placement) {
|
|
if (isMobileDevice) return;
|
|
if (element) {
|
|
if (element._tippy) {
|
|
element._tippy.destroy();
|
|
}
|
|
try {
|
|
tippy(element, {
|
|
content: content,
|
|
placement: placement,
|
|
});
|
|
} catch (err) {
|
|
console.error('setTippy error', err.message);
|
|
}
|
|
} else {
|
|
console.warn('setTippy element not found with content', content);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get peer info using D
|
|
* @returns {object} peer info
|
|
*/
|
|
function getPeerInfo() {
|
|
return {
|
|
isDesktopDevice: isDesktopDevice,
|
|
isMobileDevice: isMobileDevice,
|
|
isTabletDevice: isTabletDevice,
|
|
isIPadDevice: isIPadDevice,
|
|
osName: osName,
|
|
osVersion: osVersion,
|
|
browserName: browserName,
|
|
browserVersion: browserVersion,
|
|
extras: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get Extra info
|
|
* @returns object info
|
|
*/
|
|
function getInfo() {
|
|
try {
|
|
console.log('Info', parserResult);
|
|
|
|
// Filter out properties with 'Unknown' values
|
|
const filterUnknown = (obj) => {
|
|
const filtered = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (value && value !== 'Unknown') {
|
|
filtered[key] = value;
|
|
}
|
|
}
|
|
return filtered;
|
|
};
|
|
|
|
const filteredResult = {
|
|
//ua: parserResult.ua,
|
|
browser: filterUnknown(parserResult.browser),
|
|
cpu: filterUnknown(parserResult.cpu),
|
|
device: filterUnknown(parserResult.device),
|
|
engine: filterUnknown(parserResult.engine),
|
|
os: filterUnknown(parserResult.os),
|
|
};
|
|
|
|
// Convert the filtered result to a readable JSON string
|
|
const resultString = JSON.stringify(filteredResult, null, 2);
|
|
|
|
extraInfo.innerText = resultString;
|
|
|
|
return parserResult;
|
|
} catch (error) {
|
|
console.error('Error parsing user agent:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Signaling server URL
|
|
* @returns {string} Signaling server URL
|
|
*/
|
|
function getSignalingServer() {
|
|
console.log('00 Location', window.location);
|
|
return window.location.protocol + '//' + window.location.hostname;
|
|
}
|
|
|
|
/**
|
|
* Generate random Room id if not set
|
|
* @returns {string} Room Id
|
|
*/
|
|
function getRoomId() {
|
|
// check if passed as params /join?room=id
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let queryRoomId = filterXSS(qs.get('room'));
|
|
|
|
// skip /join/
|
|
let roomId = queryRoomId ? queryRoomId : window.location.pathname.split('/join/')[1];
|
|
|
|
// if not specified room id or 'random', create one random
|
|
if (roomId == '' || roomId === 'random') {
|
|
roomId = makeId(20);
|
|
const newUrl = signalingServer + '/join/' + roomId;
|
|
window.history.pushState({ url: newUrl }, roomId, newUrl);
|
|
}
|
|
console.log('Direct join', { room: roomId });
|
|
|
|
// Update Room name in settings
|
|
if (myRoomId) myRoomId.innerText = roomId;
|
|
|
|
// Save room name in local storage
|
|
window.localStorage.lastRoom = roomId;
|
|
return roomId;
|
|
}
|
|
|
|
/**
|
|
* Generate random Id
|
|
* @param {integer} length
|
|
* @returns {string} random id
|
|
*/
|
|
function makeId(length) {
|
|
let result = '';
|
|
let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let charactersLength = characters.length;
|
|
for (let i = 0; i < length; i++) {
|
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get UUID4
|
|
* @returns uuid4
|
|
*/
|
|
function getUUID() {
|
|
const uuid4 = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
|
|
);
|
|
if (window.localStorage.uuid) {
|
|
return window.localStorage.uuid;
|
|
}
|
|
window.localStorage.uuid = uuid4;
|
|
return uuid4;
|
|
}
|
|
|
|
/**
|
|
* Check if notify is set
|
|
* @returns {boolean} true/false (default true)
|
|
*/
|
|
function getNotify() {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let notify = filterXSS(qs.get('notify'));
|
|
if (notify) {
|
|
let queryNotify = notify === '1' || notify === 'true';
|
|
if (queryNotify != null) {
|
|
console.log('Direct join', { notify: queryNotify });
|
|
return queryNotify;
|
|
}
|
|
}
|
|
notify = lsSettings.share_on_join;
|
|
console.log('Direct join', { notify: notify });
|
|
return notify;
|
|
}
|
|
|
|
/**
|
|
* Check if chat is set
|
|
* @returns {boolean} true/false
|
|
*/
|
|
function getChat() {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let chat = filterXSS(qs.get('chat'));
|
|
if (chat) {
|
|
let queryChat = chat === '1' || chat === 'true';
|
|
if (queryChat != null) {
|
|
console.log('Direct join', { chat: queryChat });
|
|
notify = false; // From widget disable notify on join...
|
|
return queryChat;
|
|
}
|
|
}
|
|
console.log('Direct join', { chat: chat });
|
|
return chat;
|
|
}
|
|
|
|
/**
|
|
* Get Peer JWT
|
|
* @returns {mixed} boolean false or token string
|
|
*/
|
|
function getPeerToken() {
|
|
if (window.sessionStorage.peer_token) return window.sessionStorage.peer_token;
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let token = filterXSS(qs.get('token'));
|
|
let queryToken = false;
|
|
if (token) {
|
|
queryToken = token;
|
|
}
|
|
console.log('Direct join', { token: queryToken });
|
|
return queryToken;
|
|
}
|
|
|
|
/**
|
|
* Check if peer name is set
|
|
* @returns {string} Peer Name
|
|
*/
|
|
function getPeerName() {
|
|
const qs = new URLSearchParams(window.location.search);
|
|
const name = filterXSS(qs.get('name'));
|
|
if (isHtml(name)) {
|
|
console.log('Direct join', { name: 'Invalid name' });
|
|
return 'Invalid name';
|
|
}
|
|
|
|
if (name === 'random') {
|
|
const randomName = generateRandomName();
|
|
console.log('Direct join', { name: randomName });
|
|
return randomName;
|
|
}
|
|
|
|
console.log('Direct join', { name: name });
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* Generate random peer name
|
|
* @returns {string} Random Peer Name
|
|
*/
|
|
function generateRandomName() {
|
|
const adjectives = ['Quick', 'Lazy', 'Happy', 'Sad', 'Brave', 'Clever', 'Witty', 'Calm', 'Bright', 'Charming'];
|
|
const nouns = ['Fox', 'Dog', 'Cat', 'Mouse', 'Lion', 'Tiger', 'Bear', 'Wolf', 'Eagle', 'Shark'];
|
|
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
const number = Math.floor(Math.random() * 1000);
|
|
return `${adjective}${noun}${number}`;
|
|
}
|
|
|
|
/**
|
|
* Check if peer avatar is set
|
|
* @returns {string} Peer Avatar
|
|
*/
|
|
function getPeerAvatar() {
|
|
const qs = new URLSearchParams(window.location.search);
|
|
const avatar = filterXSS(qs.get('avatar'));
|
|
const avatarDisabled = avatar === '0' || avatar === 'false';
|
|
|
|
console.log('Direct join', { avatar: avatar });
|
|
|
|
if (avatarDisabled || !isImageURL(avatar)) {
|
|
return false;
|
|
}
|
|
return avatar;
|
|
}
|
|
|
|
/**
|
|
* Is screen enabled on join room
|
|
* @returns {boolean} true/false
|
|
*/
|
|
function getScreenEnabled() {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let screen = filterXSS(qs.get('screen'));
|
|
if (screen) {
|
|
screen = screen.toLowerCase();
|
|
let queryPeerScreen = screen === '1' || screen === 'true';
|
|
console.log('Direct join', { screen: queryPeerScreen });
|
|
return queryPeerScreen;
|
|
}
|
|
console.log('Direct join', { screen: false });
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Hide myself from the meeting view
|
|
* @returns {boolean} true/false
|
|
*/
|
|
function getHideMeActive() {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let hide = filterXSS(qs.get('hide'));
|
|
let queryHideMe = false;
|
|
if (hide) {
|
|
hide = hide.toLowerCase();
|
|
queryHideMe = hide === '1' || hide === 'true';
|
|
}
|
|
console.log('Direct join', { hide: queryHideMe });
|
|
return queryHideMe;
|
|
}
|
|
|
|
/**
|
|
* Check if there is peer connections
|
|
* @returns {boolean} true/false
|
|
*/
|
|
function thereArePeerConnections() {
|
|
if (Object.keys(peerConnections).length === 0) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Count the peer connections
|
|
* @returns peer connections count
|
|
*/
|
|
function countPeerConnections() {
|
|
return Object.keys(peerConnections).length;
|
|
}
|
|
|
|
/**
|
|
* Get Started...
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
initCursorLightEffect();
|
|
initClientPeer();
|
|
});
|
|
|
|
/**
|
|
* Initialize cursor light effect on video container
|
|
*/
|
|
function initCursorLightEffect() {
|
|
if (!videoMediaContainer || !isDesktopDevice) return;
|
|
videoMediaContainer.classList.add('mouse-light');
|
|
videoMediaContainer.addEventListener('mousemove', function (e) {
|
|
const rect = videoMediaContainer.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
videoMediaContainer.style.setProperty('--mouse-x', x + '%');
|
|
videoMediaContainer.style.setProperty('--mouse-y', y + '%');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* On body load Get started
|
|
*/
|
|
function initClientPeer() {
|
|
setTheme();
|
|
|
|
if (!isWebRTCSupported) {
|
|
return userLog('error', 'This browser seems not supported WebRTC!');
|
|
}
|
|
|
|
// Check if video full screen is supported by the browser
|
|
isVideoFullScreenSupported =
|
|
typeof document !== 'undefined' &&
|
|
('fullscreenEnabled' in document ||
|
|
'webkitFullscreenEnabled' in document ||
|
|
'mozFullScreenEnabled' in document ||
|
|
'msFullscreenEnabled' in document);
|
|
|
|
console.log('01. Connecting to signaling server');
|
|
|
|
// Disable the HTTP long-polling transport
|
|
signalingSocket = io({ transports: ['websocket'] });
|
|
|
|
const transport = signalingSocket.io.engine.transport.name; // in most cases, "polling"
|
|
console.log('02. Connection transport', transport);
|
|
|
|
// Check upgrade transport
|
|
signalingSocket.io.engine.on('upgrade', () => {
|
|
const upgradedTransport = signalingSocket.io.engine.transport.name; // in most cases, "websocket"
|
|
console.log('Connection upgraded transport', upgradedTransport);
|
|
});
|
|
|
|
// async - await requests
|
|
signalingSocket.request = function request(type, data = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
signalingSocket.emit(type, data, (data) => {
|
|
if (data.error) {
|
|
console.error('signalingSocket.request error', data.error);
|
|
reject(data.error);
|
|
} else {
|
|
console.log('signalingSocket.request data', data);
|
|
resolve(data);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
// on receiving data from signaling server...
|
|
signalingSocket.on('connect', handleConnect);
|
|
signalingSocket.on('unauthorized', handleUnauthorized);
|
|
signalingSocket.on('roomIsLocked', handleUnlockTheRoom);
|
|
signalingSocket.on('roomAction', handleRoomAction);
|
|
signalingSocket.on('addPeer', handleAddPeer);
|
|
signalingSocket.on('serverInfo', handleServerInfo);
|
|
signalingSocket.on('sessionDescription', handleSessionDescription);
|
|
signalingSocket.on('iceCandidate', handleIceCandidate);
|
|
signalingSocket.on('peerName', handlePeerName);
|
|
signalingSocket.on('peerStatus', handlePeerStatus);
|
|
signalingSocket.on('peerAction', handlePeerAction);
|
|
signalingSocket.on('cmd', handleCmd);
|
|
signalingSocket.on('message', handleMessage);
|
|
signalingSocket.on('caption', handleCaptionActions);
|
|
signalingSocket.on('videoPlayer', handleVideoPlayer);
|
|
signalingSocket.on('wbCanvasToJson', handleJsonToWbCanvas);
|
|
signalingSocket.on('whiteboardAction', handleWhiteboardAction);
|
|
signalingSocket.on('fileInfo', handleFileInfo);
|
|
signalingSocket.on('fileAbort', handleFileAbort);
|
|
signalingSocket.on('fileReceiveAbort', abortFileTransfer);
|
|
signalingSocket.on('kickOut', handleKickedOut);
|
|
signalingSocket.on('disconnect', handleDisconnect);
|
|
signalingSocket.on('removePeer', handleRemovePeer);
|
|
} // end [initClientPeer]
|
|
|
|
/**
|
|
* Send async data to signaling server (server.js)
|
|
* @param {string} msg msg to send to signaling server
|
|
* @param {object} config data to send to signaling server
|
|
*/
|
|
async function sendToServer(msg, config = {}) {
|
|
await signalingSocket.emit(msg, config);
|
|
}
|
|
|
|
/**
|
|
* Send async data through RTC Data Channels
|
|
* @param {object} config data
|
|
*/
|
|
async function sendToDataChannel(config) {
|
|
if (thereArePeerConnections() && typeof config === 'object' && config !== null) {
|
|
for (let peer_id in chatDataChannels) {
|
|
if (chatDataChannels[peer_id].readyState === 'open')
|
|
await chatDataChannels[peer_id].send(JSON.stringify(config));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connected to Signaling Server. Once the user has given us access to their
|
|
* microphone/cam, join the channel and start peering up
|
|
*/
|
|
async function handleConnect() {
|
|
console.log('03. Connected to signaling server');
|
|
|
|
myPeerId = signalingSocket.id;
|
|
console.log('04. My peer id [ ' + myPeerId + ' ]');
|
|
|
|
await getButtons();
|
|
|
|
// If reconnecting, force rejoin to properly sync with other peers
|
|
if (localVideoMediaStream && localAudioMediaStream) {
|
|
await joinToChannel();
|
|
} else {
|
|
await initEnumerateDevices();
|
|
await setupLocalVideoMedia();
|
|
await setupLocalAudioMedia();
|
|
// Create camera tile (even if no camera, to show avatar)
|
|
if (!useVideo || (!useVideo && !useAudio)) {
|
|
await loadLocalMedia(new MediaStream(), 'video');
|
|
}
|
|
getHtmlElementsById();
|
|
setButtonsToolTip();
|
|
handleUsernameEmojiPicker();
|
|
manageButtons();
|
|
handleButtonsRule();
|
|
setupMySettings();
|
|
loadSettingsFromLocalStorage();
|
|
setupVideoUrlPlayer();
|
|
handleDropdownHover();
|
|
setupQuickDeviceSwitchDropdowns();
|
|
startSessionTime();
|
|
await whoAreYou();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle some signaling server info
|
|
* @param {object} config data
|
|
*/
|
|
function handleServerInfo(config) {
|
|
console.log('13. Server info', config);
|
|
|
|
const { peers_count, host_protected, user_auth, is_presenter, survey, redirect, maxRoomParticipants } = config;
|
|
|
|
isHostProtected = host_protected;
|
|
isPeerAuthEnabled = user_auth;
|
|
|
|
// Get survey settings from server
|
|
surveyActive = survey.active;
|
|
surveyURL = survey.url;
|
|
|
|
// Get redirect settings from server
|
|
redirectActive = redirect.active;
|
|
redirectURL = redirect.url;
|
|
|
|
// Limit room to n peers
|
|
if (maxRoomParticipants) thisMaxRoomParticipants = maxRoomParticipants;
|
|
if (peers_count > thisMaxRoomParticipants) {
|
|
return roomIsBusy();
|
|
}
|
|
|
|
// Let start with some basic rules
|
|
isPresenter = is_presenter;
|
|
console.log('New connection - presenter status from server:', isPresenter);
|
|
isPeerPresenter.innerText = isPresenter;
|
|
|
|
// Peer identified if presenter or not then....
|
|
handleShortcuts();
|
|
|
|
if (isRulesActive) {
|
|
handleRules(isPresenter);
|
|
}
|
|
|
|
if (notify && peers_count == 1) {
|
|
shareRoomMeetingURL(true);
|
|
} else {
|
|
checkShareScreen();
|
|
}
|
|
|
|
checkChatOnJoin();
|
|
}
|
|
|
|
/**
|
|
* HOST_USER_AUTH enabled and peer not match valid username and password
|
|
*/
|
|
function handleUnauthorized() {
|
|
playSound('alert');
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false,
|
|
background: swBg,
|
|
imageUrl: images.forbidden,
|
|
title: 'Ops, Unauthorized',
|
|
text: 'The host has user authentication enabled',
|
|
confirmButtonText: `Login`,
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
}).then(() => {
|
|
// Login required to join room
|
|
openURL(`/login/?room=${roomId}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Room is busy, disconnect me and alert the user that
|
|
* will be redirected to home page
|
|
*/
|
|
function roomIsBusy() {
|
|
signalingSocket.disconnect();
|
|
playSound('alert');
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false,
|
|
background: swBg,
|
|
imageUrl: images.forbidden,
|
|
position: 'center',
|
|
title: 'Room is busy',
|
|
html: `The room is limited to ${thisMaxRoomParticipants} users. <br/> Please try again later`,
|
|
showDenyButton: false,
|
|
confirmButtonText: `OK`,
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
openURL('/');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Presenter can do anything, for others you can limit
|
|
* some functions by hidden the buttons etc.
|
|
*
|
|
* @param {boolean} isPresenter true/false
|
|
*/
|
|
function handleRules(isPresenter) {
|
|
console.log('14. Peer isPresenter: ' + isPresenter + ' Reconnected to signaling server: ' + isPeerReconnected);
|
|
if (!isPresenter) {
|
|
buttons.settings.showTabRoomParticipants = false;
|
|
buttons.settings.showTabRoomSecurity = false;
|
|
buttons.settings.showTabEmailInvitation = false;
|
|
buttons.remote.showKickOutBtn = false;
|
|
buttons.remote.showGeoLocationBtn = false;
|
|
buttons.whiteboard.whiteboardLockBtn = false;
|
|
//...
|
|
} else {
|
|
buttons.main.showShareRoomBtn = true;
|
|
buttons.settings.showMicOptionsBtn = true;
|
|
buttons.settings.showTabRoomParticipants = true;
|
|
buttons.settings.showTabRoomSecurity = true;
|
|
buttons.settings.showTabEmailInvitation = true;
|
|
buttons.settings.showLockRoomBtn = !isRoomLocked;
|
|
buttons.settings.showUnlockRoomBtn = isRoomLocked;
|
|
buttons.remote.audioBtnClickAllowed = true;
|
|
buttons.remote.videoBtnClickAllowed = true;
|
|
buttons.remote.showKickOutBtn = true;
|
|
buttons.whiteboard.whiteboardLockBtn = true;
|
|
}
|
|
|
|
handleButtonsRule();
|
|
}
|
|
|
|
/**
|
|
* Hide not desired buttons
|
|
*/
|
|
function handleButtonsRule() {
|
|
const showExtraBtn =
|
|
buttons.main.showExtraBtn &&
|
|
Array.from(settingsExtraMenu.children).filter((el) => el.style.display !== 'none').length > 0;
|
|
if (!showExtraBtn) {
|
|
mySettingsBtn.style.borderRadius = '10px';
|
|
}
|
|
|
|
// Main buttons
|
|
displayElements([
|
|
{ element: shareRoomBtn, display: buttons.main.showShareRoomBtn },
|
|
{ element: hideMeBtn, display: buttons.main.showHideMeBtn },
|
|
{ element: settingsExtraDropdown, display: showExtraBtn },
|
|
{ element: audioBtn, display: buttons.main.showAudioBtn },
|
|
{ element: videoBtn, display: buttons.main.showVideoBtn },
|
|
//{ element: screenShareBtn, display: buttons.main.showScreenBtn }, // auto-detected
|
|
{ element: recordStreamBtn, display: buttons.main.showRecordStreamBtn },
|
|
{ element: recImage, display: buttons.main.showRecordStreamBtn },
|
|
{ element: chatRoomBtn, display: buttons.main.showChatRoomBtn },
|
|
{ element: participantsBtn, display: buttons.main.showParticipantsBtn },
|
|
{ element: captionBtn, display: buttons.main.showCaptionRoomBtn && speechRecognition }, // auto-detected
|
|
{ element: roomEmojiPickerBtn, display: buttons.main.showRoomEmojiPickerBtn },
|
|
{ element: myHandBtn, display: buttons.main.showMyHandBtn },
|
|
{ element: whiteboardBtn, display: buttons.main.showWhiteboardBtn },
|
|
{ element: snapshotRoomBtn, display: buttons.main.showSnapshotRoomBtn && !isMobileDevice },
|
|
{ element: fileShareBtn, display: buttons.main.showFileShareBtn },
|
|
{ element: documentPiPBtn, display: showDocumentPipBtn && buttons.main.showDocumentPipBtn },
|
|
{ element: mySettingsBtn, display: buttons.main.showMySettingsBtn },
|
|
{ element: aboutBtn, display: buttons.main.showAboutBtn },
|
|
]);
|
|
|
|
// Chat buttons
|
|
displayElements([
|
|
{ element: msgerTogglePin, display: !isMobileDevice && buttons.chat.showTogglePinBtn },
|
|
{ element: msgerMaxBtn, display: !isMobileDevice && buttons.chat.showMaxBtn },
|
|
{ element: msgerSaveBtn, display: buttons.chat.showSaveMessageBtn },
|
|
{ element: msgerMarkdownBtn, display: buttons.chat.showMarkDownBtn },
|
|
{ element: msgerGPTBtn, display: buttons.chat.showChatGPTBtn },
|
|
{ element: msgerShareFileBtn, display: buttons.chat.showFileShareBtn },
|
|
{ element: msgerVideoUrlBtn, display: buttons.chat.showShareVideoAudioBtn },
|
|
{ element: msgerCPBtn, display: buttons.chat.showParticipantsBtn },
|
|
]);
|
|
|
|
// Caption buttons
|
|
displayElements([
|
|
{ element: captionTogglePin, display: !isMobileDevice && buttons.caption.showTogglePinBtn },
|
|
{ element: captionMaxBtn, display: !isMobileDevice && buttons.caption.showMaxBtn },
|
|
]);
|
|
|
|
// Settings buttons
|
|
displayElements([
|
|
{ element: activeRoomsBtn, display: buttons.settings.showActiveRoomsBtn },
|
|
{ element: micOptionsDiv, display: buttons.settings.showMicOptionsBtn || isPresenter },
|
|
{ element: captionEveryoneBtn, display: buttons.settings.showCaptionEveryoneBtn },
|
|
{ element: muteEveryoneBtn, display: buttons.settings.showMuteEveryoneBtn },
|
|
{ element: hideEveryoneBtn, display: buttons.settings.showHideEveryoneBtn },
|
|
{ element: ejectEveryoneBtn, display: buttons.settings.showEjectEveryoneBtn },
|
|
{ element: lockRoomBtn, display: buttons.settings.showLockRoomBtn },
|
|
{ element: unlockRoomBtn, display: buttons.settings.showUnlockRoomBtn },
|
|
{ element: tabRoomPeerName, display: buttons.settings.showTabRoomPeerName },
|
|
{ element: tabRoomParticipants, display: buttons.settings.showTabRoomParticipants },
|
|
{ element: tabRoomSecurity, display: buttons.settings.showTabRoomSecurity },
|
|
{ element: tabEmailInvitation, display: buttons.settings.showTabEmailInvitation },
|
|
{ element: noiseSuppressionBtn, display: buttons.settings.customNoiseSuppression },
|
|
]);
|
|
|
|
// Whiteboard
|
|
elemDisplay(
|
|
whiteboardLockBtn,
|
|
buttons.whiteboard.whiteboardLockBtn,
|
|
buttons.whiteboard.whiteboardLockBtn ? 'flex' : undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get Buttons config from server side and apply to current client
|
|
*/
|
|
async function getButtons() {
|
|
try {
|
|
const response = await axios.get('/buttons', {
|
|
timeout: 5000,
|
|
});
|
|
const serverButtons = response.data.message;
|
|
if (serverButtons) {
|
|
// Merge serverButtons into BUTTONS, keeping nested keys intact by performing a deep merge
|
|
buttons = mergeConfig(buttons || {}, serverButtons);
|
|
console.log('AXIOS ROOM BUTTONS SETTINGS', {
|
|
serverButtons: serverButtons,
|
|
clientButtons: buttons,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('AXIOS GET CONFIG ERROR', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deep merge two objects
|
|
* @param {object} target target object
|
|
* @param {object} source source object
|
|
* @returns {object} merged object
|
|
*/
|
|
function mergeConfig(target, source) {
|
|
if (typeof target !== 'object' || target === null) return source;
|
|
if (typeof source !== 'object' || source === null) return source;
|
|
const output = Array.isArray(target) ? target.slice() : { ...target };
|
|
for (const key of Object.keys(source)) {
|
|
const srcVal = source[key];
|
|
const tgtVal = output[key];
|
|
if (srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal)) {
|
|
output[key] = mergeConfig(tgtVal || {}, srcVal);
|
|
} else {
|
|
output[key] = srcVal;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Get user name from OIDC profile
|
|
* @returns {string} Peer Name
|
|
*/
|
|
async function getUserName() {
|
|
try {
|
|
const { data: profile } = await axios.get('/profile', { timeout: 5000 });
|
|
if (profile && profile.name) {
|
|
console.log('AXIOS GET OIDC Profile retrieved successfully', profile);
|
|
window.localStorage.peer_name = profile.name;
|
|
}
|
|
} catch (error) {
|
|
console.error('AXIOS OIDC Error fetching profile', error.message || error);
|
|
}
|
|
return window.localStorage.peer_name || '';
|
|
}
|
|
|
|
/**
|
|
* set your name for the conference
|
|
*/
|
|
async function whoAreYou() {
|
|
console.log('11. Who are you?');
|
|
|
|
document.body.style.background = 'var(--body-bg)';
|
|
|
|
if (myPeerName) {
|
|
elemDisplay(loadingDiv, false);
|
|
|
|
myPeerName = filterXSS(myPeerName);
|
|
|
|
console.log(`11.1 Check if ${myPeerName} exist in the room`, roomId);
|
|
|
|
if (await checkUserName()) {
|
|
if (!myToken) return userNameAlreadyInRoom(); // #209 Hack...
|
|
}
|
|
|
|
checkPeerAudioVideo();
|
|
whoAreYouJoin();
|
|
playSound('addPeer');
|
|
return;
|
|
}
|
|
|
|
playSound('newMessage');
|
|
|
|
// init buttons click events
|
|
|
|
initVideoBtn.onclick = async (e) => {
|
|
await handleVideo(e, true);
|
|
};
|
|
initAudioBtn.onclick = (e) => {
|
|
handleAudio(e, true);
|
|
};
|
|
initVideoMirrorBtn.onclick = (e) => {
|
|
toggleInitVideoMirror();
|
|
};
|
|
initUsernameEmojiButton.onclick = (e) => {
|
|
getId('usernameInput').value = '';
|
|
toggleUsernameEmoji();
|
|
};
|
|
initExitBtn.onclick = (e) => {
|
|
initExitMeeting();
|
|
};
|
|
|
|
await loadLocalStorage();
|
|
|
|
// detect low quality bluetooth headset
|
|
detectBluetoothHeadset(true);
|
|
|
|
if (!useVideo || !buttons.main.showVideoBtn) {
|
|
displayElements([
|
|
{ element: getId('initVideo'), display: false },
|
|
{ element: getId('initVideoBtn'), display: false },
|
|
{ element: getId('initVideoMirrorBtn'), display: false },
|
|
{ element: getId('initVideoSelect'), display: false },
|
|
]);
|
|
if (!buttons.main.showVideoBtn) {
|
|
elemDisplay(getId('tabVideoBtn'), false);
|
|
}
|
|
// Disable camera settings, keep screen available
|
|
displayElements([
|
|
{ element: getId('videoDropdown'), display: false },
|
|
{ element: getId('videoSourceDiv'), display: false },
|
|
{ element: getId('videoFitDiv'), display: false },
|
|
{ element: getId('videoFpsDiv'), display: false },
|
|
]);
|
|
}
|
|
if (!useAudio || !buttons.main.showAudioBtn) {
|
|
displayElements([
|
|
{ element: getId('initAudioBtn'), display: false },
|
|
{ element: getId('initMicrophoneSelect'), display: false },
|
|
{ element: getId('initSpeakerSelect'), display: false },
|
|
{ element: getId('tabAudioBtn'), display: false },
|
|
{ element: getId('audioDropdown'), display: false },
|
|
]);
|
|
}
|
|
if (!buttons.main.showScreenBtn) {
|
|
elemDisplay(getId('initScreenShareBtn'), false);
|
|
}
|
|
|
|
initVideoContainerShow(myVideoStatus);
|
|
|
|
window.localStorage.peer_name = await getUserName();
|
|
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false,
|
|
background: swBg,
|
|
title: brand.app?.name || 'MiroTalk P2P',
|
|
position: 'center',
|
|
input: 'text',
|
|
inputPlaceholder: 'Enter your email or name',
|
|
inputAttributes: { maxlength: 254, id: 'usernameInput' },
|
|
inputValue: window.localStorage.peer_name ? window.localStorage.peer_name : '',
|
|
html: initUser, // inject html
|
|
confirmButtonText: `Join meeting`,
|
|
customClass: { popup: 'init-modal-size' },
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
willOpen: () => {
|
|
elemDisplay(loadingDiv, false);
|
|
},
|
|
inputValidator: async (value) => {
|
|
if (!value) return 'Please enter your email or name';
|
|
|
|
// Long email or name
|
|
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
if ((isEmail && value.length > 254) || (!isEmail && value.length > 32)) {
|
|
return isEmail ? 'Email must be max 254 char' : 'Name must be max 32 char';
|
|
}
|
|
|
|
// prevent xss execution itself
|
|
myPeerName = filterXSS(value);
|
|
|
|
// prevent XSS injection to remote peer
|
|
if (isHtml(myPeerName)) {
|
|
myPeerName = '';
|
|
return 'Invalid name!';
|
|
}
|
|
|
|
// check if peer name is already in use in the room
|
|
if (await checkUserName()) {
|
|
return 'Username is already in use!';
|
|
} else {
|
|
// Hide username emoji
|
|
if (!usernameEmoji.classList.contains('hidden')) {
|
|
usernameEmoji.classList.add('hidden');
|
|
}
|
|
window.localStorage.peer_name = myPeerName;
|
|
whoAreYouJoin();
|
|
}
|
|
},
|
|
}).then(() => {
|
|
playSound('addPeer');
|
|
});
|
|
|
|
// Show initUser injected into Swal html
|
|
initUser.classList.toggle('hidden');
|
|
|
|
// select video - audio
|
|
|
|
initVideoSelect.onchange = async () => {
|
|
await changeInitCamera(initVideoSelect.value);
|
|
await handleLocalCameraMirror();
|
|
videoSelect.selectedIndex = initVideoSelect.selectedIndex;
|
|
refreshLsDevices();
|
|
};
|
|
initMicrophoneSelect.onchange = async () => {
|
|
detectBluetoothHeadset(true);
|
|
await changeLocalMicrophone(initMicrophoneSelect.value);
|
|
audioInputSelect.selectedIndex = initMicrophoneSelect.selectedIndex;
|
|
refreshLsDevices();
|
|
};
|
|
initSpeakerSelect.onchange = async () => {
|
|
await changeAudioDestination();
|
|
audioOutputSelect.selectedIndex = initSpeakerSelect.selectedIndex;
|
|
refreshLsDevices();
|
|
};
|
|
|
|
// init video -audio buttons
|
|
if (!useVideo) {
|
|
initVideoBtn.className = className.videoOff;
|
|
setMyVideoStatus(useVideo);
|
|
}
|
|
if (!useAudio) {
|
|
initAudioBtn.className = className.audioOff;
|
|
setMyAudioStatus(useAudio);
|
|
}
|
|
|
|
setTippy(initAudioBtn, 'Stop the audio', 'top');
|
|
setTippy(initVideoBtn, 'Stop the video', 'top');
|
|
}
|
|
|
|
/**
|
|
* Refresh all LS devices
|
|
*/
|
|
async function refreshLsDevices() {
|
|
lS.setLocalStorageDevices(lS.MEDIA_TYPE.video, videoSelect.selectedIndex, videoSelect.value);
|
|
lS.setLocalStorageDevices(lS.MEDIA_TYPE.audio, audioInputSelect.selectedIndex, audioInputSelect.value);
|
|
lS.setLocalStorageDevices(lS.MEDIA_TYPE.speaker, audioOutputSelect.selectedIndex, audioOutputSelect.value);
|
|
}
|
|
|
|
/**
|
|
* Check if UserName already exist in the room
|
|
* @param {string} peer_name
|
|
* @returns boolean
|
|
*/
|
|
async function checkUserName(peer_name = null) {
|
|
return signalingSocket
|
|
.request('data', {
|
|
room_id: roomId,
|
|
peer_id: myPeerId,
|
|
peer_name: peer_name ? peer_name : myPeerName,
|
|
method: 'checkPeerName',
|
|
params: {},
|
|
})
|
|
.then((response) => response);
|
|
}
|
|
|
|
/**
|
|
* Username already in the room
|
|
*/
|
|
function userNameAlreadyInRoom() {
|
|
signalingSocket.disconnect();
|
|
playSound('alert');
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false,
|
|
background: swBg,
|
|
imageUrl: images.forbidden,
|
|
position: 'center',
|
|
title: 'Username',
|
|
html: `The Username is already in use. <br/> Please 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('/');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load settings from Local Storage
|
|
*/
|
|
async function loadLocalStorage() {
|
|
const localStorageDevices = lS.getLocalStorageDevices();
|
|
console.log('12. Get Local Storage Devices before', localStorageDevices);
|
|
if (localStorageDevices) {
|
|
//
|
|
const initMicrophoneExist = selectOptionByValueExist(initMicrophoneSelect, localStorageDevices.audio.select);
|
|
const initSpeakerExist = selectOptionByValueExist(initSpeakerSelect, localStorageDevices.speaker.select);
|
|
const initVideoExist = selectOptionByValueExist(initVideoSelect, localStorageDevices.video.select);
|
|
//
|
|
const audioInputExist = selectOptionByValueExist(audioInputSelect, localStorageDevices.audio.select);
|
|
const audioOutputExist = selectOptionByValueExist(audioOutputSelect, localStorageDevices.speaker.select);
|
|
const videoExist = selectOptionByValueExist(videoSelect, localStorageDevices.video.select);
|
|
|
|
console.log('Check for audio changes', {
|
|
previous: localStorageDevices.audio.select,
|
|
current: audioInputSelect.value,
|
|
});
|
|
|
|
if (!initMicrophoneExist || !audioInputExist) {
|
|
console.log('12.1 Audio devices seems changed, use default index 0');
|
|
initMicrophoneSelect.selectedIndex = 0;
|
|
audioInputSelect.selectedIndex = 0;
|
|
refreshLsDevices();
|
|
}
|
|
|
|
console.log('Check for speaker changes', {
|
|
previous: localStorageDevices.speaker.select,
|
|
current: audioOutputSelect.value,
|
|
});
|
|
|
|
if (!initSpeakerExist || !audioOutputExist) {
|
|
console.log('12.2 Speaker devices seems changed, use default index 0');
|
|
initSpeakerSelect.selectedIndex = 0;
|
|
audioOutputSelect.selectedIndex = 0;
|
|
refreshLsDevices();
|
|
}
|
|
|
|
console.log('Check for video changes', {
|
|
previous: localStorageDevices.video.select,
|
|
current: videoSelect.value,
|
|
});
|
|
|
|
if (!initVideoExist || !videoExist) {
|
|
console.log('12.3 Video devices seems changed, use default index 0');
|
|
initVideoSelect.selectedIndex = 0;
|
|
videoSelect.selectedIndex = 0;
|
|
refreshLsDevices();
|
|
}
|
|
|
|
//
|
|
console.log('12.4 Get Local Storage Devices after', lS.getLocalStorageDevices());
|
|
}
|
|
// Start init cam
|
|
if (useVideo && initVideoSelect.value) {
|
|
await changeInitCamera(initVideoSelect.value);
|
|
await handleLocalCameraMirror();
|
|
}
|
|
// Refresh audio
|
|
if (useAudio && audioInputSelect.value) {
|
|
await changeLocalMicrophone(audioInputSelect.value);
|
|
}
|
|
// Refresh speaker
|
|
if (audioOutputSelect.value) await changeAudioDestination();
|
|
|
|
// Check init audio/video
|
|
await checkInitConfig();
|
|
}
|
|
|
|
/**
|
|
* Use the select element to check if a specific option value exists,
|
|
* and if it does, automatically set it as the selected option.
|
|
* @param {object} selectElement
|
|
* @param {string} value
|
|
* @return boolean
|
|
*/
|
|
function selectOptionByValueExist(selectElement, value) {
|
|
let foundValue = false;
|
|
for (let i = 0; i < selectElement.options.length; i++) {
|
|
if (selectElement.options[i].value === value) {
|
|
selectElement.selectedIndex = i;
|
|
foundValue = true;
|
|
break;
|
|
}
|
|
}
|
|
return foundValue;
|
|
}
|
|
|
|
/**
|
|
* Check int config from local storage
|
|
*/
|
|
async function checkInitConfig() {
|
|
const initConfig = lS.getInitConfig();
|
|
console.log('Get init config', initConfig);
|
|
if (initConfig) {
|
|
if (useAudio && !initConfig.audio) initAudioBtn.click();
|
|
if (useVideo && !initConfig.video) initVideoBtn.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects whether the camera stream is front-facing ('user') or rear-facing ('environment').
|
|
* Defaults to 'user' (front-facing) if detection fails (e.g., desktop cameras).
|
|
* @param {MediaStream} stream - The video stream from `getUserMedia`.
|
|
* @returns {string} 'user' (front) or 'environment' (rear).
|
|
*/
|
|
function detectCameraFacingMode(stream) {
|
|
if (!stream || !stream.getVideoTracks().length) {
|
|
console.warn("No video track found in the stream. Defaulting to 'user'.");
|
|
return 'user';
|
|
}
|
|
const videoTrack = stream.getVideoTracks()[0];
|
|
const settings = videoTrack.getSettings();
|
|
const capabilities = videoTrack.getCapabilities?.() || {};
|
|
// Priority: settings.facingMode (actual) → capabilities.facingMode (possible) → default 'user'
|
|
const facingMode = settings.facingMode || capabilities.facingMode?.[0] || 'user';
|
|
return facingMode === 'environment' ? 'environment' : 'user'; // Force valid output
|
|
}
|
|
|
|
/**
|
|
* Change init camera by device id
|
|
* @param {string} deviceId
|
|
*/
|
|
async function changeInitCamera(deviceId) {
|
|
// Stop media tracks to avoid issue on mobile
|
|
if (initStream) {
|
|
await stopTracks(initStream);
|
|
}
|
|
if (localVideoMediaStream) {
|
|
await stopVideoTracks(localVideoMediaStream);
|
|
}
|
|
|
|
// Get video constraints
|
|
const videoConstraints = getVideoConstraints('default');
|
|
videoConstraints['deviceId'] = { exact: deviceId };
|
|
|
|
await navigator.mediaDevices
|
|
.getUserMedia({ video: videoConstraints })
|
|
.then((camStream) => {
|
|
updateInitLocalVideoMediaStream(camStream);
|
|
})
|
|
.catch(async (err) => {
|
|
console.error('Error accessing init video device', err);
|
|
console.warn('Fallback to default constraints');
|
|
try {
|
|
const camStream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
deviceId: {
|
|
exact: deviceId, // Specify the exact device ID you want to access
|
|
},
|
|
},
|
|
}); // Fallback to default constraints
|
|
updateInitLocalVideoMediaStream(camStream);
|
|
} catch (fallbackErr) {
|
|
console.error('Error accessing init video device with default constraints', fallbackErr);
|
|
reloadBrowser(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Update Init/Local Video Stream
|
|
* @param {MediaStream} camStream
|
|
*/
|
|
function updateInitLocalVideoMediaStream(camStream) {
|
|
if (camStream) {
|
|
// Detect camera
|
|
camera = detectCameraFacingMode(camStream);
|
|
console.log('Detect Camera facing mode', camera);
|
|
// We going to update init video stream
|
|
initVideo.srcObject = camStream;
|
|
initStream = camStream;
|
|
const initVideoTrack = getVideoTrack(initStream);
|
|
if (initVideoTrack) {
|
|
console.log('Success attached init video stream', initVideoTrack.getSettings());
|
|
}
|
|
// We going to update also the local video stream
|
|
myVideo.srcObject = camStream;
|
|
localVideoMediaStream = camStream;
|
|
const localVideoTrack = getVideoTrack(localVideoMediaStream);
|
|
if (localVideoTrack) {
|
|
console.log('Success attached local video stream', localVideoTrack.getSettings());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Something going wrong
|
|
* @param {object} err
|
|
*/
|
|
function reloadBrowser(err) {
|
|
console.error('[Error] changeInitCamera', err);
|
|
userLog('error', 'Error while swapping init camera' + err);
|
|
initVideoSelect.selectedIndex = 0;
|
|
videoSelect.selectedIndex = 0;
|
|
refreshLsDevices();
|
|
// Refresh page...
|
|
setTimeout(function () {
|
|
location.reload();
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change local camera by device id
|
|
* @param {string} deviceId
|
|
*/
|
|
async function changeLocalCamera(deviceId) {
|
|
if (localVideoMediaStream) {
|
|
await stopVideoTracks(localVideoMediaStream);
|
|
}
|
|
|
|
// Get video constraints
|
|
const videoConstraints = getVideoConstraints(videoQualitySelect.value ? videoQualitySelect.value : 'default');
|
|
videoConstraints['deviceId'] = { exact: deviceId };
|
|
console.log('videoConstraints', videoConstraints);
|
|
|
|
await navigator.mediaDevices
|
|
.getUserMedia({ video: videoConstraints })
|
|
.then(async (camStream) => {
|
|
await updateLocalVideoMediaStream(camStream);
|
|
})
|
|
.catch(async (err) => {
|
|
console.error('Error accessing local video device:', err);
|
|
console.warn('Fallback to default constraints');
|
|
try {
|
|
const camStream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
deviceId: {
|
|
exact: deviceId, // Specify the exact device ID you want to access
|
|
},
|
|
},
|
|
});
|
|
await updateLocalVideoMediaStream(camStream);
|
|
} catch (fallbackErr) {
|
|
console.error('Error accessing init video device with default constraints', fallbackErr);
|
|
printError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Update Local Video Media Stream
|
|
* @param {MediaStream} camStream
|
|
*/
|
|
async function updateLocalVideoMediaStream(camStream) {
|
|
if (camStream) {
|
|
camera = detectCameraFacingMode(camStream);
|
|
console.log('Detect Camera facing mode', camera);
|
|
myVideo.srcObject = camStream;
|
|
localVideoMediaStream = camStream;
|
|
logStreamSettingsInfo('Success attached local video stream', camStream);
|
|
await refreshMyStreamToPeers(camStream);
|
|
setLocalMaxFps(videoMaxFrameRate);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SOmething going wrong
|
|
* @param {object} err
|
|
*/
|
|
function printError(err) {
|
|
console.error('[Error] changeLocalCamera', err);
|
|
userLog('error', 'Error while swapping local camera ' + err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change local microphone by device id
|
|
* @param {string} deviceId
|
|
*/
|
|
async function changeLocalMicrophone(deviceId) {
|
|
// If noise suppression is active, localAudioMediaStream may be the processed stream.
|
|
// Stop the RNNoise pipeline first and stop the *original* microphone tracks.
|
|
const oldMicStream = noiseProcessor?.originalStream || noiseProcessor?.mediaStream || localAudioMediaStream;
|
|
|
|
stopNoiseSuppressionPipeline();
|
|
|
|
if (oldMicStream) {
|
|
await stopAudioTracks(oldMicStream);
|
|
}
|
|
|
|
// Get audio constraints
|
|
const audioConstraints = getAudioConstraints(deviceId);
|
|
console.log('audioConstraints', audioConstraints);
|
|
|
|
await navigator.mediaDevices
|
|
.getUserMedia(audioConstraints)
|
|
.then(async (micStream) => {
|
|
myAudio.srcObject = micStream;
|
|
localAudioMediaStream = micStream;
|
|
logStreamSettingsInfo('Success attached local microphone stream', micStream);
|
|
getMicrophoneVolumeIndicator(micStream);
|
|
|
|
if (lsSettings.mic_noise_suppression) {
|
|
const ok = await enableNoiseSuppression();
|
|
if (!ok) {
|
|
await refreshMyStreamToPeers(micStream, true);
|
|
}
|
|
} else {
|
|
await refreshMyStreamToPeers(micStream, true);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] changeLocalMicrophone', err);
|
|
userLog('error', 'Error while swapping local microphone' + err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check peer audio and video &audio=1&video=1
|
|
* 1/true = enabled / 0/false = disabled
|
|
*/
|
|
function checkPeerAudioVideo() {
|
|
let qs = new URLSearchParams(window.location.search);
|
|
let audio = filterXSS(qs.get('audio'));
|
|
let video = filterXSS(qs.get('video'));
|
|
if (audio) {
|
|
audio = audio.toLowerCase();
|
|
let queryPeerAudio = useAudio ? audio === '1' || audio === 'true' : false;
|
|
if (queryPeerAudio != null) handleAudio(audioBtn, false, queryPeerAudio);
|
|
//elemDisplay(tabAudioBtn, queryPeerAudio);
|
|
console.log('Direct join', { audio: queryPeerAudio });
|
|
}
|
|
if (video) {
|
|
video = video.toLowerCase();
|
|
let queryPeerVideo = useVideo ? video === '1' || video === 'true' : false;
|
|
if (queryPeerVideo != null) handleVideo(videoBtn, false, queryPeerVideo);
|
|
//elemDisplay(tabVideoBtn, queryPeerVideo);
|
|
console.log('Direct join', { video: queryPeerVideo });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable RNNoise audio processing for noise suppression
|
|
*/
|
|
async function enableNoiseSuppression() {
|
|
if (!localAudioMediaStream || localAudioMediaStream.getAudioTracks().length === 0) {
|
|
userLog('error', 'No local audio stream available for noise suppression.');
|
|
return false;
|
|
}
|
|
|
|
// Reset any existing pipeline to avoid keeping stale/ended streams.
|
|
stopNoiseSuppressionPipeline();
|
|
|
|
noiseProcessor = new RNNoiseProcessor();
|
|
// Keep a reference to the raw microphone stream for safe restore.
|
|
noiseProcessor.originalStream = localAudioMediaStream;
|
|
|
|
const processedStream = await noiseProcessor.startProcessing(localAudioMediaStream);
|
|
if (!processedStream || processedStream.getAudioTracks().length === 0) {
|
|
userLog('warning', 'Noise suppression unavailable, using raw microphone.');
|
|
stopNoiseSuppressionPipeline();
|
|
await refreshMyStreamToPeers(localAudioMediaStream, true);
|
|
return false;
|
|
}
|
|
|
|
noiseProcessor.toggleNoiseSuppression();
|
|
localAudioMediaStream = processedStream;
|
|
await refreshMyStreamToPeers(localAudioMediaStream, true);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Disable RNNoise audio processing for noise suppression
|
|
*/
|
|
async function disableNoiseSuppression(restoreOriginalStream = true) {
|
|
if (noiseProcessor) {
|
|
const originalStream = noiseProcessor.originalStream || noiseProcessor.mediaStream;
|
|
if (restoreOriginalStream && originalStream) {
|
|
localAudioMediaStream = originalStream;
|
|
}
|
|
await refreshMyStreamToPeers(localAudioMediaStream, true);
|
|
stopNoiseSuppressionPipeline();
|
|
} else {
|
|
await refreshMyStreamToPeers(localAudioMediaStream, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop RNNoise audio processing pipeline
|
|
*/
|
|
function stopNoiseSuppressionPipeline() {
|
|
if (!noiseProcessor) return;
|
|
try {
|
|
noiseProcessor.stopProcessing();
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
noiseProcessor = null;
|
|
}
|
|
|
|
/**
|
|
* Restart noise suppression (e.g. after changing mic)
|
|
*/
|
|
async function restartNoiseSuppression() {
|
|
if (!lsSettings.mic_noise_suppression) return;
|
|
// Do not restore the old microphone stream when restarting.
|
|
await disableNoiseSuppression(false);
|
|
await enableNoiseSuppression();
|
|
}
|
|
|
|
/**
|
|
* Room and Peer name are ok Join Channel
|
|
*/
|
|
async function whoAreYouJoin() {
|
|
myVideoPeerName.innerText = myPeerName + ' (me)';
|
|
setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar);
|
|
setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar);
|
|
setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar);
|
|
joinToChannel();
|
|
handleHideMe(isHideMeActive);
|
|
loadGeo();
|
|
|
|
// Load screen media if needed
|
|
await loadScreenMedia();
|
|
|
|
// Refresh camera if screen streaming
|
|
if (isScreenStreaming && useVideo) {
|
|
await changeLocalCamera(videoSelect.value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* join to channel and send some peer info
|
|
*/
|
|
async function joinToChannel() {
|
|
console.log('12. join to channel', roomId);
|
|
sendToServer('join', {
|
|
join_data_time: getDataTimeString(),
|
|
channel: roomId,
|
|
channel_password: thisRoomPassword,
|
|
peer_info: peerInfo,
|
|
peer_uuid: myPeerUUID,
|
|
peer_name: myPeerName,
|
|
peer_avatar: myPeerAvatar,
|
|
peer_token: myToken,
|
|
peer_video: useVideo,
|
|
peer_audio: useAudio,
|
|
peer_video_status: myVideoStatus,
|
|
peer_audio_status: myAudioStatus,
|
|
peer_screen_status: myScreenStatus,
|
|
peer_hand_status: myHandStatus,
|
|
peer_rec_status: isStreamRecording,
|
|
peer_privacy_status: isVideoPrivacyActive,
|
|
userAgent: userAgent,
|
|
});
|
|
handleBodyOnMouseMove(); // show/hide bottomButtons ...
|
|
makeRoomPopupQR();
|
|
}
|
|
|
|
/**
|
|
* When we join a group, our signaling server will send out 'addPeer' events to each pair of users in the group (creating a fully-connected graph of users,
|
|
* ie if there are 6 people in the channel you will connect directly to the other 5, so there will be a total of 15 connections in the network).
|
|
* @param {object} config data
|
|
*/
|
|
async function handleAddPeer(config) {
|
|
//console.log("addPeer", JSON.stringify(config));
|
|
|
|
const { peer_id, should_create_offer, iceServers, peers } = config;
|
|
|
|
const peer_name = peers[peer_id]['peer_name'];
|
|
const peer_video = peers[peer_id]['peer_video'];
|
|
const peer_video_status = peers[peer_id]['peer_video_status'];
|
|
const peer_screen_status = peers[peer_id]['peer_screen_status'];
|
|
|
|
if (peer_id in peerConnections) {
|
|
// This could happen if the user joins multiple channels where the other peer is also in.
|
|
console.log('Already connected to peer', peer_id);
|
|
return;
|
|
}
|
|
|
|
console.log('iceServers', iceServers[0]);
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
|
|
const peerConnection = new RTCPeerConnection({ iceServers: iceServers });
|
|
peerConnections[peer_id] = peerConnection;
|
|
|
|
allPeers = peers;
|
|
// Ensure extras object exists for every peer to avoid undefined checks later
|
|
try {
|
|
for (const id in allPeers) {
|
|
if (!allPeers[id]) continue;
|
|
if (!allPeers[id].extras) allPeers[id].extras = {};
|
|
}
|
|
} catch (e) {
|
|
console.warn('[INIT EXTRAS] failed to normalize peers extras', e);
|
|
}
|
|
|
|
console.log('[RTCPeerConnection] - PEER_ID', peer_id); // the connected peer_id
|
|
console.log('[RTCPeerConnection] - PEER-CONNECTIONS', peerConnections); // all peers connections in the room expect myself
|
|
console.log('[RTCPeerConnection] - PEERS', peers); // all peers in the room
|
|
|
|
// As P2P check who I am connected with
|
|
let connectedPeersName = [];
|
|
for (const id in peerConnections) {
|
|
connectedPeersName.push(peers[id]['peer_name']);
|
|
}
|
|
console.log('[RTCPeerConnection] - CONNECTED TO PEERS', JSON.stringify(connectedPeersName));
|
|
// userLog('info', 'Connected to: ' + JSON.stringify(connectedPeersName));
|
|
|
|
await handlePeersConnectionStatus(peer_id);
|
|
await msgerAddPeers(peers);
|
|
await handleOnIceCandidate(peer_id);
|
|
await handleRTCDataChannels(peer_id);
|
|
await handleOnTrack(peer_id, peers);
|
|
|
|
if ((!peer_video_status || !peer_screen_status) && !needToCreateOfferByPeer[peer_id]) {
|
|
needToCreateOfferByPeer[peer_id] = true;
|
|
}
|
|
if (should_create_offer) {
|
|
await handleRtcOffer(peer_id);
|
|
console.log('[RTCPeerConnection] - SHOULD CREATE OFFER', {
|
|
peer_id: peer_id,
|
|
peer_name: peer_name,
|
|
role: 'offerer',
|
|
});
|
|
}
|
|
|
|
// Add tracks (this will trigger onnegotiationneeded if needed)
|
|
await handleAddTracks(peer_id);
|
|
|
|
// Create camera tile for peer without camera to show their avatar or has screen sharing on but camera off
|
|
if (!peer_video || (peer_screen_status && !peer_video_status)) {
|
|
await loadRemoteMediaStream(new MediaStream(), peers, peer_id, 'video');
|
|
}
|
|
|
|
await wbUpdate();
|
|
playSound('addPeer');
|
|
|
|
// Screen reader announcement for peer joined
|
|
screenReaderAccessibility.announceMessage(`${peer_name} joined the room`);
|
|
}
|
|
|
|
/**
|
|
* Handle peers connection state
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function handlePeersConnectionStatus(peer_id) {
|
|
peerConnections[peer_id].onconnectionstatechange = function (event) {
|
|
const connectionStatus = event.currentTarget.connectionState;
|
|
const signalingState = event.currentTarget.signalingState;
|
|
const peerName = allPeers[peer_id]['peer_name'];
|
|
console.log('[RTCPeerConnection] - CONNECTION', {
|
|
peer_id: peer_id,
|
|
peer_name: peerName,
|
|
connectionStatus: connectionStatus,
|
|
signalingState: signalingState,
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle ICE candidate
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onicecandidate
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icecandidateerror_event
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function handleOnIceCandidate(peer_id) {
|
|
peerConnections[peer_id].onicecandidate = (event) => {
|
|
if (!event.candidate || !event.candidate.candidate) return;
|
|
|
|
const { type, candidate, address, sdpMLineIndex } = event.candidate;
|
|
|
|
//console.log('[ICE-CANDIDATE] ---->', { type, address, candidate });
|
|
|
|
sendToServer('relayICE', {
|
|
peer_id,
|
|
ice_candidate: {
|
|
sdpMLineIndex,
|
|
candidate,
|
|
},
|
|
});
|
|
|
|
// Get Ice address
|
|
const ipRegex = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/;
|
|
let addressInfo = candidate.match(ipRegex);
|
|
if (!addressInfo && address) addressInfo = [address];
|
|
|
|
// IP
|
|
if (addressInfo) {
|
|
networkIP.innerText = addressInfo;
|
|
}
|
|
|
|
// Display network information based on candidate type
|
|
switch (type) {
|
|
case 'host':
|
|
networkHost.innerText = '🟢';
|
|
break;
|
|
case 'srflx':
|
|
networkStun.innerText = '🟢';
|
|
break;
|
|
case 'relay':
|
|
networkTurn.innerText = '🟢';
|
|
break;
|
|
default:
|
|
console.warn(`[ICE candidate] unknown type: ${type}`, candidate);
|
|
break;
|
|
}
|
|
};
|
|
|
|
// handle ICE candidate errors
|
|
peerConnections[peer_id].onicecandidateerror = (event) => {
|
|
const { url, errorText } = event;
|
|
|
|
console.warn('[ICE candidate] error', { url, error: errorText });
|
|
|
|
if (url.startsWith('host:')) networkHost.innerText = '🔴';
|
|
if (url.startsWith('stun:')) networkStun.innerText = '🔴';
|
|
if (url.startsWith('turn:')) networkTurn.innerText = '🔴';
|
|
|
|
//msgPopup('warning', `${url}: ${errorText}`, 'top-end', 6000);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/ontrack
|
|
* @param {string} peer_id socket.id
|
|
* @param {object} peers all peers info connected to the same room
|
|
*/
|
|
async function handleOnTrack(peer_id, peers) {
|
|
peerConnections[peer_id].ontrack = (event) => {
|
|
if (!event.streams?.[0]) {
|
|
console.warn('[ON TRACK] No streams found', event);
|
|
return;
|
|
}
|
|
|
|
const kind = event.track?.kind;
|
|
if (!kind) {
|
|
console.warn('[ON TRACK] Unable to determine track kind', event);
|
|
return;
|
|
}
|
|
|
|
const peerInfo = allPeers?.[peer_id] || peers?.[peer_id] || {};
|
|
const peer_name = peerInfo.peer_name || 'Unknown';
|
|
const inbound = event.streams[0];
|
|
|
|
// Helper to load or attach stream
|
|
const handleStream = (elementId, streamType) => {
|
|
const element = getId(`${peer_id}___${elementId}`);
|
|
const hasStream = element?.srcObject && (elementId === 'audio' || hasVideoTrack(element.srcObject));
|
|
|
|
if (!hasStream) {
|
|
loadRemoteMediaStream(inbound, allPeers || peers, peer_id, streamType);
|
|
} else {
|
|
attachMediaStream(element, inbound);
|
|
elemDisplay(element, true, 'block');
|
|
}
|
|
};
|
|
|
|
if (kind === 'audio') {
|
|
const audioElement = getId(`${peer_id}___audio`);
|
|
|
|
if (audioElement) {
|
|
attachMediaStream(audioElement, inbound);
|
|
if (!audioElement.srcObject) {
|
|
audioElement.play().catch((err) => {
|
|
console.warn('[AUDIO] Autoplay not allowed by device, setting up fallback:', err);
|
|
handleAudioFallback(audioElement, peer_name);
|
|
});
|
|
}
|
|
} else {
|
|
loadRemoteMediaStream(inbound, allPeers || peers, peer_id, 'audio');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Video or screen track
|
|
if (kind === 'video') {
|
|
// Determine if the incoming video track is a screen share or camera.
|
|
const extras = peerInfo.extras || {};
|
|
const label = event.track.label || '';
|
|
const settings = event?.track?.getSettings() || {};
|
|
|
|
const isDisplayCapture =
|
|
!!settings.displaySurface || settings.mediaSource === 'screen' || settings.displaySurface === 'monitor';
|
|
|
|
const isScreenByExtras =
|
|
extras.screen_track_id === event.track.id || extras.screen_stream_id === inbound.id;
|
|
|
|
const isScreenByLabel = /screen|window|monitor|display/i.test(label);
|
|
|
|
const isScreenByStatus = peerInfo.peer_screen_status && !peerInfo.peer_video_status;
|
|
|
|
const isScreen = isDisplayCapture || isScreenByExtras || isScreenByLabel || isScreenByStatus;
|
|
|
|
handleStream(isScreen ? 'screen' : 'video', isScreen ? 'screen' : 'video');
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add my localVideoMediaStream, localScreenMediaStream and localAudioMediaStream Tracks to connected peer
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addTrack
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function handleAddTracks(peer_id) {
|
|
const pc = peerConnections[peer_id];
|
|
const peer_name = allPeers[peer_id]['peer_name'];
|
|
|
|
const videoTrack = getVideoTrack(localVideoMediaStream);
|
|
const screenTrack = getVideoTrack(localScreenMediaStream);
|
|
const screenAudioTrack =
|
|
isScreenStreaming && hasAudioTrack(localScreenMediaStream) ? getAudioTrack(localScreenMediaStream) : null;
|
|
const micAudioTrack = getAudioTrack(localAudioMediaStream);
|
|
const audioTrack = screenAudioTrack || micAudioTrack;
|
|
const audioStream = screenAudioTrack ? localScreenMediaStream : localAudioMediaStream;
|
|
|
|
console.log('handleAddTracks', {
|
|
videoTrack: videoTrack,
|
|
screenTrack: screenTrack,
|
|
screenAudioTrack: screenAudioTrack,
|
|
audioTrack: audioTrack,
|
|
});
|
|
|
|
if (videoTrack) {
|
|
console.log('[ADD VIDEO TRACK] to Peer Name [' + peer_name + ']');
|
|
await pc.addTrack(videoTrack, localVideoMediaStream);
|
|
}
|
|
|
|
if (screenTrack) {
|
|
console.log('[ADD SCREEN TRACK] to Peer Name [' + peer_name + ']');
|
|
await pc.addTrack(screenTrack, localScreenMediaStream);
|
|
}
|
|
|
|
if (audioTrack && audioStream) {
|
|
console.log('[ADD AUDIO TRACK] to Peer Name [' + peer_name + ']');
|
|
await pc.addTrack(audioTrack, audioStream);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Secure RTC Data Channel
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createDataChannel
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/ondatachannel
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/onmessage
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function handleRTCDataChannels(peer_id) {
|
|
peerConnections[peer_id].ondatachannel = (event) => {
|
|
console.log('handleRTCDataChannels ' + peer_id, event);
|
|
event.channel.onmessage = (msg) => {
|
|
switch (event.channel.label) {
|
|
case 'mirotalk_chat_channel':
|
|
try {
|
|
const dataMessage = JSON.parse(msg.data);
|
|
switch (dataMessage.type) {
|
|
case 'chat':
|
|
handleDataChannelChat(dataMessage);
|
|
break;
|
|
case 'speech':
|
|
handleDataChannelSpeechTranscript(dataMessage);
|
|
break;
|
|
case 'micVolume':
|
|
handlePeerVolume(dataMessage);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.error('mirotalk_chat_channel', err);
|
|
}
|
|
break;
|
|
case 'mirotalk_file_sharing_channel':
|
|
try {
|
|
const dataFile = msg.data;
|
|
if (dataFile instanceof ArrayBuffer && dataFile.byteLength != 0) {
|
|
handleDataChannelFileSharing(dataFile);
|
|
} else {
|
|
// Work around for Firefox Bug: even if set dc.binaryType to arraybuffer it sends Blob?
|
|
if (dataFile instanceof Blob && dataFile.size != 0) {
|
|
blobToArrayBuffer(dataFile)
|
|
.then((arrayBuffer) => {
|
|
handleDataChannelFileSharing(arrayBuffer);
|
|
})
|
|
.catch((error) => {
|
|
console.error('mirotalk_file_sharing_channel', error);
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('mirotalk_file_sharing_channel', err);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
};
|
|
createChatDataChannel(peer_id);
|
|
createFileSharingDataChannel(peer_id);
|
|
}
|
|
|
|
/**
|
|
* Convert Blob to ArrayBuffer
|
|
* @param {object} blob
|
|
* @returns arrayBuffer
|
|
*/
|
|
function blobToArrayBuffer(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const arrayBuffer = reader.result;
|
|
resolve(arrayBuffer);
|
|
};
|
|
reader.onerror = () => {
|
|
reject(new Error('Error reading Blob as ArrayBuffer'));
|
|
};
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Only one side of the peer connection should create the offer, the signaling server picks one to be the offerer.
|
|
* The other user will get a 'sessionDescription' event and will create an offer, then send back an answer 'sessionDescription' to us
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function handleRtcOffer(peer_id) {
|
|
const pc = peerConnections[peer_id];
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onnegotiationneeded
|
|
pc.onnegotiationneeded = () => {
|
|
console.log('Creating RTC offer to ' + allPeers[peer_id]['peer_name']);
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer
|
|
pc.createOffer()
|
|
.then((local_description) => {
|
|
console.log('Local offer description is', local_description);
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription
|
|
pc.setLocalDescription(local_description)
|
|
.then(() => {
|
|
sendToServer('relaySDP', {
|
|
peer_id: peer_id,
|
|
session_description: local_description,
|
|
});
|
|
console.log('Offer setLocalDescription done!');
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] offer setLocalDescription', err);
|
|
userLog('error', 'Offer setLocalDescription failed ' + err);
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] sending offer', err);
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Peers exchange session descriptions which contains information about their audio / video settings and that sort of stuff. First
|
|
* the 'offerer' sends a description to the 'answerer' (with type "offer"), then the answerer sends one back (with type "answer").
|
|
* @param {object} config data
|
|
*/
|
|
function handleSessionDescription(config) {
|
|
console.log('Remote Session Description', config);
|
|
const { peer_id, session_description } = config;
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription
|
|
const remote_description = new RTCSessionDescription(session_description);
|
|
|
|
const pc = peerConnections[peer_id];
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setRemoteDescription
|
|
pc.setRemoteDescription(remote_description)
|
|
.then(() => {
|
|
console.log('setRemoteDescription done!');
|
|
if (session_description.type == 'offer') {
|
|
console.log('Creating answer');
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createAnswer
|
|
pc.createAnswer()
|
|
.then((local_description) => {
|
|
console.log('Answer description is: ', local_description);
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription
|
|
pc.setLocalDescription(local_description)
|
|
.then(() => {
|
|
sendToServer('relaySDP', {
|
|
peer_id: peer_id,
|
|
session_description: local_description,
|
|
});
|
|
console.log('Answer setLocalDescription done!');
|
|
|
|
// https://github.com/miroslavpejic85/mirotalk/issues/110
|
|
if (needToCreateOfferByPeer[peer_id]) {
|
|
needToCreateOfferByPeer[peer_id] = false;
|
|
handleRtcOffer(peer_id);
|
|
console.log('[RTCSessionDescription] - NEED TO CREATE OFFER', {
|
|
peer_id: peer_id,
|
|
});
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] answer setLocalDescription', err);
|
|
userLog('error', 'Answer setLocalDescription failed ' + err);
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] creating answer', err);
|
|
});
|
|
} // end [if type offer]
|
|
})
|
|
.catch((err) => {
|
|
console.error('[Error] setRemoteDescription', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The offerer will send a number of ICE Candidate blobs to the answerer so they
|
|
* can begin trying to find the best path to one another on the net.
|
|
* @param {object} config data
|
|
*/
|
|
function handleIceCandidate(config) {
|
|
const { peer_id, ice_candidate } = config;
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
|
peerConnections[peer_id].addIceCandidate(new RTCIceCandidate(ice_candidate)).catch((err) => {
|
|
console.error('[Error] addIceCandidate', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Disconnected from Signaling Server.
|
|
* Tear down all of our peer connections and remove all the media divs.
|
|
* @param {object} reason of disconnection
|
|
*/
|
|
function handleDisconnect(reason) {
|
|
console.log('Disconnected from signaling server', { reason: reason });
|
|
|
|
checkRecording();
|
|
|
|
for (const peer_id in peerConnections) {
|
|
const peerScreenId = peer_id + '___screen';
|
|
const peerVideoId = peer_id + '___video';
|
|
const peerAudioId = peer_id + '___audio';
|
|
|
|
const peerVideo = getId(peerVideoId);
|
|
if (peerVideo) {
|
|
// Peer video in focus mode
|
|
if (peerVideo.hasAttribute('focus-mode')) {
|
|
const remoteVideoFocusBtn = getId(peer_id + '_focusMode');
|
|
if (remoteVideoFocusBtn) {
|
|
remoteVideoFocusBtn.click();
|
|
}
|
|
}
|
|
}
|
|
|
|
const screenVideo = getId(peerScreenId);
|
|
if (screenVideo) {
|
|
// Peer screen in focus mode
|
|
if (screenVideo.hasAttribute('focus-mode')) {
|
|
const remoteScreenFocusBtn = getId(peer_id + '_screen_focusMode');
|
|
if (remoteScreenFocusBtn) {
|
|
remoteScreenFocusBtn.click();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (peerScreenMediaElements[peerScreenId] && peerScreenMediaElements[peerScreenId].parentNode) {
|
|
peerScreenMediaElements[peerScreenId].parentNode.removeChild(peerScreenMediaElements[peerScreenId]);
|
|
}
|
|
if (peerVideoMediaElements[peerVideoId] && peerVideoMediaElements[peerVideoId].parentNode) {
|
|
peerVideoMediaElements[peerVideoId].parentNode.removeChild(peerVideoMediaElements[peerVideoId]);
|
|
}
|
|
if (peerAudioMediaElements[peerAudioId] && peerAudioMediaElements[peerAudioId].parentNode) {
|
|
peerAudioMediaElements[peerAudioId].parentNode.removeChild(peerAudioMediaElements[peerAudioId]);
|
|
}
|
|
|
|
peerConnections[peer_id].close();
|
|
msgerRemovePeer(peer_id);
|
|
removeVideoPinMediaContainer(peer_id);
|
|
}
|
|
|
|
adaptAspectRatio();
|
|
|
|
chatDataChannels = {};
|
|
fileDataChannels = {};
|
|
peerConnections = {};
|
|
peerScreenMediaElements = {};
|
|
peerVideoMediaElements = {};
|
|
peerAudioMediaElements = {};
|
|
|
|
// Set reconnection flag to trigger proper rejoin
|
|
isPeerReconnected = true;
|
|
console.log('Set isPeerReconnected=true, will attempt to rejoin on reconnect');
|
|
}
|
|
|
|
/**
|
|
* When a user leaves a channel (or is disconnected from the signaling server) everyone will recieve a 'removePeer' message
|
|
* telling them to trash the media channels they have open for those that peer. If it was this client that left a channel,
|
|
* they'll also receive the removePeers. If this client was disconnected, they wont receive removePeers, but rather the
|
|
* signaling_socket.on('disconnect') code will kick in and tear down all the peer sessions.
|
|
* @param {object} config data
|
|
*/
|
|
function handleRemovePeer(config) {
|
|
console.log('Signaling server said to remove peer:', config);
|
|
|
|
const { peer_id } = config;
|
|
|
|
const peerScreenId = peer_id + '___screen';
|
|
const peerVideoId = peer_id + '___video';
|
|
const peerAudioId = peer_id + '___audio';
|
|
|
|
if (peerVideoId in peerVideoMediaElements) {
|
|
const peerVideo = getId(peerVideoId);
|
|
if (peerVideo) {
|
|
// Peer video in focus mode
|
|
if (peerVideo.hasAttribute('focus-mode')) {
|
|
const remoteVideoFocusBtn = getId(peer_id + '_focusMode');
|
|
if (remoteVideoFocusBtn) {
|
|
remoteVideoFocusBtn.click();
|
|
}
|
|
}
|
|
}
|
|
peerVideoMediaElements[peerVideoId].parentNode.removeChild(peerVideoMediaElements[peerVideoId]);
|
|
adaptAspectRatio();
|
|
}
|
|
|
|
if (peerScreenId in peerScreenMediaElements) {
|
|
const peerScreen = getId(peerScreenId);
|
|
if (peerScreen) {
|
|
// Peer screen in focus mode
|
|
if (peerScreen.hasAttribute('focus-mode')) {
|
|
const remoteScreenFocusBtn = getId(peer_id + '_screen_focusMode');
|
|
if (remoteScreenFocusBtn) {
|
|
remoteScreenFocusBtn.click();
|
|
}
|
|
}
|
|
}
|
|
peerScreenMediaElements[peerScreenId].parentNode.removeChild(peerScreenMediaElements[peerScreenId]);
|
|
adaptAspectRatio();
|
|
}
|
|
|
|
if (peerAudioId in peerAudioMediaElements) {
|
|
peerAudioMediaElements[peerAudioId].parentNode.removeChild(peerAudioMediaElements[peerAudioId]);
|
|
}
|
|
|
|
if (peer_id in peerConnections) peerConnections[peer_id].close();
|
|
|
|
msgerRemovePeer(peer_id);
|
|
removeVideoPinMediaContainer(peer_id);
|
|
|
|
delete chatDataChannels[peer_id];
|
|
delete fileDataChannels[peer_id];
|
|
delete peerConnections[peer_id];
|
|
delete peerScreenMediaElements[peerScreenId];
|
|
delete peerVideoMediaElements[peerVideoId];
|
|
delete peerAudioMediaElements[peerAudioId];
|
|
delete allPeers[peer_id];
|
|
|
|
playSound('removePeer');
|
|
|
|
// Screen reader announcement for peer left
|
|
const peer_name = allPeers && allPeers[peer_id] ? allPeers[peer_id]['peer_name'] : 'Participant';
|
|
screenReaderAccessibility.announceMessage(`${peer_name} left the room`);
|
|
|
|
console.log('ALL PEERS', allPeers);
|
|
}
|
|
|
|
/**
|
|
* Set custom theme
|
|
*/
|
|
function setCustomTheme() {
|
|
const color = themeCustom.color;
|
|
swBg = `radial-gradient(${color}, ${color})`;
|
|
setSP('--body-bg', `radial-gradient(${color}, ${color})`);
|
|
setSP('--msger-bg', `radial-gradient(${color}, ${color})`);
|
|
setSP('--msger-private-bg', `radial-gradient(${color}, ${color})`);
|
|
setSP('--wb-bg', `radial-gradient(${color}, ${color})`);
|
|
setSP('--elem-border-color', '0.5px solid rgb(255 255 255 / 32%)');
|
|
setSP('--navbar-bg', 'rgba(0, 0, 0, 0.2)');
|
|
setSP('--select-bg', `${color}`);
|
|
setSP('--tab-btn-active', `${color}`);
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.2)');
|
|
setSP('--left-msg-bg', '#252d31');
|
|
setSP('--right-msg-bg', `${color}`);
|
|
setSP('--private-msg-bg', '#6b1226');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', `${color}`);
|
|
document.body.style.background = `radial-gradient(${color}, ${color})`;
|
|
}
|
|
|
|
/**
|
|
* Set mirotalk theme | dark | grey | ...
|
|
*/
|
|
function setTheme() {
|
|
if (themeCustom.keep) return setCustomTheme();
|
|
|
|
mirotalkTheme.selectedIndex = lsSettings.theme;
|
|
const theme = mirotalkTheme.value;
|
|
switch (theme) {
|
|
case 'dark':
|
|
// dark theme
|
|
swBg = 'radial-gradient(#393939, #000000)';
|
|
setSP('--body-bg', 'radial-gradient(#393939, #000000)');
|
|
setSP('--msger-bg', 'radial-gradient(#393939, #000000)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#393939, #000000)');
|
|
setSP('--wb-bg', 'radial-gradient(#393939, #000000)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(28, 28, 28, 0.8)');
|
|
setSP('--select-bg', '#3a3a3a');
|
|
setSP('--tab-btn-active', '#4f4f4f');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--left-msg-bg', '#353535');
|
|
setSP('--right-msg-bg', '#4a4a4a');
|
|
setSP('--private-msg-bg', '#2a2a2a');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(0, 0, 0, 0.7)');
|
|
setSP('--dd-color', '#FFFFFF');
|
|
document.body.style.background = 'radial-gradient(#393939, #000000)';
|
|
mirotalkTheme.selectedIndex = 0;
|
|
break;
|
|
case 'grey':
|
|
// grey theme
|
|
swBg = 'radial-gradient(#4f4f4f, #1c1c1c)';
|
|
setSP('--body-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--msger-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--wb-bg', 'radial-gradient(#5f5f5f, #2c2c2c)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(28, 28, 28, 0.8)');
|
|
setSP('--select-bg', '#3a3a3a');
|
|
setSP('--tab-btn-active', '#4f4f4f');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#353535');
|
|
setSP('--right-msg-bg', '#4a4a4a');
|
|
setSP('--private-msg-bg', '#616161');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(0, 0, 0, 0.7)');
|
|
setSP('--dd-color', '#FFFFFF');
|
|
document.body.style.background = 'radial-gradient(#4f4f4f, #1c1c1c)';
|
|
mirotalkTheme.selectedIndex = 1;
|
|
break;
|
|
case 'green':
|
|
// green theme
|
|
swBg = 'radial-gradient(#004d40, #001f1c)';
|
|
setSP('--body-bg', 'radial-gradient(#004d40, #001f1c)');
|
|
setSP('--msger-bg', 'radial-gradient(#004d40, #001f1c)');
|
|
setSP('--wb-bg', 'radial-gradient(#004d40, #001f1c)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(0, 31, 28, 0.8)');
|
|
setSP('--select-bg', '#002e2b');
|
|
setSP('--tab-btn-active', '#004d40');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#004d40');
|
|
setSP('--right-msg-bg', '#00312c');
|
|
setSP('--private-msg-bg', '#004a47');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(0, 42, 34, 0.7)');
|
|
setSP('--dd-color', '#00FF00');
|
|
document.body.style.background = 'radial-gradient(#004d40, #001f1c)';
|
|
mirotalkTheme.selectedIndex = 2;
|
|
break;
|
|
case 'blue':
|
|
// blue theme
|
|
swBg = 'radial-gradient(#1a237e, #0d1b34)';
|
|
setSP('--body-bg', 'radial-gradient(#1a237e, #0d1b34)');
|
|
setSP('--msger-bg', 'radial-gradient(#1a237e, #0d1b34)');
|
|
setSP('--wb-bg', 'radial-gradient(#1a237e, #0d1b34)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(13, 27, 52, 0.8)');
|
|
setSP('--select-bg', '#0d1b34');
|
|
setSP('--tab-btn-active', '#1a237e');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#1a237e');
|
|
setSP('--right-msg-bg', '#0d1b34');
|
|
setSP('--private-msg-bg', '#1a237e');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(0, 39, 77, 0.7)');
|
|
setSP('--dd-color', '#1E90FF');
|
|
document.body.style.background = 'radial-gradient(#1a237e, #0d1b34)';
|
|
mirotalkTheme.selectedIndex = 3;
|
|
break;
|
|
case 'red':
|
|
// red theme
|
|
swBg = 'radial-gradient(#8B0000, #320000)';
|
|
setSP('--body-bg', 'radial-gradient(#8B0000, #320000)');
|
|
setSP('--msger-bg', 'radial-gradient(#8B0000, #320000)');
|
|
setSP('--wb-bg', 'radial-gradient(#8B0000, #320000)');
|
|
setSP('--navbar-bg', 'rgba(50, 0, 0, 0.8)');
|
|
setSP('--select-bg', '#320000');
|
|
setSP('--tab-btn-active', '#8B0000');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#8B0000');
|
|
setSP('--right-msg-bg', '#4B0000');
|
|
setSP('--private-msg-bg', '#8B0000');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(42, 13, 13, 0.7)');
|
|
setSP('--dd-color', '#FF4500');
|
|
document.body.style.background = 'radial-gradient(#8B0000, #320000)';
|
|
mirotalkTheme.selectedIndex = 4;
|
|
break;
|
|
case 'purple':
|
|
// purple theme
|
|
swBg = 'radial-gradient(#4B0082, #2C003E)';
|
|
setSP('--body-bg', 'radial-gradient(#4B0082, #2C003E)');
|
|
setSP('--msger-bg', 'radial-gradient(#4B0082, #2C003E)');
|
|
setSP('--wb-bg', 'radial-gradient(#4B0082, #2C003E)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(44, 0, 62, 0.8)');
|
|
setSP('--select-bg', '#2C003E');
|
|
setSP('--tab-btn-active', '#4B0082');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#4B0082');
|
|
setSP('--right-msg-bg', '#2C003E');
|
|
setSP('--private-msg-bg', '#4B0082');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(42, 0, 29, 0.7)');
|
|
setSP('--dd-color', '#BF00FF');
|
|
document.body.style.background = 'radial-gradient(#4B0082, #2C003E)';
|
|
mirotalkTheme.selectedIndex = 5;
|
|
break;
|
|
case 'orange':
|
|
// orange theme
|
|
swBg = 'radial-gradient(#FF8C00, #4B1C00)';
|
|
setSP('--body-bg', 'radial-gradient(#FF8C00, #4B1C00)');
|
|
setSP('--msger-bg', 'radial-gradient(#FF8C00, #4B1C00)');
|
|
setSP('--wb-bg', 'radial-gradient(#FF8C00, #4B1C00)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(75, 28, 0, 0.8)');
|
|
setSP('--select-bg', '#4B1C00');
|
|
setSP('--tab-btn-active', '#FF8C00');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#FF8C00');
|
|
setSP('--right-msg-bg', '#4B1C00');
|
|
setSP('--private-msg-bg', '#FF8C00');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(61, 26, 0, 0.7)');
|
|
setSP('--dd-color', '#FFA500');
|
|
document.body.style.background = 'radial-gradient(#FF8C00, #4B1C00)';
|
|
mirotalkTheme.selectedIndex = 6;
|
|
break;
|
|
case 'yellow':
|
|
// yellow theme
|
|
swBg = 'radial-gradient(#FFD700, #3B3B00)';
|
|
setSP('--body-bg', 'radial-gradient(#FFD700, #3B3B00)');
|
|
setSP('--msger-bg', 'radial-gradient(#FFD700, #3B3B00)');
|
|
setSP('--wb-bg', 'radial-gradient(#FFD700, #3B3B00)');
|
|
setSP('--elem-border-color', 'none');
|
|
setSP('--navbar-bg', 'rgba(59, 59, 0, 0.8)');
|
|
setSP('--select-bg', '#3B3B00');
|
|
setSP('--tab-btn-active', '#FFD700');
|
|
setSP('--box-shadow', '0px 8px 16px 0px rgba(0, 0, 0, 0.4)');
|
|
setSP('--msger-private-bg', 'radial-gradient(#4f4f4f, #1c1c1c)');
|
|
setSP('--left-msg-bg', '#FFD700');
|
|
setSP('--right-msg-bg', '#B8860B');
|
|
setSP('--private-msg-bg', '#FFD700');
|
|
setSP('--btn-bar-bg-color', '#FFFFFF');
|
|
setSP('--btn-bar-color', '#000000');
|
|
setSP('--btns-bg-color', 'rgba(77, 59, 0, 0.7)');
|
|
setSP('--dd-color', '#FFD700');
|
|
document.body.style.background = 'radial-gradient(#FFD700, #3B3B00)';
|
|
mirotalkTheme.selectedIndex = 7;
|
|
break;
|
|
// ...
|
|
default:
|
|
console.log('No theme found');
|
|
break;
|
|
}
|
|
wbIsBgTransparent = false;
|
|
//setButtonsBarPosition(mainButtonsBarPosition);
|
|
}
|
|
|
|
/**
|
|
* Set buttons bar position
|
|
* @param {string} position vertical / horizontal
|
|
*/
|
|
function setButtonsBarPosition(position) {
|
|
if (!position || isMobileDevice) return;
|
|
|
|
mainButtonsBarPosition = position;
|
|
switch (mainButtonsBarPosition) {
|
|
case 'vertical':
|
|
// bottomButtons horizontally
|
|
setSP('--bottom-btns-top', 'auto');
|
|
setSP('--bottom-btns-left', '50%');
|
|
setSP('--bottom-btns-bottom', '0');
|
|
setSP('--bottom-btns-translate-X', '-50%');
|
|
setSP('--bottom-btns-translate-Y', '0%');
|
|
setSP('--bottom-btns-margin-bottom', '16px');
|
|
setSP('--bottom-btns-flex-direction', 'row');
|
|
break;
|
|
case 'horizontal':
|
|
// bottomButtons vertically
|
|
setSP('--bottom-btns-top', '50%');
|
|
setSP('--bottom-btns-left', '15px');
|
|
setSP('--bottom-btns-bottom', 'auto');
|
|
setSP('--bottom-btns-translate-X', '0%');
|
|
setSP('--bottom-btns-translate-Y', '-50%');
|
|
setSP('--bottom-btns-margin-bottom', '0');
|
|
setSP('--bottom-btns-flex-direction', 'column');
|
|
break;
|
|
default:
|
|
console.log('No position found');
|
|
break;
|
|
}
|
|
refreshMainButtonsToolTipPlacement();
|
|
}
|
|
|
|
/**
|
|
* Init to enumerate the devices
|
|
*/
|
|
async function initEnumerateDevices() {
|
|
console.log('05. init Enumerate Video and Audio Devices');
|
|
await initEnumerateVideoDevices();
|
|
await initEnumerateAudioDevices();
|
|
}
|
|
|
|
/**
|
|
* Init to enumerate the audio devices
|
|
* @returns boolean true/false
|
|
*/
|
|
async function initEnumerateAudioDevices() {
|
|
if (isEnumerateAudioDevices) return;
|
|
// allow the audio
|
|
await navigator.mediaDevices
|
|
.getUserMedia({ audio: true })
|
|
.then(async (stream) => {
|
|
await enumerateAudioDevices(stream);
|
|
useAudio = true;
|
|
})
|
|
.catch(() => {
|
|
useAudio = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Init to enumerate the vide devices
|
|
* @returns boolean true/false
|
|
*/
|
|
async function initEnumerateVideoDevices() {
|
|
if (isEnumerateVideoDevices) return;
|
|
// allow the video
|
|
await navigator.mediaDevices
|
|
.getUserMedia({ video: true })
|
|
.then(async (stream) => {
|
|
await enumerateVideoDevices(stream);
|
|
useVideo = true;
|
|
})
|
|
.catch(() => {
|
|
useVideo = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enumerate Audio
|
|
* @param {object} stream
|
|
*/
|
|
async function enumerateAudioDevices(stream) {
|
|
console.log('06. Get Audio Devices');
|
|
await navigator.mediaDevices
|
|
.enumerateDevices()
|
|
.then((devices) =>
|
|
devices.forEach(async (device) => {
|
|
let el,
|
|
eli = null;
|
|
if ('audioinput' === device.kind) {
|
|
el = audioInputSelect;
|
|
eli = initMicrophoneSelect;
|
|
lS.DEVICES_COUNT.audio++;
|
|
} else if ('audiooutput' === device.kind) {
|
|
el = audioOutputSelect;
|
|
eli = initSpeakerSelect;
|
|
lS.DEVICES_COUNT.speaker++;
|
|
}
|
|
if (!el) return;
|
|
await addChild(device, [el, eli]);
|
|
})
|
|
)
|
|
.then(async () => {
|
|
await stopTracks(stream);
|
|
isEnumerateAudioDevices = true;
|
|
//const sinkId = 'sinkId' in HTMLMediaElement.prototype;
|
|
audioOutputSelect.disabled = !sinkId;
|
|
// Check if there is speakers
|
|
if (!sinkId || initSpeakerSelect.options.length === 0) {
|
|
displayElements([
|
|
{ element: initSpeakerSelect, display: false },
|
|
{ element: audioOutputDiv, display: false },
|
|
]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enumerate Video
|
|
* @param {object} stream
|
|
*/
|
|
async function enumerateVideoDevices(stream) {
|
|
console.log('07. Get Video Devices');
|
|
await navigator.mediaDevices
|
|
.enumerateDevices()
|
|
.then((devices) =>
|
|
devices.forEach(async (device) => {
|
|
let el,
|
|
eli = null;
|
|
if ('videoinput' === device.kind) {
|
|
el = videoSelect;
|
|
eli = initVideoSelect;
|
|
lS.DEVICES_COUNT.video++;
|
|
}
|
|
if (!el) return;
|
|
await addChild(device, [el, eli]);
|
|
})
|
|
)
|
|
.then(async () => {
|
|
await stopTracks(stream);
|
|
isEnumerateVideoDevices = true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop tracks from stream
|
|
* @param {object} stream
|
|
*/
|
|
async function stopTracks(stream) {
|
|
stream.getTracks().forEach((track) => {
|
|
track.stop();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add child to element
|
|
* @param {object} device
|
|
* @param {object} els
|
|
*/
|
|
async function addChild(device, els) {
|
|
const { kind, deviceId, label } = device;
|
|
els.forEach((el) => {
|
|
const option = document.createElement('option');
|
|
option.value = deviceId;
|
|
switch (kind) {
|
|
case 'videoinput':
|
|
option.innerText = `📹 ` + label || `📹 camera ${el.length + 1}`;
|
|
break;
|
|
case 'audioinput':
|
|
option.innerText = `🎤 ` + label || `🎤 microphone ${el.length + 1}`;
|
|
break;
|
|
case 'audiooutput':
|
|
option.innerText = `🔈 ` + label || `🔈 speaker ${el.length + 1}`;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
el.appendChild(option);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refresh audio/video devices list when hardware changes are detected
|
|
* Preserves the currently selected device if it's still available
|
|
*/
|
|
async function refreshMyAudioVideoDevices() {
|
|
console.log('Refreshing audio/video devices...');
|
|
|
|
// Store currently selected device IDs
|
|
const selectedVideoId = videoSelect?.value;
|
|
const selectedAudioInputId = audioInputSelect?.value;
|
|
const selectedAudioOutputId = audioOutputSelect?.value;
|
|
|
|
try {
|
|
// Re-enumerate all devices
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
// Clear existing options
|
|
if (videoSelect) videoSelect.innerHTML = '';
|
|
if (audioInputSelect) audioInputSelect.innerHTML = '';
|
|
if (audioOutputSelect) audioOutputSelect.innerHTML = '';
|
|
|
|
// Reset device counts
|
|
lS.DEVICES_COUNT.video = 0;
|
|
lS.DEVICES_COUNT.audio = 0;
|
|
lS.DEVICES_COUNT.speaker = 0;
|
|
|
|
// Populate select elements with new device list
|
|
for (const device of devices) {
|
|
let el = null;
|
|
if (device.kind === 'videoinput') {
|
|
el = videoSelect;
|
|
lS.DEVICES_COUNT.video++;
|
|
} else if (device.kind === 'audioinput') {
|
|
el = audioInputSelect;
|
|
lS.DEVICES_COUNT.audio++;
|
|
} else if (device.kind === 'audiooutput') {
|
|
el = audioOutputSelect;
|
|
lS.DEVICES_COUNT.speaker++;
|
|
}
|
|
if (el) await addChild(device, [el]);
|
|
}
|
|
|
|
// Update speaker availability
|
|
audioOutputSelect.disabled = !sinkId || lS.DEVICES_COUNT.speaker === 0;
|
|
|
|
// Try to restore previously selected devices
|
|
let videoChanged = false;
|
|
let audioInputChanged = false;
|
|
let audioOutputChanged = false;
|
|
|
|
if (videoSelect && selectedVideoId) {
|
|
if (selectOptionByValueExist(videoSelect, selectedVideoId)) {
|
|
videoSelect.value = selectedVideoId;
|
|
} else {
|
|
videoChanged = true;
|
|
console.log('Previously selected camera no longer available');
|
|
}
|
|
}
|
|
|
|
if (audioInputSelect && selectedAudioInputId) {
|
|
if (selectOptionByValueExist(audioInputSelect, selectedAudioInputId)) {
|
|
audioInputSelect.value = selectedAudioInputId;
|
|
} else {
|
|
audioInputChanged = true;
|
|
console.log('Previously selected microphone no longer available');
|
|
}
|
|
}
|
|
|
|
if (audioOutputSelect && selectedAudioOutputId) {
|
|
if (selectOptionByValueExist(audioOutputSelect, selectedAudioOutputId)) {
|
|
audioOutputSelect.value = selectedAudioOutputId;
|
|
} else {
|
|
audioOutputChanged = true;
|
|
console.log('Previously selected speaker no longer available');
|
|
}
|
|
}
|
|
|
|
// If active device was removed, switch to the new default
|
|
if (videoChanged && useVideo && videoSelect?.value) {
|
|
console.log('Switching to new default camera:', videoSelect.value);
|
|
await changeLocalCamera(videoSelect.value);
|
|
}
|
|
|
|
if (audioInputChanged && useAudio && audioInputSelect?.value) {
|
|
console.log('Switching to new default microphone:', audioInputSelect.value);
|
|
await changeLocalMicrophone(audioInputSelect.value);
|
|
}
|
|
|
|
if (audioOutputChanged && audioOutputSelect?.value) {
|
|
console.log('Switching to new default speaker:', audioOutputSelect.value);
|
|
await changeAudioDestination();
|
|
}
|
|
|
|
// Update local storage with new selections
|
|
await refreshLsDevices();
|
|
|
|
console.log('Device refresh complete:', {
|
|
video: lS.DEVICES_COUNT.video,
|
|
audio: lS.DEVICES_COUNT.audio,
|
|
speaker: lS.DEVICES_COUNT.speaker,
|
|
});
|
|
} catch (err) {
|
|
console.error('Error refreshing devices:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect low quality bluetooth devices
|
|
* @param {boolean} init indicates if it's during inizialization before join room
|
|
*/
|
|
function detectBluetoothHeadset(init = false) {
|
|
const selectEl = init ? initMicrophoneSelect : audioInputSelect;
|
|
if (!selectEl) return;
|
|
|
|
const micName = getSelectedOptionText(selectEl);
|
|
console.log('Selected microphone:', micName);
|
|
|
|
const lowQualityBT = /(bluetooth|headset|hands[- ]?free|hsp|hfp|sco|airpods)/i;
|
|
if (micName && lowQualityBT.test(micName)) {
|
|
alert(
|
|
"⚠️ You're using a Bluetooth headset with limited audio quality. For best results, use your device's built-in microphone or a wired headset."
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get selected option text
|
|
* @param {object} selectEl
|
|
* @returns string
|
|
*/
|
|
function getSelectedOptionText(selectEl) {
|
|
if (!selectEl || !selectEl.options || selectEl.selectedIndex < 0) return '';
|
|
const opt = selectEl.options[selectEl.selectedIndex];
|
|
return opt && opt.text ? opt.text.trim() : '';
|
|
}
|
|
|
|
/**
|
|
* Setup local video media. Ask the user for permission to use the computer's camera,
|
|
* and attach it to a <video> tag if access is granted.
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
|
|
*/
|
|
async function setupLocalVideoMedia() {
|
|
if (!useVideo || localVideoMediaStream) {
|
|
return;
|
|
}
|
|
|
|
console.log('Requesting access to video inputs');
|
|
|
|
const videoConstraints = useVideo ? getVideoConstraints('default') : false;
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints });
|
|
await updateLocalVideoMediaStream(stream);
|
|
} catch (err) {
|
|
console.error('Error accessing video device', err);
|
|
console.warn('Fallback to default constraints');
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
await updateLocalVideoMediaStream(stream);
|
|
} catch (fallbackErr) {
|
|
console.error('Error accessing video device with default constraints', fallbackErr);
|
|
handleMediaError('video', fallbackErr);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update Local Media Stream
|
|
* @param {MediaStream} stream
|
|
*/
|
|
async function updateLocalVideoMediaStream(stream) {
|
|
if (stream) {
|
|
localVideoMediaStream = stream;
|
|
await loadLocalMedia(stream, 'video');
|
|
console.log('Access granted to video device');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup local audio media. Ask the user for permission to use the computer's microphone,
|
|
* and attach it to an <audio> tag if access is granted.
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
|
|
*/
|
|
async function setupLocalAudioMedia() {
|
|
if (!useAudio || localAudioMediaStream) {
|
|
return;
|
|
}
|
|
|
|
console.log('Requesting access to audio inputs');
|
|
|
|
const audioConstraints = useAudio ? getAudioConstraints() : { audio: false };
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia(audioConstraints);
|
|
if (stream) {
|
|
await loadLocalMedia(stream, 'audio');
|
|
if (useAudio) {
|
|
localAudioMediaStream = stream;
|
|
console.log('10. Access granted to audio device');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
handleMediaError('audio', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle media access error.
|
|
* https://blog.addpipe.com/common-getusermedia-errors/
|
|
*
|
|
* @param {string} mediaType - 'video' or 'audio'
|
|
* @param {object} err - The error object
|
|
*/
|
|
function handleMediaError(mediaType, err) {
|
|
playSound('alert');
|
|
//
|
|
let errMessage = err;
|
|
|
|
switch (err.name) {
|
|
case 'NotFoundError':
|
|
case 'DevicesNotFoundError':
|
|
errMessage = 'Required track is missing';
|
|
break;
|
|
case 'NotReadableError':
|
|
case 'TrackStartError':
|
|
errMessage = 'Already in use';
|
|
break;
|
|
case 'OverconstrainedError':
|
|
case 'ConstraintNotSatisfiedError':
|
|
errMessage = 'Constraints cannot be satisfied by available devices';
|
|
break;
|
|
case 'NotAllowedError':
|
|
case 'PermissionDeniedError':
|
|
errMessage = 'Permission denied in browser';
|
|
break;
|
|
case 'TypeError':
|
|
errMessage = 'Empty constraints object';
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Print message to inform user
|
|
const $html = `
|
|
<ul style="text-align: left">
|
|
<li>Media type: ${mediaType}</li>
|
|
<li>Error name: ${err.name}</li>
|
|
<li>Error message: <p style="color: red">${errMessage}</p></li>
|
|
<li>Common: <a href="https://blog.addpipe.com/common-getusermedia-errors" target="_blank">getUserMedia errors</a></li>
|
|
</ul>
|
|
`;
|
|
|
|
msgHTML(null, images.forbidden, 'Access denied', $html, 'center', '/');
|
|
|
|
/*
|
|
it immediately stops the execution of the current function and jumps to the nearest enclosing try...catch block or,
|
|
if none exists, it interrupts the script execution and displays an error message in the console.
|
|
*/
|
|
throw new Error(
|
|
`Access denied for ${mediaType} device [${err.name}]: ${errMessage} check the common getUserMedia errors: https://blog.addpipe.com/common-getusermedia-errors/`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Load Local Media Stream obj
|
|
* @param {object} stream media stream audio - video
|
|
*/
|
|
async function loadLocalMedia(stream, kind) {
|
|
if (stream) console.log('LOAD LOCAL MEDIA STREAM TRACKS', stream.getTracks());
|
|
|
|
switch (kind) {
|
|
case 'video':
|
|
//alert('local video');
|
|
console.log('SETUP LOCAL VIDEO STREAM');
|
|
|
|
// local video elements
|
|
const myVideoWrap = document.createElement('div');
|
|
const myLocalMedia = document.createElement('video');
|
|
|
|
// html elements
|
|
const myVideoNavBar = document.createElement('div');
|
|
const myCurrentSessionTime = document.createElement('span');
|
|
const myVideoPeerName = document.createElement('p');
|
|
const myHandStatusIcon = document.createElement('button');
|
|
const myVideoToImgBtn = document.createElement('button');
|
|
const myPrivacyBtn = document.createElement('button');
|
|
const myVideoStatusIcon = document.createElement('button');
|
|
const myAudioStatusIcon = document.createElement('button');
|
|
const myVideoFullScreenBtn = document.createElement('button');
|
|
const myVideoFocusBtn = document.createElement('button');
|
|
const myVideoPinBtn = document.createElement('button');
|
|
const myVideoMirrorBtn = document.createElement('button');
|
|
const myVideoZoomInBtn = document.createElement('button');
|
|
const myVideoZoomOutBtn = document.createElement('button');
|
|
const myVideoPiPBtn = document.createElement('button');
|
|
const myVideoAvatarImage = document.createElement('img');
|
|
const myPitchMeter = document.createElement('div');
|
|
const myPitchBar = document.createElement('div');
|
|
|
|
//my current session time
|
|
myCurrentSessionTime.setAttribute('id', 'myCurrentSessionTime');
|
|
myCurrentSessionTime.className = 'notranslate';
|
|
|
|
// my peer name
|
|
myVideoPeerName.setAttribute('id', 'myVideoPeerName');
|
|
myVideoPeerName.className = 'videoPeerName notranslate';
|
|
|
|
// my hand status element
|
|
myHandStatusIcon.setAttribute('id', 'myHandStatusIcon');
|
|
myHandStatusIcon.className = className.handPulsate;
|
|
myHandStatusIcon.style.setProperty('color', 'rgb(0, 255, 0)');
|
|
|
|
// my privacy button
|
|
myPrivacyBtn.setAttribute('id', 'myPrivacyBtn');
|
|
myPrivacyBtn.className = className.privacy;
|
|
|
|
// my video status element
|
|
myVideoStatusIcon.setAttribute('id', 'myVideoStatusIcon');
|
|
myVideoStatusIcon.className = className.videoOn;
|
|
myVideoStatusIcon.style.cursor = 'default';
|
|
|
|
// my audio status element
|
|
myAudioStatusIcon.setAttribute('id', 'myAudioStatusIcon');
|
|
myAudioStatusIcon.className = className.audioOn;
|
|
myAudioStatusIcon.style.cursor = 'default';
|
|
|
|
// my video to image
|
|
myVideoToImgBtn.setAttribute('id', 'myVideoToImgBtn');
|
|
myVideoToImgBtn.className = className.snapShot;
|
|
|
|
// my video full screen mode
|
|
myVideoFullScreenBtn.setAttribute('id', 'myVideoFullScreenBtn');
|
|
myVideoFullScreenBtn.className = className.fullScreen;
|
|
|
|
// my video zoomIn/Out
|
|
myVideoZoomInBtn.setAttribute('id', 'myVideoZoomInBtn');
|
|
myVideoZoomInBtn.className = className.zoomIn;
|
|
myVideoZoomOutBtn.setAttribute('id', 'myVideoZoomOutBtn');
|
|
myVideoZoomOutBtn.className = className.zoomOut;
|
|
|
|
// my video hide all button
|
|
myVideoFocusBtn.setAttribute('id', 'myVideoFocusMode');
|
|
myVideoFocusBtn.className = className.hideAll;
|
|
|
|
// my video Picture in Picture
|
|
myVideoPiPBtn.setAttribute('id', 'myVideoPiPBtn');
|
|
myVideoPiPBtn.className = className.pip;
|
|
|
|
// my video pin/unpin button
|
|
myVideoPinBtn.setAttribute('id', 'myVideoPinBtn');
|
|
myVideoPinBtn.className = className.pinUnpin;
|
|
|
|
// my video toggle mirror
|
|
myVideoMirrorBtn.setAttribute('id', 'myVideoMirror');
|
|
myVideoMirrorBtn.className = className.mirror;
|
|
|
|
// no mobile devices
|
|
if (!isMobileDevice) {
|
|
setTippy(myHandStatusIcon, 'My hand is raised', 'bottom');
|
|
setTippy(myPrivacyBtn, 'Toggle video privacy', 'bottom');
|
|
setTippy(myVideoStatusIcon, 'My video is on', 'bottom');
|
|
setTippy(myAudioStatusIcon, 'My audio is on', 'bottom');
|
|
setTippy(myVideoToImgBtn, 'Take a snapshot', 'bottom');
|
|
setTippy(myVideoFullScreenBtn, 'Full screen mode', 'bottom');
|
|
setTippy(myVideoFocusBtn, 'Toggle Focus mode', 'bottom');
|
|
setTippy(myVideoPiPBtn, 'Toggle picture in picture', 'bottom');
|
|
setTippy(myVideoZoomInBtn, 'Zoom in video', 'bottom');
|
|
setTippy(myVideoZoomOutBtn, 'Zoom out video', 'bottom');
|
|
setTippy(myVideoPinBtn, 'Toggle Pin video', 'bottom');
|
|
setTippy(myVideoMirrorBtn, 'Toggle video mirror', 'bottom');
|
|
}
|
|
|
|
// my video avatar image
|
|
myVideoAvatarImage.setAttribute('id', 'myVideoAvatarImage');
|
|
myVideoAvatarImage.className = 'videoAvatarImage'; // pulsate
|
|
|
|
// my pitch meter
|
|
myPitchMeter.setAttribute('id', 'myPitch');
|
|
myPitchBar.setAttribute('id', 'myPitchBar');
|
|
myPitchMeter.className = 'speechbar';
|
|
myPitchBar.className = 'bar';
|
|
myPitchBar.style.height = '1%';
|
|
|
|
// my video nav bar
|
|
myVideoNavBar.className = 'navbar fadein';
|
|
|
|
myVideoNavBar.appendChild(myCurrentSessionTime);
|
|
|
|
!isMobileDevice && myVideoNavBar.appendChild(myVideoPinBtn);
|
|
|
|
buttons.local.showVideoFocusBtn && myVideoNavBar.appendChild(myVideoFocusBtn);
|
|
|
|
myVideoNavBar.appendChild(myVideoMirrorBtn);
|
|
|
|
if (showVideoPipBtn && buttons.local.showVideoPipBtn) myVideoNavBar.appendChild(myVideoPiPBtn);
|
|
|
|
if (buttons.local.showZoomInOutBtn) {
|
|
myVideoNavBar.appendChild(myVideoZoomInBtn);
|
|
myVideoNavBar.appendChild(myVideoZoomOutBtn);
|
|
}
|
|
|
|
buttons.local.showSnapShotBtn && myVideoNavBar.appendChild(myVideoToImgBtn);
|
|
buttons.local.showVideoCircleBtn && myVideoNavBar.appendChild(myPrivacyBtn);
|
|
|
|
isVideoFullScreenSupported && myVideoNavBar.appendChild(myVideoFullScreenBtn);
|
|
|
|
myVideoNavBar.appendChild(myVideoStatusIcon);
|
|
myVideoNavBar.appendChild(myAudioStatusIcon);
|
|
myVideoNavBar.appendChild(myHandStatusIcon);
|
|
|
|
// add my pitchBar
|
|
myPitchMeter.appendChild(myPitchBar);
|
|
|
|
// hand display none on default menad is raised == false
|
|
elemDisplay(myHandStatusIcon, false);
|
|
|
|
myLocalMedia.setAttribute('id', 'myVideo');
|
|
myLocalMedia.setAttribute('playsinline', true);
|
|
myLocalMedia.className = 'mirror';
|
|
myLocalMedia.autoplay = true;
|
|
myLocalMedia.muted = true;
|
|
myLocalMedia.volume = 0;
|
|
myLocalMedia.controls = false;
|
|
myLocalMedia.poster = images.poster;
|
|
|
|
myVideoWrap.className = 'Camera';
|
|
myVideoWrap.setAttribute('id', 'myVideoWrap');
|
|
|
|
// add elements to video wrap div
|
|
myVideoWrap.appendChild(myVideoNavBar);
|
|
myVideoWrap.appendChild(myVideoAvatarImage);
|
|
myVideoWrap.appendChild(myLocalMedia);
|
|
myVideoWrap.appendChild(myPitchMeter);
|
|
myVideoWrap.appendChild(myVideoPeerName);
|
|
|
|
videoMediaContainer.appendChild(myVideoWrap);
|
|
elemDisplay(myVideoWrap, false);
|
|
|
|
logStreamSettingsInfo('localVideoMediaStream', stream);
|
|
attachMediaStream(myLocalMedia, stream);
|
|
|
|
adaptAspectRatio();
|
|
|
|
handleVideoToggleMirror(myLocalMedia.id, myVideoMirrorBtn.id);
|
|
|
|
isVideoFullScreenSupported && handleVideoPlayerFs(myLocalMedia.id, myVideoFullScreenBtn.id);
|
|
|
|
handleFileDragAndDrop(myLocalMedia.id, myPeerId, true);
|
|
|
|
buttons.local.showSnapShotBtn && handleVideoToImg(myLocalMedia.id, myVideoToImgBtn.id);
|
|
|
|
buttons.local.showVideoCircleBtn && handleVideoPrivacyBtn(myLocalMedia.id, myPrivacyBtn.id);
|
|
|
|
buttons.local.showVideoFocusBtn && handleVideoFocusMode(myVideoFocusBtn, myVideoWrap, myLocalMedia);
|
|
|
|
handleVideoPinUnpin(myLocalMedia.id, myVideoPinBtn.id, myVideoWrap.id, myLocalMedia.id);
|
|
|
|
if (showVideoPipBtn && buttons.local.showVideoPipBtn)
|
|
handlePictureInPicture(myVideoPiPBtn.id, myLocalMedia.id, myPeerId);
|
|
|
|
ZOOM_IN_OUT_ENABLED &&
|
|
handleVideoZoomInOut(
|
|
myVideoStatusIcon.id,
|
|
myVideoWrap.id,
|
|
myVideoZoomInBtn.id,
|
|
myVideoZoomOutBtn.id,
|
|
myLocalMedia.id
|
|
);
|
|
|
|
refreshMyVideoStatus(stream);
|
|
|
|
if (!useVideo) {
|
|
elemDisplay(myVideoAvatarImage, true, 'block');
|
|
setMediaButtonsClass([
|
|
{ element: myVideoStatusIcon, status: false, mediaType: 'video' },
|
|
{ element: videoBtn, status: false, mediaType: 'video' },
|
|
]);
|
|
if (!isMobileDevice) {
|
|
setTippy(myVideoStatusIcon, 'My video is disabled', 'bottom');
|
|
}
|
|
}
|
|
|
|
if (!useAudio) {
|
|
setMediaButtonsClass([
|
|
{ element: myAudioStatusIcon, status: false, mediaType: 'audio' },
|
|
{ element: audioBtn, status: false, mediaType: 'audio' },
|
|
]);
|
|
if (!isMobileDevice) {
|
|
setTippy(myAudioStatusIcon, 'My audio is disabled', 'bottom');
|
|
}
|
|
}
|
|
break;
|
|
case 'screen':
|
|
//alert('local screen');
|
|
console.log('SETUP LOCAL SCREEN STREAM');
|
|
|
|
// local screen elements
|
|
const myScreenWrap = document.createElement('div');
|
|
const myScreenMedia = document.createElement('video');
|
|
|
|
// html elements
|
|
const myScreenNavBar = document.createElement('div');
|
|
const myScreenPeerName = document.createElement('p');
|
|
const myScreenToImgBtn = document.createElement('button');
|
|
const myScreenFullScreenBtn = document.createElement('button');
|
|
const myScreenFocusBtn = document.createElement('button');
|
|
const myScreenPinBtn = document.createElement('button');
|
|
const myScreenZoomInBtn = document.createElement('button');
|
|
const myScreenZoomOutBtn = document.createElement('button');
|
|
const myScreenPiPBtn = document.createElement('button');
|
|
const myScreenAvatarImage = document.createElement('img');
|
|
|
|
// my screen peer name
|
|
myScreenPeerName.setAttribute('id', 'myScreenPeerName');
|
|
myScreenPeerName.className = 'videoPeerName notranslate';
|
|
myScreenPeerName.innerText = myPeerName + ' (me)';
|
|
|
|
// my screen to image
|
|
myScreenToImgBtn.setAttribute('id', 'myScreenToImgBtn');
|
|
myScreenToImgBtn.className = className.snapShot;
|
|
|
|
// my screen full screen mode
|
|
myScreenFullScreenBtn.setAttribute('id', 'myScreenFullScreenBtn');
|
|
myScreenFullScreenBtn.className = className.fullScreen;
|
|
|
|
// my screen zoomIn/Out
|
|
myScreenZoomInBtn.setAttribute('id', 'myScreenZoomInBtn');
|
|
myScreenZoomInBtn.className = className.zoomIn;
|
|
myScreenZoomOutBtn.setAttribute('id', 'myScreenZoomOutBtn');
|
|
myScreenZoomOutBtn.className = className.zoomOut;
|
|
|
|
// my screen focus mode
|
|
myScreenFocusBtn.setAttribute('id', 'myScreenFocusMode');
|
|
myScreenFocusBtn.className = className.hideAll;
|
|
|
|
// my screen Picture in Picture
|
|
myScreenPiPBtn.setAttribute('id', 'myScreenPiPBtn');
|
|
myScreenPiPBtn.className = className.pip;
|
|
|
|
// my screen pin/unpin button
|
|
myScreenPinBtn.setAttribute('id', 'myScreenPinBtn');
|
|
myScreenPinBtn.className = className.pinUnpin;
|
|
|
|
// no mobile devices
|
|
if (!isMobileDevice) {
|
|
setTippy(myScreenToImgBtn, 'Take a snapshot', 'bottom');
|
|
setTippy(myScreenFullScreenBtn, 'Full screen mode', 'bottom');
|
|
setTippy(myScreenZoomInBtn, 'Zoom in screen', 'bottom');
|
|
setTippy(myScreenZoomOutBtn, 'Zoom out screen', 'bottom');
|
|
setTippy(myScreenPiPBtn, 'Toggle picture in picture', 'bottom');
|
|
setTippy(myScreenFocusBtn, 'Toggle Focus mode', 'bottom');
|
|
setTippy(myScreenPinBtn, 'Toggle Pin screen', 'bottom');
|
|
}
|
|
|
|
// my screen avatar image
|
|
myScreenAvatarImage.setAttribute('id', 'myScreenAvatarImage');
|
|
myScreenAvatarImage.className = 'videoAvatarImage'; // pulsate
|
|
|
|
// my screen nav bar
|
|
myScreenNavBar.className = 'navbar fadein';
|
|
|
|
// attach to screen nav bar
|
|
!isMobileDevice && myScreenNavBar.appendChild(myScreenPinBtn);
|
|
|
|
buttons.local.showVideoFocusBtn && myScreenNavBar.appendChild(myScreenFocusBtn);
|
|
|
|
buttons.local.showSnapShotBtn && myScreenNavBar.appendChild(myScreenToImgBtn);
|
|
|
|
myScreenNavBar.appendChild(myScreenPiPBtn);
|
|
|
|
if (buttons.local.showZoomInOutBtn) {
|
|
myScreenNavBar.appendChild(myScreenZoomInBtn);
|
|
myScreenNavBar.appendChild(myScreenZoomOutBtn);
|
|
}
|
|
|
|
isVideoFullScreenSupported && myScreenNavBar.appendChild(myScreenFullScreenBtn);
|
|
|
|
myScreenMedia.setAttribute('id', 'myScreen');
|
|
myScreenMedia.setAttribute('playsinline', true);
|
|
myScreenMedia.style.objectFit = 'contain';
|
|
myScreenMedia.className = '';
|
|
myScreenMedia.autoplay = true;
|
|
myScreenMedia.muted = true;
|
|
myScreenMedia.volume = 0;
|
|
myScreenMedia.controls = false;
|
|
myScreenMedia.poster = images.poster;
|
|
|
|
myScreenWrap.className = 'Screen';
|
|
myScreenWrap.setAttribute('id', 'myScreenWrap');
|
|
|
|
// add elements to screen wrap div
|
|
myScreenWrap.appendChild(myScreenNavBar);
|
|
myScreenWrap.appendChild(myScreenAvatarImage);
|
|
myScreenWrap.appendChild(myScreenMedia);
|
|
myScreenWrap.appendChild(myScreenPeerName);
|
|
|
|
videoMediaContainer.appendChild(myScreenWrap);
|
|
// Show my screen tile immediately when created
|
|
elemDisplay(myScreenWrap, true, 'inline-block');
|
|
|
|
logStreamSettingsInfo('localScreenMediaStream', stream);
|
|
attachMediaStream(myScreenMedia, stream);
|
|
|
|
adaptAspectRatio();
|
|
|
|
buttons.local.showSnapShotBtn && handleVideoToImg(myScreenMedia.id, myScreenToImgBtn.id);
|
|
|
|
isVideoFullScreenSupported && handleVideoPlayerFs(myScreenMedia.id, myScreenFullScreenBtn.id);
|
|
|
|
buttons.local.showVideoFocusBtn && handleVideoFocusMode(myScreenFocusBtn, myScreenWrap, myScreenMedia);
|
|
|
|
handleVideoPinUnpin(myScreenMedia.id, myScreenPinBtn.id, myScreenWrap.id, myScreenMedia.id, true);
|
|
|
|
myScreenPinBtn.click();
|
|
|
|
if (showVideoPipBtn && buttons.local.showVideoPipBtn)
|
|
handlePictureInPicture(myScreenPiPBtn.id, myScreenMedia.id, myPeerId);
|
|
|
|
ZOOM_IN_OUT_ENABLED &&
|
|
handleVideoZoomInOut(
|
|
'',
|
|
myScreenWrap.id,
|
|
myScreenZoomInBtn.id,
|
|
myScreenZoomOutBtn.id,
|
|
myScreenMedia.id
|
|
);
|
|
break;
|
|
case 'audio':
|
|
//alert('local audio');
|
|
console.log('SETUP LOCAL AUDIO STREAM');
|
|
// handle remote audio elements
|
|
const localAudioWrap = document.createElement('div');
|
|
const localAudioMedia = document.createElement('audio');
|
|
localAudioMedia.id = 'myAudio';
|
|
localAudioMedia.controls = false;
|
|
localAudioMedia.autoplay = true;
|
|
localAudioMedia.muted = true;
|
|
localAudioMedia.volume = 0;
|
|
localAudioWrap.appendChild(localAudioMedia);
|
|
audioMediaContainer.appendChild(localAudioWrap);
|
|
logStreamSettingsInfo('localAudioMediaStream', stream);
|
|
attachMediaStream(localAudioMedia, stream);
|
|
refreshMyAudioStatus(stream);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if screen is shared on join room
|
|
*/
|
|
function checkShareScreen() {
|
|
if (!isMobileDevice && isScreenEnabled && isScreenSharingSupported) {
|
|
playSound('newMessage');
|
|
// screenShareBtn.click(); // Chrome - Opera - Edge - Brave
|
|
// handle error: getDisplayMedia requires transient activation from a user gesture on Safari - FireFox
|
|
Swal.fire({
|
|
background: swBg,
|
|
position: 'center',
|
|
icon: 'question',
|
|
text: 'Do you want to share your screen?',
|
|
showDenyButton: true,
|
|
confirmButtonText: `Yes`,
|
|
denyButtonText: `No`,
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
screenShareBtn.click();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open chat on Join
|
|
*/
|
|
function checkChatOnJoin() {
|
|
if (chat) {
|
|
chatRoomBtn.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load Remote Media Stream obj
|
|
* @param {MediaStream} stream media stream audio - video
|
|
* @param {object} peers all peers info connected to the same room
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
async function loadRemoteMediaStream(stream, peers, peer_id, kind) {
|
|
// get data from peers obj
|
|
console.log('REMOTE PEER INFO', peers[peer_id]);
|
|
|
|
const peer_name = peers[peer_id]['peer_name'];
|
|
const peer_avatar = peers[peer_id]['peer_avatar'];
|
|
const peer_audio = peers[peer_id]['peer_audio'];
|
|
const peer_video = peers[peer_id]['peer_video'];
|
|
const peer_video_status = peers[peer_id]['peer_video_status'];
|
|
const peer_audio_status = peers[peer_id]['peer_audio_status'];
|
|
const peer_screen_status = peers[peer_id]['peer_screen_status'];
|
|
const peer_hand_status = peers[peer_id]['peer_hand_status'];
|
|
const peer_rec_status = peers[peer_id]['peer_rec_status'];
|
|
const peer_privacy_status = peers[peer_id]['peer_privacy_status'];
|
|
|
|
if (stream) console.log('LOAD REMOTE MEDIA STREAM TRACKS - PeerName:[' + peer_name + ']', stream.getTracks());
|
|
|
|
switch (kind) {
|
|
case 'video':
|
|
// alert('remote video');
|
|
console.log('SETUP REMOTE VIDEO STREAM');
|
|
|
|
// handle remote video elements
|
|
const remoteVideoWrap = document.createElement('div');
|
|
const remoteMedia = document.createElement('video');
|
|
|
|
// html elements
|
|
const remoteVideoNavBar = document.createElement('div');
|
|
const remotePeerName = document.createElement('p');
|
|
const remoteHandStatusIcon = document.createElement('button');
|
|
const remoteVideoStatusIcon = document.createElement('button');
|
|
const remoteAudioStatusIcon = document.createElement('button');
|
|
const remoteVideoAudioUrlBtn = document.createElement('button');
|
|
const remoteFileShareBtn = document.createElement('button');
|
|
const remotePrivateMsgBtn = document.createElement('button');
|
|
const remoteGeoLocationBtn = document.createElement('button');
|
|
const remotePeerKickOut = document.createElement('button');
|
|
const remoteVideoToImgBtn = document.createElement('button');
|
|
const remoteVideoFullScreenBtn = document.createElement('button');
|
|
const remoteVideoPinBtn = document.createElement('button');
|
|
const remoteVideoFocusBtn = document.createElement('button');
|
|
const remoteVideoMirrorBtn = document.createElement('button');
|
|
const remoteVideoZoomInBtn = document.createElement('button');
|
|
const remoteVideoZoomOutBtn = document.createElement('button');
|
|
const remoteVideoPiPBtn = document.createElement('button');
|
|
const remoteVideoAvatarImage = document.createElement('img');
|
|
const remotePitchMeter = document.createElement('div');
|
|
const remotePitchBar = document.createElement('div');
|
|
const remoteAudioVolume = document.createElement('input');
|
|
|
|
// Expand button UI/UX
|
|
const remoteExpandBtnDiv = document.createElement('div');
|
|
const remoteExpandBtn = document.createElement('button');
|
|
const remoteExpandContainerDiv = document.createElement('div');
|
|
|
|
// remote peer name element
|
|
remotePeerName.setAttribute('id', peer_id + '_name');
|
|
remotePeerName.className = 'videoPeerName';
|
|
|
|
const peerVideoText = document.createTextNode(peer_name);
|
|
remotePeerName.appendChild(peerVideoText);
|
|
|
|
// remote hand status element
|
|
remoteHandStatusIcon.setAttribute('id', peer_id + '_handStatus');
|
|
remoteHandStatusIcon.style.setProperty('color', 'rgb(0, 255, 0)');
|
|
remoteHandStatusIcon.className = className.handPulsate;
|
|
|
|
// remote video status element
|
|
remoteVideoStatusIcon.setAttribute('id', peer_id + '_videoStatus');
|
|
remoteVideoStatusIcon.className = className.videoOn;
|
|
remoteVideoStatusIcon.style.cursor = 'default';
|
|
|
|
// remote audio status element
|
|
remoteAudioStatusIcon.setAttribute('id', peer_id + '_audioStatus');
|
|
remoteAudioStatusIcon.className = className.audioOn;
|
|
remoteAudioStatusIcon.style.cursor = 'default';
|
|
|
|
// remote audio volume element
|
|
remoteAudioVolume.setAttribute('id', peer_id + '_audioVolume');
|
|
remoteAudioVolume.type = 'range';
|
|
remoteAudioVolume.min = 0;
|
|
remoteAudioVolume.max = 100;
|
|
remoteAudioVolume.value = 100;
|
|
|
|
// remote private message
|
|
remotePrivateMsgBtn.setAttribute('id', peer_id + '_privateMsg');
|
|
remotePrivateMsgBtn.className = className.msgPrivate;
|
|
|
|
// remote geo location
|
|
remoteGeoLocationBtn.setAttribute('id', peer_id + '_geoLocation');
|
|
remoteGeoLocationBtn.className = className.geoLocation;
|
|
|
|
// remote share file
|
|
remoteFileShareBtn.setAttribute('id', peer_id + '_shareFile');
|
|
remoteFileShareBtn.className = className.shareFile;
|
|
|
|
// remote peer YouTube video
|
|
remoteVideoAudioUrlBtn.setAttribute('id', peer_id + '_videoAudioUrl');
|
|
remoteVideoAudioUrlBtn.className = className.shareVideoAudio;
|
|
|
|
// my video to image
|
|
remoteVideoToImgBtn.setAttribute('id', peer_id + '_snapshot');
|
|
remoteVideoToImgBtn.className = className.snapShot;
|
|
|
|
// remote peer kick out
|
|
remotePeerKickOut.setAttribute('id', peer_id + '_kickOut');
|
|
remotePeerKickOut.className = className.kickOut;
|
|
|
|
// remote video zoomIn/Out
|
|
remoteVideoZoomInBtn.setAttribute('id', peer_id + 'videoZoomIn');
|
|
remoteVideoZoomInBtn.className = className.zoomIn;
|
|
remoteVideoZoomOutBtn.setAttribute('id', peer_id + 'videoZoomOut');
|
|
remoteVideoZoomOutBtn.className = className.zoomOut;
|
|
|
|
// remote video Picture in Picture
|
|
remoteVideoPiPBtn.setAttribute('id', peer_id + 'videoPIP');
|
|
remoteVideoPiPBtn.className = className.pip;
|
|
|
|
// remote video full screen mode
|
|
remoteVideoFullScreenBtn.setAttribute('id', peer_id + '_fullScreen');
|
|
remoteVideoFullScreenBtn.className = className.fullScreen;
|
|
|
|
// remote video pin/unpin button
|
|
remoteVideoPinBtn.setAttribute('id', peer_id + '_pinUnpin');
|
|
remoteVideoPinBtn.className = className.pinUnpin;
|
|
|
|
// remote video hide all button
|
|
remoteVideoFocusBtn.setAttribute('id', peer_id + '_focusMode');
|
|
remoteVideoFocusBtn.className = className.hideAll;
|
|
|
|
// remote video toggle mirror
|
|
remoteVideoMirrorBtn.setAttribute('id', peer_id + '_toggleMirror');
|
|
remoteVideoMirrorBtn.className = className.mirror;
|
|
|
|
// no mobile devices
|
|
if (!isMobileDevice) {
|
|
setTippy(remotePeerName, 'Participant name', 'bottom');
|
|
setTippy(remoteHandStatusIcon, 'Participant hand is raised', 'bottom');
|
|
setTippy(remoteVideoStatusIcon, 'Participant video is on', 'bottom');
|
|
setTippy(remoteAudioStatusIcon, 'Participant audio is on', 'bottom');
|
|
setTippy(remoteAudioVolume, '🔊 Volume', 'top');
|
|
setTippy(remoteVideoAudioUrlBtn, 'Send Video or Audio', 'bottom');
|
|
setTippy(remotePrivateMsgBtn, 'Send private message', 'bottom');
|
|
setTippy(remoteGeoLocationBtn, 'Get Geo Location', 'bottom');
|
|
setTippy(remoteFileShareBtn, 'Send file', 'bottom');
|
|
setTippy(remoteVideoToImgBtn, 'Take a snapshot', 'bottom');
|
|
setTippy(remotePeerKickOut, 'Kick out', 'bottom');
|
|
setTippy(remoteVideoFullScreenBtn, 'Full screen mode', 'bottom');
|
|
setTippy(remoteVideoZoomInBtn, 'Zoom in video', 'bottom');
|
|
setTippy(remoteVideoZoomOutBtn, 'Zoom out video', 'bottom');
|
|
setTippy(remoteVideoPiPBtn, 'Toggle picture in picture', 'bottom');
|
|
setTippy(remoteVideoPinBtn, 'Toggle Pin video', 'bottom');
|
|
setTippy(remoteVideoFocusBtn, 'Toggle Focus mode', 'bottom');
|
|
setTippy(remoteVideoMirrorBtn, 'Toggle video mirror', 'bottom');
|
|
}
|
|
|
|
// my video avatar image
|
|
remoteVideoAvatarImage.setAttribute('id', peer_id + '_avatar');
|
|
remoteVideoAvatarImage.className = 'videoAvatarImage'; // pulsate
|
|
|
|
// remote pitch meter
|
|
remotePitchMeter.setAttribute('id', peer_id + '_pitch');
|
|
remotePitchBar.setAttribute('id', peer_id + '_pitch_bar');
|
|
remotePitchMeter.className = 'speechbar';
|
|
remotePitchBar.className = 'bar';
|
|
remotePitchBar.style.height = '1%';
|
|
|
|
remotePitchMeter.appendChild(remotePitchBar);
|
|
|
|
// remote video nav bar
|
|
remoteVideoNavBar.className = 'navbar fadein';
|
|
|
|
// remote expand buttons div
|
|
remoteExpandBtnDiv.className = 'expand-video';
|
|
remoteExpandBtn.id = peer_id + '_videoExpandBtn';
|
|
remoteExpandBtn.className = 'fas fa-ellipsis-vertical';
|
|
remoteExpandContainerDiv.className = 'expand-video-content';
|
|
|
|
// attach to remote video nav bar
|
|
!isMobileDevice && remoteVideoNavBar.appendChild(remoteVideoPinBtn);
|
|
|
|
buttons.remote.showVideoFocusBtn && remoteVideoNavBar.appendChild(remoteVideoFocusBtn);
|
|
|
|
remoteVideoNavBar.appendChild(remoteVideoMirrorBtn);
|
|
|
|
if (showVideoPipBtn && buttons.remote.showVideoPipBtn) remoteVideoNavBar.appendChild(remoteVideoPiPBtn);
|
|
|
|
// Add to expand container div...
|
|
if (buttons.remote.showZoomInOutBtn) {
|
|
remoteExpandContainerDiv.appendChild(remoteVideoZoomInBtn);
|
|
remoteExpandContainerDiv.appendChild(remoteVideoZoomOutBtn);
|
|
}
|
|
buttons.remote.showPrivateMessageBtn && remoteExpandContainerDiv.appendChild(remotePrivateMsgBtn);
|
|
buttons.remote.showGeoLocationBtn && remoteExpandContainerDiv.appendChild(remoteGeoLocationBtn);
|
|
buttons.remote.showFileShareBtn && remoteExpandContainerDiv.appendChild(remoteFileShareBtn);
|
|
buttons.remote.showShareVideoAudioBtn && remoteExpandContainerDiv.appendChild(remoteVideoAudioUrlBtn);
|
|
buttons.remote.showKickOutBtn && remoteExpandContainerDiv.appendChild(remotePeerKickOut);
|
|
|
|
remoteExpandBtnDiv.appendChild(remoteExpandBtn);
|
|
remoteExpandBtnDiv.appendChild(remoteExpandContainerDiv);
|
|
|
|
buttons.remote.showSnapShotBtn && remoteVideoNavBar.appendChild(remoteVideoToImgBtn);
|
|
|
|
isVideoFullScreenSupported && remoteVideoNavBar.appendChild(remoteVideoFullScreenBtn);
|
|
|
|
remoteVideoNavBar.appendChild(remoteVideoStatusIcon);
|
|
remoteVideoNavBar.appendChild(remoteAudioStatusIcon);
|
|
|
|
// Disabled audio volume control on Mobile devices
|
|
if (!isMobileDevice && peer_audio && buttons.remote.showAudioVolume) {
|
|
remoteVideoNavBar.appendChild(remoteAudioVolume);
|
|
}
|
|
remoteVideoNavBar.appendChild(remoteHandStatusIcon);
|
|
|
|
remoteVideoNavBar.appendChild(remoteExpandBtnDiv);
|
|
|
|
remoteMedia.setAttribute('id', peer_id + '___video');
|
|
remoteMedia.setAttribute('playsinline', true);
|
|
remoteMedia.autoplay = true;
|
|
remoteMediaControls = isMobileDevice ? false : remoteMediaControls;
|
|
remoteMedia.style.objectFit = 'var(--video-object-fit)';
|
|
remoteMedia.style.name = peer_id + '_typeCam';
|
|
remoteMedia.controls = remoteMediaControls;
|
|
remoteMedia.poster = images.poster;
|
|
|
|
remoteVideoWrap.className = 'Camera';
|
|
remoteVideoWrap.setAttribute('id', peer_id + '_videoWrap');
|
|
remoteVideoWrap.style.display = isHideALLVideosActive ? 'none' : 'block';
|
|
|
|
// add elements to videoWrap div
|
|
remoteVideoWrap.appendChild(remoteVideoNavBar);
|
|
remoteVideoWrap.appendChild(remoteVideoAvatarImage);
|
|
remoteVideoWrap.appendChild(remotePitchMeter);
|
|
remoteVideoWrap.appendChild(remoteMedia);
|
|
remoteVideoWrap.appendChild(remotePeerName);
|
|
|
|
// need later on disconnect or remove peers
|
|
peerVideoMediaElements[remoteMedia.id] = remoteVideoWrap;
|
|
|
|
// append all elements to videoMediaContainer
|
|
videoMediaContainer.appendChild(remoteVideoWrap);
|
|
// attachMediaStream is a part of the adapter.js library
|
|
attachMediaStream(remoteMedia, stream);
|
|
|
|
// resize video elements
|
|
adaptAspectRatio();
|
|
|
|
// handle video to image
|
|
buttons.remote.showSnapShotBtn && handleVideoToImg(remoteMedia.id, remoteVideoToImgBtn.id, peer_id);
|
|
|
|
// handle video pin/unpin
|
|
handleVideoPinUnpin(remoteMedia.id, remoteVideoPinBtn.id, remoteVideoWrap.id, peer_id);
|
|
|
|
// handle video focus mode
|
|
handleVideoFocusMode(remoteVideoFocusBtn, remoteVideoWrap, remoteMedia);
|
|
|
|
// handle video toggle mirror
|
|
handleVideoToggleMirror(remoteMedia.id, remoteVideoMirrorBtn.id);
|
|
|
|
// handle vide picture in picture
|
|
if (showVideoPipBtn && buttons.remote.showVideoPipBtn)
|
|
handlePictureInPicture(remoteVideoPiPBtn.id, remoteMedia.id, peer_id);
|
|
|
|
// handle video zoomIn/Out
|
|
ZOOM_IN_OUT_ENABLED &&
|
|
handleVideoZoomInOut(
|
|
'',
|
|
remoteVideoWrap.id,
|
|
remoteVideoZoomInBtn.id,
|
|
remoteVideoZoomOutBtn.id,
|
|
remoteMedia.id,
|
|
peer_id
|
|
);
|
|
|
|
// handle video full screen mode
|
|
isVideoFullScreenSupported && handleVideoPlayerFs(remoteMedia.id, remoteVideoFullScreenBtn.id, peer_id);
|
|
|
|
// handle file share drag and drop
|
|
handleFileDragAndDrop(remoteMedia.id, peer_id);
|
|
|
|
// handle kick out button event
|
|
buttons.remote.showKickOutBtn && handlePeerKickOutBtn(peer_id);
|
|
|
|
// set video privacy true
|
|
peer_privacy_status && setVideoPrivacyStatus(remoteMedia.id, peer_privacy_status);
|
|
|
|
// refresh remote peers avatar name
|
|
setPeerAvatarImgName(remoteVideoAvatarImage.id, peer_name, peer_avatar);
|
|
// refresh remote peers hand icon status and title
|
|
setPeerHandStatus(peer_id, peer_name, peer_hand_status);
|
|
// refresh remote peers video icon status and title
|
|
setPeerVideoStatus(peer_id, peer_video_status);
|
|
// refresh remote peers audio icon status and title
|
|
setPeerAudioStatus(peer_id, peer_audio_status);
|
|
// handle remote peers audio on-off
|
|
handlePeerAudioBtn(peer_id);
|
|
// handle remote peers video on-off
|
|
handlePeerVideoBtn(peer_id);
|
|
|
|
// handle remote geo location
|
|
buttons.remote.showGeoLocationBtn && handlePeerGeoLocation(peer_id, peer_name);
|
|
// handle remote private messages
|
|
buttons.remote.showPrivateMessageBtn && handlePeerPrivateMsg(peer_id, peer_name, remotePrivateMsgBtn.id);
|
|
// handle remote send file
|
|
buttons.remote.showFileShareBtn && handlePeerSendFile(peer_id, remoteFileShareBtn.id);
|
|
// handle remote video - audio URL
|
|
buttons.remote.showShareVideoAudioBtn && handlePeerVideoAudioUrl(peer_id, remoteVideoAudioUrlBtn.id);
|
|
|
|
// show status menu
|
|
toggleClassElements('statusMenu', 'inline');
|
|
|
|
// notify if peer started to recording own screen + audio
|
|
if (peer_rec_status) notifyRecording(peer_id, peer_name, peer_avatar, 'Started');
|
|
|
|
// Handle different video/screen states
|
|
if (!peer_video_status && !peer_screen_status) {
|
|
// Camera OFF, Screen OFF - show avatar
|
|
console.log('[LOAD REMOTE] Camera OFF, Screen OFF - showing avatar');
|
|
videoIsOff();
|
|
} else if (!peer_video_status && peer_screen_status) {
|
|
// Camera OFF, Screen ON - show avatar on video tile, screen tile will be created when track arrives
|
|
console.log('[LOAD REMOTE] Camera OFF, Screen ON - showing avatar, waiting for screen track');
|
|
videoIsOff();
|
|
} else if (peer_video_status && !peer_screen_status) {
|
|
// Camera ON, Screen OFF - video track will show
|
|
console.log('[LOAD REMOTE] Camera ON, Screen OFF - video track active');
|
|
// Avatar hidden by default, video track will display
|
|
} else {
|
|
// Both camera and screen on
|
|
console.log('[LOAD REMOTE] Both Camera and Screen ON');
|
|
}
|
|
|
|
function videoIsOff() {
|
|
displayElements([
|
|
{ element: remoteMedia, display: false },
|
|
{ element: remoteVideoAvatarImage, display: true, mode: 'block' },
|
|
]);
|
|
setMediaButtonsClass([{ element: remoteVideoStatusIcon, status: false, mediaType: 'video' }]);
|
|
}
|
|
break;
|
|
case 'screen':
|
|
console.log('SETUP REMOTE SCREEN STREAM');
|
|
|
|
// Remote screen elements
|
|
const remoteScreenWrap = document.createElement('div');
|
|
const remoteScreenMedia = document.createElement('video');
|
|
|
|
// html elements
|
|
const remoteScreenNavBar = document.createElement('div');
|
|
const remoteScreenPeerName = document.createElement('p');
|
|
const remoteScreenToImgBtn = document.createElement('button');
|
|
const remoteScreenFullScreenBtn = document.createElement('button');
|
|
const remoteScreenPinBtn = document.createElement('button');
|
|
const remoteScreenFocusBtn = document.createElement('button');
|
|
const remoteScreenZoomInBtn = document.createElement('button');
|
|
const remoteScreenZoomOutBtn = document.createElement('button');
|
|
const remoteScreenPiPBtn = document.createElement('button');
|
|
const remoteScreenVideoAudioUrlBtn = document.createElement('button');
|
|
const remoteScreenFileShareBtn = document.createElement('button');
|
|
const remoteScreenPrivateMsgBtn = document.createElement('button');
|
|
const remoteScreenAvatarImage = document.createElement('img');
|
|
|
|
// IDs and classes
|
|
remoteScreenPeerName.setAttribute('id', peer_id + '_screen_name');
|
|
remoteScreenPeerName.className = 'videoPeerName';
|
|
remoteScreenPeerName.appendChild(document.createTextNode(peer_name + ' (screen)'));
|
|
|
|
remoteScreenPrivateMsgBtn.setAttribute('id', peer_id + '_screen_privateMsg');
|
|
remoteScreenPrivateMsgBtn.className = className.msgPrivate;
|
|
|
|
remoteScreenFileShareBtn.setAttribute('id', peer_id + '_screen_shareFile');
|
|
remoteScreenFileShareBtn.className = className.shareFile;
|
|
|
|
remoteScreenVideoAudioUrlBtn.setAttribute('id', peer_id + '_screen_videoAudioUrl');
|
|
remoteScreenVideoAudioUrlBtn.className = className.shareVideoAudio;
|
|
|
|
remoteScreenToImgBtn.setAttribute('id', peer_id + '_screen_to_img');
|
|
remoteScreenToImgBtn.className = className.snapShot;
|
|
|
|
remoteScreenFullScreenBtn.setAttribute('id', peer_id + '_screen_fullScreen');
|
|
remoteScreenFullScreenBtn.className = className.fullScreen;
|
|
|
|
remoteScreenZoomInBtn.setAttribute('id', peer_id + 'screenZoomIn');
|
|
remoteScreenZoomInBtn.className = className.zoomIn;
|
|
remoteScreenZoomOutBtn.setAttribute('id', peer_id + 'screenZoomOut');
|
|
remoteScreenZoomOutBtn.className = className.zoomOut;
|
|
|
|
remoteScreenPiPBtn.setAttribute('id', peer_id + 'screenPIP');
|
|
remoteScreenPiPBtn.className = className.pip;
|
|
|
|
remoteScreenFocusBtn.setAttribute('id', peer_id + '_screen_focusMode');
|
|
remoteScreenFocusBtn.className = className.hideAll;
|
|
|
|
remoteScreenPinBtn.setAttribute('id', peer_id + '_screen_pinUnpin');
|
|
remoteScreenPinBtn.className = className.pinUnpin;
|
|
|
|
if (!isMobileDevice) {
|
|
setTippy(remoteScreenPeerName, 'Participant screen', 'bottom');
|
|
setTippy(remoteScreenVideoAudioUrlBtn, 'Send Video or Audio', 'bottom');
|
|
setTippy(remoteScreenPrivateMsgBtn, 'Send private message', 'bottom');
|
|
setTippy(remoteScreenFileShareBtn, 'Send file', 'bottom');
|
|
setTippy(remoteScreenToImgBtn, 'Take a snapshot', 'bottom');
|
|
setTippy(remoteScreenFullScreenBtn, 'Full screen mode', 'bottom');
|
|
setTippy(remoteScreenZoomInBtn, 'Zoom in screen', 'bottom');
|
|
setTippy(remoteScreenZoomOutBtn, 'Zoom out screen', 'bottom');
|
|
setTippy(remoteScreenPiPBtn, 'Toggle picture in picture', 'bottom');
|
|
setTippy(remoteScreenFocusBtn, 'Toggle Focus mode', 'bottom');
|
|
setTippy(remoteScreenPinBtn, 'Toggle Pin screen', 'bottom');
|
|
}
|
|
|
|
remoteScreenAvatarImage.setAttribute('id', peer_id + '_screen_avatar');
|
|
remoteScreenAvatarImage.className = 'videoAvatarImage';
|
|
|
|
remoteScreenNavBar.className = 'navbar fadein';
|
|
!isMobileDevice && remoteScreenNavBar.appendChild(remoteScreenPinBtn);
|
|
|
|
buttons.remote.showVideoFocusBtn && remoteScreenNavBar.appendChild(remoteScreenFocusBtn);
|
|
|
|
buttons.remote.showSnapShotBtn && remoteScreenNavBar.appendChild(remoteScreenToImgBtn);
|
|
|
|
remoteScreenNavBar.appendChild(remoteScreenPiPBtn);
|
|
if (buttons.remote.showZoomInOutBtn) {
|
|
remoteScreenNavBar.appendChild(remoteScreenZoomInBtn);
|
|
remoteScreenNavBar.appendChild(remoteScreenZoomOutBtn);
|
|
}
|
|
isVideoFullScreenSupported && remoteScreenNavBar.appendChild(remoteScreenFullScreenBtn);
|
|
|
|
buttons.remote.showPrivateMessageBtn && remoteScreenNavBar.appendChild(remoteScreenPrivateMsgBtn);
|
|
buttons.remote.showFileShareBtn && remoteScreenNavBar.appendChild(remoteScreenFileShareBtn);
|
|
buttons.remote.showShareVideoAudioBtn && remoteScreenNavBar.appendChild(remoteScreenVideoAudioUrlBtn);
|
|
|
|
remoteScreenMedia.setAttribute('id', peer_id + '___screen');
|
|
remoteScreenMedia.setAttribute('playsinline', true);
|
|
remoteScreenMedia.autoplay = true;
|
|
remoteScreenMedia.controls = remoteMediaControls;
|
|
remoteScreenMedia.style.objectFit = 'contain';
|
|
remoteScreenMedia.style.name = peer_id + '_typeScreen';
|
|
|
|
remoteScreenMedia.poster = images.poster;
|
|
|
|
remoteScreenWrap.className = 'Screen';
|
|
remoteScreenWrap.setAttribute('id', peer_id + '_screenWrap');
|
|
remoteScreenWrap.style.display = isHideALLVideosActive ? 'none' : 'block';
|
|
|
|
remoteScreenWrap.appendChild(remoteScreenNavBar);
|
|
remoteScreenWrap.appendChild(remoteScreenAvatarImage);
|
|
remoteScreenWrap.appendChild(remoteScreenMedia);
|
|
remoteScreenWrap.appendChild(remoteScreenPeerName);
|
|
|
|
// need later on disconnect or remove peers
|
|
peerScreenMediaElements[remoteScreenMedia.id] = remoteScreenWrap;
|
|
|
|
videoMediaContainer.appendChild(remoteScreenWrap);
|
|
attachMediaStream(remoteScreenMedia, stream);
|
|
adaptAspectRatio();
|
|
|
|
// handle remote private messages
|
|
buttons.remote.showPrivateMessageBtn &&
|
|
handlePeerPrivateMsg(peer_id, peer_name, remoteScreenPrivateMsgBtn.id);
|
|
// handle remote send file
|
|
buttons.remote.showFileShareBtn && handlePeerSendFile(peer_id, remoteScreenFileShareBtn.id);
|
|
// handle remote video - audio URL
|
|
buttons.remote.showShareVideoAudioBtn && handlePeerVideoAudioUrl(peer_id, remoteScreenVideoAudioUrlBtn.id);
|
|
|
|
// screen to image
|
|
buttons.remote.showSnapShotBtn && handleVideoToImg(remoteScreenMedia.id, remoteScreenToImgBtn.id);
|
|
|
|
// pin/unpin video
|
|
handleVideoPinUnpin(remoteScreenMedia.id, remoteScreenPinBtn.id, remoteScreenWrap.id, peer_id, true);
|
|
|
|
// handle video focus mode
|
|
handleVideoFocusMode(remoteScreenFocusBtn, remoteScreenWrap, remoteScreenMedia);
|
|
|
|
// pin video on screen share
|
|
remoteScreenPinBtn.click();
|
|
|
|
if (showVideoPipBtn && buttons.remote.showVideoPipBtn)
|
|
handlePictureInPicture(remoteScreenPiPBtn.id, remoteScreenMedia.id, peer_id);
|
|
|
|
ZOOM_IN_OUT_ENABLED &&
|
|
handleVideoZoomInOut(
|
|
'',
|
|
remoteScreenWrap.id,
|
|
remoteScreenZoomInBtn.id,
|
|
remoteScreenZoomOutBtn.id,
|
|
remoteScreenMedia.id,
|
|
peer_id
|
|
);
|
|
|
|
isVideoFullScreenSupported &&
|
|
handleVideoPlayerFs(remoteScreenMedia.id, remoteScreenFullScreenBtn.id, peer_id);
|
|
break;
|
|
case 'audio':
|
|
// alert('remote audio');
|
|
console.log('SETUP REMOTE AUDIO STREAM');
|
|
// handle remote audio elements
|
|
const remoteAudioWrap = document.createElement('div');
|
|
const remoteAudioMedia = document.createElement('audio');
|
|
const remoteAudioVolumeId = peer_id + '_audioVolume';
|
|
const remoteAudioVolumeEl = getId(remoteAudioVolumeId);
|
|
remoteAudioMedia.id = peer_id + '___audio';
|
|
remoteAudioMedia.volume = 1.0;
|
|
remoteAudioMedia.autoplay = true;
|
|
remoteAudioMedia.controls = false;
|
|
|
|
if (!hasAudioTrack(stream)) {
|
|
remoteAudioMedia.muted = true;
|
|
}
|
|
|
|
remoteAudioWrap.appendChild(remoteAudioMedia);
|
|
audioMediaContainer.appendChild(remoteAudioWrap);
|
|
attachMediaStream(remoteAudioMedia, stream);
|
|
peerAudioMediaElements[remoteAudioMedia.id] = remoteAudioWrap;
|
|
|
|
// Explicitly play audio to ensure it starts (handles autoplay policies)
|
|
remoteAudioMedia.play().catch((err) => {
|
|
console.warn('[AUDIO] Autoplay prevented for ' + peer_name + ', waiting for user interaction:', err);
|
|
handleAudioFallback(remoteAudioMedia, peer_name);
|
|
});
|
|
|
|
// Only wire volume control if the element exists
|
|
if (remoteAudioVolumeEl && !isMobileDevice) {
|
|
try {
|
|
handleAudioVolume(remoteAudioVolumeId, remoteAudioMedia.id);
|
|
elemDisplay(remoteAudioVolumeEl, peer_audio_status);
|
|
} catch (e) {
|
|
console.warn('[AUDIO] handleAudioVolume failed for ' + peer_name, e);
|
|
}
|
|
}
|
|
|
|
// Change audio output if supported and audioOutputSelect is present
|
|
if (sinkId && audioOutputSelect && audioOutputSelect.value) {
|
|
try {
|
|
await changeAudioDestination(remoteAudioMedia);
|
|
} catch (e) {
|
|
console.warn('[AUDIO] changeAudioDestination failed for ' + peer_name, e);
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle remote audio fallback
|
|
* @param {object} audioMedia
|
|
* @param {string} peer_name
|
|
*/
|
|
function handleAudioFallback(audioMedia, peer_name) {
|
|
if (!audioMedia) return;
|
|
// Fallback: play audio on first user interaction
|
|
const playOnInteraction = () => {
|
|
audioMedia
|
|
.play()
|
|
.then(() => {
|
|
console.log('[AUDIO] Audio started after user interaction for ' + peer_name);
|
|
document.removeEventListener('click', playOnInteraction);
|
|
document.removeEventListener('touchstart', playOnInteraction);
|
|
document.removeEventListener('keydown', playOnInteraction);
|
|
})
|
|
.catch((e) => console.error('[AUDIO] Failed to play audio:', e));
|
|
};
|
|
document.addEventListener('click', playOnInteraction, { once: true });
|
|
document.addEventListener('touchstart', playOnInteraction, { once: true });
|
|
document.addEventListener('keydown', playOnInteraction, { once: true });
|
|
}
|
|
|
|
/**
|
|
* Log stream settings info
|
|
* @param {string} name function name called from
|
|
* @param {object} stream media stream audio - video
|
|
*/
|
|
function logStreamSettingsInfo(name, stream) {
|
|
if ((useVideo || isScreenStreaming) && hasVideoTrack(stream)) {
|
|
const videoTrack = getVideoTrack(stream);
|
|
if (videoTrack) {
|
|
console.log(name, {
|
|
video: {
|
|
label: videoTrack.label,
|
|
settings: videoTrack.getSettings(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (useAudio && hasAudioTrack(stream)) {
|
|
const audioTrack = getAudioTrack(stream);
|
|
if (audioTrack) {
|
|
console.log(name, {
|
|
audio: {
|
|
label: audioTrack.label,
|
|
settings: audioTrack.getSettings(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle aspect ratio
|
|
* ['0:0', '4:3', '16:9', '1:1', '1:2'];
|
|
* 0 1 2 3 4
|
|
*/
|
|
function adaptAspectRatio() {
|
|
const participantsCount = videoMediaContainer.childElementCount;
|
|
if (peersCount) peersCount.innerText = participantsCount;
|
|
let desktop,
|
|
mobile = 1;
|
|
|
|
// Update the participants count badge
|
|
if (participantsCountBadge) {
|
|
participantsCountBadge.textContent = participantsCount;
|
|
participantsCount > 1
|
|
? elemDisplay(participantsCountBadge, true, 'flex')
|
|
: elemDisplay(participantsCountBadge, false);
|
|
}
|
|
|
|
// desktop aspect ratio
|
|
switch (participantsCount) {
|
|
// case 1:
|
|
// desktop = 0; // (0:0)
|
|
// break;
|
|
case 1:
|
|
case 3:
|
|
case 4:
|
|
case 7:
|
|
case 9:
|
|
desktop = 2; // (16:9)
|
|
break;
|
|
case 5:
|
|
case 6:
|
|
case 10:
|
|
case 11:
|
|
desktop = 1; // (4:3)
|
|
break;
|
|
case 2:
|
|
case 8:
|
|
desktop = 3; // (1:1)
|
|
break;
|
|
default:
|
|
desktop = 0; // (0:0)
|
|
}
|
|
// mobile aspect ratio
|
|
switch (participantsCount) {
|
|
case 3:
|
|
case 9:
|
|
case 10:
|
|
mobile = 2; // (16:9)
|
|
break;
|
|
case 2:
|
|
case 7:
|
|
case 8:
|
|
case 11:
|
|
mobile = 1; // (4:3)
|
|
break;
|
|
case 1:
|
|
case 4:
|
|
case 5:
|
|
case 6:
|
|
mobile = 3; // (1:1)
|
|
break;
|
|
default:
|
|
mobile = 3; // (1:1)
|
|
}
|
|
if (participantsCount > 11) {
|
|
desktop = 1; // (4:3)
|
|
mobile = 3; // (1:1)
|
|
}
|
|
setAspectRatio(isMobileDevice ? mobile : desktop);
|
|
}
|
|
|
|
/**
|
|
* Get Gravatar from email
|
|
* @param {string} email
|
|
* @param {integer} size
|
|
* @returns object image
|
|
*/
|
|
function genGravatar(email, size = false) {
|
|
const hash = md5(email.toLowerCase().trim());
|
|
const gravatarURL = `https://www.gravatar.com/avatar/${hash}` + (size ? `?s=${size}` : '?s=250');
|
|
return gravatarURL;
|
|
function md5(input) {
|
|
return CryptoJS.MD5(input).toString();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if valid email
|
|
* @param {string} email
|
|
* @returns boolean
|
|
*/
|
|
function isValidEmail(email) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
}
|
|
|
|
/**
|
|
* Create round svg image with first 2 letters of peerName in center
|
|
* Thank you: https://github.com/phpony
|
|
*
|
|
* @param {string} peerName
|
|
* @param {integer} avatarImgSize width and height in px
|
|
*/
|
|
function genAvatarSvg(peerName, avatarImgSize) {
|
|
const charCodeRed = peerName.charCodeAt(0);
|
|
const charCodeGreen = peerName.charCodeAt(1) || charCodeRed;
|
|
const red = Math.pow(charCodeRed, 7) % 200;
|
|
const green = Math.pow(charCodeGreen, 7) % 200;
|
|
const blue = (red + green) % 200;
|
|
const bgColor = `rgb(${red}, ${green}, ${blue})`;
|
|
const textColor = '#ffffff';
|
|
const svg = `
|
|
<svg xmlns="http://www.w3.org/2000/svg"
|
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
width="${avatarImgSize}px"
|
|
height="${avatarImgSize}px"
|
|
viewBox="0 0 ${avatarImgSize} ${avatarImgSize}"
|
|
version="1.1">
|
|
<circle
|
|
fill="${bgColor}"
|
|
width="${avatarImgSize}"
|
|
height="${avatarImgSize}"
|
|
cx="${avatarImgSize / 2}"
|
|
cy="${avatarImgSize / 2}"
|
|
r="${avatarImgSize / 2}"/>
|
|
<text
|
|
x="50%"
|
|
y="50%"
|
|
style="color:${textColor};
|
|
line-height:1;
|
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Fira Sans, Droid Sans, Helvetica Neue, sans-serif"
|
|
alignment-baseline="middle"
|
|
text-anchor="middle"
|
|
font-size="${Math.round(avatarImgSize * 0.4)}"
|
|
font-weight="normal"
|
|
dy=".1em"
|
|
dominant-baseline="middle"
|
|
fill="${textColor}">${peerName.substring(0, 2).toUpperCase()}
|
|
</text>
|
|
</svg>`;
|
|
return 'data:image/svg+xml,' + svg.replace(/#/g, '%23').replace(/"/g, "'").replace(/&/g, '&');
|
|
}
|
|
|
|
/**
|
|
* Refresh video - chat image avatar on name changes: https://eu.ui-avatars.com/
|
|
* @param {string} videoAvatarImageId element id
|
|
* @param {string} peerName
|
|
* @param {string} peerAvatar
|
|
*/
|
|
function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) {
|
|
const videoAvatarImageElement = getId(videoAvatarImageId);
|
|
videoAvatarImageElement.style.pointerEvents = 'none';
|
|
|
|
// If a valid avatar image URL is provided
|
|
if (peerAvatar && isImageURL(peerAvatar)) {
|
|
videoAvatarImageElement.setAttribute('src', peerAvatar);
|
|
}
|
|
// If not, use SVG based on the email validity
|
|
else if (useAvatarSvg) {
|
|
const avatarImgSize = isMobileDevice ? 128 : 256;
|
|
const avatarImgSvg = isValidEmail(peerName) ? genGravatar(peerName) : genAvatarSvg(peerName, avatarImgSize);
|
|
videoAvatarImageElement.setAttribute('src', avatarImgSvg);
|
|
}
|
|
// Default fallback avatar
|
|
else {
|
|
videoAvatarImageElement.setAttribute('src', images.avatar);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set Chat avatar image by peer name
|
|
* @param {string} avatar position left/right
|
|
* @param {string} peerName me or peer name
|
|
* @param {string} peerAvatar me or peer avatar
|
|
*/
|
|
function setPeerChatAvatarImgName(avatar, peerName, peerAvatar) {
|
|
const avatarImg =
|
|
peerAvatar && isImageURL(peerAvatar)
|
|
? peerAvatar
|
|
: isValidEmail(peerName)
|
|
? genGravatar(peerName)
|
|
: genAvatarSvg(peerName, 32);
|
|
|
|
switch (avatar) {
|
|
case 'left':
|
|
// console.log("Set Friend chat avatar image");
|
|
leftChatAvatar = avatarImg;
|
|
break;
|
|
case 'right':
|
|
// console.log("Set My chat avatar image");
|
|
rightChatAvatar = avatarImg;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Video Toggle Mirror
|
|
* @param {string} videoId
|
|
* @param {string} videoToggleMirrorBtnId
|
|
*/
|
|
function handleVideoToggleMirror(videoId, videoToggleMirrorBtnId) {
|
|
const videoPlayer = getId(videoId);
|
|
const videoToggleMirrorBtn = getId(videoToggleMirrorBtnId);
|
|
if (videoPlayer && videoToggleMirrorBtn) {
|
|
// Toggle video mirror
|
|
videoToggleMirrorBtn.addEventListener('click', (e) => {
|
|
videoPlayer.classList.toggle('mirror');
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On video player click, go on full screen mode ||
|
|
* On button click, go on full screen mode.
|
|
* Press Esc to exit from full screen mode, or click again.
|
|
* @param {string} videoId uuid video element
|
|
* @param {string} videoFullScreenBtnId uuid full screen btn
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
function handleVideoPlayerFs(videoId, videoFullScreenBtnId, peer_id = null) {
|
|
const videoPlayer = getId(videoId);
|
|
const videoFullScreenBtn = getId(videoFullScreenBtnId);
|
|
|
|
// handle Chrome Firefox Opera Microsoft Edge videoPlayer ESC
|
|
videoPlayer.addEventListener('fullscreenchange', (e) => {
|
|
// if Controls enabled, or document on FS do nothing
|
|
if (videoPlayer.controls || isDocumentOnFullScreen) return;
|
|
const fullscreenElement = document.fullscreenElement;
|
|
if (!fullscreenElement) {
|
|
videoPlayer.style.pointerEvents = 'auto';
|
|
isVideoOnFullScreen = false;
|
|
// console.log("Esc FS isVideoOnFullScreen", isVideoOnFullScreen);
|
|
}
|
|
});
|
|
|
|
// handle Safari videoPlayer ESC
|
|
videoPlayer.addEventListener('webkitfullscreenchange', (e) => {
|
|
// if Controls enabled, or document on FS do nothing
|
|
if (videoPlayer.controls || isDocumentOnFullScreen) return;
|
|
const webkitIsFullScreen = document.webkitIsFullScreen;
|
|
if (!webkitIsFullScreen) {
|
|
videoPlayer.style.pointerEvents = 'auto';
|
|
isVideoOnFullScreen = false;
|
|
// console.log("Esc FS isVideoOnFullScreen", isVideoOnFullScreen);
|
|
}
|
|
});
|
|
|
|
// on button click go on FS mobile/desktop
|
|
videoFullScreenBtn.addEventListener('click', (e) => {
|
|
if (videoPlayer.classList.contains('videoCircle')) {
|
|
return userLog('toast', 'Full Screen not allowed if video on privacy mode');
|
|
}
|
|
gotoFS();
|
|
});
|
|
|
|
// on video click go on FS
|
|
videoPlayer.addEventListener('click', (e) => {
|
|
if (videoPlayer.classList.contains('videoCircle')) {
|
|
return userLog('toast', 'Full Screen not allowed if video on privacy mode');
|
|
}
|
|
// not mobile on click go on FS or exit from FS
|
|
if (!isMobileDevice) {
|
|
gotoFS();
|
|
} else {
|
|
// mobile on click exit from FS, for enter use videoFullScreenBtn
|
|
if (isVideoOnFullScreen) handleFSVideo();
|
|
}
|
|
});
|
|
|
|
function gotoFS() {
|
|
// handle remote peer video fs
|
|
if (peer_id !== null) {
|
|
const remoteVideoStatusBtn = getId(peer_id + '_videoStatus');
|
|
if (remoteVideoStatusBtn.className === className.videoOn) {
|
|
handleFSVideo();
|
|
} else {
|
|
showMsg();
|
|
}
|
|
} else {
|
|
// handle local video fs
|
|
if (myVideoStatusIcon.className === className.videoOn || isScreenStreaming) {
|
|
handleFSVideo();
|
|
} else {
|
|
showMsg();
|
|
}
|
|
}
|
|
}
|
|
|
|
function showMsg() {
|
|
userLog('toast', 'Full screen mode work when video is on');
|
|
}
|
|
|
|
function handleFSVideo() {
|
|
// if Controls enabled, or document on FS do nothing
|
|
if (videoPlayer.controls || isDocumentOnFullScreen) return;
|
|
|
|
if (!isVideoOnFullScreen) {
|
|
if (videoPlayer.requestFullscreen) {
|
|
// Chrome Firefox Opera Microsoft Edge
|
|
videoPlayer.requestFullscreen();
|
|
} else if (videoPlayer.webkitRequestFullscreen) {
|
|
// Safari request full screen mode
|
|
videoPlayer.webkitRequestFullscreen();
|
|
} else if (videoPlayer.msRequestFullscreen) {
|
|
// IE11 request full screen mode
|
|
videoPlayer.msRequestFullscreen();
|
|
}
|
|
isVideoOnFullScreen = true;
|
|
videoPlayer.style.pointerEvents = 'none';
|
|
// console.log("Go on FS isVideoOnFullScreen", isVideoOnFullScreen);
|
|
} else {
|
|
if (document.exitFullscreen) {
|
|
// Chrome Firefox Opera Microsoft Edge
|
|
document.exitFullscreen();
|
|
} else if (document.webkitCancelFullScreen) {
|
|
// Safari exit full screen mode ( Not work... )
|
|
document.webkitCancelFullScreen();
|
|
} else if (document.msExitFullscreen) {
|
|
// IE11 exit full screen mode
|
|
document.msExitFullscreen();
|
|
}
|
|
isVideoOnFullScreen = false;
|
|
videoPlayer.style.pointerEvents = 'auto';
|
|
// console.log("Esc FS isVideoOnFullScreen", isVideoOnFullScreen);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle file drag and drop on video element
|
|
* @param {string} elemId element id
|
|
* @param {string} peer_id peer id
|
|
* @param {boolean} itsMe true/false
|
|
*/
|
|
function handleFileDragAndDrop(elemId, peer_id, itsMe = false) {
|
|
const videoPeer = getId(elemId);
|
|
|
|
videoPeer.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.target.parentElement.style.outline = '3px dashed var(--dd-color)';
|
|
document.querySelector('.Camera').style.outline = 'none';
|
|
});
|
|
|
|
videoPeer.addEventListener('dragleave', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.target.parentElement.style.outline = 'none';
|
|
});
|
|
|
|
videoPeer.addEventListener('drop', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.target.parentElement.style.outline = 'none';
|
|
if (itsMe) {
|
|
return userLog('warning', 'You cannot send files to yourself.');
|
|
}
|
|
if (sendInProgress) {
|
|
return userLog('warning', 'Please wait for the previous file to be sent.');
|
|
}
|
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 1) {
|
|
return userLog('warning', 'Please drag and drop a single file.');
|
|
}
|
|
// Use DataTransferItemList interface to access the file(s)
|
|
if (e.dataTransfer.items) {
|
|
// If dropped items aren't files, reject them
|
|
const item = e.dataTransfer.items[0].webkitGetAsEntry();
|
|
console.log('Drag and drop', item);
|
|
if (item.isDirectory) {
|
|
return userLog('warning', 'Please drag and drop a single file not a folder.', 'top-end');
|
|
}
|
|
const file = e.dataTransfer.items[0].getAsFile();
|
|
sendFileInformations(file, peer_id);
|
|
} else {
|
|
// Use DataTransfer interface to access the file(s)
|
|
sendFileInformations(e.dataTransfer.files[0], peer_id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle video privacy button click event
|
|
* @param {string} videoId
|
|
* @param {boolean} privacyBtnId
|
|
*/
|
|
function handleVideoPrivacyBtn(videoId, privacyBtnId) {
|
|
const video = getId(videoId);
|
|
const privacyBtn = getId(privacyBtnId);
|
|
if (useVideo && video && privacyBtn) {
|
|
privacyBtn.addEventListener('click', () => {
|
|
playSound('click');
|
|
isVideoPrivacyActive = !isVideoPrivacyActive;
|
|
setVideoPrivacyStatus(videoId, isVideoPrivacyActive);
|
|
emitPeerStatus('privacy', isVideoPrivacyActive);
|
|
});
|
|
} else {
|
|
if (privacyBtn) elemDisplay(privacyBtn, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set video privacy status
|
|
* @param {string} peerVideoId
|
|
* @param {boolean} peerPrivacyActive
|
|
*/
|
|
function setVideoPrivacyStatus(peerVideoId, peerPrivacyActive) {
|
|
const video = getId(peerVideoId);
|
|
if (!video) return;
|
|
if (peerPrivacyActive) {
|
|
video.classList.remove('videoDefault');
|
|
video.classList.add('videoCircle');
|
|
video.style.objectFit = 'cover';
|
|
} else {
|
|
video.classList.remove('videoCircle');
|
|
video.classList.add('videoDefault');
|
|
video.style.objectFit = 'var(--video-object-fit)';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle video pin/unpin
|
|
* @param {string} elemId video id
|
|
* @param {string} pnId button pin id
|
|
* @param {string} camId video wrap id
|
|
* @param {string} peerId peer id
|
|
* @param {boolean} isScreen stream
|
|
*/
|
|
function handleVideoPinUnpin(elemId, pnId, camId, peerId, isScreen = false) {
|
|
const videoPlayer = getId(elemId);
|
|
const btnPn = getId(pnId);
|
|
const cam = getId(camId);
|
|
if (btnPn && videoPlayer && cam) {
|
|
btnPn.addEventListener('click', () => {
|
|
if (isMobileDevice) return;
|
|
playSound('click');
|
|
isVideoPinned = !isVideoPinned;
|
|
if (isVideoPinned) {
|
|
if (!videoPlayer.classList.contains('videoCircle')) {
|
|
videoPlayer.style.objectFit = 'contain';
|
|
}
|
|
cam.className = '';
|
|
cam.style.width = '100%';
|
|
cam.style.height = '100%';
|
|
toggleVideoPin(pinVideoPositionSelect.value);
|
|
videoPinMediaContainer.appendChild(cam);
|
|
elemDisplay(videoPinMediaContainer, true, 'block');
|
|
pinnedVideoPlayerId = elemId;
|
|
setColor(btnPn, 'lime');
|
|
} else {
|
|
if (pinnedVideoPlayerId != videoPlayer.id) {
|
|
isVideoPinned = true;
|
|
if (isScreenEnabled) return;
|
|
return userLog('toast', 'Another video seems pinned, unpin it before to pin this one', 5000);
|
|
}
|
|
if (!isScreenStreaming) videoPlayer.style.objectFit = 'var(--video-object-fit)';
|
|
if (isScreen || videoPlayer.style.name == peerId + '_typeScreen')
|
|
videoPlayer.style.objectFit = 'contain';
|
|
videoPinMediaContainer.removeChild(cam);
|
|
cam.className = isScreen ? 'Screen' : 'Camera';
|
|
videoMediaContainer.appendChild(cam);
|
|
removeVideoPinMediaContainer(peerId, true);
|
|
setColor(btnPn, 'white');
|
|
}
|
|
adaptAspectRatio();
|
|
});
|
|
}
|
|
}
|
|
|
|
function toggleVideoPin(position) {
|
|
if (!isVideoPinned) return;
|
|
switch (position) {
|
|
case 'top':
|
|
videoPinMediaContainer.style.top = '25%';
|
|
videoPinMediaContainer.style.width = '100%';
|
|
videoPinMediaContainer.style.height = '70%';
|
|
videoMediaContainer.style.top = 0;
|
|
videoMediaContainer.style.width = '100%';
|
|
videoMediaContainer.style.height = '25%';
|
|
videoMediaContainer.style.right = 0;
|
|
break;
|
|
case 'vertical':
|
|
videoPinMediaContainer.style.top = 0;
|
|
videoPinMediaContainer.style.width = '75%';
|
|
videoPinMediaContainer.style.height = '100%';
|
|
videoMediaContainer.style.top = 0;
|
|
videoMediaContainer.style.width = '25%';
|
|
videoMediaContainer.style.height = '100%';
|
|
videoMediaContainer.style.right = 0;
|
|
break;
|
|
case 'horizontal':
|
|
videoPinMediaContainer.style.top = 0;
|
|
videoPinMediaContainer.style.width = '100%';
|
|
videoPinMediaContainer.style.height = '75%';
|
|
videoMediaContainer.style.top = '75%';
|
|
videoMediaContainer.style.right = null;
|
|
videoMediaContainer.style.width = null;
|
|
videoMediaContainer.style.width = '100% !important';
|
|
videoMediaContainer.style.height = '25%';
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
resizeVideoMedia();
|
|
}
|
|
|
|
/**
|
|
* Handle video focus mode (hide all except selected one)
|
|
* @param {object} remoteVideoFocusBtn button
|
|
* @param {object} remoteVideoWrap videoWrapper
|
|
* @param {object} remoteMedia videoMedia
|
|
*/
|
|
function handleVideoFocusMode(remoteVideoFocusBtn, remoteVideoWrap, remoteMedia) {
|
|
if (remoteVideoFocusBtn) {
|
|
remoteVideoFocusBtn.addEventListener('click', (e) => {
|
|
if (isHideMeActive) {
|
|
return userLog('toast', 'To use this feature, please toggle Hide self view before', 'top-end', 6000);
|
|
}
|
|
isHideALLVideosActive = !isHideALLVideosActive;
|
|
e.target.style.color = isHideALLVideosActive ? 'lime' : 'white';
|
|
if (isHideALLVideosActive) {
|
|
remoteVideoWrap.style.width = '100%';
|
|
remoteVideoWrap.style.height = '100%';
|
|
remoteMedia.setAttribute('focus-mode', 'true');
|
|
} else {
|
|
resizeVideoMedia();
|
|
remoteMedia.removeAttribute('focus-mode');
|
|
}
|
|
const children = videoMediaContainer.children;
|
|
for (let child of children) {
|
|
if (child.id != remoteVideoWrap.id) {
|
|
child.style.display = isHideALLVideosActive ? 'none' : 'block';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zoom in/out video element center or by cursor position
|
|
* @param {string} zoomInBtnId
|
|
* @param {string} zoomOutBtnId
|
|
* @param {string} mediaId
|
|
* @param {string} peerId
|
|
*/
|
|
function handleVideoZoomInOut(statusId, videoWrapId, zoomInBtnId, zoomOutBtnId, mediaId, peerId = null) {
|
|
const id = statusId;
|
|
const videoWrap = getId(videoWrapId);
|
|
const zoomIn = getId(zoomInBtnId);
|
|
const zoomOut = getId(zoomOutBtnId);
|
|
const video = getId(mediaId);
|
|
|
|
/**
|
|
* 1.1: This value is used when the `zoomDirection` is 'zoom-in'.
|
|
* It means that when the user scrolls the mouse wheel up (indicating a zoom-in action), the scale factor is set to 1.1.
|
|
* This means that the content will be scaled up to 110% of its original size with each scroll event, effectively making it larger.
|
|
*/
|
|
const ZOOM_IN_FACTOR = 1.1;
|
|
/**
|
|
* 0.9: This value is used when the zoomDirection is 'zoom-out'.
|
|
* It means that when the user scrolls the mouse wheel down (indicating a zoom-out action), the scale factor is set to 0.9.
|
|
* This means that the content will be scaled down to 90% of its original size with each scroll event, effectively making it smaller.
|
|
*/
|
|
const ZOOM_OUT_FACTOR = 0.9;
|
|
const MAX_ZOOM = 15;
|
|
const MIN_ZOOM = 1;
|
|
|
|
let zoom = 1;
|
|
|
|
function setTransform() {
|
|
if (isVideoOf(id) || isVideoPrivacyMode(video)) return;
|
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
|
video.style.scale = zoom;
|
|
}
|
|
|
|
function resetZoom(video) {
|
|
zoom = 1;
|
|
video.style.transform = '';
|
|
video.style.transformOrigin = 'center';
|
|
}
|
|
|
|
if (!isMobileDevice) {
|
|
// Zoom center
|
|
if (ZOOM_CENTER_MODE) {
|
|
video.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
let delta = e.wheelDelta ? e.wheelDelta : -e.deltaY;
|
|
delta > 0 ? (zoom *= 1.2) : (zoom /= 1.2);
|
|
setTransform();
|
|
});
|
|
} else {
|
|
// Zoom on cursor position
|
|
video.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
if (isVideoOf(id) || isVideoPrivacyMode(video)) return;
|
|
|
|
const rect = videoWrap.getBoundingClientRect();
|
|
const cursorX = e.clientX - rect.left;
|
|
const cursorY = e.clientY - rect.top;
|
|
|
|
const zoomDirection = e.deltaY > 0 ? 'zoom-out' : 'zoom-in';
|
|
const scaleFactor = zoomDirection === 'zoom-out' ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR;
|
|
|
|
zoom *= scaleFactor;
|
|
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
|
|
|
video.style.transformOrigin = `${cursorX}px ${cursorY}px`;
|
|
video.style.transform = `scale(${zoom})`;
|
|
video.style.cursor = zoom === 1 ? 'pointer' : zoomDirection;
|
|
});
|
|
|
|
videoWrap.addEventListener('mouseleave', () => {
|
|
video.style.cursor = 'pointer';
|
|
if (video.id === myVideo.id && !isScreenStreaming) {
|
|
resetZoom(video);
|
|
}
|
|
});
|
|
|
|
video.addEventListener('mouseleave', () => {
|
|
video.style.cursor = 'pointer';
|
|
});
|
|
}
|
|
}
|
|
|
|
if (buttons.local.showZoomInOutBtn) {
|
|
zoomIn.addEventListener('click', () => {
|
|
if (isVideoOf(id)) return userLog('toast', 'Zoom in work when video is on');
|
|
if (isVideoPrivacyMode(video)) return userLog('toast', 'Zoom in not allowed if video on privacy mode');
|
|
zoom = zoom + 0.1;
|
|
setTransform();
|
|
});
|
|
|
|
zoomOut.addEventListener('click', () => {
|
|
if (isVideoOf(id)) return userLog('toast', 'Zoom out work when video is on');
|
|
if (isVideoPrivacyMode(video)) return userLog('toast', 'Zoom out not allowed if video on privacy mode');
|
|
zoom = zoom - 0.1;
|
|
setTransform();
|
|
});
|
|
}
|
|
|
|
function isVideoOf(id) {
|
|
const videoStatusBtn = getId(id);
|
|
return videoStatusBtn && videoStatusBtn.className === className.videoOff;
|
|
}
|
|
function isVideoPrivacyMode() {
|
|
return video && video.classList.contains('videoCircle');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Video Picture in Picture mode
|
|
*
|
|
* @param {string} btnId
|
|
* @param {string} videoId
|
|
* @param {string} peerId
|
|
*/
|
|
function handlePictureInPicture(btnId, videoId, peerId) {
|
|
const btnPiP = getId(btnId);
|
|
const video = getId(videoId);
|
|
const myVideoStatus = getId('myVideoStatusIcon');
|
|
const remoteVideoStatus = getId(peerId + '_videoStatus');
|
|
btnPiP.addEventListener('click', () => {
|
|
if (video.pictureInPictureElement) {
|
|
video.exitPictureInPicture();
|
|
} else if (document.pictureInPictureEnabled) {
|
|
if (
|
|
(myVideoStatus && myVideoStatus.className === className.videoOff) ||
|
|
(remoteVideoStatus && remoteVideoStatus.className === className.videoOff)
|
|
) {
|
|
return msgPopup('warning', 'Prohibit Picture-in-Picture (PIP) on disabled video', 'top-end', 6000);
|
|
}
|
|
video.requestPictureInPicture().catch((error) => {
|
|
console.error('Failed to enter Picture-in-Picture mode:', error);
|
|
msgPopup('warning', error.message, 'top-end', 6000);
|
|
elemDisplay(btnPiP, false);
|
|
});
|
|
}
|
|
});
|
|
if (video) {
|
|
video.addEventListener('leavepictureinpicture', (event) => {
|
|
console.log('Exited PiP mode');
|
|
// Check if the video is paused
|
|
if (video.paused) {
|
|
// Play the video again
|
|
video.play().catch((error) => {
|
|
console.error('Error playing video after exit PIP mode:', error);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove video pin media container
|
|
* @param {string} peer_id aka socket.id
|
|
* @param {boolean} force_remove force to remove
|
|
*/
|
|
function removeVideoPinMediaContainer(peer_id, force_remove = false) {
|
|
//alert(pinnedVideoPlayerId + '==' + peer_id);
|
|
if (
|
|
(isVideoPinned &&
|
|
(pinnedVideoPlayerId == peer_id + '___video' ||
|
|
pinnedVideoPlayerId == peer_id + '___screen' ||
|
|
pinnedVideoPlayerId == peer_id)) ||
|
|
force_remove
|
|
) {
|
|
elemDisplay(videoPinMediaContainer, false);
|
|
isVideoPinned = false;
|
|
pinnedVideoPlayerId = null;
|
|
videoMediaContainerUnpin();
|
|
if (isChatPinned) {
|
|
chatPin();
|
|
}
|
|
if (isCaptionPinned) {
|
|
captionPin();
|
|
}
|
|
resizeVideoMedia();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pin videoMediaContainer
|
|
*/
|
|
function videoMediaContainerPin() {
|
|
if (!isVideoPinned) {
|
|
videoMediaContainer.style.top = 0;
|
|
videoMediaContainer.style.width = '75%';
|
|
videoMediaContainer.style.height = '100%';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unpin videoMediaContainer
|
|
*/
|
|
function videoMediaContainerUnpin() {
|
|
if (!isVideoPinned) {
|
|
videoMediaContainer.style.top = 0;
|
|
videoMediaContainer.style.right = null;
|
|
videoMediaContainer.style.width = '100%';
|
|
videoMediaContainer.style.height = '100%';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Video to Img click event
|
|
* @param {string} videoStream uuid video element
|
|
* @param {string} videoToImgBtn uuid snapshot btn
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
function handleVideoToImg(videoStream, videoToImgBtn, peer_id = null) {
|
|
const videoTIBtn = getId(videoToImgBtn);
|
|
const video = getId(videoStream);
|
|
videoTIBtn.addEventListener('click', () => {
|
|
if (video.classList.contains('videoCircle')) {
|
|
return userLog('toast', 'Snapshot not allowed if video on privacy mode');
|
|
}
|
|
if (peer_id !== null) {
|
|
// handle remote video snapshot
|
|
const remoteVideoStatusBtn = getId(peer_id + '_videoStatus');
|
|
if (remoteVideoStatusBtn.className === className.videoOn) {
|
|
return takeSnapshot(video);
|
|
}
|
|
} else {
|
|
// handle local video snapshot
|
|
if (myVideoStatusIcon.className === className.videoOn) {
|
|
return takeSnapshot(video);
|
|
}
|
|
}
|
|
userLog('toast', 'Snapshot not work on video disabled');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save Video Frame to Image
|
|
* @param {object} video element from where to take the snapshot
|
|
*/
|
|
function takeSnapshot(video) {
|
|
playSound('snapshot');
|
|
let context, canvas, width, height, dataURL;
|
|
width = video.videoWidth;
|
|
height = video.videoHeight;
|
|
canvas = canvas || document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
context = canvas.getContext('2d');
|
|
context.drawImage(video, 0, 0, width, height);
|
|
dataURL = canvas.toDataURL('image/png'); // or image/jpeg
|
|
// console.log(dataURL);
|
|
saveDataToFile(dataURL, getDataTimeString() + '-SNAPSHOT.png');
|
|
}
|
|
|
|
/**
|
|
* Start session time
|
|
*/
|
|
function startSessionTime() {
|
|
let callStartTime = Date.now();
|
|
let callElapsedSecondsTime = 0;
|
|
elemDisplay(mySessionTime, true);
|
|
setInterval(function printTime() {
|
|
callElapsedSecondsTime++;
|
|
let callElapsedTime = Date.now() - callStartTime;
|
|
mySessionTime.innerText = getTimeToString(callElapsedTime);
|
|
const myCurrentSessionTime = getId('myCurrentSessionTime');
|
|
if (myCurrentSessionTime) {
|
|
myCurrentSessionTime.innerText = secondsToHms(callElapsedSecondsTime);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
/**
|
|
* Refresh my localVideoMediaStream video status
|
|
* @param {MediaStream} localVideoMediaStream
|
|
*/
|
|
function refreshMyVideoStatus(localVideoMediaStream) {
|
|
if (!localVideoMediaStream) return;
|
|
// check Track video status
|
|
localVideoMediaStream.getTracks().forEach((track) => {
|
|
if (track.kind === 'video') {
|
|
myVideoStatus = track.enabled;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refresh my localAudioMediaStream audio status
|
|
* @param {MediaStream} localAudioMediaStream
|
|
*/
|
|
function refreshMyAudioStatus(localAudioMediaStream) {
|
|
if (!localAudioMediaStream) return;
|
|
// check Track audio status
|
|
localAudioMediaStream.getTracks().forEach((track) => {
|
|
if (track.kind === 'audio') {
|
|
myAudioStatus = track.enabled;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle WebRTC left buttons
|
|
*/
|
|
function manageButtons() {
|
|
// Buttons bar
|
|
setShareRoomBtn();
|
|
setRecordStreamBtn();
|
|
setScreenShareBtn();
|
|
setFullScreenBtn();
|
|
setChatRoomBtn();
|
|
setParticipantsBtn();
|
|
setCaptionRoomBtn();
|
|
setRoomEmojiButton();
|
|
setChatEmojiBtn();
|
|
setMyWhiteboardBtn();
|
|
setSnapshotRoomBtn();
|
|
setMyFileShareBtn();
|
|
setDocumentPiPBtn();
|
|
setMySettingsBtn();
|
|
setMySettingsExtraBtns();
|
|
setAboutBtn();
|
|
|
|
// Buttons bottom
|
|
setAudioBtn();
|
|
setVideoBtn();
|
|
setSwapCameraBtn();
|
|
setHideMeButton();
|
|
setMyHandBtn();
|
|
setLeaveRoomBtn();
|
|
}
|
|
|
|
/**
|
|
* Copy - share room url button click event
|
|
*/
|
|
function setShareRoomBtn() {
|
|
shareRoomBtn.addEventListener('click', async (e) => {
|
|
shareRoomUrl();
|
|
});
|
|
shareRoomBtn.addEventListener('mouseenter', () => {
|
|
if (isMobileDevice || !buttons.main.showShareQr) return;
|
|
elemDisplay(qrRoomPopupContainer, true);
|
|
screenReaderAccessibility.announceMessage('Room QR code displayed');
|
|
});
|
|
shareRoomBtn.addEventListener('mouseleave', () => {
|
|
if (isMobileDevice || !buttons.main.showShareQr) return;
|
|
elemDisplay(qrRoomPopupContainer, false);
|
|
screenReaderAccessibility.announceMessage('Room QR code hidden');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hide myself from room view
|
|
*/
|
|
function setHideMeButton() {
|
|
hideMeBtn.addEventListener('click', (e) => {
|
|
if (isHideALLVideosActive) {
|
|
return userLog('toast', 'To use this feature, please toggle video focus mode', 'top-end', 6000);
|
|
}
|
|
isHideMeActive = !isHideMeActive;
|
|
handleHideMe(isHideMeActive);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Audio mute - unmute button click event
|
|
*/
|
|
function setAudioBtn() {
|
|
audioBtn.addEventListener('click', (e) => {
|
|
handleAudio(e, false);
|
|
});
|
|
|
|
document.onkeydown = (e) => {
|
|
if (!isPushToTalkActive || isChatRoomVisible) return;
|
|
if (e.code === 'Space') {
|
|
if (isSpaceDown) return; // prevent multiple call
|
|
handleAudio(audioBtn, false, true);
|
|
isSpaceDown = true;
|
|
console.log('Push-to-talk: audio ON');
|
|
}
|
|
};
|
|
document.onkeyup = (e) => {
|
|
e.preventDefault();
|
|
if (!isPushToTalkActive || isChatRoomVisible) return;
|
|
if (e.code === 'Space') {
|
|
handleAudio(audioBtn, false, false);
|
|
isSpaceDown = false;
|
|
console.log('Push-to-talk: audio OFF');
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Video hide - show button click event
|
|
*/
|
|
function setVideoBtn() {
|
|
videoBtn.addEventListener('click', async (e) => {
|
|
await handleVideo(e, false);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if can swap or not the cam, if yes show the button else hide it
|
|
*/
|
|
function setSwapCameraBtn() {
|
|
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
|
const videoInput = devices.filter((device) => device.kind === 'videoinput');
|
|
if (videoInput.length > 1 && isMobileDevice) {
|
|
swapCameraBtn.addEventListener('click', (e) => {
|
|
swapCamera();
|
|
});
|
|
} else {
|
|
elemDisplay(swapCameraBtn, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if i can share the screen, if yes show button else hide it
|
|
*/
|
|
function setScreenShareBtn() {
|
|
if (
|
|
!isMobileDevice &&
|
|
(navigator.getDisplayMedia || navigator.mediaDevices.getDisplayMedia) &&
|
|
buttons.main.showScreenBtn
|
|
) {
|
|
isScreenSharingSupported = true;
|
|
initScreenShareBtn.addEventListener('click', async (e) => {
|
|
await toggleScreenSharing(true);
|
|
});
|
|
screenShareBtn.addEventListener('click', async (e) => {
|
|
await toggleScreenSharing();
|
|
});
|
|
} else {
|
|
displayElements([
|
|
{ element: initScreenShareBtn, display: false },
|
|
{ element: screenShareBtn, display: false },
|
|
{ element: screenFpsDiv, display: false },
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start - Stop Stream recording
|
|
*/
|
|
function setRecordStreamBtn() {
|
|
recordStreamBtn.addEventListener('click', (e) => {
|
|
isStreamRecording ? stopStreamRecording() : startStreamRecording();
|
|
});
|
|
recImage.addEventListener('click', (e) => {
|
|
recordStreamBtn.click();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Full screen button click event
|
|
*/
|
|
function setFullScreenBtn() {
|
|
const fsSupported =
|
|
buttons.main.showFullScreenBtn &&
|
|
(document.fullscreenEnabled ||
|
|
document.webkitFullscreenEnabled ||
|
|
document.mozFullScreenEnabled ||
|
|
document.msFullscreenEnabled);
|
|
|
|
if (fsSupported) {
|
|
// detect esc from full screen mode
|
|
document.addEventListener('fullscreenchange', (e) => {
|
|
const fullScreenIcon = fullScreenBtn.querySelector('i');
|
|
|
|
let fullscreenElement = document.fullscreenElement;
|
|
if (!fullscreenElement) {
|
|
fullScreenIcon.className = className.fsOff;
|
|
isDocumentOnFullScreen = false;
|
|
}
|
|
});
|
|
fullScreenBtn.addEventListener('click', (e) => {
|
|
toggleFullScreen();
|
|
});
|
|
} else {
|
|
elemDisplay(fullScreenBtn, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chat room buttons click event
|
|
*/
|
|
function setChatRoomBtn() {
|
|
// adapt chat room size for mobile
|
|
setChatRoomAndCaptionForMobile();
|
|
|
|
// Search peer by name
|
|
searchPeerBarName.addEventListener('keyup', () => {
|
|
searchPeer();
|
|
});
|
|
|
|
// open hide chat room
|
|
chatRoomBtn.addEventListener('click', (e) => {
|
|
if (!isChatRoomVisible) {
|
|
showChatRoomDraggable();
|
|
} else {
|
|
hideChatRoomAndEmojiPicker();
|
|
e.target.className = className.chatOn;
|
|
}
|
|
});
|
|
|
|
// pin/unpin
|
|
msgerTogglePin.addEventListener('click', () => {
|
|
toggleChatPin();
|
|
});
|
|
|
|
// ghost theme + undo
|
|
msgerTheme.addEventListener('click', (e) => {
|
|
if (e.target.className == className.ghost) {
|
|
e.target.className = className.undo;
|
|
setSP('--msger-bg', 'rgba(0, 0, 0, 0.100)');
|
|
} else {
|
|
e.target.className = className.ghost;
|
|
setTheme();
|
|
}
|
|
});
|
|
|
|
// dropdown chat menu
|
|
msgerDropDownMenuBtn.addEventListener('click', () => {
|
|
toggleChatDropDownMenu();
|
|
});
|
|
|
|
// dropdown msgerCP menu
|
|
msgerCPDropDownMenuBtn.addEventListener('click', () => {
|
|
toggleMsgerCPDropDownMenu();
|
|
});
|
|
|
|
// show msger participants section
|
|
msgerCPBtn.addEventListener('click', (e) => {
|
|
elemDisplay(msgerCP, true, 'flex');
|
|
});
|
|
|
|
// hide msger participants section
|
|
msgerCPCloseBtn.addEventListener('click', (e) => {
|
|
elemDisplay(msgerCP, false);
|
|
});
|
|
|
|
// clean chat messages
|
|
msgerClean.addEventListener('click', (e) => {
|
|
if (chatMessages.length != 0) {
|
|
return cleanMessages();
|
|
}
|
|
userLog('info', 'No chat messages to delete');
|
|
});
|
|
|
|
// save chat messages to file
|
|
msgerSaveBtn.addEventListener('click', (e) => {
|
|
if (chatMessages.length != 0) {
|
|
return downloadChatMsgs();
|
|
}
|
|
userLog('info', 'No chat messages to save');
|
|
});
|
|
|
|
// close chat room - show left button and status menu if hide
|
|
msgerClose.addEventListener('click', (e) => {
|
|
chatMinimize();
|
|
hideChatRoomAndEmojiPicker();
|
|
showButtonsBarAndMenu();
|
|
});
|
|
|
|
// Maximize chat
|
|
msgerMaxBtn.addEventListener('click', (e) => {
|
|
chatMaximize();
|
|
});
|
|
// minimize chat
|
|
msgerMinBtn.addEventListener('click', (e) => {
|
|
chatMinimize();
|
|
});
|
|
|
|
// Markdown on-off
|
|
msgerMarkdownBtn.addEventListener('click', (e) => {
|
|
isChatMarkdownOn = !isChatMarkdownOn;
|
|
setColor(msgerMarkdownBtn, isChatMarkdownOn ? 'lime' : 'white');
|
|
});
|
|
|
|
// ChatGPT/OpenAI
|
|
msgerGPTBtn.addEventListener('click', (e) => {
|
|
isChatGPTOn = !isChatGPTOn;
|
|
setColor(msgerGPTBtn, isChatGPTOn ? 'lime' : 'white');
|
|
});
|
|
|
|
// share file from chat
|
|
msgerShareFileBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
selectFileToShare(myPeerId, true);
|
|
});
|
|
|
|
// open Video Url Player
|
|
msgerVideoUrlBtn.addEventListener('click', (e) => {
|
|
sendVideoUrl();
|
|
});
|
|
|
|
// Execute a function when the user releases a key on the keyboard
|
|
msgerInput.addEventListener('keyup', (e) => {
|
|
// Number 13 is the "Enter" key on the keyboard
|
|
if (e.keyCode === 13 && (isMobileDevice || !e.shiftKey)) {
|
|
e.preventDefault();
|
|
msgerSendBtn.click();
|
|
}
|
|
});
|
|
|
|
// on input check 4emoji from map
|
|
msgerInput.oninput = function () {
|
|
if (isChatPasteTxt) return;
|
|
for (let i in chatInputEmoji) {
|
|
let regex = new RegExp(escapeSpecialChars(i), 'gim');
|
|
this.value = this.value.replace(regex, chatInputEmoji[i]);
|
|
}
|
|
checkLineBreaks();
|
|
};
|
|
|
|
msgerInput.onpaste = () => {
|
|
isChatPasteTxt = true;
|
|
checkLineBreaks();
|
|
};
|
|
|
|
// clean input msg txt
|
|
msgerCleanTextBtn.addEventListener('click', (e) => {
|
|
cleanMessageInput();
|
|
});
|
|
|
|
// paste to input msg txt
|
|
msgerPasteBtn.addEventListener('click', (e) => {
|
|
pasteToMessageInput();
|
|
});
|
|
|
|
// chat show on message
|
|
msgerShowChatOnMsg.addEventListener('change', (e) => {
|
|
playSound('switch');
|
|
showChatOnMessage = e.currentTarget.checked;
|
|
showChatOnMessage
|
|
? msgPopup('info', 'Chat will be shown, when you receive a new message', 'top-end', 3000)
|
|
: msgPopup('info', 'Chat not will be shown, when you receive a new message', 'top-end', 3000);
|
|
lsSettings.show_chat_on_msg = showChatOnMessage;
|
|
lS.setSettings(lsSettings);
|
|
});
|
|
|
|
// speech incoming message
|
|
if (isSpeechSynthesisSupported) {
|
|
msgerSpeechMsg.addEventListener('change', (e) => {
|
|
playSound('switch');
|
|
speechInMessages = e.currentTarget.checked;
|
|
speechInMessages
|
|
? msgPopup('info', 'When You receive a new message, it will be converted into speech', 'top-end', 3000)
|
|
: msgPopup('info', 'You have disabled speech messages', 'top-end', 3000);
|
|
lsSettings.speech_in_msg = speechInMessages;
|
|
lS.setSettings(lsSettings);
|
|
});
|
|
} else {
|
|
elemDisplay(msgerSpeechMsgDiv, false);
|
|
}
|
|
|
|
// chat send msg
|
|
msgerSendBtn.addEventListener('click', async (e) => {
|
|
// prevent refresh page
|
|
e.preventDefault();
|
|
await sendChatMessage();
|
|
});
|
|
|
|
// adapt input font size 4 mobile
|
|
if (isMobileDevice) msgerInput.style.fontSize = 'xx-small';
|
|
}
|
|
|
|
/**
|
|
* Participants room buttons click event
|
|
*/
|
|
function setParticipantsBtn() {
|
|
participantsBtn.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
if (isParticipantsVisible) {
|
|
hideChatRoomAndEmojiPicker();
|
|
elemDisplay(msgerCP, false);
|
|
}
|
|
if (!isChatRoomVisible && !isParticipantsVisible) {
|
|
showChatRoomDraggable();
|
|
await sleep(500);
|
|
elemDisplay(msgerCP, true, 'flex');
|
|
}
|
|
isParticipantsVisible = !isParticipantsVisible;
|
|
screenReaderAccessibility.announceMessage(
|
|
isParticipantsVisible ? 'Participants panel opened' : 'Participants panel closed'
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Caption room buttons click event
|
|
*/
|
|
function setCaptionRoomBtn() {
|
|
if (buttons.main.showCaptionRoomBtn) {
|
|
// open hide caption
|
|
captionBtn.addEventListener('click', (e) => {
|
|
!isCaptionBoxVisible ? showCaptionDraggable() : hideCaptionBox();
|
|
});
|
|
|
|
// Maximize caption
|
|
captionMaxBtn.addEventListener('click', (e) => {
|
|
captionMaximize();
|
|
});
|
|
// minimize caption
|
|
captionMinBtn.addEventListener('click', (e) => {
|
|
captionMinimize();
|
|
});
|
|
|
|
// toggle caption pin
|
|
captionTogglePin.addEventListener('click', () => {
|
|
toggleCaptionPin();
|
|
});
|
|
|
|
// ghost theme + undo
|
|
captionTheme.addEventListener('click', (e) => {
|
|
if (e.target.className == className.ghost) {
|
|
e.target.className = className.undo;
|
|
setSP('--msger-bg', 'rgba(0, 0, 0, 0.100)');
|
|
} else {
|
|
e.target.className = className.ghost;
|
|
setTheme();
|
|
}
|
|
});
|
|
|
|
// clean caption transcripts
|
|
captionClean.addEventListener('click', (e) => {
|
|
if (transcripts.length != 0) {
|
|
return cleanCaptions();
|
|
}
|
|
userLog('info', 'No captions to delete');
|
|
});
|
|
|
|
// save caption transcripts to file
|
|
captionSaveBtn.addEventListener('click', (e) => {
|
|
if (transcripts.length != 0) {
|
|
return downloadCaptions();
|
|
}
|
|
userLog('info', 'No captions to save');
|
|
});
|
|
|
|
// close caption box - show left button and status menu if hide
|
|
captionClose.addEventListener('click', (e) => {
|
|
captionMinimize();
|
|
hideCaptionBox();
|
|
showButtonsBarAndMenu();
|
|
});
|
|
|
|
// hide it
|
|
elemDisplay(speechRecognitionStop, false);
|
|
|
|
if (speechRecognition) {
|
|
// start recognition speech
|
|
speechRecognitionStart.addEventListener('click', (e) => {
|
|
startSpeech();
|
|
});
|
|
// stop recognition speech
|
|
speechRecognitionStop.addEventListener('click', (e) => {
|
|
stopSpeech();
|
|
});
|
|
} else {
|
|
elemDisplay(captionFooter, false);
|
|
}
|
|
} else {
|
|
elemDisplay(captionBtn, false);
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API#browser_compatibility
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set room emoji reaction button
|
|
*/
|
|
function setRoomEmojiButton() {
|
|
// Map sound emojis to their shortcodes for sound playback
|
|
const soundEmojis = [
|
|
{ emoji: '👍', shortcodes: ':+1:' },
|
|
{ emoji: '👎', shortcodes: ':-1:' },
|
|
{ emoji: '👌', shortcodes: ':ok_hand:' },
|
|
{ emoji: '😀', shortcodes: ':grinning:' },
|
|
{ emoji: '😃', shortcodes: ':smiley:' },
|
|
{ emoji: '😂', shortcodes: ':joy:' },
|
|
{ emoji: '😘', shortcodes: ':kissing_heart:' },
|
|
{ emoji: '❤️', shortcodes: ':heart:' },
|
|
{ emoji: '🎺', shortcodes: ':trumpet:' },
|
|
{ emoji: '🎉', shortcodes: ':tada:' },
|
|
{ emoji: '😮', shortcodes: ':open_mouth:' },
|
|
{ emoji: '👏', shortcodes: ':clap:' },
|
|
{ emoji: '✨', shortcodes: ':sparkles:' },
|
|
{ emoji: '⭐', shortcodes: ':star:' },
|
|
{ emoji: '🌟', shortcodes: ':star2:' },
|
|
{ emoji: '💫', shortcodes: ':dizzy:' },
|
|
{ emoji: '🚀', shortcodes: ':rocket:' },
|
|
];
|
|
|
|
// Header with close button
|
|
const header = document.createElement('div');
|
|
header.className = 'room-emoji-header';
|
|
|
|
const title = document.createElement('span');
|
|
title.textContent = 'Emoji Picker';
|
|
title.className = 'room-emoji-title';
|
|
|
|
// Create a close button for the emoji picker
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.className = 'room-emoji-close-btn';
|
|
closeBtn.innerHTML = icons.close;
|
|
|
|
header.appendChild(title);
|
|
header.appendChild(closeBtn);
|
|
|
|
// Tabs (less invasive style)
|
|
const tabContainer = document.createElement('div');
|
|
tabContainer.className = 'room-emoji-tab-container';
|
|
|
|
const allTab = document.createElement('button');
|
|
allTab.textContent = 'All';
|
|
allTab.className = 'room-emoji-tab active';
|
|
|
|
const soundTab = document.createElement('button');
|
|
soundTab.textContent = 'Sounds';
|
|
soundTab.className = 'room-emoji-tab';
|
|
|
|
tabContainer.appendChild(allTab);
|
|
tabContainer.appendChild(soundTab);
|
|
|
|
// EmojiMart picker (default)
|
|
const emojiMartDiv = document.createElement('div');
|
|
emojiMartDiv.className = 'room-emoji-mart';
|
|
const pickerRoomOptions = {
|
|
theme: 'dark',
|
|
onEmojiSelect: sendEmojiToRoom,
|
|
};
|
|
const emojiRoomPicker = new EmojiMart.Picker(pickerRoomOptions);
|
|
emojiMartDiv.appendChild(emojiRoomPicker);
|
|
|
|
// Custom sound emoji grid (6 per row, circular hover effect)
|
|
const emojiGrid = document.createElement('div');
|
|
emojiGrid.className = 'room-emoji-grid';
|
|
|
|
// Set grid layout only when visible
|
|
function showEmojiGrid() {
|
|
emojiGrid.classList.add('visible');
|
|
}
|
|
function hideEmojiGrid() {
|
|
emojiGrid.classList.remove('visible');
|
|
}
|
|
|
|
soundEmojis.forEach(({ emoji, shortcodes }) => {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = emoji;
|
|
btn.className = 'room-emoji-btn';
|
|
btn.onclick = () => sendEmojiToRoom({ native: emoji, shortcodes });
|
|
emojiGrid.appendChild(btn);
|
|
});
|
|
|
|
// Tab switching
|
|
allTab.onclick = () => {
|
|
allTab.classList.add('active');
|
|
soundTab.classList.remove('active');
|
|
emojiMartDiv.style.display = 'block';
|
|
hideEmojiGrid();
|
|
};
|
|
soundTab.onclick = () => {
|
|
soundTab.classList.add('active');
|
|
allTab.classList.remove('active');
|
|
emojiMartDiv.style.display = 'none';
|
|
showEmojiGrid();
|
|
};
|
|
|
|
// Picker container
|
|
emojiPickerContainer.innerHTML = '';
|
|
emojiPickerContainer.appendChild(header);
|
|
emojiPickerContainer.appendChild(tabContainer);
|
|
emojiPickerContainer.appendChild(emojiMartDiv);
|
|
emojiPickerContainer.appendChild(emojiGrid);
|
|
elemDisplay(emojiPickerContainer, false);
|
|
|
|
if (!isMobileDevice) {
|
|
dragElement(emojiPickerContainer, header);
|
|
}
|
|
|
|
roomEmojiPickerBtn.addEventListener('click', (e) => {
|
|
toggleEmojiPicker();
|
|
});
|
|
closeBtn.addEventListener('click', (e) => {
|
|
toggleEmojiPicker();
|
|
});
|
|
|
|
function sendEmojiToRoom(data) {
|
|
const message = {
|
|
type: 'roomEmoji',
|
|
room_id: roomId,
|
|
peer_name: myPeerName,
|
|
emoji: data.native,
|
|
shortcodes: data.shortcodes,
|
|
};
|
|
if (thereArePeerConnections()) {
|
|
sendToServer('message', message);
|
|
}
|
|
handleEmoji(message);
|
|
}
|
|
|
|
function toggleEmojiPicker() {
|
|
const roomEmojiPickerIcon = roomEmojiPickerBtn.querySelector('i');
|
|
if (emojiPickerContainer.style.display === 'block') {
|
|
elemDisplay(emojiPickerContainer, false);
|
|
setColor(roomEmojiPickerIcon, 'var(--btn-bar-bg-color)');
|
|
} else {
|
|
emojiPickerContainer.style.display = 'block';
|
|
setColor(roomEmojiPickerIcon, 'yellow');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emoji picker chat room button click event
|
|
*/
|
|
function setChatEmojiBtn() {
|
|
msgerEmojiBtn.addEventListener('click', (e) => {
|
|
// prevent refresh page
|
|
e.preventDefault();
|
|
hideShowEmojiPicker();
|
|
});
|
|
// Add emoji picker
|
|
const pickerOptions = {
|
|
theme: 'dark',
|
|
onEmojiSelect: addEmojiToMsg,
|
|
};
|
|
const emojiPicker = new EmojiMart.Picker(pickerOptions);
|
|
msgerEmojiPicker.appendChild(emojiPicker);
|
|
|
|
handleClickOutside(emojiPicker, msgerEmojiBtn, () => {
|
|
if (isChatEmojiVisible) {
|
|
elemDisplay(msgerEmojiPicker, false);
|
|
setColor(msgerEmojiBtn, '#FFFFFF');
|
|
isChatEmojiVisible = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add emoji to chat message
|
|
*/
|
|
function addEmojiToMsg(data) {
|
|
//console.log(data);
|
|
msgerInput.value += data.native;
|
|
hideShowEmojiPicker();
|
|
}
|
|
|
|
/**
|
|
* Set my hand button click event
|
|
*/
|
|
function setMyHandBtn() {
|
|
myHandBtn.addEventListener('click', async (e) => {
|
|
setMyHandStatus();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whiteboard: https://github.com/fabricjs/fabric.js
|
|
*/
|
|
function setMyWhiteboardBtn() {
|
|
dragElement(whiteboard, whiteboardHeader);
|
|
|
|
setupWhiteboard();
|
|
|
|
whiteboardBtn.addEventListener('click', (e) => {
|
|
handleWhiteboardToggle();
|
|
});
|
|
whiteboardPencilBtn.addEventListener('click', (e) => {
|
|
whiteboardResetAllMode();
|
|
whiteboardIsPencilMode(true);
|
|
});
|
|
whiteboardObjectBtn.addEventListener('click', (e) => {
|
|
whiteboardResetAllMode();
|
|
whiteboardIsObjectMode(true);
|
|
});
|
|
whiteboardStickyNoteBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('stickyNote');
|
|
});
|
|
whiteboardVanishingBtn.addEventListener('click', (e) => {
|
|
whiteboardResetAllMode();
|
|
whiteboardIsVanishingMode(true);
|
|
});
|
|
whiteboardUndoBtn.addEventListener('click', (e) => {
|
|
whiteboardAction(getWhiteboardAction('undo'));
|
|
});
|
|
whiteboardRedoBtn.addEventListener('click', (e) => {
|
|
whiteboardAction(getWhiteboardAction('redo'));
|
|
});
|
|
whiteboardDropDownMenuBtn.addEventListener('click', function () {
|
|
whiteboardDropdownMenu.style.display === 'block'
|
|
? elemDisplay(whiteboardDropdownMenu, false)
|
|
: elemDisplay(whiteboardDropdownMenu, true, 'block');
|
|
});
|
|
whiteboardSaveBtn.addEventListener('click', (e) => {
|
|
wbCanvasSaveImg();
|
|
});
|
|
whiteboardImgFileBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('imgFile');
|
|
});
|
|
whiteboardPdfFileBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('pdfFile');
|
|
});
|
|
whiteboardImgUrlBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('imgUrl');
|
|
});
|
|
whiteboardTextBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('text');
|
|
});
|
|
whiteboardLineBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('line');
|
|
});
|
|
whiteboardRectBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('rect');
|
|
});
|
|
whiteboardTriangleBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('triangle');
|
|
});
|
|
whiteboardCircleBtn.addEventListener('click', (e) => {
|
|
whiteboardAddObj('circle');
|
|
});
|
|
whiteboardEraserBtn.addEventListener('click', (e) => {
|
|
whiteboardResetAllMode();
|
|
whiteboardIsEraserMode(true);
|
|
});
|
|
whiteboardCleanBtn.addEventListener('click', (e) => {
|
|
confirmCleanBoard();
|
|
});
|
|
whiteboardLockBtn.addEventListener('click', (e) => {
|
|
toggleLockUnlockWhiteboard();
|
|
});
|
|
whiteboardUnlockBtn.addEventListener('click', (e) => {
|
|
toggleLockUnlockWhiteboard();
|
|
});
|
|
whiteboardCloseBtn.addEventListener('click', (e) => {
|
|
handleWhiteboardToggle();
|
|
});
|
|
wbDrawingColorEl.addEventListener('change', (e) => {
|
|
wbCanvas.freeDrawingBrush.color = wbDrawingColorEl.value;
|
|
whiteboardResetAllMode();
|
|
whiteboardIsPencilMode(true);
|
|
});
|
|
wbBackgroundColorEl.addEventListener('change', (e) => {
|
|
setWhiteboardBgColor(wbBackgroundColorEl.value);
|
|
});
|
|
whiteboardGhostButton.addEventListener('click', (e) => {
|
|
wbIsBgTransparent = !wbIsBgTransparent;
|
|
//setWhiteboardBgColor(wbIsBgTransparent ? 'rgba(0, 0, 0, 0.100)' : wbBackgroundColorEl.value);
|
|
wbIsBgTransparent ? wbCanvasBackgroundColor('rgba(0, 0, 0, 0.100)') : setTheme();
|
|
});
|
|
// Canvas Grid
|
|
if (isDesktopDevice) {
|
|
whiteboardGridBtn.addEventListener('click', (e) => {
|
|
toggleCanvasGrid();
|
|
});
|
|
} else {
|
|
elemDisplay(whiteboardGridBtn, false);
|
|
}
|
|
whiteboardShortcutsBtn.addEventListener('click', (e) => {
|
|
showWhiteboardShortcuts();
|
|
});
|
|
|
|
// Hide the whiteboard dropdown menu if clicked outside
|
|
document.addEventListener('click', (event) => {
|
|
if (!whiteboardDropDownMenuBtn.contains(event.target) && !whiteboardDropDownMenuBtn.contains(event.target)) {
|
|
elemDisplay(whiteboardDropdownMenu, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* File Transfer button click event
|
|
*/
|
|
function setMyFileShareBtn() {
|
|
// make send-receive file div draggable
|
|
if (!isMobileDevice) {
|
|
dragElement(sendFileDiv, imgShareSend);
|
|
dragElement(receiveFileDiv, imgShareReceive);
|
|
}
|
|
|
|
fileShareBtn.addEventListener('click', (e) => {
|
|
//window.open("https://fromsmash.com"); // for Big Data
|
|
selectFileToShare(myPeerId, true);
|
|
});
|
|
sendAbortBtn.addEventListener('click', (e) => {
|
|
abortFileTransfer();
|
|
});
|
|
receiveAbortBtn.addEventListener('click', (e) => {
|
|
abortReceiveFileTransfer();
|
|
});
|
|
receiveHideBtn.addEventListener('click', (e) => {
|
|
hideFileTransfer();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set snapshot room button click event
|
|
*/
|
|
function setSnapshotRoomBtn() {
|
|
snapshotRoomBtn.addEventListener('click', async (e) => {
|
|
await snapshotRoom();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Snapshot Screen, Window or Tab
|
|
*/
|
|
async function snapshotRoom() {
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
const video = document.createElement('video');
|
|
|
|
try {
|
|
const captureStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: true,
|
|
});
|
|
|
|
video.srcObject = captureStream;
|
|
video.onloadedmetadata = () => {
|
|
video.play();
|
|
};
|
|
|
|
// Wait for the video to start playing
|
|
video.onplay = async () => {
|
|
playSound('snapshot');
|
|
|
|
// Sleep some ms
|
|
await sleep(1000);
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Create a link element to download the image
|
|
const link = document.createElement('a');
|
|
link.href = canvas.toDataURL('image/png');
|
|
link.download = 'Room_' + roomId + '_' + getDataTimeString() + '_snapshot.png';
|
|
link.click();
|
|
|
|
// Stop all video tracks to release the capture stream
|
|
captureStream.getTracks().forEach((track) => track.stop());
|
|
|
|
// Clean up: remove references to avoid memory leaks
|
|
video.srcObject = null;
|
|
canvas.width = 0;
|
|
canvas.height = 0;
|
|
};
|
|
} catch (err) {
|
|
console.error('Error: ' + err);
|
|
userLog('error', 'Snapshot room error ' + err.message, 6000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Document Picture-in-Picture button click event
|
|
*/
|
|
function setDocumentPiPBtn() {
|
|
documentPiPBtn.addEventListener('click', async () => {
|
|
if (!showDocumentPipBtn) return;
|
|
if (documentPictureInPicture.window) {
|
|
documentPictureInPicture.window.close();
|
|
console.log('DOCUMENT PIP close');
|
|
return;
|
|
}
|
|
await documentPictureInPictureOpen();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restart documentPictureInPicture
|
|
* @returns void
|
|
*/
|
|
async function documentPictureInPictureRestart() {
|
|
if (!showDocumentPipBtn || !documentPictureInPicture.window) return;
|
|
documentPictureInPictureClose();
|
|
setTimeout(async () => {
|
|
await documentPictureInPictureOpen();
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* Close documentPictureInPicture
|
|
*/
|
|
async function documentPictureInPictureClose() {
|
|
if (!showDocumentPipBtn) return;
|
|
if (documentPictureInPicture.window) {
|
|
documentPictureInPicture.window.close();
|
|
console.log('DOCUMENT PIP close');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open documentPictureInPicture
|
|
*/
|
|
async function documentPictureInPictureOpen() {
|
|
if (!showDocumentPipBtn) return;
|
|
try {
|
|
const pipWindow = await documentPictureInPicture.requestWindow({
|
|
width: 300,
|
|
height: 720,
|
|
});
|
|
|
|
function updateCustomProperties() {
|
|
const documentStyle = getComputedStyle(document.documentElement);
|
|
|
|
pipWindow.document.documentElement.style = `
|
|
--body-bg: ${documentStyle.getPropertyValue('--body-bg')};
|
|
`;
|
|
}
|
|
|
|
updateCustomProperties();
|
|
|
|
const pipStylesheet = document.createElement('link');
|
|
const pipVideoContainer = document.createElement('div');
|
|
|
|
pipStylesheet.type = 'text/css';
|
|
pipStylesheet.rel = 'stylesheet';
|
|
pipStylesheet.href = '../css/documentPiP.css';
|
|
|
|
pipVideoContainer.className = 'pipVideoContainer';
|
|
|
|
pipWindow.document.head.append(pipStylesheet);
|
|
pipWindow.document.body.append(pipVideoContainer);
|
|
|
|
function cloneVideoElements() {
|
|
let foundVideo = false;
|
|
|
|
pipVideoContainer.innerHTML = '';
|
|
|
|
[...getSlALL('video')].forEach((video) => {
|
|
console.log('DOCUMENT PIP found video id -----> ' + video.id);
|
|
|
|
// No video stream detected or is video share from URL...
|
|
if (!video.srcObject || video.id === 'videoAudioUrlElement') return;
|
|
|
|
// get video element
|
|
const videoPlayer = getId(video.id);
|
|
|
|
const isLocalVideo = video.id === 'myVideo';
|
|
|
|
const isPIPAllowed = !videoPlayer.classList.contains('videoCircle'); // not in privacy mode
|
|
|
|
// Check if video can be add on pipVideo
|
|
isLocalVideo
|
|
? console.log('DOCUMENT PIP LOCAL: PiP allowed? -----> ' + isPIPAllowed)
|
|
: console.log('DOCUMENT PIP REMOTE: PiP allowed? -----> ' + isPIPAllowed);
|
|
|
|
if (!isPIPAllowed) return;
|
|
|
|
// Video is ON and not in privacy mode continue....
|
|
|
|
foundVideo = true;
|
|
|
|
const pipVideo = document.createElement('video');
|
|
|
|
pipVideo.classList.add('pipVideo');
|
|
pipVideo.classList.toggle('mirror', video.classList.contains('mirror'));
|
|
pipVideo.srcObject = video.srcObject;
|
|
pipVideo.autoplay = true;
|
|
pipVideo.muted = true;
|
|
|
|
pipVideoContainer.append(pipVideo);
|
|
|
|
function observeElementClassChanges(element, observerName) {
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
|
console.log(
|
|
`${observerName}: Element ${mutation.target.id} class changed:`,
|
|
mutation.target.className
|
|
);
|
|
cloneVideoElements(); // Or other desired function
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(element, { attributes: true, attributeFilter: ['class'] });
|
|
return observer;
|
|
}
|
|
|
|
// Start observing for new videos and class changes (Video Privacy ON/OFF)
|
|
if (video) observeElementClassChanges(video, 'Video');
|
|
|
|
// Get videoStatus...
|
|
const parts = video.id.split('___');
|
|
const peer_id = parts[0];
|
|
const videoStatus = getId(isLocalVideo ? 'myVideoStatusIcon' : peer_id + '_videoStatus');
|
|
|
|
// Start observing for new videosStatus and class changes (video ON/OFF)
|
|
if (videoStatus) observeElementClassChanges(videoStatus, 'VideoStatus');
|
|
});
|
|
|
|
return foundVideo;
|
|
}
|
|
|
|
if (!cloneVideoElements()) {
|
|
documentPictureInPictureClose();
|
|
return userLog('toast', 'No video allowed for Document PIP');
|
|
}
|
|
|
|
const videoObserver = new MutationObserver(() => {
|
|
cloneVideoElements();
|
|
});
|
|
|
|
videoObserver.observe(videoMediaContainer, {
|
|
childList: true,
|
|
});
|
|
|
|
const documentObserver = new MutationObserver(() => {
|
|
updateCustomProperties();
|
|
});
|
|
|
|
documentObserver.observe(document.documentElement, {
|
|
attributeFilter: ['style'],
|
|
});
|
|
|
|
pipWindow.addEventListener('unload', () => {
|
|
videoObserver.disconnect();
|
|
documentObserver.disconnect();
|
|
});
|
|
} catch (err) {
|
|
userLog('warning', err.message, 6000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* My settings button click event
|
|
*/
|
|
function setMySettingsBtn() {
|
|
mySettingsBtn.addEventListener('click', (e) => {
|
|
if (isMobileDevice) {
|
|
elemDisplay(bottomButtons, false);
|
|
isButtonsVisible = false;
|
|
}
|
|
hideShowMySettings();
|
|
});
|
|
mySettingsCloseBtn.addEventListener('click', (e) => {
|
|
hideShowMySettings();
|
|
});
|
|
speakerTestBtn.addEventListener('click', (e) => {
|
|
playSpeaker(audioOutputSelect?.value, 'ring');
|
|
});
|
|
myPeerNameSetBtn.addEventListener('click', (e) => {
|
|
updateMyPeerName();
|
|
});
|
|
// Sounds
|
|
switchSounds.addEventListener('change', (e) => {
|
|
notifyBySound = e.currentTarget.checked;
|
|
lsSettings.sounds = notifyBySound;
|
|
lS.setSettings(lsSettings);
|
|
userLog('toast', `${icons.sounds} Notify & sounds ` + (notifyBySound ? 'ON' : 'OFF'));
|
|
playSound('switch');
|
|
});
|
|
switchShare.addEventListener('change', (e) => {
|
|
notify = e.currentTarget.checked;
|
|
lsSettings.share_on_join = notify;
|
|
lS.setSettings(lsSettings);
|
|
userLog('toast', `${icons.share} Share room on join ` + (notify ? 'ON' : 'OFF'));
|
|
playSound('switch');
|
|
});
|
|
switchKeepButtonsVisible.addEventListener('change', (e) => {
|
|
isButtonsBarOver = isKeepButtonsVisible = e.currentTarget.checked;
|
|
lsSettings.keep_buttons_visible = isButtonsBarOver;
|
|
lS.setSettings(lsSettings);
|
|
const status = isButtonsBarOver ? 'enabled' : 'disabled';
|
|
userLog('toast', `Buttons always visible ${status}`);
|
|
playSound('switch');
|
|
});
|
|
|
|
// WakeLock for mobile/tablet
|
|
if (!isDesktopDevice && isWakeLockSupported()) {
|
|
switchKeepAwake.addEventListener('change', (e) => {
|
|
applyKeepAwake(e.currentTarget.checked);
|
|
playSound('switch');
|
|
});
|
|
} else {
|
|
elemDisplay(keepAwakeButton, false);
|
|
}
|
|
|
|
if (isMobileDevice) {
|
|
elemDisplay(pushToTalkDiv, false);
|
|
} else {
|
|
// Push to talk
|
|
switchPushToTalk.addEventListener('change', (e) => {
|
|
isPushToTalkActive = e.currentTarget.checked;
|
|
userLog('toast', `👆 Push to talk ` + (isPushToTalkActive ? 'ON' : 'OFF'));
|
|
playSound('switch');
|
|
});
|
|
}
|
|
|
|
switchAudioPitchBar.addEventListener('change', (e) => {
|
|
isAudioPitchBar = e.currentTarget.checked;
|
|
lsSettings.pitch_bar = isAudioPitchBar;
|
|
lS.setSettings(lsSettings);
|
|
userLog('toast', `${icons.pitchBar} Audio pitch bar ` + (isAudioPitchBar ? 'ON' : 'OFF'));
|
|
playSound('switch');
|
|
});
|
|
|
|
// make chat room draggable for desktop
|
|
if (!isMobileDevice) dragElement(mySettings, mySettingsHeader);
|
|
|
|
// Recording pause/resume
|
|
pauseRecBtn.addEventListener('click', (e) => {
|
|
pauseRecording();
|
|
});
|
|
resumeRecBtn.addEventListener('click', (e) => {
|
|
resumeRecording();
|
|
});
|
|
// Styles
|
|
keepCustomTheme.onchange = (e) => {
|
|
themeCustom.keep = e.currentTarget.checked;
|
|
themeSelect.disabled = themeCustom.keep;
|
|
lsSettings.theme_custom = themeCustom.keep;
|
|
lsSettings.theme_color = themeCustom.color;
|
|
lS.setSettings(lsSettings);
|
|
setTheme();
|
|
userLog('toast', `${icons.theme} Custom theme keep ` + (themeCustom.keep ? 'ON' : 'OFF'));
|
|
playSound('switch');
|
|
e.target.blur();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Settings extra buttons
|
|
*/
|
|
function setMySettingsExtraBtns() {
|
|
// Settings Split Dropdown logic (desktop hover support)
|
|
if (settingsSplit && settingsExtraDropdown && settingsExtraToggle && settingsExtraMenu) {
|
|
let showTimeout;
|
|
let hideTimeout;
|
|
function showMenu() {
|
|
clearTimeout(hideTimeout);
|
|
settingsExtraMenu.classList.remove('hidden');
|
|
settingsExtraMenu.classList.add('show');
|
|
}
|
|
function hideMenu() {
|
|
clearTimeout(showTimeout);
|
|
settingsExtraMenu.classList.remove('show');
|
|
settingsExtraMenu.classList.add('hidden');
|
|
}
|
|
// Toggle on click (arrow button)
|
|
settingsExtraToggle.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
!settingsExtraMenu.classList.contains('hidden') ? hideMenu() : showMenu();
|
|
});
|
|
|
|
// Desktop hover support
|
|
const supportsHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
if (supportsHover) {
|
|
let closeTimeout;
|
|
const cancelClose = () => {
|
|
if (!closeTimeout) return;
|
|
clearTimeout(closeTimeout);
|
|
closeTimeout = null;
|
|
};
|
|
const scheduleClose = () => {
|
|
cancelClose();
|
|
closeTimeout = setTimeout(() => hideMenu(), 180);
|
|
};
|
|
settingsExtraToggle.addEventListener('mouseenter', () => {
|
|
cancelClose();
|
|
showMenu();
|
|
});
|
|
settingsExtraToggle.addEventListener('mouseleave', scheduleClose);
|
|
settingsExtraMenu.addEventListener('mouseenter', cancelClose);
|
|
settingsExtraMenu.addEventListener('mouseleave', scheduleClose);
|
|
}
|
|
|
|
// Optional: close on click outside
|
|
document.addEventListener('click', function (e) {
|
|
if (!settingsExtraToggle.contains(e.target) && !settingsExtraMenu.contains(e.target)) {
|
|
hideMenu();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* About button click event
|
|
*/
|
|
function setAboutBtn() {
|
|
aboutBtn.addEventListener('click', (e) => {
|
|
showAbout();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Leave room button click event
|
|
*/
|
|
function setLeaveRoomBtn() {
|
|
leaveRoomBtn.addEventListener('click', (e) => {
|
|
leaveRoom();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle left buttons - status menù show - hide on body mouse move
|
|
*/
|
|
function handleBodyOnMouseMove() {
|
|
document.body.addEventListener('mousemove', (e) => {
|
|
showButtonsBarAndMenu();
|
|
});
|
|
|
|
bottomButtons.addEventListener('mouseover', () => {
|
|
isButtonsBarOver = true;
|
|
});
|
|
bottomButtons.addEventListener('mouseout', () => {
|
|
isButtonsBarOver = false;
|
|
});
|
|
|
|
checkButtonsBarAndMenu();
|
|
}
|
|
|
|
/**
|
|
* Setup local audio - video devices - theme ...
|
|
*/
|
|
function setupMySettings() {
|
|
// tab buttons
|
|
tabRoomBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabRoom');
|
|
});
|
|
tabVideoBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabVideo');
|
|
});
|
|
tabAudioBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabAudio');
|
|
});
|
|
tabVideoShareBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabMedia');
|
|
});
|
|
tabRecordingBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabRecording');
|
|
});
|
|
tabProfileBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabProfile');
|
|
});
|
|
tabShortcutsBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabShortcuts');
|
|
});
|
|
tabNetworkBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabNetwork');
|
|
});
|
|
tabStylingBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabStyling');
|
|
});
|
|
tabLanguagesBtn.addEventListener('click', (e) => {
|
|
openTab(e, 'tabLanguages');
|
|
});
|
|
// copy room URL
|
|
myRoomId.addEventListener('click', () => {
|
|
isMobileDevice ? shareRoomUrl() : copyRoomURL();
|
|
});
|
|
// Fetch and display all active rooms with their participant counts
|
|
activeRoomsBtn.addEventListener('click', () => {
|
|
getActiveRooms();
|
|
});
|
|
// send invite by email to join room in a specified data-time
|
|
roomSendEmailBtn.addEventListener('click', () => {
|
|
shareRoomByEmail();
|
|
});
|
|
// tab media
|
|
shareMediaAudioVideoBtn.addEventListener('click', (e) => {
|
|
sendVideoUrl();
|
|
});
|
|
// select audio input
|
|
audioInputSelect.addEventListener('change', async () => {
|
|
detectBluetoothHeadset();
|
|
await changeLocalMicrophone(audioInputSelect.value);
|
|
refreshLsDevices();
|
|
});
|
|
// audio options
|
|
switchNoiseSuppression.onchange = async (e) => {
|
|
if (!buttons.settings.customNoiseSuppression) return;
|
|
const desired = e.currentTarget.checked;
|
|
|
|
if (desired) {
|
|
lsSettings.mic_noise_suppression = true;
|
|
lS.setSettings(lsSettings);
|
|
|
|
const ok = await enableNoiseSuppression();
|
|
if (!ok) {
|
|
lsSettings.mic_noise_suppression = false;
|
|
lS.setSettings(lsSettings);
|
|
e.currentTarget.checked = false;
|
|
toastMessage('warning', 'Noise suppression unavailable');
|
|
} else {
|
|
toastMessage('success', 'Noise suppression enabled');
|
|
}
|
|
} else {
|
|
lsSettings.mic_noise_suppression = false;
|
|
lS.setSettings(lsSettings);
|
|
await disableNoiseSuppression(true);
|
|
toastMessage('info', 'Noise suppression disabled');
|
|
}
|
|
e.target.blur();
|
|
};
|
|
// select audio output
|
|
audioOutputSelect.addEventListener('change', async () => {
|
|
await changeAudioDestination();
|
|
refreshLsDevices();
|
|
});
|
|
// select video input
|
|
videoSelect.addEventListener('change', async () => {
|
|
await changeLocalCamera(videoSelect.value);
|
|
await handleLocalCameraMirror();
|
|
await documentPictureInPictureRestart();
|
|
refreshLsDevices();
|
|
});
|
|
// select video quality
|
|
videoQualitySelect.addEventListener('change', async (e) => {
|
|
await setLocalVideoQuality();
|
|
});
|
|
|
|
// Firefox may not handle well...
|
|
if (isFirefox) {
|
|
elemDisplay(videoFpsDiv, false);
|
|
}
|
|
|
|
// select video fps
|
|
videoFpsSelect.addEventListener('change', (e) => {
|
|
videoMaxFrameRate = parseInt(videoFpsSelect.value, 10);
|
|
setLocalMaxFps(videoMaxFrameRate);
|
|
lsSettings.video_fps = e.currentTarget.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
});
|
|
// select screen fps
|
|
screenFpsSelect.addEventListener('change', (e) => {
|
|
screenMaxFrameRate = parseInt(screenFpsSelect.value, 10);
|
|
if (isScreenStreaming) setLocalMaxFps(screenMaxFrameRate, 'screen');
|
|
lsSettings.screen_fps = e.currentTarget.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
});
|
|
|
|
// Mobile not support screen sharing
|
|
if (isMobileDevice) {
|
|
screenFpsSelect.value = null;
|
|
screenFpsSelect.disabled = true;
|
|
}
|
|
// select themes
|
|
themeSelect.addEventListener('change', (e) => {
|
|
lsSettings.theme = themeSelect.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
setTheme();
|
|
});
|
|
// video object fit
|
|
videoObjFitSelect.addEventListener('change', (e) => {
|
|
lsSettings.video_obj_fit = videoObjFitSelect.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
setSP('--video-object-fit', videoObjFitSelect.value);
|
|
});
|
|
// Mobile not support buttons bar position horizontal
|
|
if (isMobileDevice) {
|
|
btnsBarSelect.disabled = true;
|
|
} else {
|
|
btnsBarSelect.addEventListener('change', (e) => {
|
|
lsSettings.buttons_bar = btnsBarSelect.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
setButtonsBarPosition(btnsBarSelect.value);
|
|
});
|
|
}
|
|
|
|
// Mobile not support pin/unpin video
|
|
if (!isMobileDevice) {
|
|
pinVideoPositionSelect.addEventListener('change', (e) => {
|
|
lsSettings.pin_grid = pinVideoPositionSelect.selectedIndex;
|
|
lS.setSettings(lsSettings);
|
|
toggleVideoPin(pinVideoPositionSelect.value);
|
|
});
|
|
} else {
|
|
elemDisplay(pinUnpinGridDiv, false);
|
|
}
|
|
// room actions
|
|
captionEveryoneBtn.addEventListener('click', (e) => {
|
|
sendToServer('caption', {
|
|
room_id: roomId,
|
|
peer_name: myPeerName,
|
|
action: 'start',
|
|
data: {
|
|
recognitionLanguageIndex: recognitionLanguage.selectedIndex,
|
|
recognitionDialectIndex: recognitionDialect.selectedIndex,
|
|
},
|
|
});
|
|
elemDisplay(captionEveryoneBtn, false);
|
|
elemDisplay(captionEveryoneStopBtn, true, 'inline');
|
|
});
|
|
captionEveryoneStopBtn.addEventListener('click', (e) => {
|
|
sendToServer('caption', {
|
|
room_id: roomId,
|
|
peer_name: myPeerName,
|
|
action: 'stop',
|
|
});
|
|
elemDisplay(captionEveryoneBtn, true, 'inline');
|
|
elemDisplay(captionEveryoneStopBtn, false);
|
|
});
|
|
muteEveryoneBtn.addEventListener('click', (e) => {
|
|
disableAllPeers('audio');
|
|
});
|
|
hideEveryoneBtn.addEventListener('click', (e) => {
|
|
disableAllPeers('video');
|
|
});
|
|
ejectEveryoneBtn.addEventListener('click', (e) => {
|
|
ejectEveryone();
|
|
});
|
|
lockRoomBtn.addEventListener('click', (e) => {
|
|
handleRoomAction({ action: 'lock' }, true);
|
|
});
|
|
unlockRoomBtn.addEventListener('click', (e) => {
|
|
handleRoomAction({ action: 'unlock' }, true);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle keyboard shortcuts
|
|
*/
|
|
function handleShortcuts() {
|
|
if (!isDesktopDevice || !buttons.settings.showShortcutsBtn) {
|
|
elemDisplay(tabShortcutsBtn, false);
|
|
setKeyboardShortcuts(false);
|
|
} else {
|
|
switchShortcuts.addEventListener('change', (e) => {
|
|
const status = setKeyboardShortcuts(e.currentTarget.checked);
|
|
userLog('toast', `Keyboard shortcuts ${status}`);
|
|
playSound('switch');
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (!isShortcutsEnabled || isChatRoomVisible || wbIsOpen) return;
|
|
|
|
const notPresenter = isRulesActive && !isPresenter;
|
|
|
|
const key = event.key.toLowerCase(); // Convert to lowercase for simplicity
|
|
|
|
console.log(`Detected shortcut: ${key}`);
|
|
|
|
switch (key) {
|
|
case 'a':
|
|
if (notPresenter && !buttons.main.showAudioBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to enable audio');
|
|
break;
|
|
}
|
|
audioBtn.click();
|
|
break;
|
|
case 'v':
|
|
if (notPresenter && !buttons.main.showVideoBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to enable video');
|
|
break;
|
|
}
|
|
videoBtn.click();
|
|
break;
|
|
case 's':
|
|
if (notPresenter && !buttons.main.showScreenBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to share the screen');
|
|
break;
|
|
}
|
|
screenShareBtn.click();
|
|
break;
|
|
case 'h':
|
|
if (notPresenter && !buttons.main.showMyHandBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to raise your hand');
|
|
break;
|
|
}
|
|
myHandBtn.click();
|
|
break;
|
|
case 'c':
|
|
if (notPresenter && !buttons.main.showChatRoomBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to open the chat');
|
|
break;
|
|
}
|
|
chatRoomBtn.click();
|
|
break;
|
|
case 'o':
|
|
if (notPresenter && !buttons.main.showMySettingsBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to open the settings');
|
|
break;
|
|
}
|
|
mySettingsBtn.click();
|
|
break;
|
|
case 'x':
|
|
if (notPresenter && !button.main.showHideMeBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to hide yourself');
|
|
break;
|
|
}
|
|
hideMeBtn.click();
|
|
break;
|
|
case 'r':
|
|
if (notPresenter && !buttons.main.showRecordStreamBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to start recording');
|
|
break;
|
|
}
|
|
recordStreamBtn.click();
|
|
break;
|
|
case 'e':
|
|
if (notPresenter && !buttons.main.showRoomEmojiPickerBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to open the room emoji');
|
|
break;
|
|
}
|
|
roomEmojiPickerBtn.click();
|
|
break;
|
|
case 'k':
|
|
if (notPresenter && !buttons.main.showCaptionRoomBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to start transcription');
|
|
break;
|
|
}
|
|
captionBtn.click();
|
|
break;
|
|
case 'w':
|
|
if (notPresenter && !buttons.main.showWhiteboardBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to open the whiteboard');
|
|
break;
|
|
}
|
|
whiteboardBtn.click();
|
|
break;
|
|
case 'd':
|
|
if (!showDocumentPipBtn) {
|
|
toastMessage('warning', 'The document PIP is not supported in this browser');
|
|
break;
|
|
}
|
|
if (notPresenter && !buttons.main.showDocumentPipBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to open the document PIP');
|
|
break;
|
|
}
|
|
documentPiPBtn.click();
|
|
break;
|
|
case 't':
|
|
if (notPresenter && !buttons.main.showSnapshotRoomBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to take a snapshot');
|
|
break;
|
|
}
|
|
snapshotRoomBtn.click();
|
|
break;
|
|
case 'f':
|
|
if (notPresenter && !buttons.settings.showFileShareBtn) {
|
|
toastMessage('warning', 'The presenter has disabled your ability to share files');
|
|
break;
|
|
}
|
|
fileShareBtn.click();
|
|
break;
|
|
//...
|
|
default:
|
|
console.log(`Unhandled shortcut key: ${key}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set Keyboard Shortcuts enabled
|
|
* @param {boolean} enabled
|
|
* @return {String} enabled/disabled
|
|
*/
|
|
function setKeyboardShortcuts(enabled) {
|
|
isShortcutsEnabled = enabled;
|
|
lsSettings.keyboard_shortcuts = isShortcutsEnabled;
|
|
lS.setSettings(lsSettings);
|
|
return isShortcutsEnabled ? 'enabled' : 'disabled';
|
|
}
|
|
|
|
/**
|
|
* Load settings from local storage
|
|
*/
|
|
function loadSettingsFromLocalStorage() {
|
|
showChatOnMessage = lsSettings.show_chat_on_msg;
|
|
speechInMessages = lsSettings.speech_in_msg;
|
|
msgerShowChatOnMsg.checked = showChatOnMessage;
|
|
msgerSpeechMsg.checked = speechInMessages;
|
|
screenFpsSelect.selectedIndex = lsSettings.screen_fps;
|
|
videoFpsSelect.selectedIndex = lsSettings.video_fps;
|
|
screenFpsSelectedIndex = screenFpsSelect.selectedIndex;
|
|
videoFpsSelectedIndex = videoFpsSelect.selectedIndex;
|
|
screenMaxFrameRate = parseInt(getSelectedIndexValue(screenFpsSelect), 10);
|
|
videoMaxFrameRate = parseInt(getSelectedIndexValue(videoFpsSelect), 10);
|
|
notifyBySound = lsSettings.sounds;
|
|
isKeepButtonsVisible = lsSettings.keep_buttons_visible;
|
|
isAudioPitchBar = lsSettings.pitch_bar;
|
|
isShortcutsEnabled = lsSettings.keyboard_shortcuts;
|
|
switchSounds.checked = notifyBySound;
|
|
switchShare.checked = notify;
|
|
switchKeepButtonsVisible.checked = isKeepButtonsVisible;
|
|
switchAudioPitchBar.checked = isAudioPitchBar;
|
|
switchShortcuts.checked = isShortcutsEnabled;
|
|
keepCustomTheme.checked = themeCustom.keep;
|
|
themeSelect.disabled = themeCustom.keep;
|
|
themeCustom.input.value = themeCustom.color;
|
|
|
|
switchNoiseSuppression.checked = lsSettings.mic_noise_suppression;
|
|
|
|
videoObjFitSelect.selectedIndex = lsSettings.video_obj_fit;
|
|
btnsBarSelect.selectedIndex = lsSettings.buttons_bar;
|
|
pinVideoPositionSelect.selectedIndex = lsSettings.pin_grid;
|
|
setSP('--video-object-fit', videoObjFitSelect.value);
|
|
setButtonsBarPosition(btnsBarSelect.value);
|
|
toggleVideoPin(pinVideoPositionSelect.value);
|
|
}
|
|
|
|
/**
|
|
* Get value from element selected index
|
|
* @param {object} elem
|
|
* @returns any value
|
|
*/
|
|
function getSelectedIndexValue(elem) {
|
|
return elem.options[elem.selectedIndex].value;
|
|
}
|
|
|
|
/**
|
|
* Make video Url player draggable
|
|
*/
|
|
function setupVideoUrlPlayer() {
|
|
if (isMobileDevice) {
|
|
// adapt video player iframe for mobile
|
|
setSP('--iframe-width', '320px');
|
|
setSP('--iframe-height', '240px');
|
|
} else {
|
|
dragElement(videoUrlCont, videoUrlHeader);
|
|
dragElement(videoAudioUrlCont, videoAudioUrlHeader);
|
|
}
|
|
videoUrlCloseBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
closeVideoUrlPlayer();
|
|
emitVideoPlayer('close');
|
|
});
|
|
videoAudioCloseBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
closeVideoUrlPlayer();
|
|
emitVideoPlayer('close');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle Camera mirror logic
|
|
*/
|
|
async function handleLocalCameraMirror() {
|
|
if (camera === 'environment') {
|
|
// Back camera → No mirror
|
|
initVideo.classList.remove('mirror');
|
|
myVideo.classList.remove('mirror');
|
|
} else {
|
|
// Disable mirror for rear camera
|
|
initVideo.classList.add('mirror');
|
|
myVideo.classList.add('mirror');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle username emoji
|
|
*/
|
|
function toggleUsernameEmoji() {
|
|
usernameEmoji.classList.toggle('hidden');
|
|
}
|
|
|
|
/**
|
|
* Handle username emoji picker
|
|
*/
|
|
function handleUsernameEmojiPicker() {
|
|
const pickerOptions = {
|
|
theme: 'dark',
|
|
onEmojiSelect: addEmojiToUsername,
|
|
};
|
|
const emojiUsernamePicker = new EmojiMart.Picker(pickerOptions);
|
|
usernameEmoji.appendChild(emojiUsernamePicker);
|
|
|
|
function addEmojiToUsername(data) {
|
|
getId('usernameInput').value += data.native;
|
|
toggleUsernameEmoji();
|
|
}
|
|
|
|
handleClickOutside(emojiUsernamePicker, initUsernameEmojiButton, () => {
|
|
if (usernameEmoji && !usernameEmoji.classList.contains('hidden')) {
|
|
usernameEmoji.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggle vide mirror
|
|
*/
|
|
function toggleInitVideoMirror() {
|
|
initVideo.classList.toggle('mirror');
|
|
// myVideo may not exist yet before joining/creating local tile
|
|
if (typeof myVideo !== 'undefined' && myVideo) {
|
|
myVideo.classList.toggle('mirror');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get audio - video constraints
|
|
* @returns {object} audio - video constraints
|
|
*/
|
|
function getAudioVideoConstraints() {
|
|
const audioSource = audioInputSelect.value;
|
|
const videoSource = videoSelect.value;
|
|
let videoConstraints = useVideo;
|
|
if (videoConstraints) {
|
|
videoConstraints = getVideoConstraints(videoQualitySelect.value ? videoQualitySelect.value : 'default');
|
|
videoConstraints['deviceId'] = videoSource ? { exact: videoSource } : undefined;
|
|
}
|
|
let audioConstraints = { audio: false };
|
|
if (useAudio) {
|
|
audioConstraints = getAudioConstraints(audioSource);
|
|
}
|
|
return {
|
|
audioConstraints,
|
|
video: videoConstraints,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get video constraints: https://webrtc.github.io/samples/src/content/getusermedia/resolution/
|
|
* WebCam resolution: https://webcamtests.com/resolution
|
|
* @param {string} videoQuality desired video quality
|
|
* @returns {object} video constraints
|
|
*/
|
|
function getVideoConstraints(videoQuality) {
|
|
const frameRate = videoMaxFrameRate;
|
|
|
|
// Function to construct constraints with ideal or exact width/height
|
|
function createConstraints(width, height, frameRate, isIdeal = false) {
|
|
const constraints = {
|
|
width: isIdeal ? { ideal: width } : { exact: width },
|
|
height: isIdeal ? { ideal: height } : { exact: height },
|
|
};
|
|
// Only add frameRate for non-Firefox browsers
|
|
if (!isFirefox) {
|
|
constraints.frameRate = isIdeal ? { ideal: frameRate } : frameRate;
|
|
}
|
|
return constraints;
|
|
}
|
|
|
|
let constraints = {};
|
|
|
|
switch (videoQuality) {
|
|
case 'default':
|
|
forceCamMaxResolutionAndFps
|
|
? (constraints = createConstraints(7680, 4320, 60, true)) // 8K resolution, 60fps (ideal)
|
|
: (constraints = createConstraints(1280, 720, 30, true)); // HD resolution, 30fps (ideal)
|
|
break;
|
|
case 'qvgaVideo':
|
|
constraints = createConstraints(320, 240, frameRate, isFirefox); // Low bandwidth (exact)
|
|
break;
|
|
case 'vgaVideo':
|
|
constraints = createConstraints(640, 480, frameRate, isFirefox); // Medium bandwidth (exact)
|
|
break;
|
|
case 'hdVideo':
|
|
constraints = createConstraints(1280, 720, frameRate, isFirefox); // High bandwidth (exact)
|
|
break;
|
|
case 'fhdVideo':
|
|
constraints = createConstraints(1920, 1080, frameRate, isFirefox); // Very high bandwidth (exact)
|
|
break;
|
|
case '2kVideo':
|
|
constraints = createConstraints(2560, 1440, frameRate, isFirefox); // Ultra high bandwidth (exact)
|
|
break;
|
|
case '4kVideo':
|
|
constraints = createConstraints(3840, 2160, frameRate, isFirefox); // Ultra high bandwidth (exact)
|
|
break;
|
|
case '6kVideo':
|
|
constraints = createConstraints(6144, 3456, frameRate, isFirefox); // Very ultra high bandwidth (exact)
|
|
break;
|
|
case '8kVideo':
|
|
constraints = createConstraints(7680, 4320, frameRate, isFirefox); // Very ultra high bandwidth (exact)
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
console.log('Get Video constraints', constraints);
|
|
return constraints;
|
|
}
|
|
|
|
/**
|
|
* Get audio constraints
|
|
* @param {string} deviceId audio input device ID
|
|
* @returns {object} audio constraints
|
|
*/
|
|
function getAudioConstraints(deviceId = null) {
|
|
// Enhanced audio constraints for better quality and volume on all devices
|
|
const audioConstraints = {
|
|
echoCancellation: true, // Prevents echo/feedback
|
|
autoGainControl: true, // Automatically adjusts microphone volume
|
|
noiseSuppression: false, // Use RNNoise instead
|
|
sampleRate: 48000, // High-quality audio sample rate
|
|
channelCount: 2, // Stereo for better audio quality
|
|
};
|
|
|
|
// Add device ID if specified
|
|
if (deviceId) {
|
|
audioConstraints.deviceId = { exact: deviceId };
|
|
}
|
|
|
|
return {
|
|
audio: audioConstraints,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set local max fps: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/applyConstraints
|
|
* @param {string} maxFrameRate desired max frame rate
|
|
* @param {string} type camera/screen default camera
|
|
*/
|
|
async function setLocalMaxFps(maxFrameRate, type = 'camera') {
|
|
if (!useVideo || isFirefox) return;
|
|
|
|
const videoTrack = getVideoTrack(localVideoMediaStream);
|
|
const screenTrack = getVideoTrack(localScreenMediaStream);
|
|
|
|
if (!videoTrack && !screenTrack) return;
|
|
|
|
(isScreenStreaming ? screenTrack : videoTrack)
|
|
.applyConstraints({ frameRate: maxFrameRate })
|
|
.then(() => {
|
|
logStreamSettingsInfo('setLocalMaxFps', videoTrack ? localVideoMediaStream : localScreenMediaStream);
|
|
type === 'camera'
|
|
? (videoFpsSelectedIndex = videoFpsSelect.selectedIndex)
|
|
: (screenFpsSelectedIndex = screenFpsSelect.selectedIndex);
|
|
})
|
|
.catch((err) => {
|
|
console.error('setLocalMaxFps', err);
|
|
type === 'camera'
|
|
? (videoFpsSelect.selectedIndex = videoFpsSelectedIndex)
|
|
: (screenFpsSelect.selectedIndex = screenFpsSelectedIndex);
|
|
userLog('toast', "Your device doesn't support the selected fps, please select the another one.");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set local video quality: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/applyConstraints
|
|
*/
|
|
async function setLocalVideoQuality() {
|
|
const videoTrack = getVideoTrack(localVideoMediaStream);
|
|
const screenTrack = getVideoTrack(localScreenMediaStream);
|
|
|
|
if (!videoTrack && !screenTrack) return;
|
|
|
|
const videoQuality = videoQualitySelect.value ? videoQualitySelect.value : 'default';
|
|
const videoConstraints = getVideoConstraints(videoQuality);
|
|
|
|
(isScreenStreaming ? screenTrack : videoTrack)
|
|
.applyConstraints(videoConstraints)
|
|
.then(() => {
|
|
logStreamSettingsInfo('setLocalVideoQuality', videoTrack ? localVideoMediaStream : localScreenMediaStream);
|
|
videoQualitySelectedIndex = videoQualitySelect.selectedIndex;
|
|
})
|
|
.catch((err) => {
|
|
videoQualitySelect.selectedIndex = videoQualitySelectedIndex;
|
|
console.error('setLocalVideoQuality', err);
|
|
userLog('toast', "Your device doesn't support the selected video quality, please select the another one.");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Change audio output (Speaker)
|
|
*/
|
|
async function changeAudioDestination(audioElement = false) {
|
|
const audioDestination = audioOutputSelect.value;
|
|
if (audioElement) {
|
|
// change audio output to specified participant audio
|
|
await attachSinkId(audioElement, audioDestination);
|
|
} else {
|
|
const audioElements = audioMediaContainer.querySelectorAll('audio');
|
|
// change audio output for all participants audio
|
|
const promises = [];
|
|
audioElements.forEach((audioElement) => {
|
|
// discard my own audio on this device, so I won't hear myself.
|
|
if (audioElement.id != 'myAudio') {
|
|
promises.push(attachSinkId(audioElement, audioDestination));
|
|
}
|
|
});
|
|
// Wait for all audio outputs to be changed
|
|
await Promise.allSettled(promises);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach audio output device to audio element using device/sink ID.
|
|
* @param {object} element audio element to attach the audio output
|
|
* @param {string} sinkId uuid audio output device
|
|
*/
|
|
async function attachSinkId(element, sinkId) {
|
|
if (typeof element.sinkId === 'undefined') {
|
|
console.warn('Browser does not support output device selection.');
|
|
return;
|
|
}
|
|
|
|
// Helper to actually set the sinkId and handle errors uniformly
|
|
const doSetSinkId = async () => {
|
|
try {
|
|
await element.setSinkId(sinkId);
|
|
console.log(`Success, audio output device attached: ${sinkId}`);
|
|
} catch (err) {
|
|
let errorMessage = err;
|
|
if (err.name === 'SecurityError') {
|
|
errorMessage = 'SecurityError: You need to use HTTPS for selecting audio output device';
|
|
} else if (err.name === 'NotAllowedError') {
|
|
errorMessage = 'NotAllowedError: Permission to use audio output device is not granted';
|
|
} else if (err.name === 'NotFoundError') {
|
|
errorMessage = 'NotFoundError: The specified audio output device was not found';
|
|
} else if (err.message) {
|
|
errorMessage = `Error: ${err.message}`;
|
|
} else {
|
|
errorMessage = `Error: ${err}`;
|
|
}
|
|
console.error(`attachSinkId error: ${errorMessage}`);
|
|
// Jump back to first output device in the list as it's the default.
|
|
if (typeof audioOutputSelect !== 'undefined' && audioOutputSelect) {
|
|
audioOutputSelect.selectedIndex = 0;
|
|
}
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
// If a user gesture is required (Chrome policy), defer until the next interaction
|
|
const needsUserGesture = !!(navigator.userActivation && !navigator.userActivation.isActive);
|
|
if (needsUserGesture) {
|
|
// Show a single notification prompting the user to click
|
|
if (!window.__sinkGestureNotified) {
|
|
window.__sinkGestureNotified = true;
|
|
console.warn('Click anywhere to apply the speaker change');
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const applyOnGesture = async () => {
|
|
try {
|
|
await doSetSinkId();
|
|
resolve(true);
|
|
} catch (e) {
|
|
resolve(false);
|
|
} finally {
|
|
// Clean up all event listeners
|
|
window.removeEventListener('pointerdown', applyOnGesture);
|
|
window.removeEventListener('keydown', applyOnGesture);
|
|
window.removeEventListener('touchstart', applyOnGesture);
|
|
window.__sinkGestureNotified = false;
|
|
}
|
|
};
|
|
const opts = { once: true };
|
|
// Use pointerdown (covers mouse/touch/pen), touchstart (fallback for older browsers), and keydown
|
|
window.addEventListener('pointerdown', applyOnGesture, opts);
|
|
window.addEventListener('keydown', applyOnGesture, opts);
|
|
window.addEventListener('touchstart', applyOnGesture, opts);
|
|
});
|
|
}
|
|
|
|
// Otherwise, set immediately
|
|
return doSetSinkId();
|
|
}
|
|
|
|
/**
|
|
* AttachMediaStream stream to element
|
|
* @param {object} element element to attach the stream
|
|
* @param {object} stream media stream audio - video
|
|
*/
|
|
function attachMediaStream(element, stream) {
|
|
if (!element || !stream) return;
|
|
//console.log("DEPRECATED, attachMediaStream will soon be removed.");
|
|
element.srcObject = stream;
|
|
console.log('Success, media stream attached', stream.getTracks());
|
|
}
|
|
|
|
/**
|
|
* Show left buttons & status
|
|
* if buttons visible or I'm on hover do nothing return
|
|
* if mobile and chatroom open do nothing return
|
|
* if mobile and myCaption visible do nothing
|
|
* if mobile and mySettings open do nothing return
|
|
*/
|
|
function showButtonsBarAndMenu() {
|
|
if (
|
|
wbIsBgTransparent ||
|
|
isButtonsBarOver ||
|
|
isButtonsVisible ||
|
|
(isMobileDevice && isChatRoomVisible) ||
|
|
(isMobileDevice && isCaptionBoxVisible) ||
|
|
(isMobileDevice && isMySettingsVisible)
|
|
)
|
|
return;
|
|
toggleClassElements('navbar', 'block');
|
|
elemDisplay(bottomButtons, true, 'flex');
|
|
isButtonsVisible = true;
|
|
}
|
|
|
|
/**
|
|
* Check every 10 sec if need to hide buttons bar and status menu
|
|
*/
|
|
function checkButtonsBarAndMenu() {
|
|
if (lsSettings.keep_buttons_visible) {
|
|
toggleClassElements('navbar', 'block');
|
|
elemDisplay(bottomButtons, true, 'flex');
|
|
isButtonsVisible = true;
|
|
} else {
|
|
if (!isButtonsBarOver) {
|
|
toggleClassElements('navbar', 'none');
|
|
elemDisplay(bottomButtons, false);
|
|
isButtonsVisible = false;
|
|
}
|
|
}
|
|
setTimeout(() => {
|
|
checkButtonsBarAndMenu();
|
|
}, 10000);
|
|
}
|
|
|
|
/**
|
|
* Copy room url to clipboard and share it with navigator share if supported
|
|
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share
|
|
*/
|
|
async function shareRoomUrl() {
|
|
// navigator share
|
|
if (navigator.share) {
|
|
try {
|
|
// not add title and description to load metadata from url
|
|
const roomURL = getRoomURL();
|
|
await navigator.share({ url: roomURL });
|
|
userLog('toast', 'Room Shared successfully!');
|
|
} catch (err) {
|
|
/*
|
|
This feature is available only in secure contexts (HTTPS),
|
|
in some or all supporting browsers and mobile devices
|
|
console.error("navigator.share", err);
|
|
*/
|
|
console.error('Navigator share error', err);
|
|
|
|
shareRoomMeetingURL();
|
|
}
|
|
} else {
|
|
shareRoomMeetingURL();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Share meeting room
|
|
* @param {boolean} checkScreen check screen share
|
|
*/
|
|
function shareRoomMeetingURL(checkScreen = false) {
|
|
playSound('newMessage');
|
|
const roomURL = getRoomURL();
|
|
Swal.fire({
|
|
background: swBg,
|
|
position: 'center',
|
|
title: 'Share the room',
|
|
html: `
|
|
<div id="qrRoomContainer">
|
|
<canvas id="qrRoom"></canvas>
|
|
</div>
|
|
<br/>
|
|
<p style="color:rgb(8, 189, 89);">Join from your mobile device</p>
|
|
<p style="background:transparent; color:white; font-family: Arial, Helvetica, sans-serif;">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</p>
|
|
<p style="color:rgb(8, 189, 89);">${roomURL}</p>`,
|
|
showDenyButton: true,
|
|
showCancelButton: true,
|
|
cancelButtonColor: 'red',
|
|
denyButtonColor: 'green',
|
|
confirmButtonText: `Copy URL`,
|
|
denyButtonText: `Email invite`,
|
|
cancelButtonText: `Close`,
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
copyRoomURL();
|
|
} else if (result.isDenied) {
|
|
shareRoomByEmail();
|
|
}
|
|
// share screen on join room
|
|
if (checkScreen) checkShareScreen();
|
|
});
|
|
makeRoomQR();
|
|
}
|
|
|
|
/**
|
|
* Make Room QR
|
|
* https://github.com/neocotic/qrious
|
|
*/
|
|
function makeRoomQR() {
|
|
const qr = new QRious({
|
|
element: getId('qrRoom'),
|
|
value: window.location.href,
|
|
});
|
|
qr.set({
|
|
size: 256,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make Room Popup QR
|
|
*/
|
|
function makeRoomPopupQR() {
|
|
const qr = new QRious({
|
|
element: document.getElementById('qrRoomPopup'),
|
|
value: window.location.href,
|
|
});
|
|
qr.set({
|
|
size: 256,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Copy Room URL to clipboard
|
|
*/
|
|
function copyRoomURL() {
|
|
const roomURL = getRoomURL();
|
|
const tmpInput = document.createElement('input');
|
|
document.body.appendChild(tmpInput);
|
|
tmpInput.value = roomURL;
|
|
tmpInput.select();
|
|
tmpInput.setSelectionRange(0, 99999); // For mobile devices
|
|
navigator.clipboard.writeText(tmpInput.value);
|
|
console.log('Copied to clipboard Join Link ', roomURL);
|
|
document.body.removeChild(tmpInput);
|
|
userLog('toast', 'Meeting URL copied to clipboard 👍');
|
|
}
|
|
|
|
/**
|
|
* Send the room ID via email at the scheduled date and time.
|
|
*/
|
|
function shareRoomByEmail() {
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
allowEscapeKey: false,
|
|
background: swBg,
|
|
imageUrl: images.message,
|
|
position: 'center',
|
|
title: 'Select a Date and Time',
|
|
html: '<input type="text" id="datetimePicker" class="flatpickr" />',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'OK',
|
|
cancelButtonColor: 'red',
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
preConfirm: () => {
|
|
const roomURL = getRoomURL();
|
|
const newLine = '%0D%0A%0D%0A';
|
|
const selectedDateTime = document.getElementById('datetimePicker').value;
|
|
const roomPassword = isRoomLocked && thisRoomPassword ? 'Password: ' + thisRoomPassword + newLine : '';
|
|
const email = '';
|
|
const emailSubject = `Please join our MiroTalk P2P Video Chat Meeting`;
|
|
const emailBody = `The meeting is scheduled at: ${newLine} DateTime: ${selectedDateTime} ${newLine}${roomPassword}Click to join: ${roomURL} ${newLine}`;
|
|
document.location = 'mailto:' + email + '?subject=' + emailSubject + '&body=' + emailBody;
|
|
},
|
|
});
|
|
flatpickr('#datetimePicker', {
|
|
enableTime: true,
|
|
dateFormat: 'Y-m-d H:i',
|
|
time_24hr: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get Room URL
|
|
* @returns {url} roomURL
|
|
*/
|
|
function getRoomURL() {
|
|
return myRoomUrl;
|
|
// return isHostProtected && isPeerAuthEnabled
|
|
// ? window.location.origin + '/join/?room=' + roomId + '&token=' + myToken
|
|
// : myRoomUrl;
|
|
}
|
|
|
|
/**
|
|
* Handle Audio ON - OFF
|
|
* @param {object} e event
|
|
* @param {boolean} init on join room
|
|
* @param {null|boolean} force audio off (default null can be true/false)
|
|
*/
|
|
function handleAudio(e, init, force = null) {
|
|
if (!useAudio) return;
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/getAudioTracks
|
|
|
|
const audioStatus = force !== null ? force : !myAudioStatus;
|
|
|
|
myAudioStatus = audioStatus;
|
|
|
|
// Safely enable/disable audio track
|
|
const audioTrack = getAudioTrack(localAudioMediaStream);
|
|
if (audioTrack) {
|
|
audioTrack.enabled = audioStatus;
|
|
} else {
|
|
console.warn('[handleAudio] No audio track found');
|
|
}
|
|
|
|
// Update button classes
|
|
if (force != null) {
|
|
setMediaButtonsClass([{ element: e, status: audioStatus, mediaType: 'audio' }]);
|
|
} else {
|
|
setMediaButtonsClass([{ element: e.target, status: audioStatus, mediaType: 'audio' }]);
|
|
}
|
|
|
|
setMediaButtonsClass([{ element: audioBtn, status: audioStatus, mediaType: 'audio' }]);
|
|
|
|
if (init) {
|
|
setMediaButtonsClass([{ element: initAudioBtn, status: audioStatus, mediaType: 'audio' }]);
|
|
setTippy(initAudioBtn, audioStatus ? 'Stop the audio' : 'Start the audio', 'right');
|
|
initMicrophoneSelect.disabled = !audioStatus;
|
|
initSpeakerSelect.disabled = !audioStatus;
|
|
lS.setInitConfig(lS.MEDIA_TYPE.audio, audioStatus);
|
|
} else {
|
|
applyKeepAwake(myAudioStatus);
|
|
}
|
|
|
|
setMyAudioStatus(myAudioStatus);
|
|
|
|
// Screen reader announcement
|
|
screenReaderAccessibility.announceMessage(audioStatus ? 'Microphone on' : 'Microphone off');
|
|
}
|
|
|
|
/**
|
|
* Stop audio track from MediaStream
|
|
* @param {MediaStream} stream
|
|
*/
|
|
async function stopAudioTracks(stream) {
|
|
if (!stream) return;
|
|
stream.getTracks().forEach((track) => {
|
|
if (track.kind === 'audio') track.stop();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle Video ON - OFF
|
|
* @param {object} e event
|
|
* @param {boolean} init on join room
|
|
* @param {null|boolean} force video off (default null can be true/false)
|
|
*/
|
|
async function handleVideo(e, init, force = null) {
|
|
if (!useVideo) return;
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/getVideoTracks
|
|
|
|
const videoStatus = force !== null ? force : !myVideoStatus;
|
|
|
|
myVideoStatus = videoStatus;
|
|
|
|
const videoTrack = getVideoTrack(localVideoMediaStream);
|
|
if (videoTrack) {
|
|
videoTrack.enabled = videoStatus;
|
|
}
|
|
|
|
// Update button classes
|
|
if (force != null) {
|
|
setMediaButtonsClass([{ element: e, status: videoStatus, mediaType: 'video' }]);
|
|
} else {
|
|
setMediaButtonsClass([{ element: e.target, status: videoStatus, mediaType: 'video' }]);
|
|
}
|
|
|
|
setMediaButtonsClass([{ element: videoBtn, status: videoStatus, mediaType: 'video' }]);
|
|
|
|
if (init) {
|
|
setMediaButtonsClass([{ element: initVideoBtn, status: videoStatus, mediaType: 'video' }]);
|
|
setTippy(initVideoBtn, videoStatus ? 'Stop the video' : 'Start the video', 'top');
|
|
displayElements([
|
|
{ element: initVideo, display: videoStatus, mode: 'block' },
|
|
{ element: initVideoMirrorBtn, display: videoStatus },
|
|
]);
|
|
initVideoSelect.disabled = !videoStatus;
|
|
lS.setInitConfig(lS.MEDIA_TYPE.video, videoStatus);
|
|
initVideoContainerShow(videoStatus);
|
|
} else {
|
|
applyKeepAwake(myVideoStatus);
|
|
}
|
|
|
|
if (!videoStatus) {
|
|
if (!isScreenStreaming) {
|
|
// Stop the video track based on the condition
|
|
init
|
|
? await stopVideoTracks(initStream) // Stop init video track (camera LED off)
|
|
: await stopVideoTracks(localVideoMediaStream); // Stop local video track (camera LED off)
|
|
}
|
|
} else {
|
|
init
|
|
? await changeInitCamera(initVideoSelect.value) // Resume the video track for the init camera (camera LED on)
|
|
: await changeLocalCamera(videoSelect.value); // Resume the video track for the local camera (camera LED on)
|
|
}
|
|
|
|
setMyVideoStatus(videoStatus);
|
|
|
|
// Screen reader announcement
|
|
screenReaderAccessibility.announceMessage(videoStatus ? 'Camera on' : 'Camera off');
|
|
}
|
|
|
|
/**
|
|
* Handle initVideoContainer
|
|
* @param {boolean} show
|
|
*/
|
|
function initVideoContainerShow(show = true) {
|
|
initVideoContainer.style.width = show ? '100%' : 'auto';
|
|
initVideoContainer.style.padding = show ? '10px' : '0px';
|
|
}
|
|
|
|
/**
|
|
* Stop video track from MediaStream
|
|
* @param {MediaStream} stream
|
|
*/
|
|
async function stopVideoTracks(stream) {
|
|
if (!stream) return;
|
|
stream.getTracks().forEach((track) => {
|
|
if (track.kind === 'video') track.stop();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* SwapCamera front (user) - rear (environment)
|
|
*/
|
|
async function swapCamera() {
|
|
// setup camera
|
|
let camVideo = false;
|
|
camera = camera == 'user' ? 'environment' : 'user';
|
|
camVideo = camera == 'user' ? true : { facingMode: { exact: camera } };
|
|
|
|
// some devices can't swap the cam, if have Video Track already in execution.
|
|
await stopLocalVideoTrack();
|
|
|
|
try {
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
|
|
const camStream = await navigator.mediaDevices.getUserMedia({ video: camVideo });
|
|
if (camStream) {
|
|
await refreshMyLocalStream(camStream);
|
|
await refreshMyStreamToPeers(camStream);
|
|
await setLocalMaxFps(videoMaxFrameRate);
|
|
await handleLocalCameraMirror();
|
|
await setMyVideoStatusTrue();
|
|
}
|
|
} catch (err) {
|
|
console.log('[Error] to swapping camera', err);
|
|
userLog('error', 'Error to swapping the camera ' + err);
|
|
// https://blog.addpipe.com/common-getusermedia-errors/
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop Local Video Track
|
|
*/
|
|
async function stopLocalVideoTrack() {
|
|
if (useVideo || !isScreenStreaming) {
|
|
const localVideoTrack = getVideoTrack(localVideoMediaStream);
|
|
if (localVideoTrack) {
|
|
console.log('stopLocalVideoTrack', localVideoTrack);
|
|
localVideoTrack.stop();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop Local Audio Track
|
|
*/
|
|
async function stopLocalAudioTrack() {
|
|
const localAudioTrack = getAudioTrack(localAudioMediaStream);
|
|
if (localAudioTrack) {
|
|
console.log('stopLocalAudioTrack', localAudioTrack);
|
|
localAudioTrack.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load Screen media to video element
|
|
*/
|
|
async function loadScreenMedia() {
|
|
// If user started screen sharing before joining, create the screen tile now
|
|
if (myScreenStatus && localScreenMediaStream) {
|
|
await loadLocalMedia(localScreenMediaStream, 'screen');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle screen sharing and handle related actions
|
|
* @param {boolean} init - Indicates if it's the initial screen share state
|
|
*/
|
|
async function toggleScreenSharing(init = false) {
|
|
try {
|
|
screenMaxFrameRate = parseInt(screenFpsSelect.value, 10);
|
|
const constraints = getScreenShareConstraints();
|
|
isVideoPrivacyActive = false;
|
|
if (!init) emitPeerStatus('privacy', isVideoPrivacyActive);
|
|
|
|
!isScreenStreaming ? await startScreenSharing(constraints, init) : await stopScreenSharing(init);
|
|
|
|
updateScreenSharingUI(isScreenStreaming, init);
|
|
} catch (err) {
|
|
if (err && err.name === 'NotAllowedError') {
|
|
console.error('[ScreenShare] Screen sharing permission was denied by the user.');
|
|
} else {
|
|
await handleToggleScreenException(`[Warning] Unable to share the screen: ${err}`, init);
|
|
}
|
|
if (init) return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get screen share constraints
|
|
*/
|
|
function getScreenShareConstraints() {
|
|
return {
|
|
audio: true,
|
|
video: { frameRate: screenMaxFrameRate },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start screen sharing with given constraints
|
|
* @param {object} constraints - MediaStreamConstraints for screen sharing
|
|
* @param {boolean} init - Indicates if it's the initial screen share
|
|
*/
|
|
async function startScreenSharing(constraints, init) {
|
|
const displayStream = await navigator.mediaDevices.getDisplayMedia(constraints);
|
|
if (!displayStream) return;
|
|
localScreenDisplayStream = displayStream;
|
|
const screenVideoTrack = getVideoTrack(displayStream);
|
|
if (!screenVideoTrack) {
|
|
console.error('[ScreenShare] No video track in display stream');
|
|
return;
|
|
}
|
|
const screenAudioTrack = getAudioTrack(displayStream);
|
|
const micAudioTrack =
|
|
myAudioStatus && hasAudioTrack(localAudioMediaStream) ? getAudioTrack(localAudioMediaStream) : null;
|
|
if (screenShareAudioContext) {
|
|
try {
|
|
await screenShareAudioContext.close();
|
|
} catch (_) {}
|
|
screenShareAudioContext = null;
|
|
}
|
|
const outgoingAudioTrack = await mixScreenAndMicAudio(screenAudioTrack, micAudioTrack);
|
|
localScreenMediaStream = outgoingAudioTrack
|
|
? new MediaStream([screenVideoTrack, outgoingAudioTrack])
|
|
: new MediaStream([screenVideoTrack]);
|
|
isScreenStreaming = true;
|
|
myScreenStatus = true;
|
|
const extras = getLocalScreenExtras();
|
|
if (extras) {
|
|
try {
|
|
peerInfo.extras = { ...(peerInfo.extras || {}), ...extras };
|
|
} catch (_) {}
|
|
await emitPeerStatus('screen', true, extras);
|
|
}
|
|
if (!init) {
|
|
emitPeersAction('screenStart', extras);
|
|
await loadScreenMedia();
|
|
await refreshMyStreamToPeers(undefined, true);
|
|
}
|
|
screenVideoTrack.onended = () => {
|
|
if (isScreenStreaming) toggleScreenSharing(init);
|
|
};
|
|
if (init) {
|
|
if (initStream) await stopTracks(initStream);
|
|
initStream = displayStream;
|
|
const initVideoTrack = getVideoTrack(initStream);
|
|
if (initVideoTrack) {
|
|
const newInitStream = new MediaStream([initVideoTrack]);
|
|
elemDisplay(initVideo, true, 'block');
|
|
initVideo.classList.toggle('mirror');
|
|
initVideo.srcObject = newInitStream;
|
|
disable(initVideoSelect, true);
|
|
disable(initVideoBtn, true);
|
|
} else {
|
|
elemDisplay(initVideo, false);
|
|
}
|
|
initVideoContainerShow();
|
|
}
|
|
if (!init) {
|
|
screenReaderAccessibility.announceMessage('Screen sharing started');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop screen sharing and clean up resources
|
|
* @param {boolean} init - Indicates if it's the initial screen share
|
|
*/
|
|
async function stopScreenSharing(init) {
|
|
const myScreenWrap = getId('myScreenWrap');
|
|
const myScreenPinBtn = getId('myScreenPinBtn');
|
|
if (!init && myScreenWrap && isVideoPinned && pinnedVideoPlayerId === 'myScreen') {
|
|
console.log('[ScreenShare] Unpinning my screen before removal');
|
|
if (myScreenPinBtn) myScreenPinBtn.click();
|
|
}
|
|
if (!init && myScreenWrap) myScreenWrap.remove();
|
|
if (localScreenMediaStream) {
|
|
localScreenMediaStream.getTracks().forEach((t) => t.stop());
|
|
}
|
|
if (localScreenDisplayStream) {
|
|
localScreenDisplayStream.getTracks().forEach((t) => t.stop());
|
|
}
|
|
localScreenDisplayStream = null;
|
|
if (screenShareAudioContext) {
|
|
try {
|
|
await screenShareAudioContext.close();
|
|
} catch (_) {}
|
|
screenShareAudioContext = null;
|
|
}
|
|
localScreenMediaStream = null;
|
|
if (!init) adaptAspectRatio();
|
|
isScreenStreaming = false;
|
|
myScreenStatus = false;
|
|
if (!init) {
|
|
emitPeersAction('screenStop');
|
|
try {
|
|
peerInfo.extras = {};
|
|
} catch (_) {}
|
|
await emitPeerStatus('screen', false, {});
|
|
const micTrack = getAudioTrack(localAudioMediaStream);
|
|
if (useAudio && (!micTrack || micTrack.readyState === 'ended')) {
|
|
try {
|
|
await changeLocalMicrophone(audioInputSelect.value);
|
|
console.log('[ScreenShare] Require microphone after screen share stop');
|
|
} catch (err) {
|
|
console.error('[ScreenShare] Failed to reacquire microphone after screen share stop:', err);
|
|
}
|
|
} else {
|
|
if (micTrack) {
|
|
micTrack.enabled = true;
|
|
await refreshMyStreamToPeers(localAudioMediaStream, true);
|
|
console.log('[ScreenShare] Refreshing mic audio after screen share stop');
|
|
}
|
|
}
|
|
screenReaderAccessibility.announceMessage('Screen sharing stopped');
|
|
}
|
|
if (init) {
|
|
if (initStream) await stopTracks(initStream);
|
|
if (useVideo && myVideoStatus) {
|
|
try {
|
|
await changeInitCamera(initVideoSelect.value);
|
|
initVideo.classList.toggle('mirror');
|
|
} catch (err) {
|
|
console.error('[ScreenShare] Error restarting camera after screen share stop:', err);
|
|
initStream = null;
|
|
elemDisplay(initVideo, false);
|
|
}
|
|
} else {
|
|
initStream = null;
|
|
elemDisplay(initVideo, false);
|
|
initVideoContainerShow(false);
|
|
}
|
|
disable(initVideoSelect, false);
|
|
disable(initVideoBtn, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mix screen and microphone audio tracks into a single audio track
|
|
* @param {MediaStreamTrack} screenAudioTrack - The audio track from the screen share
|
|
* @param {MediaStreamTrack} micAudioTrack - The audio track from the microphone
|
|
* @returns {Promise<MediaStreamTrack|null>} - 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');
|
|
}
|
|
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}
|
|
<br /><br />
|
|
<span>${msg}</span>
|
|
<br /><br />
|
|
${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 = `
|
|
<br/>
|
|
<br/>
|
|
<ul>
|
|
<li>Time: ${recordingTime.innerText}</li>
|
|
<li>File: ${recFileName}</li>
|
|
<li>Codecs: ${recCodecs}</li>
|
|
<li>Size: ${blobFileSize}</li>
|
|
</ul>
|
|
<br/>
|
|
`;
|
|
lastRecordingInfo.innerHTML = `<br/>Last recording info: ${recordingInfo}`;
|
|
recordingTime.innerText = '';
|
|
|
|
msgHTML(
|
|
null,
|
|
null,
|
|
'Recording',
|
|
`<div style="text-align: left;">
|
|
🔴 Recording Info:
|
|
${recordingInfo}
|
|
Please wait to be processed, then will be downloaded to your ${currentDevice} device.
|
|
</div>`,
|
|
'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');
|
|
// Hide Participants chat
|
|
elemDisplay(msgerCP, false);
|
|
|
|
if (isMobileDevice) {
|
|
elemDisplay(bottomButtons, false);
|
|
isButtonsVisible = false;
|
|
}
|
|
//chatLeftCenter();
|
|
chatCenter();
|
|
|
|
chatRoomBtn.className = className.chatOff;
|
|
isChatRoomVisible = true;
|
|
|
|
if (isDesktopDevice && canBePinned()) {
|
|
toggleChatPin();
|
|
}
|
|
|
|
setTippy(chatRoomBtn, 'Close the chat', bottomButtonsPlacement);
|
|
screenReaderAccessibility.announceMessage('Chat opened');
|
|
}
|
|
|
|
/**
|
|
* 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()) {
|
|
toggleCaptionPin();
|
|
}
|
|
|
|
screenReaderAccessibility.announceMessage('Caption opened');
|
|
}
|
|
|
|
/**
|
|
* Toggle Chat dropdown menu
|
|
*/
|
|
function toggleChatDropDownMenu() {
|
|
msgerDropDownContent.style.display === 'block'
|
|
? (msgerDropDownContent.style.display = 'none')
|
|
: (msgerDropDownContent.style.display = 'block');
|
|
}
|
|
|
|
function toggleMsgerCPDropDownMenu() {
|
|
msgerCPDropDownContent.style.display === 'block'
|
|
? (msgerCPDropDownContent.style.display = 'none')
|
|
: (msgerCPDropDownContent.style.display = 'block');
|
|
}
|
|
|
|
/**
|
|
* 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', '420px');
|
|
setSP('--msger-height', '680px');
|
|
}
|
|
} else {
|
|
setSP('--msger-width', '25%');
|
|
setSP('--msger-height', '100%');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
setColor(msgerTogglePin, 'lime');
|
|
resizeVideoMedia();
|
|
msgerDraggable.style.resize = 'none';
|
|
if (!isMobileDevice) {
|
|
undragElement(msgerDraggable, msgerHeader);
|
|
undragElement(msgerDraggable, msgerCPHeader);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle chat unpin
|
|
*/
|
|
function chatUnpin() {
|
|
videoMediaContainerUnpin();
|
|
setSP('--msger-width', '420px');
|
|
setSP('--msger-height', '680px');
|
|
elemDisplay(msgerMinBtn, false);
|
|
buttons.chat.showMaxBtn && elemDisplay(msgerMaxBtn, true);
|
|
isChatPinned = false;
|
|
//chatLeftCenter();
|
|
chatCenter();
|
|
setColor(msgerTogglePin, 'white');
|
|
resizeVideoMedia();
|
|
msgerDraggable.style.resize = 'both';
|
|
if (!isMobileDevice) {
|
|
dragElement(msgerDraggable, msgerHeader);
|
|
dragElement(msgerDraggable, msgerCPHeader);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
setColor(captionTogglePin, 'lime');
|
|
resizeVideoMedia();
|
|
captionDraggable.style.resize = 'none';
|
|
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;
|
|
//captionRightCenter();
|
|
captionCenter();
|
|
setColor(captionTogglePin, 'white');
|
|
resizeVideoMedia();
|
|
captionDraggable.style.resize = 'both';
|
|
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(msgerEmojiPicker, false);
|
|
setColor(msgerEmojiBtn, '#FFFFFF');
|
|
chatRoomBtn.className = className.chatOn;
|
|
isChatRoomVisible = 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() && !isChatGPTOn) {
|
|
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();
|
|
}
|
|
|
|
isChatGPTOn ? await getChatGPTmessage(msg) : 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}`);
|
|
}
|
|
|
|
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 = '';
|
|
msgerInput.style.height = '15px';
|
|
}
|
|
|
|
/**
|
|
* 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) showCaptionDraggable();
|
|
|
|
const msgHTML = `
|
|
<div class="msg left-msg">
|
|
<img class="msg-img" src="${avatar_image}" />
|
|
<div class="msg-caption-bubble">
|
|
<div class="msg-info">
|
|
<div class="msg-info-name">${peer_name} : ${time_stamp}</div>
|
|
</div>
|
|
<div class="msg-text">${text_data}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = isChatGPTOn && getSide === 'left' ? images.chatgpt : filterXSS(img);
|
|
const getMsg = filterXSS(msg);
|
|
const getPrivateMsg = filterXSS(privateMsg);
|
|
const getMsgId = filterXSS(msgId);
|
|
|
|
const isChatGPT = getFrom === 'ChatGPT';
|
|
|
|
// collect chat messages to save it later
|
|
chatMessages.push({
|
|
time: time,
|
|
from: getFrom,
|
|
msg: getMsg,
|
|
privateMsg: getPrivateMsg,
|
|
});
|
|
|
|
// check if i receive a private message
|
|
let msgBubble = getPrivateMsg ? 'private-msg-bubble' : 'msg-bubble';
|
|
|
|
const isValidPrivateMessage = getPrivateMsg && getMsgId != null && getMsgId != myPeerId;
|
|
|
|
let msgHTML = `
|
|
<div id="msg-${chatMessagesId}" class="msg ${getSide}-msg">
|
|
<img class="msg-img" src="${getImg}" />
|
|
<div class=${msgBubble}>
|
|
<div class="msg-info">
|
|
<div class="msg-info-name">${getFrom}</div>
|
|
<div class="msg-info-time">${time}</div>
|
|
</div>
|
|
<div class="msg-text">
|
|
<span id="message-${chatMessagesId}"></span>
|
|
<hr/>
|
|
`;
|
|
// add btn direct reply to private message
|
|
if (isValidPrivateMessage) {
|
|
const privateMessageTag =
|
|
getSide === 'left' ? `Private message from ${getFrom}` : `Private message to ${getTo}`;
|
|
|
|
msgHTML += `<p class="b-yellow">${privateMessageTag}</p>`;
|
|
|
|
if (!isChatGPT && getSide === 'left') {
|
|
msgHTML += `
|
|
<button
|
|
class="${className.msgPrivate} b-yellow"
|
|
id="msg-private-reply-${chatMessagesId}"
|
|
style="color:#fff; border:none; background:transparent;"
|
|
onclick="sendPrivateMsgToPeer('${myPeerId}','${getFrom}')"
|
|
></button>`;
|
|
}
|
|
}
|
|
msgHTML += `
|
|
<button
|
|
id="msg-delete-${chatMessagesId}"
|
|
class="${className.trash}"
|
|
style="color:#fff; border:none; background:transparent;"
|
|
onclick="deleteMessage('msg-${chatMessagesId}')"
|
|
></button>
|
|
<button
|
|
id="msg-copy-${chatMessagesId}"
|
|
class="${className.copy}"
|
|
style="color:#fff; border:none; background:transparent;"
|
|
onclick="copyToClipboard('message-${chatMessagesId}')"
|
|
></button>`;
|
|
if (isSpeechSynthesisSupported) {
|
|
msgHTML += `
|
|
<button
|
|
id="msg-speech-${chatMessagesId}"
|
|
class="${className.speech}"
|
|
style="color:#fff; border:none; background:transparent;"
|
|
onclick="speechElementText(false, '${getFrom}', 'message-${chatMessagesId}')"
|
|
></button>`;
|
|
}
|
|
msgHTML += `
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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;
|
|
if (!isMobileDevice) {
|
|
setTippy(getId('msg-delete-' + chatMessagesId), 'Delete', 'top');
|
|
setTippy(getId('msg-copy-' + chatMessagesId), 'Copy', 'top');
|
|
setTippy(getId('msg-speech-' + chatMessagesId), 'Speech', 'top');
|
|
if (isValidPrivateMessage) {
|
|
setTippy(getId('msg-private-reply-' + chatMessagesId), 'Reply to ' + getTo, 'top');
|
|
}
|
|
}
|
|
chatMessagesId++;
|
|
toggleMsgerEmptyNotice();
|
|
}
|
|
|
|
/**
|
|
* Toggle empty chat notice
|
|
*/
|
|
function toggleMsgerEmptyNotice() {
|
|
const messages = msgerChat.querySelectorAll('.msg');
|
|
messages.length === 0 ? msgerEmptyNotice.classList.remove('hidden') : msgerEmptyNotice.classList.add('hidden');
|
|
}
|
|
|
|
/**
|
|
* Toggle empty participants notice
|
|
*/
|
|
function toggleMsgerParticipantsEmptyNotice() {
|
|
const participants = msgerCPList.querySelectorAll('.msger-peer-inputarea');
|
|
const isEmpty = participants.length === 0;
|
|
msgerEmptyParticipantsNotice.classList.toggle('hidden', !isEmpty);
|
|
elemDisplay(msgerCPList, !isEmpty, 'block');
|
|
}
|
|
|
|
/**
|
|
* 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 `<pre><code class="language-${part.lang || ''}">${part.value}</code></pre>`;
|
|
}
|
|
})
|
|
.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();
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<li><button id="${peer_id}_pKickOut" class="dropdown-item"><i class='fas fa-user-slash red'></i> Eject Participant</button></li>
|
|
<li><button id="${peer_id}_pToggleAudio" class="dropdown-item"><i class='fas fa-microphone red'></i> Mute Microphone</button></li>
|
|
<li><button id="${peer_id}_pToggleVideo" class="dropdown-item"><i class='fas fa-video red'></i> Stop Video</button></li>
|
|
<li><button id="${peer_id}_pToggleScreen" class="dropdown-item"><i class='fas fa-desktop red'></i> Stop Screen</button></li>
|
|
<li><button id="${peer_id}_pSelectFile" class="dropdown-item"><i class='fas fa-upload'></i> Send File</button></li>
|
|
<li><button id="${peer_id}_pSendVideoUrl" class="dropdown-item"><i class='fab fa-youtube'></i> Share Video/Audio</button></li>
|
|
<li><button id="${peer_id}_pRequestGeo" class="dropdown-item"><i class='fas fa-location-dot'></i> Req. Geolocation</button></li>
|
|
`;
|
|
} else {
|
|
elemDisplay(msgerCPDropDownMenuBtn, false);
|
|
dropdownOptions = `
|
|
<li><button id="${peer_id}_pSelectFile" class="dropdown-item"><i class='fas fa-upload'></i> Send File</button></li>
|
|
<li><button id="${peer_id}_pSendVideoUrl" class="dropdown-item"><i class='fab fa-youtube'></i> Share Video/Audio</button></li>
|
|
`;
|
|
}
|
|
|
|
const msgerPrivateDiv = `
|
|
<div id="${peer_id}_pMsgDiv" class="msger-peer-inputarea">
|
|
<span>${peer_name}</span>
|
|
<img id="${peer_id}_pMsgAvatar" class="peer-img" src="${chatAvatar}">
|
|
<div id="${peer_id}_pMsgInputWrap" class="msger-peer-inputwrap">
|
|
<textarea
|
|
rows="1"
|
|
cols="1"
|
|
id="${peer_id}_pMsgInput"
|
|
class="msger-input"
|
|
placeholder="Write message..."
|
|
></textarea>
|
|
<button id="${peer_id}_pMsgBtn" class="${className.msgPrivate}" value="${peer_name}"></button>
|
|
<div id="${peer_id}_pDropdownMenu" class="dropdown-menu-custom">
|
|
<button id="${peer_id}_pDropdownToggle" class="dropdown-toggle">
|
|
<i class="fas fa-ellipsis-vertical"></i>
|
|
</button>
|
|
<ul id="${peer_id}_pDropdownMenuList" class="dropdown-menu-custom-list">
|
|
${dropdownOptions}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
msgerCPList.insertAdjacentHTML('beforeend', msgerPrivateDiv);
|
|
msgerCPList.scrollTop += 500;
|
|
|
|
const msgerPrivateAvatar = getId(peer_id + '_pMsgAvatar');
|
|
const msgerPrivateMsgInput = getId(peer_id + '_pMsgInput');
|
|
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');
|
|
|
|
if (!isMobileDevice) {
|
|
setTippy(msgerPrivateBtn, 'Send Private Message', 'top');
|
|
setTippy(msgerPrivateAvatar, peer_name, 'top');
|
|
}
|
|
|
|
addMsgerPrivateBtn(
|
|
msgerPrivateBtn,
|
|
msgerPrivateMsgInput,
|
|
msgerPrivateKickOutBtn,
|
|
msgerPrivateToggleAudioBtn,
|
|
msgerPrivateToggleVideoBtn,
|
|
msgerPrivateToggleScreenBtn,
|
|
msgerPrivateSelectFileBtn,
|
|
msgerPrivateSendVideoUrlBtn,
|
|
msgerPrivateRequestGeoBtn,
|
|
myPeerId,
|
|
peer_id
|
|
);
|
|
|
|
// 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) {
|
|
dropdownToggle.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
closeAllDropdownMenu();
|
|
const displayMode = dropdownContent.style.display === 'block' ? 'none' : 'block';
|
|
dropdownContent.style.display = displayMode;
|
|
});
|
|
|
|
dropdownToggle.addEventListener('mouseenter', () => {
|
|
closeAllDropdownMenu();
|
|
dropdownContent.style.display = 'block';
|
|
});
|
|
dropdownContent.addEventListener('mouseleave', () => {
|
|
closeAllDropdownMenu();
|
|
});
|
|
|
|
document.addEventListener('click', function () {
|
|
closeAllDropdownMenu();
|
|
});
|
|
|
|
function closeAllDropdownMenu() {
|
|
document.querySelectorAll('.dropdown-menu-custom-list').forEach((el) => {
|
|
el.style.display = 'none';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
toggleMsgerParticipantsEmptyNotice();
|
|
}
|
|
|
|
/**
|
|
* Search peer by name in chat room lists to send the private messages
|
|
*/
|
|
function searchPeer() {
|
|
const searchPeerBarName = getId('searchPeerBarName').value.toLowerCase();
|
|
const msgerPeerInputarea = getEcN('msger-peer-inputarea');
|
|
for (let i = 0; i < msgerPeerInputarea.length; i++) {
|
|
const span = msgerPeerInputarea[i].getElementsByTagName('span')[0];
|
|
//console.log(span);
|
|
span && span.innerText.toLowerCase().includes(searchPeerBarName)
|
|
? elemDisplay(msgerPeerInputarea[i], true, 'flex')
|
|
: elemDisplay(msgerPeerInputarea[i], false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove participant from chat room lists
|
|
* @param {string} peer_id socket.id
|
|
*/
|
|
function msgerRemovePeer(peer_id) {
|
|
const msgerPrivateDiv = getId(peer_id + '_pMsgDiv');
|
|
if (msgerPrivateDiv) {
|
|
let peerToRemove = msgerPrivateDiv.firstChild;
|
|
while (peerToRemove) {
|
|
msgerPrivateDiv.removeChild(peerToRemove);
|
|
peerToRemove = msgerPrivateDiv.firstChild;
|
|
}
|
|
msgerPrivateDiv.remove();
|
|
// No peer connected
|
|
if (msgerCPList.children.length === 0 && isChatRoomVisible && isParticipantsVisible) {
|
|
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();
|
|
sendPrivateMessage();
|
|
});
|
|
|
|
// Enter key to send private message
|
|
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.value = filterXSS(msgerPrivateBtn.value);
|
|
myPeerName = filterXSS(myPeerName);
|
|
|
|
if (isHtml(myPeerName) && isHtml(msgerPrivateBtn.value)) {
|
|
msgerPrivateMsgInput.value = '';
|
|
isChatPasteTxt = false;
|
|
return;
|
|
}
|
|
|
|
const toPeerName = msgerPrivateBtn.value;
|
|
emitMsg(myPeerName, myPeerAvatar, toPeerName, pMsg, true, myPeerId);
|
|
appendMessage(myPeerName, rightChatAvatar, 'right', pMsg, true, null, toPeerName);
|
|
msgerPrivateMsgInput.value = '';
|
|
elemDisplay(msgerCP, 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() {
|
|
msgerInput.style.height = '';
|
|
if (getLineBreaks(msgerInput.value) > 0 || msgerInput.value.length > 50) {
|
|
msgerInput.style.height = '200px';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 : [];
|
|
setPeerChatAvatarImgName('left', 'ChatGPT');
|
|
appendMessage('ChatGPT', leftChatAvatar, 'left', message, true);
|
|
cleanMessageInput();
|
|
speechInMessages ? speechMessage(true, 'ChatGPT', 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 in json format
|
|
* https://developer.mozilla.org/it/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
|
|
*/
|
|
function downloadChatMsgs() {
|
|
let a = document.createElement('a');
|
|
a.href = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(chatMessages, null, 1));
|
|
a.download = getDataTimeString() + '-CHAT.txt';
|
|
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, 'green');
|
|
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 },
|
|
]);
|
|
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(myPeerId, toPeerName);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send Private messages to peers
|
|
* @param {string} toPeerId
|
|
* @param {string} toPeerName
|
|
*/
|
|
function sendPrivateMsgToPeer(toPeerId, toPeerName) {
|
|
Swal.fire({
|
|
background: swBg,
|
|
position: 'center',
|
|
imageUrl: images.message,
|
|
title: 'Send private message',
|
|
input: 'text',
|
|
showCancelButton: true,
|
|
confirmButtonText: `Send`,
|
|
showClass: { popup: 'animate__animated animate__fadeInDown' },
|
|
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
|
|
}).then((result) => {
|
|
if (result.value) {
|
|
result.value = filterXSS(result.value);
|
|
const pMsg = checkMsg(result.value);
|
|
if (!pMsg) {
|
|
isChatPasteTxt = false;
|
|
return;
|
|
}
|
|
emitMsg(myPeerName, myPeerAvatar, toPeerName, pMsg, true, toPeerId);
|
|
appendMessage(myPeerName, rightChatAvatar, 'right', pMsg, true, null, toPeerName);
|
|
userLog('toast', 'Message sent to ' + toPeerName + ' 👍');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
|
|
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' },
|
|
]);
|
|
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: `
|
|
<div class="sticky-note-form">
|
|
<textarea id="stickyNoteText" class="sticky-note-textarea" rows="4" placeholder="Type your note here...">Note</textarea>
|
|
<div class="sticky-note-colors-row">
|
|
<div class="sticky-note-color-group">
|
|
<label for="stickyNoteColor" class="sticky-note-color-label">
|
|
<i class="fas fa-palette"></i> Background
|
|
</label>
|
|
<input id="stickyNoteColor" type="color" value="#FFEB3B" class="sticky-note-color-input">
|
|
</div>
|
|
<div class="sticky-note-color-group">
|
|
<label for="stickyNoteTextColor" class="sticky-note-color-label">
|
|
<i class="fas fa-font"></i> Text
|
|
</label>
|
|
<input id="stickyNoteTextColor" type="color" value="#000000" class="sticky-note-color-input">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
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: `
|
|
<div id="dropArea">
|
|
<p>Drag and drop your file here</p>
|
|
</div>
|
|
`,
|
|
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) {
|
|
playSound('newMessage');
|
|
|
|
Swal.fire({
|
|
allowOutsideClick: false,
|
|
background: swBg,
|
|
imageAlt: 'mirotalk-file-sharing',
|
|
imageUrl: images.share,
|
|
position: 'center',
|
|
title: 'Share file',
|
|
input: 'file',
|
|
html: `
|
|
<div id="dropArea">
|
|
<p>Drag and drop your file here</p>
|
|
</div>
|
|
`,
|
|
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);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
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 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:
|
|
<br/>
|
|
<ul>
|
|
<li>Name: ${fileToSend.name}</li>
|
|
<li>Size: ${bytesToSize(fileToSend.size)}</li>
|
|
</ul>`,
|
|
false
|
|
);
|
|
|
|
// 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 '<pre>' + JSON.stringify(obj, null, 4) + '</pre>';
|
|
}
|
|
|
|
/**
|
|
* 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:
|
|
<br/>
|
|
<ul>
|
|
<li>From: ${incomingFileInfo.peer_name}</li>
|
|
<li>Name: ${incomingFileInfo.file.fileName}</li>
|
|
<li>Size: ${bytesToSize(incomingFileInfo.file.fileSize)}</li>
|
|
</ul>`,
|
|
!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) {
|
|
playSound('newMessage');
|
|
|
|
Swal.fire({
|
|
background: swBg,
|
|
position: 'center',
|
|
imageUrl: images.vaShare,
|
|
title: 'Share a Video or Audio',
|
|
text: 'Paste a Video or audio URL',
|
|
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,
|
|
};
|
|
openVideoUrlPlayer(config);
|
|
emitVideoPlayer('open', config);
|
|
}
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle Video Player
|
|
* @param {object} config data
|
|
*/
|
|
function handleVideoPlayer(config) {
|
|
const { peer_name, video_action } = config;
|
|
//
|
|
switch (video_action) {
|
|
case 'open':
|
|
userLog('toast', `${icons.user} ${peer_name} \n open video player`);
|
|
openVideoUrlPlayer(config);
|
|
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:
|
|
`<h2 style="color: #FF2D00;">` +
|
|
`User ` +
|
|
peer_name +
|
|
`</h2> will kick out you after <b style="color: #FF2D00;"></b> 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.13',
|
|
imageUrl: brand.about?.imageUrl && brand.about.imageUrl.trim() !== '' ? brand.about.imageUrl : images.about,
|
|
customClass: { image: 'img-about' },
|
|
html: `
|
|
<br/>
|
|
<div id="about">
|
|
${
|
|
brand.about?.html && brand.about.html.trim() !== ''
|
|
? brand.about.html
|
|
: `
|
|
<button
|
|
id="support-button"
|
|
data-umami-event="Support button"
|
|
onclick="window.open('https://codecanyon.net/user/miroslavpejic85', '_blank')">
|
|
<i class="${className.heart}"></i> Support
|
|
</button>
|
|
<br /><br /><br />
|
|
Author:
|
|
<a
|
|
id="linkedin-button"
|
|
data-umami-event="Linkedin button"
|
|
href="https://www.linkedin.com/in/miroslav-pejic-976a07101/"
|
|
target="_blank">
|
|
Miroslav Pejic
|
|
</a>
|
|
<br /><br />
|
|
Email:
|
|
<a
|
|
id="email-button"
|
|
data-umami-event="Email button"
|
|
href="mailto:miroslav.pejic.85@gmail.com?subject=MiroTalk P2P info">
|
|
miroslav.pejic.85@gmail.com
|
|
</a>
|
|
<br /><br />
|
|
<hr />
|
|
<span>© 2025 MiroTalk P2P, all rights reserved</span>
|
|
<hr />
|
|
`
|
|
}
|
|
</div>
|
|
`,
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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.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.disabled = true;
|
|
btn.textContent = emptyLabel;
|
|
menuEl.appendChild(btn);
|
|
return;
|
|
}
|
|
|
|
options.forEach((opt) => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
|
|
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 = '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.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 = '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 = '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 && typeof navigator.mediaDevices.addEventListener === 'function') {
|
|
let deviceChangeFrame;
|
|
navigator.mediaDevices.addEventListener('devicechange', () => {
|
|
if (deviceChangeFrame) cancelAnimationFrame(deviceChangeFrame);
|
|
deviceChangeFrame = requestAnimationFrame(async () => {
|
|
if (typeof refreshMyAudioVideoDevices === 'function') {
|
|
try {
|
|
await refreshMyAudioVideoDevices();
|
|
} catch (err) {
|
|
console.error('Error in devicechange handler:', err);
|
|
}
|
|
}
|
|
rebuildVideoMenu();
|
|
rebuildAudioMenu();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
// 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 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));
|
|
}
|