'use strict'; // This user agent const userAgent = navigator.userAgent; // WebSocket connection to the signaling server const socket = io(); // WebRTC configuration const config = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; // DOM elements const appTitle = document.getElementById('appTitle'); const appName = document.getElementById('appName'); const attribution = document.getElementById('attribution'); const randomImage = document.getElementById('randomImage'); const sessionTime = document.getElementById('sessionTime'); const githubDiv = document.getElementById('githubDiv'); const signInPage = document.getElementById('signInPage'); const usernameIn = document.getElementById('usernameIn'); const signInBtn = document.getElementById('signInBtn'); const joinVideoToggle = document.getElementById('joinVideoToggle'); const joinAudioToggle = document.getElementById('joinAudioToggle'); const roomPage = document.getElementById('roomPage'); const exitSidebarBtn = document.getElementById('exitSidebarBtn'); const userSidebar = document.getElementById('userSidebar'); const userSearchInput = document.getElementById('userSearchInput'); const userList = document.getElementById('userList'); const sidebarBtn = document.getElementById('sidebarBtn'); const usersTab = document.getElementById('usersTab'); const chatTab = document.getElementById('chatTab'); const settingsTab = document.getElementById('settingsTab'); const usersContent = document.getElementById('usersContent'); const chatContent = document.getElementById('chatContent'); const settingsContent = document.getElementById('settingsContent'); const chatNotification = document.getElementById('chatNotification'); const participantCount = document.getElementById('participantCount'); const messageCount = document.getElementById('messageCount'); const chatMessages = document.getElementById('chatMessages'); const chatForm = document.getElementById('chatForm'); const chatInput = document.getElementById('chatInput'); const emojiBtn = document.getElementById('emojiBtn'); const emojiPicker = document.getElementById('emojiPicker'); const saveChatBtn = document.getElementById('saveChatBtn'); const clearChatBtn = document.getElementById('clearChatBtn'); const videoSelect = document.getElementById('videoSelect'); const audioSelect = document.getElementById('audioSelect'); const audioOutputSelect = document.getElementById('audioOutputSelect'); const testDevicesBtn = document.getElementById('testDevicesBtn'); const refreshDevicesBtn = document.getElementById('refreshDevicesBtn'); const shareRoomBtn = document.getElementById('shareRoomBtn'); const hideLocalVideoToggle = document.getElementById('hideLocalVideoToggle'); const swapCameraBtn = document.getElementById('swapCameraBtn'); const videoBtn = document.getElementById('videoBtn'); const audioBtn = document.getElementById('audioBtn'); const screenShareBtn = document.getElementById('screenShareBtn'); const leaveBtn = document.getElementById('leaveBtn'); const localVideoContainer = document.getElementById('localVideoContainer'); const localVideo = document.getElementById('localVideo'); const remoteAudioDisabled = document.getElementById('remoteAudioDisabled'); const remoteVideoDisabled = document.getElementById('remoteVideoDisabled'); const localUsername = document.getElementById('localUsername'); const remoteUsername = document.getElementById('remoteUsername'); const remoteVideo = document.getElementById('remoteVideo'); const directCallInput = document.getElementById('directCallInput'); const directCallBtn = document.getElementById('directCallBtn'); const callingOverlay = document.getElementById('callingOverlay'); const callingUsername = document.getElementById('callingUsername'); const callingTimer = document.getElementById('callingTimer'); const cancelCallBtn = document.getElementById('cancelCallBtn'); const incomingCallOverlay = document.getElementById('incomingCallOverlay'); const incomingCallUsername = document.getElementById('incomingCallUsername'); const incomingCallTimer = document.getElementById('incomingCallTimer'); const acceptCallBtn = document.getElementById('acceptCallBtn'); const declineCallBtn = document.getElementById('declineCallBtn'); const pushNotificationGroup = document.getElementById('pushNotificationGroup'); const pushNotificationToggle = document.getElementById('pushNotificationToggle'); const pushTestBtn = document.getElementById('pushTestBtn'); // Ensure app is defined, even if config.js is not loaded const app = window.myAppConfig || {}; // User and connection information let userInfo; let userName; let connectedUser; let pendingUser; // Track outgoing call target let callingTimerId = null; // Timer for calling overlay let callingElapsed = 0; // Seconds elapsed while calling let incomingCallData = null; // Pending incoming call data let incomingCallTimerId = null; // Auto-decline timer for incoming call let ringTimeout = 30; // Seconds before unanswered call is auto-cancelled/declined (from server) let ringingAudio = null; // Looping ring sound for incoming call let thisConnection; let camera = 'user'; let stream; let isScreenSharing = false; let originalStream = null; // Store original camera stream let wasCameraOffBeforeScreenShare = false; // Track camera state before screen share // User list state let userSignedIn = false; let allConnectedUsers = []; let filteredUsers = []; let selectedUser = null; // Push notification state let pushEnabled = false; let pushSubscription = null; // Chat state let unreadMessages = 0; let currentTab = 'users'; // Store last applied media status for reapplication after stream connection let lastAppliedMediaStatus = null; // Device state let availableDevices = { videoInputs: [], audioInputs: [], audioOutputs: [], }; let selectedDevices = { videoInput: null, audioInput: null, audioOutput: null, }; // Variable to store the interval ID let sessionTimerId = null; // Data channel and file transfer state let dataChannel = null; let incomingFileMeta = null; let incomingFileData = null; let fileInput = null; let pendingFileRecipient = null; let incomingFileBuffers = []; let incomingFileReceivedBytes = 0; let outgoingTransfer = null; let incomingTransfer = null; let fileTransferStatusEl = null; // On html page loaded... document.addEventListener('DOMContentLoaded', async function () { userInfo = getUserInfo(userAgent); await initI18n(); handleConfig(); handleToolTip(); handleLocalStorage(); await handleDirectJoin(); handleListeners(); initializeFileSharing(); await fetchRandomImage(); }); // Get user information from User-Agent string function getUserInfo(userAgent) { const parser = new UAParser(userAgent); const { device, os, browser } = parser.getResult(); // Determine device type and specific characteristics const deviceType = device.type || 'desktop'; const isIPad = device.model?.toLowerCase() === 'ipad'; const osName = os.name || 'Unknown OS'; const osVersion = os.version || 'Unknown Version'; const browserName = browser.name || 'Unknown Browse'; const browserVersion = browser.version || 'Unknown Version'; return { device: { isMobile: deviceType === 'mobile', isTablet: deviceType === 'tablet', isDesktop: deviceType === 'desktop', isIPad, }, os: `${osName} ${osVersion}`, browser: `${browserName} ${browserVersion}`, userAgent, }; } // Handle config - only set custom values if provided, otherwise let i18n handle it function handleConfig() { // Only override if custom values are provided in config if (app?.title && app.title !== 'Call-me') { appTitle.innerText = app.title; } if (app?.name && app.name !== 'Call-me') { appName.innerText = app.name; } // Hide elements based on config conditions const elementsToHide = [ { condition: !(app?.showGithub ?? true), element: githubDiv }, { condition: !(app?.attribution ?? true), element: attribution }, ]; elementsToHide.forEach(({ condition, element }) => { if (condition && element) { elemDisplay(element, false); } }); } async function checkHostPassword(maxRetries = 3, attempts = 0) { try { // Fetch host configuration const { data: config } = await axios.get('/api/hostPassword'); if (config.isPasswordRequired) { // Show prompt for the password const { value: password } = await Swal.fire({ heightAuto: false, scrollbarPadding: false, title: t('host.protectedTitle'), text: t('host.enterPassword'), input: 'password', inputPlaceholder: t('host.passwordPlaceholder'), inputAttributes: { autocapitalize: 'off', autocorrect: 'off', }, imageUrl: 'assets/locked.png', imageWidth: 150, imageHeight: 150, allowOutsideClick: false, allowEscapeKey: false, showDenyButton: true, confirmButtonText: t('host.submit'), denyButtonText: t('settings.cancel'), preConfirm: (password) => { if (!password) { Swal.showValidationMessage(t('host.passwordEmpty')); } return password; }, }); // If the user cancels, exit if (!password) { return; } // Validate the password const { data: validationResult } = await axios.post('/api/hostPasswordValidate', { password }); if (validationResult.success) { await Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'top', icon: 'success', title: t('host.accessGrantedTitle'), text: t('host.accessGrantedText'), timer: 1500, showConfirmButton: false, }); elemDisplay(signInPage, true); } else { attempts++; if (attempts < maxRetries) { await Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'top', icon: 'error', title: t('host.invalidPasswordTitle'), text: t('host.invalidPasswordText', { attempts, maxRetries }), }); // Retry the process await checkHostPassword(maxRetries, attempts); } else { await Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'top', icon: 'warning', title: t('host.tooManyAttemptsTitle'), text: t('host.tooManyAttemptsText'), }); } } } else { // No password required elemDisplay(signInPage, true); } } catch (error) { console.error('Error:', error); Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'top', icon: 'error', title: t('messages.error'), text: t('host.joinError'), }); } } // Get Random Images async function fetchRandomImage() { if (sessionStorage.cachedImage) { // If there's cached data, use it randomImage.src = sessionStorage.cachedImage; console.log('Using cached image'); return; } try { const response = await axios.get('/randomImage'); const data = response.data; // Cache the image URL for subsequent calls sessionStorage.cachedImage = data.urls.regular; // Update the image source randomImage.src = sessionStorage.cachedImage; console.log('Fetched and cached image'); // Create and display attribution const attributionText = `Photo by ${data.user.name} on Unsplash`; // Assuming you have an element with id 'attribution' for the attribution text attribution.innerHTML = attributionText; } catch (error) { console.error('Error fetching image', error.message); } } // Initialize tooltips and handle hiding them when clicked function handleToolTip() { if (userInfo.device.isMobile) return; const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-toggle="tooltip"]')); const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { // Always force placement to bottom for sidebar tabs if ( tooltipTriggerEl.classList.contains('sidebar-tab') && tooltipTriggerEl.getAttribute('data-placement') !== 'bottom' ) { tooltipTriggerEl.setAttribute('data-placement', 'bottom'); } return new bootstrap.Tooltip(tooltipTriggerEl, { placement: tooltipTriggerEl.getAttribute('data-placement') || 'bottom', }); }); // Add click event listener to hide all tooltips tooltipTriggerList.forEach(function (tooltipTriggerEl) { tooltipTriggerEl.addEventListener('click', function () { tooltipList.forEach(function (tooltip) { tooltip.hide(); // Hide all tooltips }); }); }); } // Handle localStorage data function handleLocalStorage() { usernameIn.value = localStorage.callMeUsername ? localStorage.callMeUsername : 'Guest' + Math.floor(Math.random() * 10000); // Restore media toggle states if (localStorage.callMeJoinVideo !== undefined) { joinVideoToggle.checked = localStorage.callMeJoinVideo === 'true'; } if (localStorage.callMeJoinAudio !== undefined) { joinAudioToggle.checked = localStorage.callMeJoinAudio === 'true'; } // Persist toggle changes joinVideoToggle.addEventListener('change', () => { localStorage.callMeJoinVideo = joinVideoToggle.checked; }); joinAudioToggle.addEventListener('change', () => { localStorage.callMeJoinAudio = joinAudioToggle.checked; }); } // Handle Room direct join async function handleDirectJoin() { const usp = new URLSearchParams(window.location.search); const user = usp.get('user'); const call = usp.get('call'); const password = usp.get('password'); if (user) { console.log('Direct Join detected', { user, call, password }); // SignIn usernameIn.value = user; handleSignInClick(); if (call) { // Call user if call is provided setTimeout(() => { handleUserClickToCall(call); }, 3000); } } if (!password) await checkHostPassword(); } // Start Session Time function startSessionTime() { console.log('Start session time'); elemDisplay(sessionTime, true, 'inline-flex'); let sessionElapsedTime = 0; if (sessionTimerId !== null) { clearInterval(sessionTimerId); } sessionTimerId = setInterval(function printTime() { sessionElapsedTime++; sessionTime.innerText = secondsToHms(sessionElapsedTime); }, 1000); } // Stop Session Time function stopSessionTime() { console.log('Stop session time'); if (sessionTimerId !== null) { clearInterval(sessionTimerId); sessionTimerId = null; } elemDisplay(sessionTime, false); } // Session Time in h/m/s function secondsToHms(d) { d = Number(d); let h = Math.floor(d / 3600); let m = Math.floor((d % 3600) / 60); let s = Math.floor((d % 3600) % 60); let hDisplay = h > 0 ? h + 'h' : ''; let mDisplay = m > 0 ? m + 'm' : ''; let sDisplay = s > 0 ? s + 's' : ''; return hDisplay + ' ' + mDisplay + ' ' + sDisplay; } // WebSocket event listeners socket.on('connect', handleSocketConnect); socket.on('message', handleMessage); socket.on('error', handleSocketError); // Handle WebSocket connection establishment function handleSocketConnect() { console.log('Connected to the signaling server'); } // Handle WebSocket errors function handleSocketError(err) { handleError(t('errors.socketError'), err.message); } // Handle incoming messages based on type function handleMessage(data) { const { type } = data; console.log('Got message', type); switch (type) { case 'ping': handlePing(data); break; case 'signIn': handleSignIn(data); break; case 'notfound': handleNotFound(data); break; case 'pushSent': handlePushSent(data); break; case 'offerAccept': offerAccept(data); break; case 'offerCreate': // Apply caller's media status when receiving offerCreate if (data.callerMediaStatus) { applyCallerMediaStatus(data.callerMediaStatus); } offerCreate(); break; case 'offerDecline': handleOfferDecline(data); break; case 'offerBusy': handleOfferBusy(data); break; case 'offer': handleOffer(data); break; case 'answer': handleAnswer(data); break; case 'candidate': handleCandidate(data); break; case 'users': handleUsers(data); break; case 'remoteAudio': handleRemoteAudio(data); break; case 'remoteVideo': handleRemoteVideo(data); break; case 'remoteScreenShare': handleRemoteScreenShare(data); break; case 'chat': addChatMessage(data, false); break; case 'leave': handleLeave(false); break; case 'error': handleError(data.message, data.message); break; default: break; } } // Enumerate Devices for camera swap functionality async function handleEnumerateDevices() { try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoInputs = devices.filter((device) => device.kind === 'videoinput'); if (videoInputs.length > 1 && userInfo.device.isMobile) { swapCameraBtn.addEventListener('click', swapCamera); elemDisplay(swapCameraBtn, true, 'inline-flex'); } // Check if screen sharing is supported if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) { elemDisplay(screenShareBtn, true, 'inline-flex'); } else { elemDisplay(screenShareBtn, false); console.log('Screen sharing not supported in this browser'); } } catch (error) { handleError(t('errors.enumerateDevicesFailed'), error); } } // Handle Listeners function handleListeners() { signInBtn.addEventListener('click', handleSignInClick); shareRoomBtn.addEventListener('click', async () => { await handleShareRoomClick(); }); hideLocalVideoToggle.addEventListener('change', toggleLocalVideo); videoBtn.addEventListener('click', handleVideoClick); audioBtn.addEventListener('click', handleAudioClick); screenShareBtn.addEventListener('click', handleScreenShareClick); leaveBtn.addEventListener('click', handleLeaveClick); exitSidebarBtn.addEventListener('click', handleExitSidebarClick); localVideoContainer.addEventListener('click', toggleFullScreen); remoteVideo.addEventListener('click', toggleFullScreen); usernameIn.addEventListener('keyup', (e) => handleKeyUp(e, handleSignInClick)); document.getElementById('randomUsernameBtn').addEventListener('click', handleRandomUsername); document.getElementById('copyUsernameBtn').addEventListener('click', handleCopyUsername); usersTab.addEventListener('click', () => switchTab('users')); chatTab.addEventListener('click', () => switchTab('chat')); settingsTab.addEventListener('click', () => switchTab('settings')); // Settings event listeners videoSelect.addEventListener('change', handleVideoDeviceChange); audioSelect.addEventListener('change', handleAudioDeviceChange); audioOutputSelect.addEventListener('change', handleAudioOutputDeviceChange); testDevicesBtn.addEventListener('click', testDevices); refreshDevicesBtn.addEventListener('click', refreshDevices); // Chat event listeners emojiBtn.addEventListener('click', handleEmojiClick); saveChatBtn.addEventListener('click', handleSaveChatClick); clearChatBtn.addEventListener('click', handleClearChatClick); // Direct call listeners directCallBtn.addEventListener('click', handleDirectCallClick); directCallInput.addEventListener('keyup', (e) => handleKeyUp(e, handleDirectCallClick)); cancelCallBtn.addEventListener('click', handleCancelCall); acceptCallBtn.addEventListener('click', handleAcceptIncomingCall); declineCallBtn.addEventListener('click', handleDeclineIncomingCall); // Language change event listener - reapply config after translation window.addEventListener('languageChanged', () => { handleConfig(); renderUserList(); }); // Sidebar toggle if (sidebarBtn && userSidebar) { sidebarBtn.addEventListener('click', (e) => { e.stopPropagation(); userSidebar.classList.toggle('active'); // Clear message badge when opening sidebar with chat tab active if (userSidebar.classList.contains('active') && currentTab === 'chat') { unreadMessages = 0; updateChatNotification(); } }); // Utility to handle click outside for any element 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(); }); } // Hide sidebar on mobile when clicking outside handleClickOutside( userSidebar, sidebarBtn, () => { if (userSidebar.classList.contains('active')) { userSidebar.classList.remove('active'); } }, 768 ); // Hide emoji picker when clicking outside handleClickOutside(emojiPicker, emojiBtn, () => { if (emojiPicker && emojiPicker.classList.contains('show')) { emojiPicker.classList.remove('show'); } }); } } // Initialize hidden file input and handlers for file sharing function initializeFileSharing() { fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileInput.addEventListener('change', async () => { const file = fileInput.files[0]; if (!file) return; if (!dataChannel || dataChannel.readyState !== 'open') { toast(t('file.dataChannelNotReady'), 'warning', 'top', 3000); fileInput.value = ''; return; } if (!connectedUser || pendingFileRecipient !== connectedUser) { toast(t('file.onlyActiveParticipant'), 'warning', 'top', 3000); fileInput.value = ''; return; } try { await sendFileOverDataChannel(file); } catch (error) { handleError(t('file.failedToSend'), error.message || error); } finally { fileInput.value = ''; pendingFileRecipient = null; } }); // Create global file transfer status container (top-center of the screen) if (!fileTransferStatusEl) { fileTransferStatusEl = document.createElement('div'); fileTransferStatusEl.id = 'fileTransferStatus'; fileTransferStatusEl.className = 'file-transfer-status'; document.body.appendChild(fileTransferStatusEl); } } // Hide sidebar after user selection (on mobile) function handleUserClickToCall(user) { if (!user) { handleError(t('errors.noUserSelected')); return; } if (user === userName) { handleError(t('errors.cannotCallSelf')); return; } selectedUser = user; pendingUser = user; renderUserList(); sendMsg({ type: 'offerAccept', from: userName, to: user, }); showCallingOverlay(user); if (userSidebar.classList.contains('active')) { userSidebar.classList.remove('active'); } } // Handle element display function elemDisplay(element, display, mode = 'block') { if (element) element.style.display = display ? mode : 'none'; } // Generic keyUp handler function handleKeyUp(e, callback) { if (e.key === 'Enter') { e.preventDefault(); callback(); } } // Handle sign-in button click function handleSignInClick() { userName = usernameIn.value.trim(); if (userName.length > 0) { sendMsg({ type: 'signIn', name: userName, }); localStorage.callMeUsername = userName; } } // Handle direct call button click (sign in + auto-call) function handleDirectCallClick() { const myName = usernameIn.value.trim(); const callTarget = directCallInput ? directCallInput.value.trim() : ''; if (!myName) { handleError(t('signIn.enterUsername')); usernameIn.focus(); return; } if (!callTarget) { handleError(t('errors.noUserSelected')); if (directCallInput) directCallInput.focus(); return; } // Store call target for after sign-in userName = myName; localStorage.callMeUsername = myName; sendMsg({ type: 'signIn', name: myName, }); // After sign-in completes, auto-call the target user const waitForSignIn = setInterval(() => { if (userSignedIn) { clearInterval(waitForSignIn); setTimeout(() => handleUserClickToCall(callTarget), 1500); } }, 200); // Safety timeout to avoid infinite loop setTimeout(() => clearInterval(waitForSignIn), 15000); } // Show calling overlay function showCallingOverlay(targetUser) { if (!callingOverlay) return; callingElapsed = 0; if (callingUsername) callingUsername.textContent = targetUser; if (callingTimer) callingTimer.textContent = '0s'; callingOverlay.style.display = 'flex'; if (callingTimerId) clearInterval(callingTimerId); callingTimerId = setInterval(() => { callingElapsed++; if (callingTimer) callingTimer.textContent = callingElapsed + 's'; if (callingElapsed >= ringTimeout) { handleCancelCall(); } }, 1000); } // Hide calling overlay function hideCallingOverlay() { if (!callingOverlay) return; callingOverlay.style.display = 'none'; if (callingTimerId) { clearInterval(callingTimerId); callingTimerId = null; } callingElapsed = 0; } // Handle cancel call button click function handleCancelCall() { hideCallingOverlay(); if (pendingUser) { // Notify the remote user that the call was cancelled sendMsg({ type: 'leave', name: pendingUser }); pendingUser = null; } toast(t('messages.callEnded'), 'info', 'top', 2000); } // Share Room click handler async function handleShareRoomClick() { const roomUrl = window.location.origin; if (navigator.share) { try { await navigator.share({ title: document.title, text: t('messages.shareRoomText'), url: roomUrl, }); } catch (error) { await copyToClipboard(roomUrl, false); } } else { await copyToClipboard(roomUrl); } } // Generate random username function handleRandomUsername() { const adjectives = ['Cool', 'Fast', 'Bright', 'Swift', 'Bold', 'Calm', 'Lucky', 'Brave', 'Clever', 'Happy']; const nouns = ['Fox', 'Eagle', 'Tiger', 'Wolf', 'Hawk', 'Lion', 'Bear', 'Panda', 'Falcon', 'Shark']; const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const num = Math.floor(Math.random() * 1000); usernameIn.value = `${adj}${noun}${num}`; usernameIn.focus(); } // Copy username to clipboard async function handleCopyUsername() { const username = usernameIn.value.trim(); if (!username) { toast(t('signIn.enterUsername'), 'warning', 'top', 2000); usernameIn.focus(); return; } try { await navigator.clipboard.writeText(username); toast(t('messages.usernameCopied', { username }), 'success', 'top', 3000); } catch (error) { handleError(t('errors.copyToClipboardFailed'), error.message); } } // Copy text to clipboard async function copyToClipboard(text, showError = true) { try { await navigator.clipboard.writeText(text); toast(t('messages.roomCopied', { text }), 'success', 'top', 3000); } catch (error) { showError ? handleError(t('errors.copyToClipboardFailed'), error.message) : console.error('Failed to copy to clipboard', error); } } // Toggle local video visibility function toggleLocalVideo() { localVideoContainer.classList.toggle('hide'); } // Handle call button click function handleCallBtnClick() { handleUserClickToCall(selectedUser); } // Find the video sender reliably — works even when sender.track is null (camera off) function findVideoSender() { if (!thisConnection) return null; // First try direct match const direct = thisConnection.getSenders().find((s) => s.track && s.track.kind === 'video'); if (direct) return direct; // Fallback: use transceivers (receiver.track.kind is always set even when sender.track is null) const transceiver = thisConnection .getTransceivers() .find((t) => t.receiver && t.receiver.track && t.receiver.track.kind === 'video'); return transceiver ? transceiver.sender : null; } // Toggle video stream — actually stop/restart the camera to release hardware (turn off LED) async function handleVideoClick() { // During screen sharing, toggle the screen share track visibility instead of camera if (isScreenSharing) { const screenTrack = stream.getVideoTracks()[0]; if (!screenTrack) return; screenTrack.enabled = !screenTrack.enabled; videoBtn.classList.toggle('btn-danger'); showCameraOffOverlay('local', !screenTrack.enabled); // Update peer connection if (thisConnection) { const videoSender = findVideoSender(); if (videoSender) { try { await videoSender.replaceTrack(screenTrack.enabled ? screenTrack : null); } catch (error) { console.warn('Failed to toggle screen share track on peer:', error); } } } sendMediaStatusToServer(); sendMsg({ type: 'remoteVideo', enabled: screenTrack.enabled }); return; } // Normal camera toggle (not screen sharing) const videoTrack = stream.getVideoTracks()[0]; const isCameraOn = videoTrack && videoTrack.readyState === 'live' && videoTrack.enabled; if (isCameraOn) { // Stop the camera track to release the hardware and turn off the LED videoTrack.stop(); videoTrack.enabled = false; videoBtn.classList.add('btn-danger'); showCameraOffOverlay('local', true); // Replace the track on the peer connection with null to stop sending video if (thisConnection) { const videoSender = findVideoSender(); if (videoSender) { try { await videoSender.replaceTrack(null); } catch (error) { console.warn('Failed to replace video track with null:', error); } } } // Send media status to server sendMediaStatusToServer(); sendMsg({ type: 'remoteVideo', enabled: false }); } else { // Re-acquire camera to restart the hardware try { const constraints = { video: selectedDevices.videoInput ? { deviceId: { exact: selectedDevices.videoInput } } : true, audio: false, }; const newStream = await navigator.mediaDevices.getUserMedia(constraints); const newVideoTrack = newStream.getVideoTracks()[0]; if (!newVideoTrack) { throw new Error('No video track found in new stream'); } // Replace the old stopped track in the stream const oldVideoTrack = stream.getVideoTracks()[0]; if (oldVideoTrack) { stream.removeTrack(oldVideoTrack); } stream.addTrack(newVideoTrack); localVideo.srcObject = stream; handleVideoMirror(localVideo, stream); // Update peer connection with the new track if (thisConnection) { const videoSender = findVideoSender(); if (videoSender) { await videoSender.replaceTrack(newVideoTrack); } } videoBtn.classList.remove('btn-danger'); showCameraOffOverlay('local', false); // Send media status to server sendMediaStatusToServer(); sendMsg({ type: 'remoteVideo', enabled: true }); } catch (error) { console.error('Failed to restart camera:', error); handleError(t('errors.cameraRestartFailed') || 'Failed to restart camera'); } } } // Toggle audio stream function handleAudioClick() { const audioTrack = stream.getAudioTracks()[0]; audioTrack.enabled = !audioTrack.enabled; audioBtn.classList.toggle('btn-danger'); // Send media status to server sendMediaStatusToServer(); sendMsg({ type: 'remoteAudio', enabled: audioTrack.enabled, }); } // Handle screen sharing toggle async function handleScreenShareClick() { try { if (isScreenSharing) { // Stop screen sharing and return to camera await stopScreenSharing(); } else { // Start screen sharing await startScreenSharing(); } } catch (error) { handleError(t('errors.screenShareFailed'), error.message); console.error('Screen sharing error:', error); } } // Update remote video styling based on screen sharing state function updateRemoteVideoStyling(isScreenSharing) { console.log('updateRemoteVideoStyling called with:', isScreenSharing); console.log('remoteVideo element:', remoteVideo); console.log('remoteVideo has srcObject:', !!remoteVideo?.srcObject); if (isScreenSharing) { remoteVideo.classList.add('screen-share'); remoteVideo.classList.remove('camera-feed'); console.log('Applied screen-share styling. Classes:', remoteVideo.className); } else { remoteVideo.classList.add('camera-feed'); remoteVideo.classList.remove('screen-share'); console.log('Applied camera-feed styling. Classes:', remoteVideo.className); } } // Start screen sharing async function startScreenSharing() { try { // Store original camera stream originalStream = stream; // Determine if camera was off (track stopped) before screen share wasCameraOffBeforeScreenShare = videoBtn && videoBtn.classList.contains('btn-danger'); // Get screen share stream const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always', displaySurface: 'monitor', }, audio: true, }); // Keep original audio track if available const audioTrack = originalStream.getAudioTracks()[0]; if (audioTrack) { screenStream.addTrack(audioTrack); } // Screen share video respects camera state: if camera was off, start with screen share hidden const screenVideoTrack = screenStream.getVideoTracks()[0]; if (screenVideoTrack) { screenVideoTrack.enabled = !wasCameraOffBeforeScreenShare; } // Update stream and local video stream = screenStream; localVideo.srcObject = stream; localVideo.classList.remove('mirror'); // Remove mirror for screen share localVideo.classList.add('screen-share'); // Apply screen share styling localVideo.classList.remove('camera-feed'); // Show/hide camera off overlay based on camera state showCameraOffOverlay('local', wasCameraOffBeforeScreenShare); console.log('Local video classes after screen share start:', localVideo.className); console.log('Camera was off before screen share:', wasCameraOffBeforeScreenShare); // Update peer connection if it exists (use helper to find sender even when track is null) if (thisConnection) { const videoSender = findVideoSender(); if (videoSender) { await videoSender.replaceTrack(wasCameraOffBeforeScreenShare ? null : screenStream.getVideoTracks()[0]); } } // Update UI isScreenSharing = true; screenShareBtn.classList.add('btn-warning'); screenShareBtn.classList.remove('btn-success'); screenShareBtn.title = t('controls.stopScreenShare'); screenShareBtn.innerHTML = ''; // Keep video button state matching camera state if (wasCameraOffBeforeScreenShare) { videoBtn.classList.add('btn-danger'); } else { videoBtn.classList.remove('btn-danger'); } // Send screen sharing status to server sendMediaStatusToServer(); // Listen for screen share end (user clicks browser's stop sharing) screenStream.getVideoTracks()[0].onended = () => { stopScreenSharing(); }; toast(t('messages.screenShareStarted'), 'success', 'top', 2000); console.log('Screen sharing started'); } catch (error) { if (error.name === 'NotAllowedError') { handleError(t('errors.screenSharePermissionDenied')); } else if (error.name === 'NotSupportedError') { handleError(t('errors.screenShareNotSupported')); } else { handleError(t('errors.screenShareStartFailed'), error.message); } throw error; } } // Stop screen sharing async function stopScreenSharing() { try { if (!originalStream) { handleError(t('errors.noOriginalStreamAvailable')); return; } // Use stored flag for camera state (video button now reflects screen share, not camera) const wasCameraOff = wasCameraOffBeforeScreenShare; // Stop screen share tracks if (stream) { stream.getTracks().forEach((track) => { if (track.kind === 'video' || (track.kind === 'audio' && track.label.includes('monitor'))) { track.stop(); } }); } // Restore original camera stream stream = originalStream; const cameraVideoTrack = stream.getVideoTracks()[0]; if (wasCameraOff) { // Camera was off before screen share — keep it off (track is already stopped) // Don't try to set .enabled on a stopped track console.log('Camera was off before screen share, keeping it off'); } else { // Camera was on — ensure track is enabled if (cameraVideoTrack && cameraVideoTrack.readyState === 'live') { cameraVideoTrack.enabled = true; } } localVideo.srcObject = stream; handleVideoMirror(localVideo, stream); // Restore mirror for camera localVideo.classList.remove('screen-share'); // Remove screen share styling localVideo.classList.add('camera-feed'); // Apply camera feed styling // Show/hide camera off overlay based on camera state showCameraOffOverlay('local', wasCameraOff); console.log('Local video classes after screen share stop:', localVideo.className); console.log('Camera was off before screen share:', wasCameraOff); // Update peer connection if it exists if (thisConnection) { const videoSender = findVideoSender(); if (videoSender) { if (wasCameraOff) { // Camera was off — send null to peer await videoSender.replaceTrack(null); } else if (cameraVideoTrack && cameraVideoTrack.readyState === 'live') { // Camera was on — send camera track to peer await videoSender.replaceTrack(cameraVideoTrack); } } } // Update UI isScreenSharing = false; screenShareBtn.classList.remove('btn-warning'); screenShareBtn.classList.add('btn-success'); screenShareBtn.title = t('controls.startScreenShare'); screenShareBtn.innerHTML = ''; // Send screen sharing status to server sendMediaStatusToServer(); // Reset original stream reference originalStream = null; wasCameraOffBeforeScreenShare = false; // Ensure UI button state matches actual video state checkVideoAudioStatus(); toast(t('messages.screenShareStopped'), 'success', 'top', 2000); console.log('Screen sharing stopped'); } catch (error) { handleError(t('errors.screenShareStopFailed'), error.message); console.error('Stop screen sharing error:', error); } } // Detect if back or front camera 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?.() || {}; const facingMode = settings.facingMode || capabilities.facingMode?.[0] || 'user'; return facingMode === 'environment' ? 'environment' : 'user'; } // Function to swap between user-facing and environment cameras async function swapCamera() { camera = camera === 'user' ? 'environment' : 'user'; const videoConstraints = camera === 'user' ? true : { facingMode: { exact: camera } }; try { const newStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints }); // Stop the previous video track const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { videoTrack.stop(); } // Update selectedDevices.videoInput so video toggle re-acquires the correct camera const newVideoTrack = newStream.getVideoTracks()[0]; if (newVideoTrack) { const newSettings = newVideoTrack.getSettings(); if (newSettings.deviceId) { selectedDevices.videoInput = newSettings.deviceId; } } // Refresh video streams refreshLocalVideoStream(newStream); await refreshPeerVideoStreams(newStream); // Check video/audio status checkVideoAudioStatus(); } catch (error) { handleError(t('errors.swapCameraFailed'), error); } } // Update the local video stream function refreshLocalVideoStream(newStream) { const videoTrack = newStream.getVideoTracks()[0]; if (!videoTrack) { handleError(t('errors.noVideoTrackInStream')); return; } // Preserve previous video enabled state (if the user had video disabled) let wasVideoEnabled = true; try { if (stream && stream.getVideoTracks().length > 0) { wasVideoEnabled = stream.getVideoTracks()[0].enabled; } else if (videoBtn && videoBtn.classList.contains('btn-danger')) { wasVideoEnabled = false; } } catch (e) { console.warn('Could not determine previous video enabled state:', e); } // Apply preserved enabled state to new track videoTrack.enabled = wasVideoEnabled; const audioTrack = stream.getAudioTracks()[0]; if (!audioTrack) { handleError(t('errors.noAudioTrackInStream')); return; } const updatedStream = new MediaStream([videoTrack, audioTrack]); // Create a new stream only with valid tracks stream = updatedStream; localVideo.srcObject = stream; // Ensure camera feed styling is maintained during swap localVideo.classList.add('camera-feed'); localVideo.classList.remove('screen-share'); // Reflect video state in UI and overlays if (wasVideoEnabled) { videoBtn && videoBtn.classList.remove('btn-danger'); showCameraOffOverlay('local', false); } else { videoBtn && videoBtn.classList.add('btn-danger'); showCameraOffOverlay('local', true); } handleVideoMirror(localVideo, stream); } // handle video mirror function handleVideoMirror(video, stream) { const cameraFacingMode = detectCameraFacingMode(stream); cameraFacingMode === 'environment' ? video.classList.remove('mirror') // Back camera → No mirror : video.classList.add('mirror'); // Disable mirror for rear camera } // Update the video stream for all peers async function refreshPeerVideoStreams(newStream) { if (!thisConnection) return; const videoTrack = newStream.getVideoTracks()[0]; if (!videoTrack) { handleError(t('errors.noVideoTrackForPeer')); return; } const videoSender = findVideoSender(); if (videoSender) { try { await videoSender.replaceTrack(videoTrack); } catch (error) { handleError(t('errors.replacingTrack', { message: error.message })); } } } // Check video audio status function checkVideoAudioStatus() { if (videoBtn.classList.contains('btn-danger')) { const videoTrack = stream.getVideoTracks()[0]; if (videoTrack && videoTrack.readyState === 'live') { videoTrack.enabled = false; } // Show camera off overlay for local video showCameraOffOverlay('local', true); } else { // Hide camera off overlay for local video showCameraOffOverlay('local', false); } if (audioBtn.classList.contains('btn-danger')) { const audioTrack = stream.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = false; } } } // Handle leave button click function handleLeaveClick() { sendMsg({ type: 'leave', name: socket.recipient }); handleLeave(); } // Handle leaving the call function handleExitSidebarClick() { if (userSidebar.classList.contains('active')) { userSidebar.classList.remove('active'); } } // Toggle video full screen mode function toggleFullScreen(e) { if (!e.target.srcObject) return; document.fullscreenElement ? document.exitFullscreen() : e.target.requestFullscreen?.(); } // Handle ping message from the server function handlePing(data) { const { iceServers } = data; if (iceServers) { config.iceServers = iceServers; } if (data.pushEnabled !== undefined) { pushEnabled = data.pushEnabled; } if (data.ringTimeout !== undefined) { ringTimeout = data.ringTimeout; } sendMsg({ type: 'pong', message: { client: 'Hello Server!', userInfo, }, }); } // Handle user not found from the server function handleNotFound(data) { const { username } = data; hideCallingOverlay(); handleError(t('errors.userNotFound', { username })); // Remove from user list if present allConnectedUsers = allConnectedUsers.filter((u) => u !== username); filterUserList(userSearchInput.value || ''); updateParticipantCount(); } // Handle push notification sent confirmation function handlePushSent(data) { const { username } = data; hideCallingOverlay(); toast( t('push.notificationSent', { username }) || `Notification sent to ${username}. Waiting for them to come online...`, 'info', 'top', 6000 ); sound('notify'); } // Handle call declined by remote user function handleOfferDecline(data) { const { from } = data; hideCallingOverlay(); pendingUser = null; toast(t('room.callDeclined', { username: from }), 'warning', 'top', 4000); sound('notify'); } // Handle remote user busy in another call function handleOfferBusy(data) { const { from } = data; hideCallingOverlay(); pendingUser = null; toast(t('room.callBusy', { username: from }), 'warning', 'top', 4000); sound('notify'); } // Handle sign-in response from the server async function handleSignIn(data) { const { success, message } = data; if (!success) { handleError(message); if (!message.startsWith('Invalid username')) { setTimeout(handleLeaveClick, 3000); } } else { userSignedIn = true; if (userInfo.device.isDesktop) userSidebar.classList.toggle('active'); if (userInfo.device.isMobile) userSidebar.style.width = '100%'; elemDisplay(githubDiv, false); elemDisplay(attribution, false); elemDisplay(signInPage, false); elemDisplay(roomPage, true); let myStream = null; let lastError = null; // Try to get media with progressive fallback try { // Try video + audio first myStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); console.log('Successfully obtained video + audio stream'); } catch (error) { lastError = error; console.warn('Video + audio failed, trying video only:', error.name); try { // Try video only myStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); console.log('Successfully obtained video-only stream'); } catch (error2) { lastError = error2; console.warn('Video only failed, trying audio only:', error2.name); try { // Try audio only myStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); console.log('Successfully obtained audio-only stream'); } catch (error3) { lastError = error3; console.error('All getUserMedia attempts failed:', error3.name); } } } if (myStream) { stream = myStream; localVideo.srcObject = stream; localVideo.playsInline = true; localVideo.autoplay = true; localVideo.muted = true; localVideo.volume = 0; localVideo.controls = false; localVideo.classList.add('camera-feed'); // Set default styling for camera localUsername.innerText = userName; updateUsernameDisplay(); initializeConnection(); await handleEnumerateDevices(); // Initialize device settings after getting media await initializeDeviceSettings(); handleVideoMirror(localVideo, myStream); // Apply initial media toggles from sign-in card if (!joinVideoToggle.checked) { const vt = stream.getVideoTracks()[0]; if (vt) { vt.stop(); vt.enabled = false; } videoBtn.classList.add('btn-danger'); showCameraOffOverlay('local', true); } if (!joinAudioToggle.checked) { const at = stream.getAudioTracks()[0]; if (at) at.enabled = false; audioBtn.classList.add('btn-danger'); } // Update UI based on available devices updateUIForAvailableDevices(); // Send initial media status to server sendMediaStatusToServer(); // Show push notification toggle if server has push enabled if (pushEnabled) { initPushNotificationToggle(); } } else { // All attempts failed, show error only now handleMediaStreamError(lastError); } } } // Handle common media stream error function handleMediaStreamError(error) { console.error('GetUserMedia error', error.message); let errorMessage = error; let shouldHandleGetUserMediaError = true; switch (error.name) { case 'NotFoundError': case 'DevicesNotFoundError': errorMessage = t('errors.getUserMediaNotFound'); break; case 'NotReadableError': case 'TrackStartError': errorMessage = t('errors.getUserMediaInUse'); break; case 'OverconstrainedError': case 'ConstraintNotSatisfiedError': errorMessage = t('errors.getUserMediaConstraints'); break; case 'NotAllowedError': case 'PermissionDeniedError': errorMessage = t('errors.getUserMediaPermissionDenied'); break; case 'AbortError': errorMessage = t('errors.getUserMediaAborted'); break; case 'SecurityError': errorMessage = t('errors.getUserMediaSecurity'); break; default: errorMessage = t('errors.getUserMediaDefault'); shouldHandleGetUserMediaError = false; break; } if (shouldHandleGetUserMediaError) { errorMessage += t('errors.getUserMediaHelp'); } Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'top', icon: 'warning', html: errorMessage, denyButtonText: t('common.exit'), showDenyButton: true, showConfirmButton: false, allowOutsideClick: false, allowEscapeKey: false, showClass: { popup: 'animate__animated animate__fadeInDown' }, hideClass: { popup: 'animate__animated animate__fadeOutUp' }, }).then((result) => { if (result.isDenied) { window.location.href = '/'; } }); sound('alert'); } // Initialize WebRTC connection function initializeConnection() { thisConnection = new RTCPeerConnection(config); // Handle incoming data channels (file transfer, etc.) thisConnection.ondatachannel = (event) => { const channel = event.channel; setupDataChannel(channel); }; // Add existing tracks from local stream stream.getTracks().forEach((track) => thisConnection.addTrack(track, stream)); // Ensure we have transceivers for both audio and video, even if we don't have those tracks // This allows us to receive video/audio from remote peer even if we don't have camera/mic const hasVideo = stream.getVideoTracks().length > 0; const hasAudio = stream.getAudioTracks().length > 0; if (!hasVideo) { // Add video transceiver to receive video from remote peer thisConnection.addTransceiver('video', { direction: 'recvonly' }); console.log('Added recvonly video transceiver (no local camera)'); } if (!hasAudio) { // Add audio transceiver to receive audio from remote peer thisConnection.addTransceiver('audio', { direction: 'recvonly' }); console.log('Added recvonly audio transceiver (no local microphone)'); } thisConnection.ontrack = (e) => { if (e.streams && e.streams[0]) { const remoteStream = e.streams[0]; remoteVideo.srcObject = remoteStream; remoteVideo.playsInline = true; remoteVideo.autoplay = true; remoteVideo.controls = false; // Initial styling - will be updated via server status remoteVideo.classList.add('camera-feed'); remoteVideo.classList.remove('screen-share'); startSessionTime(); renderUserList(); // Update UI to show hang-up button console.log('Remote stream set to video element'); // Reapply media status after stream is connected (in case caller had screen sharing on) reapplyRemoteMediaStatus(); } else { handleError(t('errors.noStreamInOnTrack')); } }; thisConnection.onicecandidate = (event) => { if (event.candidate) { sendMsg({ type: 'candidate', candidate: event.candidate, }); } }; // For debugging purposes thisConnection.onconnectionstatechange = (event) => { console.log('Connection state change:', thisConnection.connectionState); }; thisConnection.oniceconnectionstatechange = (event) => { console.log('ICE connection state change:', thisConnection.iceConnectionState); }; } // Create and send an offer async function offerCreate() { // Always initialize a fresh connection for new calls console.log('Creating new offer - initializing fresh connection'); initializeConnection(); // Create data channel for file transfer on the offerer side const channel = thisConnection.createDataChannel('fileTransfer'); setupDataChannel(channel); try { const offer = await thisConnection.createOffer(); await thisConnection.setLocalDescription(offer); sendMsg({ type: 'offer', offer, }); console.log('Offer created and sent successfully'); } catch (error) { handleError(t('errors.offerCreateFailed'), error); } } // Accept incoming offer function offerAccept(data) { // I'm already in call decline the new one! if (remoteVideo.srcObject) { // Send busy response directly to avoid sendMsg overriding name with connectedUser socket.emit('message', { type: 'offerBusy', from: data.from, // Original caller (for server routing) name: userName, // This user (the busy one) }); return; } // Show client-side notification if tab is backgrounded if (document.hidden && Notification.permission === 'granted') { try { const notification = new Notification('Call-me', { body: t('push.incomingCallBody', { caller: data.from }) || `${data.from} is calling you`, icon: '/favicon/favicon-32x32.png', tag: 'call-me-incoming', requireInteraction: true, }); notification.onclick = () => { window.focus(); notification.close(); }; } catch (e) { console.warn('Client notification failed:', e); } } incomingCallData = data; showIncomingCallOverlay(data.from); } // Show incoming call overlay function showIncomingCallOverlay(callerName) { if (!incomingCallOverlay) return; if (incomingCallUsername) incomingCallUsername.textContent = callerName; // Reset timer bar animation with dynamic duration if (incomingCallTimer) { incomingCallTimer.style.setProperty('--ring-duration', ringTimeout + 's'); incomingCallTimer.style.animation = 'none'; incomingCallTimer.offsetHeight; // Force reflow incomingCallTimer.style.animation = ''; } // Start looping ring sound with a 1s gap between plays if (ringingAudio) { ringingAudio.pause(); ringingAudio = null; } let ringingDelayTimer = null; function playRing() { if (!ringingAudio) return; ringingAudio.currentTime = 0; ringingAudio.play().catch(() => {}); } ringingAudio = new Audio('./assets/ring.wav'); ringingAudio.volume = 0.5; ringingAudio.addEventListener('ended', () => { ringingDelayTimer = setTimeout(playRing, 3000); }); // Store the delay timer on the audio object so hideIncomingCallOverlay can clear it ringingAudio._delayTimer = null; Object.defineProperty(ringingAudio, '_delayTimer', { get: () => ringingDelayTimer, set: (v) => { ringingDelayTimer = v; }, }); playRing(); incomingCallOverlay.style.display = 'flex'; // Auto-decline after ringTimeout seconds if (incomingCallTimerId) clearTimeout(incomingCallTimerId); incomingCallTimerId = setTimeout(() => { handleDeclineIncomingCall(); }, ringTimeout * 1000); } // Hide incoming call overlay function hideIncomingCallOverlay() { if (!incomingCallOverlay) return; incomingCallOverlay.style.display = 'none'; if (incomingCallTimerId) { clearTimeout(incomingCallTimerId); incomingCallTimerId = null; } if (ringingAudio) { clearTimeout(ringingAudio._delayTimer); ringingAudio.pause(); ringingAudio = null; } incomingCallData = null; } // Handle accept incoming call function handleAcceptIncomingCall() { if (!incomingCallData) return; const data = incomingCallData; hideIncomingCallOverlay(); // Apply caller's media status before accepting the call if (data.callerMediaStatus) { applyCallerMediaStatus(data.callerMediaStatus); } data.type = 'offerCreate'; socket.recipient = data.from; sendMsg({ ...data }); } // Handle decline incoming call function handleDeclineIncomingCall() { if (!incomingCallData) return; const data = incomingCallData; hideIncomingCallOverlay(); data.type = 'offerDecline'; sendMsg({ ...data }); } // Handle incoming offer async function handleOffer(data) { const { offer, name } = data; console.log('Handling offer from:', name); connectedUser = name; pendingUser = null; updateUsernameDisplay(); // Initialize fresh connection for incoming call initializeConnection(); await thisConnection.setRemoteDescription(new RTCSessionDescription(offer)); try { const answer = await thisConnection.createAnswer(); await thisConnection.setLocalDescription(answer); sendMsg({ type: 'answer', answer, }); console.log('Answer sent successfully to:', name); } catch (error) { handleError(t('errors.answerCreateFailed'), error); } } // Handle incoming answer async function handleAnswer(data) { const { answer } = data; try { await thisConnection.setRemoteDescription(new RTCSessionDescription(answer)); hideCallingOverlay(); // Set connectedUser from pendingUser after call is accepted if (pendingUser) { connectedUser = pendingUser; pendingUser = null; updateUsernameDisplay(); renderUserList(); // Update UI to show hang-up button for caller } } catch (error) { hideCallingOverlay(); handleError(t('errors.remoteDescriptionFailed'), error); } } // Handle incoming ICE candidate async function handleCandidate(data) { const { candidate } = data; try { await thisConnection.addIceCandidate(new RTCIceCandidate(candidate)); } catch (error) { handleError(t('errors.addIceCandidateFailed'), error); } } // Handle connected users function handleUsers(data) { // Show toast for new users const prevUsers = new Set(allConnectedUsers); const currentUsers = data.users.filter((u) => u !== userName); allConnectedUsers = currentUsers; filterUserList(userSearchInput.value || ''); updateParticipantCount(); if (userSignedIn) { currentUsers.forEach((u) => { if (!prevUsers.has(u)) { toast(t('room.userJoined', { username: u }), 'success'); } }); } } // Handle remote video status function handleRemoteVideo(data) { data.enabled ? remoteVideoDisabled.classList.remove('show') : remoteVideoDisabled.classList.add('show'); // Show/hide camera off overlay for remote video showCameraOffOverlay('remote', !data.enabled); } // Handle remote audio status function handleRemoteAudio(data) { data.enabled ? remoteAudioDisabled.classList.remove('show') : remoteAudioDisabled.classList.add('show'); } // Update username displays on video containers function updateUsernameDisplay() { if (localUsername) { localUsername.innerText = userName || 'You'; } // Only show remoteUsername if call is established (not just pending) if (remoteUsername && connectedUser) { remoteUsername.innerText = connectedUser; remoteUsername.classList.remove('hide'); } else if (remoteUsername) { remoteUsername.innerText = ''; remoteUsername.classList.add('hide'); } } // Show/hide camera off overlay with username display function showCameraOffOverlay(type, show) { const container = type === 'local' ? document.getElementById('localVideoContainer') : document.getElementById('remoteVideoContainer'); let overlay = container.querySelector(':scope > .camera-off-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'camera-off-overlay'; container.appendChild(overlay); } if (show) { const username = type === 'local' ? userName : connectedUser; overlay.innerHTML = ` ${t('room.videoOff')} ${username || (type === 'local' ? t('room.localUsername') : t('room.remoteUsername'))} ${type === 'local' ? t('room.videoOff') : t('room.videoDisabled')} `; } overlay.classList.toggle('show', show); // Hide/show username elements when video is off/on const usernameElement = type === 'local' ? localUsername : remoteUsername; if (usernameElement) { if (show) { // Video is off - hide username usernameElement.classList.add('hide'); } else { // Video is on - show username only if conditions are met if (type === 'local') { // Always show local username when video is on usernameElement.classList.remove('hide'); } else { // For remote username, only show if user is connected if (connectedUser) { usernameElement.classList.remove('hide'); } // If no connected user, keep it hidden (handled by updateUsernameDisplay) } } } } // Play audio sound async function sound(name) { const sound = './assets/' + name + '.wav'; const audio = new Audio(sound); try { audio.volume = 0.5; await audio.play(); } catch (err) { return false; } } // Helper function to stop all tracks and clear media stream function stopMediaStream(videoElement) { if (videoElement.srcObject) { videoElement.srcObject.getTracks().forEach((track) => track.stop()); videoElement.srcObject = null; } } // Helper function to disconnect and clean up the connection function disconnectConnection() { if (thisConnection) { thisConnection.close(); thisConnection = null; } if (dataChannel) { try { dataChannel.close(); } catch (e) { console.warn('Error closing data channel', e); } dataChannel = null; } incomingFileMeta = null; incomingFileData = null; incomingFileBuffers = []; incomingFileReceivedBytes = 0; outgoingTransfer = null; incomingTransfer = null; renderFileTransferStatus(); } // Handle leaving the room function handleLeave(disconnect = true) { hideCallingOverlay(); hideIncomingCallOverlay(); if (disconnect) { // Stop screen sharing if active if (isScreenSharing) { stopScreenSharing(); } // Stop local and remote video tracks stopMediaStream(localVideo); stopMediaStream(remoteVideo); // Disconnect from server and reset state disconnectConnection(); connectedUser = null; lastAppliedMediaStatus = null; // Clear stored media status updateUsernameDisplay(); // Redirect to homepage window.location.href = '/'; } else { // Remote user left - clean up properly for new connections console.log('Remote user left - cleaning up connection'); // Stop screen sharing if active if (isScreenSharing) { stopScreenSharing(); } // Stop remote video tracks only stopMediaStream(remoteVideo); // Reset remote video styling remoteVideo.classList.remove('screen-share', 'camera-feed'); // Clean up the peer connection so new connections work properly disconnectConnection(); // Stop session time stopSessionTime(); // Reset remote video & audio status indicators handleRemoteVideo({ enabled: true }); handleRemoteAudio({ enabled: true }); // Reset state connectedUser = null; lastAppliedMediaStatus = null; // Clear stored media status updateUsernameDisplay(); renderUserList(); console.log('Remote user cleanup completed - ready for new connections'); } } // Display toast messages using Toastify function toast(message, icon = 'info', position = 'top', timer = 3000) { const backgroundMap = { success: 'linear-gradient(to right, #10b981, #059669)', warning: 'linear-gradient(to right, #f59e0b, #d97706)', error: 'linear-gradient(to right, #ef4444, #dc2626)', info: 'linear-gradient(to right, #3b82f6, #2563eb)', }; const iconMap = { success: '', warning: '', error: '', info: '', }; const gravity = position === 'bottom' ? 'bottom' : 'top'; const toastIcon = iconMap[icon] || iconMap.info; const background = backgroundMap[icon] || backgroundMap.info; const node = document.createElement('span'); node.innerHTML = `${toastIcon} ${message}`; node.style.display = 'inline-flex'; node.style.alignItems = 'center'; node.style.gap = '8px'; Toastify({ node, duration: timer, gravity, position: 'center', escapeMarkup: false, className: 'toastify-custom', style: { background, borderRadius: 'var(--border-radius, 12px)', fontFamily: 'inherit', fontSize: '0.9rem', padding: '12px 20px', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', maxWidth: '400px', }, close: timer > 3000, stopOnFocus: true, }).showToast(); } // Handle and display errors function handleError(message, error = false, position = 'top', timer = 6000) { if (error) console.error(error); toast(message, 'warning', position, timer); sound('notify'); } // Display Message to user function popupMsg(message, position = 'top', timer = 4000) { Swal.fire({ heightAuto: false, scrollbarPadding: false, position, html: message, timerProgressBar: true, timer, showClass: { popup: 'animate__animated animate__fadeInDown' }, hideClass: { popup: 'animate__animated animate__fadeOutUp' }, }); } // Send current media status to server function sendMediaStatusToServer() { if (!stream) return; const videoTrack = stream.getVideoTracks()[0]; const audioTrack = stream.getAudioTracks()[0]; const mediaStatus = { type: 'mediaStatus', video: videoTrack ? videoTrack.enabled : false, audio: audioTrack ? audioTrack.enabled : false, screenSharing: isScreenSharing, }; socket.emit('message', mediaStatus); console.log('Sent media status to server:', mediaStatus); } // Apply caller's media status to remote video/audio indicators function applyCallerMediaStatus(callerMediaStatus) { if (!callerMediaStatus) return; console.log('Applying caller media status:', callerMediaStatus); // Store for potential reapplication after stream connection lastAppliedMediaStatus = callerMediaStatus; // Apply video status if (!callerMediaStatus.video) { remoteVideoDisabled.classList.add('show'); showCameraOffOverlay('remote', true); } else { remoteVideoDisabled.classList.remove('show'); showCameraOffOverlay('remote', false); } // Apply audio status if (!callerMediaStatus.audio) { remoteAudioDisabled.classList.add('show'); } else { remoteAudioDisabled.classList.remove('show'); } // Apply screen sharing status if (callerMediaStatus.screenSharing) { updateRemoteVideoStyling(true); } else { updateRemoteVideoStyling(false); } } // Reapply media status after stream connection function reapplyRemoteMediaStatus() { if (lastAppliedMediaStatus) { console.log('Reapplying media status after stream connection:', lastAppliedMediaStatus); // Only reapply screen sharing status since video/audio indicators should be preserved if (lastAppliedMediaStatus.screenSharing) { updateRemoteVideoStyling(true); } else { updateRemoteVideoStyling(false); } } } // Handle remote screen sharing status updates function handleRemoteScreenShare(data) { const { from, screenSharing } = data; // Only apply if this message is from the connected user if (from === connectedUser) { console.log('Remote screen sharing status changed:', screenSharing); // Update stored media status if (lastAppliedMediaStatus) { lastAppliedMediaStatus.screenSharing = screenSharing; } updateRemoteVideoStyling(screenSharing); } } // Initialize push notification toggle in settings async function initPushNotificationToggle() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.log('Push notifications not supported in this browser'); return; } // Show the toggle group elemDisplay(pushNotificationGroup, true, 'flex'); // Check current state const registration = await navigator.serviceWorker.getRegistration('/sw.js'); if (registration) { const existing = await registration.pushManager.getSubscription(); pushNotificationToggle.checked = !!existing; if (existing) { pushSubscription = existing; // Re-send subscription to server (in case server restarted) sendMsg({ type: 'pushSubscription', subscription: existing.toJSON() }); } } // Restore from localStorage if (localStorage.callMePushEnabled === 'true' && !pushNotificationToggle.checked) { pushNotificationToggle.checked = true; await registerPushNotifications(); } // Show/hide test button based on current state updatePushTestBtn(); // Handle toggle change pushNotificationToggle.addEventListener('change', handlePushToggle); // Handle test button click pushTestBtn.addEventListener('click', handlePushTest); } // Handle push notification toggle async function handlePushToggle() { if (pushNotificationToggle.checked) { await registerPushNotifications(); if (!pushSubscription) { // Permission denied or failed — revert toggle pushNotificationToggle.checked = false; localStorage.callMePushEnabled = 'false'; toast(t('push.permissionDenied') || 'Notification permission denied', 'warning', 'top', 3000); updatePushTestBtn(); return; } localStorage.callMePushEnabled = 'true'; toast(t('push.enabled') || 'Push notifications enabled', 'success', 'top', 3000); } else { await unregisterPushNotifications(); localStorage.callMePushEnabled = 'false'; toast(t('push.disabled') || 'Push notifications disabled', 'info', 'top', 3000); } updatePushTestBtn(); } // Show/hide test push button based on toggle state function updatePushTestBtn() { elemDisplay(pushTestBtn, pushNotificationToggle.checked); } // Handle test push notification button async function handlePushTest() { try { const registration = await navigator.serviceWorker.ready; await registration.showNotification('Call-me', { body: 'Push notifications are working!', icon: '/favicon/favicon-32x32.png', badge: '/favicon/favicon-16x16.png', data: { url: '/', type: 'testPush', caller: '' }, }); } catch (err) { console.warn('Direct notification failed, falling back to push:', err); sendMsg({ type: 'testPush' }); } toast(t('push.testSent') || 'Test notification sent', 'info', 'top', 3000); } // Register service worker and subscribe to push notifications async function registerPushNotifications() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.log('Push notifications not supported in this browser'); return; } try { // Fetch VAPID public key from server const { data: vapidData } = await axios.get('/api/v1/vapidPublicKey'); if (!vapidData.enabled || !vapidData.vapidPublicKey) { console.log('Push notifications not enabled on server'); return; } // Register service worker and wait for it to become active await navigator.serviceWorker.register('/sw.js'); const registration = await navigator.serviceWorker.ready; console.log('Service worker registered and active'); // Request notification permission const permission = await Notification.requestPermission(); if (permission !== 'granted') { console.log('Notification permission denied'); pushSubscription = null; return; } // Check for existing subscription first pushSubscription = await registration.pushManager.getSubscription(); if (!pushSubscription) { // Subscribe to push const applicationServerKey = urlBase64ToUint8Array(vapidData.vapidPublicKey); pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey, }); console.log('Push notification: new subscription created'); } else { console.log('Push notification: using existing subscription'); } // Send subscription to server via Socket.IO sendMsg({ type: 'pushSubscription', subscription: pushSubscription.toJSON(), }); console.log('Push notification subscription successful', pushSubscription.endpoint); } catch (err) { console.warn('Push notification registration failed:', err); pushSubscription = null; } } // Unsubscribe from push notifications async function unregisterPushNotifications() { try { if (pushSubscription) { const endpoint = pushSubscription.endpoint; await pushSubscription.unsubscribe(); console.log('Push subscription removed from browser'); // Tell server to remove the subscription sendMsg({ type: 'pushUnsubscribe', endpoint: endpoint, }); } pushSubscription = null; } catch (err) { console.warn('Push unsubscribe failed:', err); } } // Convert URL-safe base64 string to Uint8Array (for VAPID key) function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; i++) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } // Send messages to the server function sendMsg(message) { // Use connectedUser if call is established, otherwise use pendingUser for signaling if (connectedUser) { message.name = connectedUser; } else if (pendingUser) { message.name = pendingUser; } socket.emit('message', message); } // Set up a data channel for file transfer function setupDataChannel(channel) { dataChannel = channel; dataChannel.onopen = () => { console.log('Data channel open for file transfer'); }; dataChannel.onclose = () => { console.log('Data channel closed'); }; dataChannel.onerror = (event) => { // Some browsers emit an error event during/after close (e.g. "User-Initiated Abort, reason=Close called"). // That's expected during hang-up/teardown and shouldn't be shown to the user. const rtcError = event?.error; const message = (rtcError && (rtcError.message || rtcError.reason)) || ''; const isClosingOrClosed = dataChannel?.readyState && dataChannel.readyState !== 'open'; const isBenignCloseError = rtcError?.name === 'OperationError' && /close called|user-initiated abort|abort/i.test(String(message)); if (isClosingOrClosed || isBenignCloseError) { console.debug('Ignoring data channel close-related error:', event); return; } console.error('Data channel error:', event); toast(t('file.dataChannelError'), 'warning', 'top', 3000); }; dataChannel.onmessage = async (event) => { // Meta information is sent as JSON string, file as binary if (typeof event.data === 'string') { try { const msg = JSON.parse(event.data); if (msg && msg.type === 'file-meta') { incomingFileMeta = msg; incomingFileData = null; incomingFileBuffers = []; incomingFileReceivedBytes = 0; incomingTransfer = { id: msg.id, name: msg.name, size: msg.size, receivedBytes: 0, cancelled: false, }; console.log('Received file meta:', msg); renderFileTransferStatus(); return; } if (msg && msg.type === 'file-cancel') { console.log('Received file cancel:', msg); if (incomingTransfer && (!msg.id || msg.id === incomingTransfer.id)) { incomingTransfer.cancelled = true; incomingTransfer = null; incomingFileMeta = null; incomingFileData = null; incomingFileBuffers = []; incomingFileReceivedBytes = 0; toast(t('file.cancelledByRemote'), 'info', 'top', 3000); renderFileTransferStatus(); } if (outgoingTransfer && (!msg.id || msg.id === outgoingTransfer.id)) { outgoingTransfer.cancelled = true; outgoingTransfer = null; toast(t('file.cancelledByRemote'), 'info', 'top', 3000); renderFileTransferStatus(); } return; } } catch (e) { console.warn('Non-JSON data received on data channel:', event.data); } } else { if (!incomingFileMeta) { console.warn('Received binary data without file meta'); return; } let arrayBuffer; if (event.data instanceof ArrayBuffer) { arrayBuffer = event.data; } else if (event.data instanceof Blob) { arrayBuffer = await event.data.arrayBuffer(); } else { console.warn('Unknown binary data type on data channel'); return; } // Accumulate chunks until full file is received incomingFileBuffers.push(arrayBuffer); incomingFileReceivedBytes += arrayBuffer.byteLength; if (incomingTransfer) { incomingTransfer.receivedBytes = incomingFileReceivedBytes; renderFileTransferStatus(); } if (incomingFileReceivedBytes >= incomingFileMeta.size) { const blob = new Blob(incomingFileBuffers, { type: incomingFileMeta.mime || 'application/octet-stream', }); const url = URL.createObjectURL(blob); addFileMessageToChat({ from: connectedUser || 'Remote', name: incomingFileMeta.name, size: incomingFileMeta.size, url, isSelf: false, }); incomingFileMeta = null; incomingFileData = null; incomingFileBuffers = []; incomingFileReceivedBytes = 0; incomingTransfer = null; renderFileTransferStatus(); } } }; } // Send a file over the data channel to the connected peer async function sendFileOverDataChannel(file) { if (!dataChannel || dataChannel.readyState !== 'open') { throw new Error('Data channel is not open'); } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB if (file.size > MAX_FILE_SIZE) { handleError(t('file.tooLarge', { maxSizeMb: 10 })); return; } const transferId = Date.now() + '-' + Math.random().toString(16).slice(2); const meta = { type: 'file-meta', id: transferId, name: file.name, size: file.size, mime: file.type || 'application/octet-stream', }; try { outgoingTransfer = { id: transferId, name: file.name, size: file.size, sentBytes: 0, cancelled: false, }; renderFileTransferStatus(); dataChannel.send(JSON.stringify(meta)); const arrayBuffer = await file.arrayBuffer(); // Send the file in small chunks to avoid data channel send errors const CHUNK_SIZE = 16 * 1024; // 16 KB for (let offset = 0; offset < arrayBuffer.byteLength; offset += CHUNK_SIZE) { if (!dataChannel || dataChannel.readyState !== 'open') { throw new Error('Data channel closed during file transfer'); } if (!outgoingTransfer || outgoingTransfer.cancelled) { outgoingTransfer = null; renderFileTransferStatus(); return; } const chunk = arrayBuffer.slice(offset, offset + CHUNK_SIZE); dataChannel.send(chunk); if (outgoingTransfer) { outgoingTransfer.sentBytes = offset + chunk.byteLength; renderFileTransferStatus(); } await waitForDataChannelDrain(); } const blobUrl = URL.createObjectURL(new Blob([arrayBuffer], { type: meta.mime })); addFileMessageToChat({ from: userName || t('chat.me'), name: file.name, size: file.size, url: blobUrl, isSelf: true, }); toast(t('file.sent', { filename: file.name }), 'success', 'top', 3000); outgoingTransfer = null; renderFileTransferStatus(); } catch (error) { console.error('Error sending file over data channel:', error); handleError(t('file.sendError'), error.message || error); outgoingTransfer = null; renderFileTransferStatus(); } } // Wait for data channel buffer to drain below a threshold async function waitForDataChannelDrain() { if (!dataChannel || dataChannel.readyState !== 'open') return; const MAX_BUFFERED_AMOUNT = 64 * 1024; // 64 KB if (dataChannel.bufferedAmount < MAX_BUFFERED_AMOUNT) return; await new Promise((resolve) => { const check = () => { if (!dataChannel || dataChannel.readyState !== 'open' || dataChannel.bufferedAmount < MAX_BUFFERED_AMOUNT) { resolve(); } else { setTimeout(check, 50); } }; check(); }); } // Select user by value in the user list function renderUserList() { // Dispose existing Bootstrap tooltips before clearing the list userList.querySelectorAll('[data-toggle="tooltip"]').forEach((el) => { const tip = bootstrap.Tooltip.getInstance(el); if (tip) tip.dispose(); }); userList.innerHTML = ''; // Show empty state if no users (only after sign-in) if (filteredUsers.length === 0 && userSignedIn) { const emptyLi = document.createElement('li'); emptyLi.className = 'user-list-empty'; emptyLi.innerHTML = `

