// Configuration variables let config = {}; let localStream; let peerConnections = {}; let username = ''; let isVideoEnabled = true; let isAudioEnabled = true; let isSelfViewVisible = false; // Performance monitoring variables let performanceStats = {}; let qualityMonitoringInterval = null; let currentQualityProfile = 'medium'; // DOM elements const usernameModal = document.getElementById('usernameModal'); const usernameForm = document.getElementById('usernameForm'); const usernameInput = document.getElementById('usernameInput'); const chatInterface = document.getElementById('chatInterface'); const localVideo = document.getElementById('localVideo'); const selfVideo = document.getElementById('selfVideo'); const remoteVideos = document.getElementById('remoteVideos'); const muteButton = document.getElementById('muteButton'); const videoButton = document.getElementById('videoButton'); const hangupButton = document.getElementById('hangupButton'); const toggleSelfViewButton = document.getElementById('toggleSelfViewButton'); const selfViewContainer = document.getElementById('selfViewContainer'); const participantCount = document.getElementById('participantCount'); const selfViewUsername = document.getElementById('selfViewUsername'); const themeToggle = document.getElementById('themeToggle'); const connectionQuality = document.getElementById('connectionQuality'); const qualityIndicator = connectionQuality?.querySelector('.quality-indicator'); const qualityText = connectionQuality?.querySelector('.quality-text'); // Chat elements const chatPanel = document.getElementById('chatPanel'); const chatMessages = document.getElementById('chatMessages'); const chatInput = document.getElementById('chatInput'); const sendButton = document.getElementById('sendButton'); const toggleChatButton = document.getElementById('toggleChatButton'); // Mobile chat elements const mobileChatToggle = document.getElementById('mobileChatToggle'); const mobileChatOverlay = document.getElementById('mobileChatOverlay'); const mobileChatMessages = document.getElementById('mobileChatMessages'); const mobileChatInput = document.getElementById('mobileChatInput'); const mobileSendButton = document.getElementById('mobileSendButton'); const closeMobileChatButton = document.getElementById('closeMobileChatButton'); // Chat state let isChatCollapsed = false; let chatHistory = []; let isMobileChatOpen = false; let isMobileDevice = false; // Initialize Socket.IO connection (but don't connect yet) let socket; // Load configuration from config.json async function loadConfig() { try { const response = await fetch('/static/config.json'); config = await response.json(); console.log('Configuration loaded:', config); // Apply UI configuration if (config.ui) { document.getElementById('usernameInput').maxLength = config.ui.maxUsernameLength || 20; isSelfViewVisible = config.ui.showSelfViewByDefault || false; } // Apply media configuration if (config.media) { isVideoEnabled = config.media.defaultVideoEnabled !== false; isAudioEnabled = config.media.defaultAudioEnabled !== false; } return config; } catch (error) { console.error('Failed to load configuration, using defaults:', error); // Fallback configuration config = { server: { port: 3232, ssl: true, rejectUnauthorized: false }, webrtc: { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }, ui: { maxUsernameLength: 20, defaultTheme: 'light', showSelfViewByDefault: false }, media: { defaultVideoEnabled: true, defaultAudioEnabled: true, videoConstraints: { video: true, audio: true } } }; return config; } } // Username form submission usernameForm.addEventListener('submit', async (e) => { e.preventDefault(); const inputUsername = usernameInput.value.trim(); if (inputUsername.length < 1) { alert('Please enter a valid username'); return; } // Load configuration if not already loaded if (!config.server) { await loadConfig(); } username = inputUsername; selfViewUsername.textContent = username; try { // Get user media using configuration with mobile optimizations let mediaConstraints = config.media?.videoConstraints || {video: true, audio: true}; // Optimize constraints for mobile devices if (isMobileDevice) { mediaConstraints = { video: { width: { ideal: 480, max: 720 }, height: { ideal: 360, max: 480 }, frameRate: { ideal: 15, max: 24 }, facingMode: 'user' // Prefer front camera }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: 22050 // Lower sample rate for mobile } }; } localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints); localVideo.srcObject = localStream; selfVideo.srcObject = localStream; console.log('Local stream obtained'); // Hide modal and show chat interface usernameModal.classList.add('hidden'); chatInterface.classList.remove('hidden'); // Add your own video to the grid addLocalVideoToGrid(); // Initialize Socket.IO connection initializeSocket(); // Display welcome message displaySystemMessage(`* You have joined #videochat as ${username}`, 'join-message'); } catch (error) { console.error('Error accessing media devices:', error); alert('Unable to access camera/microphone. Please check your permissions and try again.'); } }); // Initialize Socket.IO connection function initializeSocket() { // Use current host and port instead of hardcoded localhost:3000 const protocol = window.location.protocol === 'https:' ? 'https://' : 'http://'; const host = window.location.hostname; const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); const serverUrl = `${protocol}${host}:${port}`; console.log('Connecting to:', serverUrl); // Use configuration for socket options const socketOptions = { secure: config.server?.ssl !== false && window.location.protocol === 'https:', rejectUnauthorized: config.server?.rejectUnauthorized !== false }; socket = io(serverUrl, socketOptions); setupSocketListeners(); // Start performance monitoring if enabled if (config.performance?.enableStats) { startPerformanceMonitoring(); } } // Performance monitoring functions function startPerformanceMonitoring() { const interval = config.performance?.statsInterval || 5000; qualityMonitoringInterval = setInterval(() => { monitorConnectionQuality(); }, interval); } function stopPerformanceMonitoring() { if (qualityMonitoringInterval) { clearInterval(qualityMonitoringInterval); qualityMonitoringInterval = null; } } async function monitorConnectionQuality() { for (const [userId, peerConnectionObj] of Object.entries(peerConnections)) { try { const stats = await peerConnectionObj.pc.getStats(); const quality = analyzeConnectionStats(stats, userId); if (quality.shouldAdjust) { adjustVideoQuality(userId, quality.recommendedProfile); } } catch (error) { console.warn('Failed to get stats for peer:', userId, error); } } // Update connection quality UI updateConnectionQualityUI(); } function analyzeConnectionStats(stats, userId) { let bytesReceived = 0; let bytesSent = 0; let packetsLost = 0; let packetsReceived = 0; let roundTripTime = 0; stats.forEach(report => { if (report.type === 'inbound-rtp' && report.mediaType === 'video') { bytesReceived += report.bytesReceived || 0; packetsLost += report.packetsLost || 0; packetsReceived += report.packetsReceived || 0; } else if (report.type === 'outbound-rtp' && report.mediaType === 'video') { bytesSent += report.bytesSent || 0; } else if (report.type === 'candidate-pair' && report.state === 'succeeded') { roundTripTime = report.currentRoundTripTime || 0; } }); // Calculate quality metrics const packetLossRate = packetsReceived > 0 ? packetsLost / packetsReceived : 0; const bandwidth = bytesReceived * 8 / 5; // rough estimate in bps // Determine recommended profile let recommendedProfile = currentQualityProfile; if (packetLossRate > 0.05 || roundTripTime > 0.3 || bandwidth < 200000) { recommendedProfile = 'low'; } else if (packetLossRate < 0.01 && roundTripTime < 0.1 && bandwidth > 800000) { recommendedProfile = 'high'; } else { recommendedProfile = 'medium'; } const shouldAdjust = recommendedProfile !== currentQualityProfile; performanceStats[userId] = { packetLossRate, bandwidth, roundTripTime, recommendedProfile, timestamp: Date.now() }; return { shouldAdjust, recommendedProfile }; } // Update UI connection quality indicator function updateConnectionQualityUI() { if (!qualityIndicator || !qualityText) return; const activeConnections = Object.values(performanceStats); if (activeConnections.length === 0) { qualityIndicator.className = 'quality-indicator good'; qualityText.textContent = 'Good'; return; } // Calculate overall quality based on worst performing connection let worstPacketLoss = 0; let worstRTT = 0; let lowestBandwidth = Infinity; activeConnections.forEach(stats => { worstPacketLoss = Math.max(worstPacketLoss, stats.packetLossRate || 0); worstRTT = Math.max(worstRTT, stats.roundTripTime || 0); lowestBandwidth = Math.min(lowestBandwidth, stats.bandwidth || Infinity); }); let qualityClass = 'good'; let qualityLabel = 'Good'; if (worstPacketLoss > 0.1 || worstRTT > 0.5 || lowestBandwidth < 100000) { qualityClass = 'poor'; qualityLabel = 'Poor'; } else if (worstPacketLoss > 0.05 || worstRTT > 0.3 || lowestBandwidth < 300000) { qualityClass = 'fair'; qualityLabel = 'Fair'; } qualityIndicator.className = `quality-indicator ${qualityClass}`; qualityText.textContent = qualityLabel; // Update tooltip with detailed stats const tooltip = `Packet Loss: ${(worstPacketLoss * 100).toFixed(1)}% | RTT: ${(worstRTT * 1000).toFixed(0)}ms | Bandwidth: ${Math.round(lowestBandwidth / 1000)}kbps`; connectionQuality.title = tooltip; } async function adjustVideoQuality(userId, profileName) { if (!config.media?.adaptiveQuality?.enabled) return; const profile = config.media.adaptiveQuality.profiles[profileName]; if (!profile) return; console.log(`Adjusting video quality for ${userId} to ${profileName}:`, profile); const peerConnectionObj = peerConnections[userId]; if (!peerConnectionObj) return; try { const sender = peerConnectionObj.pc.getSenders().find(s => s.track && s.track.kind === 'video' ); if (sender && sender.track) { const params = sender.getParameters(); if (params.encodings && params.encodings.length > 0) { params.encodings[0].maxBitrate = profile.bitrate; params.encodings[0].maxFramerate = profile.frameRate; await sender.setParameters(params); currentQualityProfile = profileName; console.log(`Video quality adjusted to ${profileName} for peer ${userId}`); } } } catch (error) { console.error('Failed to adjust video quality:', error); } } // Setup Socket.IO event listeners function setupSocketListeners() { socket.on('connect', () => { console.log('Connected to server'); // Join the room after connecting socket.emit('join_room', { username: username }); }); socket.on('disconnect', () => { console.log('Disconnected from server'); }); socket.on('user_joined', (data) => { console.log('User joined:', data.id, data.username); createPeerConnection(data.id, true, data.username); // This client will create offer updateParticipantCount(); }); socket.on('existing_users', (data) => { console.log('Existing users:', data.users); // Connect to existing users (they will create offers to us) data.users.forEach(user => { createPeerConnection(user.id, false, user.username); // Don't create offer, wait for existing users to offer }); updateParticipantCount(); }); socket.on('user_disconnected', (data) => { console.log('User disconnected:', data.id); handleUserDisconnected(data.id); updateParticipantCount(); }); socket.on('offer', async (data) => { console.log('Received offer from:', data.id); await handleOffer(data); }); socket.on('answer', async (data) => { console.log('Received answer from:', data.id); await handleAnswer(data); }); socket.on('ice_candidate', async (data) => { console.log('Received ICE candidate'); await handleIceCandidate(data); }); // Chat event listeners socket.on('chat_message', (data) => { displayChatMessage(data.username, data.message, data.timestamp, false); }); socket.on('user_joined_chat', (data) => { displaySystemMessage(`* ${data.username} has joined #videochat`, 'join-message'); }); socket.on('user_left_chat', (data) => { displaySystemMessage(`* ${data.username} has left #videochat`, 'leave-message'); }); } // WebRTC functions function createPeerConnection(userId, shouldCreateOffer = false, remoteUsername = null) { // Check connection limit if (Object.keys(peerConnections).length >= (config.performance?.maxConnections || 8)) { console.warn('Maximum connections reached, cannot create new peer connection'); return null; } // Use ICE servers from configuration with enhanced settings const rtcConfig = { iceServers: config.webrtc?.iceServers || [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ], iceCandidatePoolSize: 10, bundlePolicy: 'balanced', rtcpMuxPolicy: 'require' }; const peerConnection = new RTCPeerConnection(rtcConfig); peerConnections[userId] = { pc: peerConnection, username: remoteUsername, created: Date.now(), state: 'connecting' }; // Set up connection timeout setupConnectionTimeout(userId); // Enhanced connection state monitoring peerConnection.onconnectionstatechange = () => { const state = peerConnection.connectionState; console.log(`Connection state for ${userId}: ${state}`); if (peerConnections[userId]) { peerConnections[userId].state = state; } if (state === 'failed' || state === 'disconnected') { console.warn(`Connection ${state} for peer ${userId}, cleaning up...`); setTimeout(() => cleanupPeerConnection(userId), 5000); // Grace period } }; peerConnection.oniceconnectionstatechange = () => { const state = peerConnection.iceConnectionState; console.log(`ICE connection state for ${userId}: ${state}`); if (state === 'failed' || state === 'disconnected') { console.warn(`ICE connection ${state} for peer ${userId}`); } }; // Add local stream to peer connection if (localStream) { localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); }); } // Handle remote stream peerConnection.ontrack = (event) => { console.log('Received remote stream from:', userId); addRemoteVideo(userId, event.streams[0], remoteUsername); }; // Handle ICE candidates peerConnection.onicecandidate = (event) => { if (event.candidate) { console.log('Sending ICE candidate'); socket.emit('ice_candidate', { candidate: event.candidate, target_id: userId }); } }; // Create offer if this is the initiating peer if (shouldCreateOffer) { createOffer(peerConnection, userId); } return peerConnection; } async function createOffer(peerConnection, userId) { try { // Create offer with optimized settings for performance const offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true, voiceActivityDetection: true, iceRestart: false }; const offer = await peerConnection.createOffer(offerOptions); // Optimize SDP for better codec preferences const optimizedOffer = optimizeSDP(offer); await peerConnection.setLocalDescription(optimizedOffer); socket.emit('offer', { offer: optimizedOffer, target_id: userId }); console.log('Offer sent to:', userId); } catch (error) { console.error('Error creating offer:', error); } } // Optimize SDP for better performance function optimizeSDP(sessionDescription) { let sdp = sessionDescription.sdp; // Prefer VP8 over other codecs for better performance sdp = preferCodec(sdp, 'video', 'VP8'); // Prefer Opus for audio sdp = preferCodec(sdp, 'audio', 'opus'); // Set bandwidth limitations const profile = config.media?.adaptiveQuality?.profiles?.[currentQualityProfile]; if (profile && profile.bitrate) { sdp = setBandwidth(sdp, profile.bitrate); } return new RTCSessionDescription({ type: sessionDescription.type, sdp: sdp }); } function preferCodec(sdp, mediaType, codecName) { const lines = sdp.split('\r\n'); const mLineIndex = lines.findIndex(line => line.startsWith(`m=${mediaType}`)); if (mLineIndex === -1) return sdp; const codecRegex = new RegExp(`a=rtpmap:(\\d+) ${codecName}`, 'i'); const codecMatch = sdp.match(codecRegex); if (!codecMatch) return sdp; const codecPayloadType = codecMatch[1]; const mLine = lines[mLineIndex]; const formats = mLine.split(' '); const mediaDescription = formats.slice(0, 3).join(' '); const payloadTypes = formats.slice(3); // Move preferred codec to front const reorderedPayloadTypes = [codecPayloadType, ...payloadTypes.filter(pt => pt !== codecPayloadType)]; lines[mLineIndex] = `${mediaDescription} ${reorderedPayloadTypes.join(' ')}`; return lines.join('\r\n'); } function setBandwidth(sdp, bitrate) { // Add bandwidth limitation const lines = sdp.split('\r\n'); const videoMLineIndex = lines.findIndex(line => line.startsWith('m=video')); if (videoMLineIndex !== -1) { const bandwidthLine = `b=AS:${Math.floor(bitrate / 1000)}`; lines.splice(videoMLineIndex + 1, 0, bandwidthLine); } return lines.join('\r\n'); } async function handleOffer(data) { try { // Check if peer connection already exists, if not create one let peerConnection; if (peerConnections[data.id]) { peerConnection = peerConnections[data.id].pc; } else { peerConnection = createPeerConnection(data.id, false); } await peerConnection.setRemoteDescription(data.offer); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); socket.emit('answer', { answer: answer, id: data.id }); console.log('Answer sent to:', data.id); } catch (error) { console.error('Error handling offer:', error); } } async function handleAnswer(data) { try { const peerConnectionObj = peerConnections[data.id]; if (peerConnectionObj) { await peerConnectionObj.pc.setRemoteDescription(data.answer); console.log('Answer processed from:', data.id); } } catch (error) { console.error('Error handling answer:', error); } } async function handleIceCandidate(data) { try { // Find the correct peer connection based on who sent the ICE candidate const senderId = data.id || data.senderId; const peerConnectionObj = peerConnections[senderId]; if (peerConnectionObj && data.candidate) { await peerConnectionObj.pc.addIceCandidate(data.candidate); console.log('ICE candidate added for peer:', senderId); } else { console.warn('No peer connection found for ICE candidate from:', senderId); } } catch (error) { console.error('Error handling ICE candidate:', error); } } // Video element pool for reuse const videoElementPool = []; const maxPoolSize = 10; function getVideoElement() { if (videoElementPool.length > 0) { return videoElementPool.pop(); } const video = document.createElement('video'); video.className = 'remoteVideo'; video.autoplay = true; video.playsInline = true; // Important for mobile devices video.muted = false; // Add performance optimizations video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); return video; } function returnVideoElement(video) { if (videoElementPool.length < maxPoolSize && video) { // Clean up the video element video.srcObject = null; video.removeAttribute('id'); video.className = 'remoteVideo'; videoElementPool.push(video); } } function addRemoteVideo(userId, stream, remoteUsername = null) { // Remove existing video if any const existingContainer = document.getElementById(userId); if (existingContainer) { const existingVideo = existingContainer.querySelector('video'); if (existingVideo) { returnVideoElement(existingVideo); } existingContainer.remove(); } // Create container with optimized DOM operations const fragment = document.createDocumentFragment(); const remoteVideoContainer = document.createElement('div'); remoteVideoContainer.className = 'remoteVideoContainer'; remoteVideoContainer.id = userId; const remoteVideo = getVideoElement(); remoteVideo.srcObject = stream; // Add error handling for video playback remoteVideo.addEventListener('loadstart', () => { console.log(`Video loading started for ${userId}`); }); remoteVideo.addEventListener('canplay', () => { console.log(`Video ready to play for ${userId}`); }); remoteVideo.addEventListener('error', (e) => { console.error(`Video error for ${userId}:`, e); }); const usernameElement = document.createElement('div'); usernameElement.className = 'remoteUsername'; usernameElement.textContent = `${remoteUsername || `user_${userId.substring(0, 8)}`}`; // Use fragment to minimize DOM reflows remoteVideoContainer.appendChild(remoteVideo); remoteVideoContainer.appendChild(usernameElement); fragment.appendChild(remoteVideoContainer); // Single DOM insertion remoteVideos.appendChild(fragment); console.log(`Remote video added for ${userId}`); } // Add local video to the main grid function addLocalVideoToGrid() { const localVideoContainer = document.createElement('div'); localVideoContainer.className = 'remoteVideoContainer'; localVideoContainer.id = 'local-video-display'; const localVideoDisplay = document.createElement('video'); localVideoDisplay.className = 'remoteVideo'; localVideoDisplay.autoplay = true; localVideoDisplay.muted = true; // Prevent echo localVideoDisplay.srcObject = localStream; localVideoDisplay.style.transform = 'scaleX(-1)'; // Mirror effect localVideoContainer.appendChild(localVideoDisplay); const localUsernameElement = document.createElement('div'); localUsernameElement.className = 'remoteUsername'; localUsernameElement.textContent = `${username} (you)`; localVideoContainer.appendChild(localUsernameElement); remoteVideos.appendChild(localVideoContainer); } // Update participant count function updateParticipantCount() { const count = Object.keys(peerConnections).length + 1; // +1 for local user participantCount.textContent = `${count} user${count !== 1 ? 's' : ''}`; } function handleUserDisconnected(userId) { cleanupPeerConnection(userId); updateParticipantCount(); } // Improved cleanup function to prevent memory leaks function cleanupPeerConnection(userId) { console.log('Cleaning up peer connection for:', userId); // Remove video container const videoContainer = document.getElementById(userId); if (videoContainer) { // Stop all video tracks to free resources const video = videoContainer.querySelector('video'); if (video && video.srcObject) { const tracks = video.srcObject.getTracks(); tracks.forEach(track => track.stop()); video.srcObject = null; } videoContainer.remove(); } // Clean up peer connection const peerConnectionObj = peerConnections[userId]; if (peerConnectionObj) { // Remove all event listeners to prevent memory leaks peerConnectionObj.pc.ontrack = null; peerConnectionObj.pc.onicecandidate = null; peerConnectionObj.pc.onconnectionstatechange = null; peerConnectionObj.pc.oniceconnectionstatechange = null; // Close the connection peerConnectionObj.pc.close(); // Remove from our tracking delete peerConnections[userId]; delete performanceStats[userId]; } console.log(`Peer connection cleanup completed for ${userId}`); } // Enhanced connection timeout handling function setupConnectionTimeout(userId) { const timeout = config.performance?.connectionTimeout || 30000; setTimeout(() => { const peerConnectionObj = peerConnections[userId]; if (peerConnectionObj && peerConnectionObj.pc.connectionState === 'connecting') { console.warn(`Connection timeout for peer ${userId}, cleaning up...`); cleanupPeerConnection(userId); } }, timeout); } // Button event handlers muteButton.addEventListener('click', () => { if (localStream) { const audioTrack = localStream.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = !audioTrack.enabled; isAudioEnabled = audioTrack.enabled; const iconSpan = muteButton.querySelector('.btn-icon'); const textSpan = muteButton.querySelector('.btn-text'); if (isAudioEnabled) { if (iconSpan) iconSpan.textContent = '🎤'; if (textSpan) textSpan.textContent = 'mic'; muteButton.classList.remove('muted'); } else { if (iconSpan) iconSpan.textContent = '🔇'; if (textSpan) textSpan.textContent = 'muted'; muteButton.classList.add('muted'); } } } }); videoButton.addEventListener('click', () => { if (localStream) { const videoTrack = localStream.getVideoTracks()[0]; if (videoTrack) { videoTrack.enabled = !videoTrack.enabled; isVideoEnabled = videoTrack.enabled; const iconSpan = videoButton.querySelector('.btn-icon'); const textSpan = videoButton.querySelector('.btn-text'); // Update local video display visibility const localVideoDisplay = document.querySelector('#local-video-display .remoteVideo'); if (isVideoEnabled) { if (iconSpan) iconSpan.textContent = '📹'; if (textSpan) textSpan.textContent = 'cam'; videoButton.classList.remove('disabled'); if (localVideoDisplay) { localVideoDisplay.style.visibility = 'visible'; } if (selfVideo && isSelfViewVisible) { selfVideo.style.visibility = 'visible'; } } else { if (iconSpan) iconSpan.textContent = '📹'; if (textSpan) textSpan.textContent = 'nocam'; videoButton.classList.add('disabled'); if (localVideoDisplay) { localVideoDisplay.style.visibility = 'hidden'; } if (selfVideo) { selfVideo.style.visibility = 'hidden'; } } } } }); toggleSelfViewButton.addEventListener('click', () => { isSelfViewVisible = !isSelfViewVisible; if (isSelfViewVisible) { selfViewContainer.classList.remove('hidden'); toggleSelfViewButton.textContent = '[HIDE]'; toggleSelfViewButton.title = 'Hide self view'; // Sync video state with main video if (selfVideo && !isVideoEnabled) { selfVideo.style.visibility = 'hidden'; } } else { selfViewContainer.classList.add('hidden'); toggleSelfViewButton.textContent = '[VIEW]'; toggleSelfViewButton.title = 'Show self view'; } }); hangupButton.addEventListener('click', () => { // Stop performance monitoring stopPerformanceMonitoring(); // Clean up all peer connections properly Object.keys(peerConnections).forEach(userId => { cleanupPeerConnection(userId); }); peerConnections = {}; performanceStats = {}; // Stop local stream if (localStream) { localStream.getTracks().forEach(track => { track.stop(); track.enabled = false; }); localVideo.srcObject = null; selfVideo.srcObject = null; localStream = null; } // Clean up video elements efficiently const videoContainers = remoteVideos.querySelectorAll('.remoteVideoContainer'); videoContainers.forEach(container => { const video = container.querySelector('video'); if (video) { returnVideoElement(video); } container.remove(); }); // Disconnect from server if (socket) { socket.disconnect(); socket = null; } // Reset UI chatInterface.classList.add('hidden'); usernameModal.classList.remove('hidden'); usernameInput.value = ''; selfViewContainer.classList.add('hidden'); isSelfViewVisible = false; currentQualityProfile = 'medium'; // Clear chat clearChatHistory(); isChatCollapsed = false; if (chatPanel) { chatPanel.classList.remove('collapsed'); } // Close mobile chat if open if (isMobileChatOpen) { closeMobileChat(); } console.log('Left the chat and cleaned up resources'); }); // Theme toggle functionality function initTheme() { const defaultTheme = config.ui?.defaultTheme || 'light'; const savedTheme = localStorage.getItem('theme') || defaultTheme; document.documentElement.setAttribute('data-theme', savedTheme); updateThemeToggleIcon(savedTheme); } function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'light' ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); updateThemeToggleIcon(newTheme); } function updateThemeToggleIcon(theme) { themeToggle.textContent = theme === 'light' ? '🌙' : '☀️'; } themeToggle.addEventListener('click', toggleTheme); // Mobile detection function detectMobileDevice() { isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768 || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); console.log('Mobile device detected:', isMobileDevice); // Add mobile class to body if (isMobileDevice) { document.body.classList.add('mobile-device'); } return isMobileDevice; } // IRC-style Chat Functions function setupChatEventListeners() { // Desktop chat listeners if (chatInput && sendButton) { chatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); } }); sendButton.addEventListener('click', sendChatMessage); chatInput.addEventListener('focus', () => { chatInput.classList.add('focused'); }); chatInput.addEventListener('blur', () => { chatInput.classList.remove('focused'); }); } // Desktop toggle if (toggleChatButton) { toggleChatButton.addEventListener('click', toggleChatPanel); } // Mobile chat listeners if (mobileChatToggle) { mobileChatToggle.addEventListener('click', openMobileChat); } if (closeMobileChatButton) { closeMobileChatButton.addEventListener('click', closeMobileChat); } if (mobileChatInput && mobileSendButton) { mobileChatInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMobileChatMessage(); } }); mobileSendButton.addEventListener('click', sendMobileChatMessage); // Prevent zoom on iOS when focusing input if (isMobileDevice && /iPhone|iPad|iPod/i.test(navigator.userAgent)) { mobileChatInput.addEventListener('focus', (e) => { e.target.style.fontSize = '16px'; }); mobileChatInput.addEventListener('blur', (e) => { e.target.style.fontSize = ''; }); } } // Handle orientation changes window.addEventListener('orientationchange', () => { setTimeout(() => { handleOrientationChange(); }, 100); }); // Handle window resize for responsive updates window.addEventListener('resize', debounce(() => { detectMobileDevice(); handleOrientationChange(); }, 250)); } function sendChatMessage() { const message = chatInput.value.trim(); if (!message || !socket) return; // Check for IRC-style commands if (message.startsWith('/')) { handleChatCommand(message); chatInput.value = ''; chatInput.focus(); return; } // Display own message immediately displayChatMessage(username, message, Date.now(), true); // Send to server socket.emit('chat_message', { username: username, message: message, timestamp: Date.now() }); // Clear input chatInput.value = ''; chatInput.focus(); } function handleChatCommand(command) { const [cmd, ...args] = command.toLowerCase().split(' '); switch (cmd) { case '/clear': clearChatHistory(); displaySystemMessage('* Chat cleared'); break; case '/me': const action = args.join(' '); if (action) { const actionMessage = `* ${username} ${action}`; displaySystemMessage(actionMessage, 'system-message'); socket.emit('chat_message', { username: username, message: `/me ${action}`, timestamp: Date.now() }); } break; case '/help': displaySystemMessage('* Available commands:'); displaySystemMessage('* /clear - Clear chat history'); displaySystemMessage('* /me - Send action message'); displaySystemMessage('* /users - List connected users'); displaySystemMessage('* /time - Show current time'); break; case '/users': const userCount = Object.keys(peerConnections).length + 1; displaySystemMessage(`* Users in #videochat: ${userCount} (including you)`); break; case '/time': const now = new Date().toLocaleString(); displaySystemMessage(`* Current time: ${now}`); break; default: displaySystemMessage(`* Unknown command: ${cmd}. Type /help for available commands.`); } } function displayChatMessage(senderUsername, message, timestamp, isOwnMessage = false) { const messageElement = document.createElement('div'); messageElement.className = `message ${isOwnMessage ? 'own-message' : ''}`; const time = new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); // Handle /me actions if (message.startsWith('/me ')) { const action = message.substring(4); messageElement.innerHTML = ` [${time}] * ${senderUsername} ${escapeHtml(action)} `; messageElement.className = 'message action-message'; } else { messageElement.innerHTML = ` [${time}] <${senderUsername}> ${escapeHtml(message)} `; } // Add to both desktop and mobile chat containers if (chatMessages) { chatMessages.appendChild(messageElement); scrollChatToBottom(); } // Clone message for mobile chat if (mobileChatMessages) { const mobileMessageElement = messageElement.cloneNode(true); mobileChatMessages.appendChild(mobileMessageElement); if (isMobileChatOpen) { scrollMobileChatToBottom(); } } // Store in history chatHistory.push({ username: senderUsername, message: message, timestamp: timestamp, isOwnMessage: isOwnMessage }); // Limit chat history to prevent memory issues if (chatHistory.length > 1000) { chatHistory = chatHistory.slice(-500); // Remove old DOM elements from both containers if (chatMessages && chatMessages.children.length > 500) { while (chatMessages.children.length > 500) { chatMessages.removeChild(chatMessages.firstChild); } } if (mobileChatMessages && mobileChatMessages.children.length > 500) { while (mobileChatMessages.children.length > 500) { mobileChatMessages.removeChild(mobileChatMessages.firstChild); } } } } function displaySystemMessage(message, className = 'system-message') { const messageElement = document.createElement('div'); messageElement.className = className; messageElement.textContent = message; // Add to both containers if (chatMessages) { chatMessages.appendChild(messageElement); scrollChatToBottom(); } if (mobileChatMessages) { const mobileMessageElement = messageElement.cloneNode(true); mobileChatMessages.appendChild(mobileMessageElement); if (isMobileChatOpen) { scrollMobileChatToBottom(); } } } function scrollChatToBottom() { chatMessages.scrollTop = chatMessages.scrollHeight; } function toggleChatPanel() { isChatCollapsed = !isChatCollapsed; chatPanel.classList.toggle('collapsed', isChatCollapsed); if (!isChatCollapsed) { // Focus input when expanding setTimeout(() => chatInput.focus(), 300); scrollChatToBottom(); } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function clearChatHistory() { chatHistory = []; const defaultMessages = `
* Now talking in #videochat
* Topic is: Welcome to the video chat room
`; if (chatMessages) { chatMessages.innerHTML = defaultMessages; } if (mobileChatMessages) { mobileChatMessages.innerHTML = defaultMessages; } } // Mobile-specific chat functions function openMobileChat() { isMobileChatOpen = true; mobileChatOverlay.classList.remove('hidden'); // Sync messages from desktop chat syncChatMessages(); // Focus input after animation setTimeout(() => { if (mobileChatInput) { mobileChatInput.focus(); } scrollMobileChatToBottom(); }, 300); // Prevent body scrolling when chat is open document.body.style.overflow = 'hidden'; } function closeMobileChat() { isMobileChatOpen = false; mobileChatOverlay.classList.add('hidden'); // Restore body scrolling document.body.style.overflow = ''; // Blur input to hide keyboard if (mobileChatInput) { mobileChatInput.blur(); } } function sendMobileChatMessage() { const message = mobileChatInput.value.trim(); if (!message || !socket) return; // Check for IRC-style commands if (message.startsWith('/')) { handleChatCommand(message); mobileChatInput.value = ''; mobileChatInput.focus(); return; } // Display own message immediately displayChatMessage(username, message, Date.now(), true); // Send to server socket.emit('chat_message', { username: username, message: message, timestamp: Date.now() }); // Clear input and maintain focus mobileChatInput.value = ''; mobileChatInput.focus(); } function syncChatMessages() { if (!mobileChatMessages) return; // Copy all messages from desktop chat to mobile chat mobileChatMessages.innerHTML = chatMessages ? chatMessages.innerHTML : ''; scrollMobileChatToBottom(); } function scrollMobileChatToBottom() { if (mobileChatMessages) { mobileChatMessages.scrollTop = mobileChatMessages.scrollHeight; } } function handleOrientationChange() { if (isMobileDevice) { // Adjust layout after orientation change setTimeout(() => { if (isMobileChatOpen) { scrollMobileChatToBottom(); } else { scrollChatToBottom(); } // Force video layout recalculation const videos = document.querySelectorAll('video'); videos.forEach(video => { if (video.srcObject) { video.style.height = 'auto'; } }); }, 500); } } // Utility function for debouncing function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Focus username input on load and initialize theme document.addEventListener('DOMContentLoaded', async () => { await loadConfig(); detectMobileDevice(); // Only focus input on non-mobile devices to prevent keyboard popup if (!isMobileDevice) { usernameInput.focus(); } initTheme(); setupChatEventListeners(); });