${t('room.noUsersOnline')}

${t('room.shareToInvite')}

`; userList.appendChild(emptyLi); return; } filteredUsers.forEach((user) => { const li = document.createElement('li'); li.tabIndex = 0; if (user === selectedUser) li.classList.add('selected'); // Check if this user is currently in an active call (has answered) const isInActiveCall = connectedUser === user && remoteVideo.srcObject && remoteVideo.srcObject.getTracks().length > 0; // Create call/hangup button based on active call state const actionBtnEl = document.createElement('button'); actionBtnEl.style.cursor = 'pointer'; if (isInActiveCall) { // Show hang-up button only if in active call (user has answered) actionBtnEl.className = 'btn btn-custom btn-danger btn-m hangup-user-btn'; actionBtnEl.innerHTML = ''; actionBtnEl.title = t('room.hangup'); actionBtnEl.addEventListener('click', (e) => { e.stopPropagation(); if (!userSignedIn) return; console.log(`Hanging up call with ${user}`); // Send leave message to notify the other user sendMsg({ type: 'leave', name: connectedUser }); handleLeave(false); // End call but stay in room renderUserList(); // Refresh the list to show call button again }); } else { // Show call button if not in active call actionBtnEl.className = 'btn btn-custom btn-warning btn-m call-user-btn'; actionBtnEl.innerHTML = ''; actionBtnEl.title = t('room.call'); actionBtnEl.addEventListener('click', (e) => { e.stopPropagation(); if (!userSignedIn) return; handleUserClickToCall(user); }); } // Username span const nameSpan = document.createElement('span'); nameSpan.textContent = user; // Send file button const sendFileBtn = document.createElement('button'); sendFileBtn.className = 'btn btn-custom btn-success btn-m'; sendFileBtn.innerHTML = ''; sendFileBtn.style.cursor = 'pointer'; sendFileBtn.title = t('file.shareFile'); sendFileBtn.addEventListener('click', (e) => { e.stopPropagation(); if (!userSignedIn) return; if (!connectedUser || connectedUser !== user || !remoteVideo.srcObject) { toast(t('file.onlyUserInCall'), 'warning', 'top', 3000); return; } if (!dataChannel || dataChannel.readyState !== 'open') { toast(t('file.dataChannelNotReadyTryAgain'), 'warning', 'top', 3000); return; } pendingFileRecipient = user; if (fileInput) { fileInput.click(); } }); // User avatar with initials const avatarColors = [ 'linear-gradient(135deg, #3b82f6, #1d4ed8)', 'linear-gradient(135deg, #8b5cf6, #6d28d9)', 'linear-gradient(135deg, #10b981, #047857)', 'linear-gradient(135deg, #f59e0b, #d97706)', 'linear-gradient(135deg, #ef4444, #b91c1c)', 'linear-gradient(135deg, #06b6d4, #0e7490)', 'linear-gradient(135deg, #ec4899, #be185d)', ]; const avatarDiv = document.createElement('div'); avatarDiv.className = 'user-avatar'; const initials = user .split(/[\s_-]+/) .map((w) => w[0]) .join('') .substring(0, 2); avatarDiv.textContent = initials; let hash = 0; for (let i = 0; i < user.length; i++) hash = user.charCodeAt(i) + ((hash << 5) - hash); avatarDiv.style.background = avatarColors[Math.abs(hash) % avatarColors.length]; li.appendChild(avatarDiv); li.appendChild(nameSpan); li.appendChild(sendFileBtn); li.appendChild(actionBtnEl); li.addEventListener('click', () => { if (!userSignedIn) return; selectedUser = user; renderUserList(); }); li.addEventListener('keydown', (e) => { if (!userSignedIn) return; if (e.key === 'Enter') { const isInActiveCall = connectedUser === user && remoteVideo.srcObject && remoteVideo.srcObject.getTracks().length > 0; if (isInActiveCall) { // Send leave message to notify the other user sendMsg({ type: 'leave', name: connectedUser }); handleLeave(false); renderUserList(); } else { handleUserClickToCall(user); } } }); userList.appendChild(li); }); // Initialize Bootstrap tooltips on dynamically created buttons (skip on mobile) if (userInfo && !userInfo.device.isMobile) { const tooltipEls = userList.querySelectorAll('[title]'); tooltipEls.forEach((el) => { el.setAttribute('data-toggle', 'tooltip'); el.setAttribute('data-placement', 'top'); const tip = new bootstrap.Tooltip(el); el.addEventListener('click', () => tip.hide()); }); } } // Filter user list based on search input function filterUserList(query) { filteredUsers = allConnectedUsers.filter((u) => u.toLowerCase().includes(query.toLowerCase())); // If selected user is filtered out, deselect if (!filteredUsers.includes(selectedUser)) selectedUser = null; renderUserList(); } // Handle user search input userSearchInput?.addEventListener('input', (e) => { filterUserList(e.target.value); }); // Tab switching function function switchTab(tabName) { currentTab = tabName; // Update tab buttons if (usersTab && chatTab && settingsTab) { usersTab.classList.remove('active'); chatTab.classList.remove('active'); settingsTab.classList.remove('active'); if (tabName === 'users') { usersTab.classList.add('active'); } else if (tabName === 'chat') { chatTab.classList.add('active'); // Clear unread messages when switching to chat unreadMessages = 0; updateChatNotification(); // Show empty state if no messages if (chatMessages && chatMessages.children.length === 0) { showChatEmptyState(); } } else if (tabName === 'settings') { settingsTab.classList.add('active'); // Refresh devices when switching to settings refreshDevices(false); } } // Update tab content if (usersContent && chatContent && settingsContent) { usersContent.classList.remove('active'); chatContent.classList.remove('active'); settingsContent.classList.remove('active'); if (tabName === 'users') { usersContent.classList.add('active'); } else if (tabName === 'chat') { chatContent.classList.add('active'); } else if (tabName === 'settings') { settingsContent.classList.add('active'); } } } // Update participant count badge function updateParticipantCount() { if (participantCount) { const count = allConnectedUsers.length; if (count > 0) { participantCount.textContent = count > 99 ? '99+' : count.toString(); participantCount.classList.remove('hidden'); } else { participantCount.classList.add('hidden'); } } } // Update chat notification badge function updateChatNotification() { if (chatNotification) { if (unreadMessages > 0) { chatNotification.textContent = unreadMessages > 99 ? '99+' : unreadMessages.toString(); chatNotification.classList.remove('hidden'); } else { chatNotification.classList.add('hidden'); } } if (messageCount) { if (unreadMessages > 0) { messageCount.textContent = unreadMessages > 99 ? '99+' : unreadMessages.toString(); messageCount.classList.remove('hidden'); } else { messageCount.classList.add('hidden'); } } } // Chat form handler if (chatForm && chatInput) { chatForm.addEventListener('submit', (e) => { e.preventDefault(); const text = chatInput.value.trim(); if (text.length > 0) { if (allConnectedUsers.length === 0) { toast(t('chat.cannotSendNoUsers'), 'warning', 'top', 2000); chatInput.value = ''; return; } socket.emit('message', { type: 'chat', text }); addChatMessage({ from: userName || t('chat.me'), text, timestamp: Date.now() }, true); chatInput.value = ''; } }); } function addChatMessage(msg, isSelf = false) { // Remove empty state if present const emptyState = chatMessages.querySelector('.chat-empty-state'); if (emptyState) emptyState.remove(); const div = document.createElement('div'); div.className = 'chat-message'; if (isSelf) { div.classList.add('own-message'); } const userSpan = document.createElement('span'); userSpan.className = 'chat-user'; userSpan.textContent = isSelf ? t('chat.me') : msg.from; const textSpan = document.createElement('span'); textSpan.className = 'chat-text'; textSpan.textContent = ': ' + msg.text; const timeSpan = document.createElement('span'); timeSpan.className = 'chat-time'; timeSpan.textContent = formatChatTime(msg.timestamp); div.appendChild(userSpan); div.appendChild(textSpan); div.appendChild(timeSpan); chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; // Handle unread message counter if (!isSelf && (currentTab !== 'chat' || !userSidebar.classList.contains('active'))) { unreadMessages++; updateChatNotification(); // Show toast notification for new messages only if sidebar is not opened if (!userSidebar.classList.contains('active')) { toast(t('chat.newMessageFrom', { username: msg.from }), 'info', 'top', 2000); } } } // Add a file message entry into the chat with download link function addFileMessageToChat({ from, name, size, url, isSelf }) { if (!chatMessages) return; const div = document.createElement('div'); div.className = 'chat-message file-message'; if (isSelf) { div.classList.add('own-message'); } const userSpan = document.createElement('span'); userSpan.className = 'chat-user'; userSpan.textContent = isSelf ? t('chat.me') : from; const linkSpan = document.createElement('span'); linkSpan.className = 'chat-text'; const link = document.createElement('a'); link.href = url; link.download = name; const sizeKb = Math.max(1, Math.round(size / 1024)); link.textContent = `${name} (${sizeKb} KB)`; linkSpan.appendChild(document.createTextNode(t('file.sentFileLabel'))); linkSpan.appendChild(link); const timeSpan = document.createElement('span'); timeSpan.className = 'chat-time'; timeSpan.textContent = formatChatTime(Date.now()); div.appendChild(userSpan); div.appendChild(linkSpan); div.appendChild(timeSpan); chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; // Handle unread message counter for received files if (!isSelf && (currentTab !== 'chat' || !userSidebar.classList.contains('active'))) { unreadMessages++; updateChatNotification(); if (!userSidebar.classList.contains('active')) { toast(t('file.newFileFrom', { username: from }), 'info', 'top', 2000); } } } // Render file transfer status UI (outgoing + incoming) function renderFileTransferStatus() { if (!fileTransferStatusEl) return; fileTransferStatusEl.innerHTML = ''; const anyTransfer = outgoingTransfer || incomingTransfer; if (!anyTransfer) { fileTransferStatusEl.style.display = 'none'; return; } fileTransferStatusEl.style.display = 'block'; if (outgoingTransfer) { const row = document.createElement('div'); row.className = 'file-transfer-row outgoing'; const info = document.createElement('div'); info.className = 'file-transfer-info'; const nameEl = document.createElement('div'); nameEl.className = 'file-transfer-name'; nameEl.textContent = t('file.sending', { filename: outgoingTransfer.name }); const progressWrapper = document.createElement('div'); progressWrapper.className = 'file-transfer-progress'; const bar = document.createElement('div'); bar.className = 'file-transfer-progress-bar'; const percent = outgoingTransfer.size ? Math.min(100, Math.round((outgoingTransfer.sentBytes / outgoingTransfer.size) * 100)) : 0; bar.style.width = percent + '%'; progressWrapper.appendChild(bar); const meta = document.createElement('div'); meta.className = 'file-transfer-meta'; const sentKb = Math.round(outgoingTransfer.sentBytes / 1024); const totalKb = Math.round(outgoingTransfer.size / 1024); meta.textContent = `${sentKb}/${totalKb} KB (${percent}%)`; info.appendChild(nameEl); info.appendChild(progressWrapper); info.appendChild(meta); const actions = document.createElement('div'); actions.className = 'file-transfer-actions'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-sm btn-danger'; cancelBtn.textContent = t('settings.cancel'); cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); handleOutgoingFileCancel(); }); actions.appendChild(cancelBtn); row.appendChild(info); row.appendChild(actions); fileTransferStatusEl.appendChild(row); } if (incomingTransfer) { const row = document.createElement('div'); row.className = 'file-transfer-row incoming'; const info = document.createElement('div'); info.className = 'file-transfer-info'; const nameEl = document.createElement('div'); nameEl.className = 'file-transfer-name'; nameEl.textContent = t('file.receiving', { filename: incomingTransfer.name }); const progressWrapper = document.createElement('div'); progressWrapper.className = 'file-transfer-progress'; const bar = document.createElement('div'); bar.className = 'file-transfer-progress-bar'; const percent = incomingTransfer.size ? Math.min(100, Math.round((incomingTransfer.receivedBytes / incomingTransfer.size) * 100)) : 0; bar.style.width = percent + '%'; progressWrapper.appendChild(bar); const meta = document.createElement('div'); meta.className = 'file-transfer-meta'; const receivedKb = Math.round(incomingTransfer.receivedBytes / 1024); const totalKb = Math.round(incomingTransfer.size / 1024); meta.textContent = `${receivedKb}/${totalKb} KB (${percent}%)`; info.appendChild(nameEl); info.appendChild(progressWrapper); info.appendChild(meta); const actions = document.createElement('div'); actions.className = 'file-transfer-actions'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-sm btn-danger'; cancelBtn.textContent = t('settings.cancel'); cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); handleIncomingFileCancel(); }); actions.appendChild(cancelBtn); row.appendChild(info); row.appendChild(actions); fileTransferStatusEl.appendChild(row); } } // Cancel outgoing file transfer (sender side) function handleOutgoingFileCancel() { if (!outgoingTransfer) return; const id = outgoingTransfer.id; // Try to notify remote, but cancel locally even if this fails if (dataChannel && dataChannel.readyState === 'open') { try { dataChannel.send( JSON.stringify({ type: 'file-cancel', id, by: 'sender', }) ); } catch (e) { console.warn('Error sending cancel message over data channel', e); } } // Mark as cancelled; send loop will see this and stop outgoingTransfer.cancelled = true; toast(t('file.cancelled'), 'info', 'top', 3000); } // Cancel incoming file transfer (receiver side) function handleIncomingFileCancel() { if (!incomingTransfer) return; const id = incomingTransfer.id; if (dataChannel && dataChannel.readyState === 'open') { try { dataChannel.send( JSON.stringify({ type: 'file-cancel', id, by: 'receiver', }) ); } catch (e) { console.warn('Error sending cancel message over data channel', e); } } incomingTransfer = null; incomingFileMeta = null; incomingFileData = null; incomingFileBuffers = []; incomingFileReceivedBytes = 0; toast(t('file.cancelled'), 'info', 'top', 3000); renderFileTransferStatus(); } function formatChatTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // Handle emoji button click function handleEmojiClick() { console.log('Emoji button clicked, current show state:', emojiPicker.classList.contains('show')); if (!emojiPicker.classList.contains('show')) { handleChatEmojiPicker(); emojiPicker.classList.add('show'); console.log('Emoji picker shown'); } else { emojiPicker.classList.remove('show'); console.log('Emoji picker hidden'); } } // Initialize and handle chat emoji picker function handleChatEmojiPicker() { // Clear any existing picker emojiPicker.innerHTML = ''; console.log('Initializing EmojiMart picker...'); const pickerOptions = { theme: 'dark', perLine: 8, onEmojiSelect: addEmojiToMsg, }; const picker = new EmojiMart.Picker(pickerOptions); emojiPicker.appendChild(picker); console.log('EmojiMart picker initialized and appended'); function addEmojiToMsg(data) { console.log('Emoji selected:', data.native); // Insert emoji at cursor position or at the end const cursorPosition = chatInput.selectionStart; const currentValue = chatInput.value; const newValue = currentValue.slice(0, cursorPosition) + data.native + currentValue.slice(cursorPosition); chatInput.value = newValue; // Set cursor position after the emoji const newCursorPosition = cursorPosition + data.native.length; chatInput.setSelectionRange(newCursorPosition, newCursorPosition); chatInput.focus(); console.log('Emoji inserted into chat input at position:', cursorPosition); // Hide emoji picker after selection toggleChatEmoji(); } } // Toggle chat emoji picker visibility function toggleChatEmoji() { emojiPicker.classList.remove('show'); console.log('Emoji picker hidden via toggle'); } // Generate chat export text function generateChatExportText() { const messages = chatMessages.children; if (messages.length === 0) { return null; } let chatText = ''; const currentDate = new Date(); const dateString = currentDate.toLocaleDateString(); const timeString = currentDate.toLocaleTimeString(); // Add header to the file chatText += `Call-me Chat Export\n`; chatText += `Exported on: ${dateString} at ${timeString}\n`; chatText += `Session participants: ${userName || 'Unknown'}, ${connectedUser || 'Unknown'}\n`; chatText += `${'='.repeat(50)}\n\n`; // Extract messages for (let i = 0; i < messages.length; i++) { const message = messages[i]; const userSpan = message.querySelector('.chat-user'); const textSpan = message.querySelector('.chat-text'); const timeSpan = message.querySelector('.chat-time'); if (userSpan && textSpan && timeSpan) { const user = userSpan.textContent; const text = textSpan.textContent.replace(': ', ''); // Remove the ': ' prefix const time = timeSpan.textContent; chatText += `[${time}] ${user}: ${text}\n`; } } return chatText; } // Download text as file function downloadTextAsFile(text, filename) { const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // Handle save chat button click function handleSaveChatClick() { const chatText = generateChatExportText(); if (!chatText) { toast(t('chat.noMessagesToSave'), 'info', 'top', 2000); return; } const currentDate = new Date(); const fileName = `call-me-chat-${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}-${String(currentDate.getHours()).padStart(2, '0')}${String(currentDate.getMinutes()).padStart(2, '0')}.txt`; downloadTextAsFile(chatText, fileName); toast(t('chat.savedAs', { filename: fileName }), 'success', 'top', 3000); } // Handle clear chat button click function handleClearChatClick() { if (!thereAreChatMessages()) { toast(t('chat.noMessagesToClear'), 'info', 'top', 2000); return; } Swal.fire({ heightAuto: false, scrollbarPadding: false, position: 'center', icon: 'question', title: t('chat.clearTitle'), text: t('chat.clearConfirm'), showCancelButton: true, confirmButtonText: t('chat.clearConfirmYes'), cancelButtonText: t('settings.cancel'), confirmButtonColor: '#dc3545', cancelButtonColor: '#6c757d', }).then((result) => { if (result.isConfirmed) { // Clear all chat messages chatMessages.innerHTML = ''; // Reset unread messages counter unreadMessages = 0; updateChatNotification(); // Show success message toast(t('chat.cleared'), 'success', 'top', 2000); } }); } function thereAreChatMessages() { // Exclude the empty state element from the count const children = Array.from(chatMessages.children); return children.some((el) => !el.classList.contains('chat-empty-state')); } // Show chat empty state placeholder function showChatEmptyState() { if (!chatMessages) return; const existing = chatMessages.querySelector('.chat-empty-state'); if (existing) return; const emptyDiv = document.createElement('div'); emptyDiv.className = 'chat-empty-state'; emptyDiv.innerHTML = `

${t('room.noChatMessages')}

`; chatMessages.appendChild(emptyDiv); } // Device Management Functions async function initializeDeviceSettings() { try { // Set initial device IDs from current stream if (stream) { const videoTrack = stream.getVideoTracks()[0]; const audioTrack = stream.getAudioTracks()[0]; if (videoTrack && videoTrack.readyState === 'live') { const videoSettings = videoTrack.getSettings(); selectedDevices.videoInput = videoSettings.deviceId; } if (audioTrack && audioTrack.readyState === 'live') { const audioSettings = audioTrack.getSettings(); selectedDevices.audioInput = audioSettings.deviceId; } } // Enumerate and populate device lists await enumerateDevices(); } catch (error) { console.error('Error initializing device settings:', error); } } async function enumerateDevices() { try { const devices = await navigator.mediaDevices.enumerateDevices(); availableDevices = { videoInputs: devices.filter((device) => device.kind === 'videoinput'), audioInputs: devices.filter((device) => device.kind === 'audioinput'), audioOutputs: devices.filter((device) => device.kind === 'audiooutput'), }; populateDeviceSelects(); } catch (error) { console.error('Error enumerating devices:', error); handleError(t('errors.mediaDevices')); } } function populateDeviceSelects() { // Populate video select if (videoSelect) { videoSelect.innerHTML = ''; // Check if we actually have camera access by checking the stream // A stopped track (readyState === 'ended') still counts — the device was available const hasCamera = stream && stream.getVideoTracks().length > 0 && availableDevices.videoInputs.length > 0; if (!hasCamera) { videoSelect.innerHTML = ``; videoSelect.disabled = true; videoSelect.parentElement.style.opacity = '0.5'; } else { videoSelect.disabled = false; videoSelect.parentElement.style.opacity = '1'; availableDevices.videoInputs.forEach((device) => { const option = document.createElement('option'); option.value = device.deviceId; option.textContent = device.label || `${t('settings.videoInput')} ${device.deviceId.slice(0, 8)}`; if (device.deviceId === selectedDevices.videoInput) { option.selected = true; } videoSelect.appendChild(option); }); } } // Populate audio input select if (audioSelect) { audioSelect.innerHTML = ''; // Check if we actually have microphone access by checking the stream const hasMic = stream && stream.getAudioTracks().length > 0; if (!hasMic) { audioSelect.innerHTML = ``; audioSelect.disabled = true; audioSelect.parentElement.style.opacity = '0.5'; } else { audioSelect.disabled = false; audioSelect.parentElement.style.opacity = '1'; availableDevices.audioInputs.forEach((device) => { const option = document.createElement('option'); option.value = device.deviceId; option.textContent = device.label || `${t('settings.audioInput')} ${device.deviceId.slice(0, 8)}`; if (device.deviceId === selectedDevices.audioInput) { option.selected = true; } audioSelect.appendChild(option); }); } } // Populate audio output select if (audioOutputSelect) { audioOutputSelect.innerHTML = ''; if (availableDevices.audioOutputs.length === 0) { audioOutputSelect.innerHTML = ``; } else { availableDevices.audioOutputs.forEach((device) => { const option = document.createElement('option'); option.value = device.deviceId; option.textContent = device.label || `${t('settings.audioOutput')} ${device.deviceId.slice(0, 8)}`; if (device.deviceId === selectedDevices.audioOutput) { option.selected = true; } audioOutputSelect.appendChild(option); }); } } } // Update UI elements based on available devices function updateUIForAvailableDevices() { // A stopped track (readyState 'ended') still counts — the device was available at start const hasCamera = stream && stream.getVideoTracks().length > 0 && availableDevices.videoInputs.length > 0; const hasMic = stream && stream.getAudioTracks().length > 0; // Handle video button and local video if (!hasCamera) { if (videoBtn) { videoBtn.classList.add('btn-danger'); videoBtn.disabled = true; videoBtn.style.opacity = '0.5'; videoBtn.title = t('errors.noCameraAvailable'); } if (swapCameraBtn) { swapCameraBtn.style.display = 'none'; } if (localVideoContainer) { showCameraOffOverlay('local', true); } } else { if (videoBtn) { videoBtn.disabled = false; videoBtn.style.opacity = '1'; } } // Handle audio button if (!hasMic) { if (audioBtn) { audioBtn.classList.add('btn-danger'); audioBtn.disabled = true; audioBtn.style.opacity = '0.5'; audioBtn.title = t('errors.noMicrophoneAvailable'); } } else { if (audioBtn) { audioBtn.disabled = false; audioBtn.style.opacity = '1'; } } } async function refreshDevices(showToast = true) { if (refreshDevicesBtn) { refreshDevicesBtn.disabled = true; } try { // Only request permissions if we don't already have an active stream // This prevents re-opening the camera (turning on LED) when it was turned off const hasLiveVideoTrack = stream && stream.getVideoTracks().some((t) => t.readyState === 'live'); const hasLiveAudioTrack = stream && stream.getAudioTracks().some((t) => t.readyState === 'live'); if (!hasLiveVideoTrack && !hasLiveAudioTrack) { // No live tracks — we need to request permission to enumerate labeled devices // Use audio-only to avoid turning on the camera LED try { const tempStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); tempStream.getTracks().forEach((t) => t.stop()); } catch (e) { // If audio also fails, try video but stop it immediately try { const tempStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); tempStream.getTracks().forEach((t) => t.stop()); } catch (videoError) { console.warn('No media devices available for permission request'); } } } await enumerateDevices(); updateUIForAvailableDevices(); if (showToast) { toast(t('messages.devicesRefreshed'), 'success', 'top', 2000); } } catch (error) { console.error('Error refreshing devices:', error); handleError(t('errors.refreshDevicesFailed')); } finally { if (refreshDevicesBtn) { refreshDevicesBtn.disabled = false; } } } async function handleVideoDeviceChange() { const newDeviceId = videoSelect.value; if (newDeviceId && newDeviceId !== selectedDevices.videoInput) { selectedDevices.videoInput = newDeviceId; await updateVideoStream(); updateUIForAvailableDevices(); toast(t('messages.cameraChanged'), 'success', 'top', 2000); } } async function handleAudioDeviceChange() { const newDeviceId = audioSelect.value; if (newDeviceId && newDeviceId !== selectedDevices.audioInput) { selectedDevices.audioInput = newDeviceId; await updateAudioStream(); updateUIForAvailableDevices(); toast(t('messages.microphoneChanged'), 'success', 'top', 2000); } } async function handleAudioOutputDeviceChange() { const newDeviceId = audioOutputSelect.value; if (newDeviceId && newDeviceId !== selectedDevices.audioOutput) { selectedDevices.audioOutput = newDeviceId; await setAudioOutputDevice(newDeviceId); toast(t('messages.speakerChanged'), 'success', 'top', 2000); } } async function updateVideoStream() { try { // Determine if camera was on before the switch const oldVideoTrack = stream ? stream.getVideoTracks()[0] : null; const wasCameraOff = videoBtn && videoBtn.classList.contains('btn-danger'); const constraints = { video: { deviceId: selectedDevices.videoInput ? { exact: selectedDevices.videoInput } : true }, audio: false, }; const newStream = await navigator.mediaDevices.getUserMedia(constraints); const videoTrack = newStream.getVideoTracks()[0]; if (!videoTrack) { throw new Error('No video track found in new stream'); } // Remove old track from stream if (stream && oldVideoTrack) { stream.removeTrack(oldVideoTrack); if (oldVideoTrack.readyState === 'live') { oldVideoTrack.stop(); } } if (wasCameraOff) { // Camera was off — stop the newly acquired track to keep LED off, // but still add it to stream so the device selection is remembered videoTrack.stop(); videoTrack.enabled = false; if (stream) { stream.addTrack(videoTrack); } // Replace peer track with null to ensure nothing is sent if (thisConnection) { const sender = findVideoSender(); if (sender) { await sender.replaceTrack(null); } } // Update local video element if (localVideo) { localVideo.srcObject = stream; } videoBtn && videoBtn.classList.add('btn-danger'); showCameraOffOverlay('local', true); } else { // Camera was on — keep the new track live if (stream) { stream.addTrack(videoTrack); } // Update peer connection if (thisConnection) { const sender = findVideoSender(); if (sender) { await sender.replaceTrack(videoTrack); } else { thisConnection.addTrack(videoTrack, stream); } } // Update local video element if (localVideo) { localVideo.srcObject = stream; handleVideoMirror(localVideo, stream); } videoBtn && videoBtn.classList.remove('btn-danger'); showCameraOffOverlay('local', false); } // Notify server about media status change so remote user is aware sendMediaStatusToServer(); // Stop other tracks from the temporary stream newStream.getAudioTracks().forEach((track) => track.stop()); } catch (error) { console.error('Error updating video stream:', error); handleError(t('errors.changeCameraFailed')); } } async function updateAudioStream() { try { const constraints = { video: false, audio: { deviceId: selectedDevices.audioInput ? { exact: selectedDevices.audioInput } : true }, }; const newStream = await navigator.mediaDevices.getUserMedia(constraints); const audioTrack = newStream.getAudioTracks()[0]; if (!audioTrack) { throw new Error('No audio track found in new stream'); } // Update peer connection if it exists if (thisConnection) { const sender = thisConnection.getSenders().find((s) => s.track && s.track.kind === 'audio'); if (sender) { await sender.replaceTrack(audioTrack); } else { // If no sender exists, add the track thisConnection.addTrack(audioTrack, stream); } } // Update local stream if (stream) { const oldAudioTrack = stream.getAudioTracks()[0]; if (oldAudioTrack) { stream.removeTrack(oldAudioTrack); oldAudioTrack.stop(); } stream.addTrack(audioTrack); } // Stop other tracks from the temporary stream newStream.getVideoTracks().forEach((track) => track.stop()); } catch (error) { console.error('Error updating audio stream:', error); handleError(t('errors.changeMicrophoneFailed')); } } async function setAudioOutputDevice(deviceId) { try { if (remoteVideo && typeof remoteVideo.setSinkId === 'function') { await remoteVideo.setSinkId(deviceId); selectedDevices.audioOutput = deviceId; } else { console.warn('Browser does not support audio output device selection'); toast(t('errors.audioOutputNotSupported'), 'warning', 'top', 3000); } } catch (error) { console.error('Error setting audio output device:', error); handleError(t('errors.changeSpeakerFailed')); } } async function testDevices() { if (testDevicesBtn) { testDevicesBtn.disabled = true; } try { // Build constraints based on available devices const hasCamera = availableDevices.videoInputs.length > 0; const hasMic = availableDevices.audioInputs.length > 0; if (!hasCamera && !hasMic) { toast(t('errors.noDevicesToTest'), 'warning', 'top', 2000); return; } const constraints = { video: hasCamera ? selectedDevices.videoInput ? { deviceId: { exact: selectedDevices.videoInput } } : true : false, audio: hasMic ? selectedDevices.audioInput ? { deviceId: { exact: selectedDevices.audioInput } } : true : false, }; const testStream = await navigator.mediaDevices.getUserMedia(constraints); // Log stream info for debugging console.log('Test stream tracks:', { video: testStream.getVideoTracks().length, audio: testStream.getAudioTracks().length, videoSettings: testStream.getVideoTracks()[0]?.getSettings(), audioSettings: testStream.getAudioTracks()[0]?.getSettings(), }); // Test for 2 seconds then stop setTimeout(() => { testStream.getTracks().forEach((track) => track.stop()); toast(t('messages.deviceTestCompleted'), 'success', 'top', 2000); }, 2000); } catch (error) { console.error('Error testing devices:', error); handleError(t('errors.deviceTestFailed', { message: error.message })); } finally { if (testDevicesBtn) { testDevicesBtn.disabled = false; } } } // Initialize devices when settings tab is accessed (not on page load) // This prevents conflicts with initial stream setup // Clean up before window close or reload window.onbeforeunload = () => { handleLeaveClick(); };