diff --git a/main.js b/main.js new file mode 100644 index 0000000..72cd605 --- /dev/null +++ b/main.js @@ -0,0 +1,427 @@ +let localStream; +let peerConnections = {}; +let username = ''; +let isVideoEnabled = true; +let isAudioEnabled = true; +let isSelfViewVisible = false; + +// 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'); + +// Initialize Socket.IO connection (but don't connect yet) +let socket; + +// ICE servers configuration for WebRTC +const iceServers = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] +}; + +// 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; + } + + username = inputUsername; + selfViewUsername.textContent = username; + + try { + // Get user media + localStream = await navigator.mediaDevices.getUserMedia({video: true, audio: true}); + 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(); + + } 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() { + socket = io('https://localhost:3000', { + secure: true, + rejectUnauthorized: false // For self-signed certificates in development + }); + + setupSocketListeners(); +} + +// 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); + }); +} + +// WebRTC functions +function createPeerConnection(userId, shouldCreateOffer = false, remoteUsername = null) { + const peerConnection = new RTCPeerConnection(iceServers); + peerConnections[userId] = { + pc: peerConnection, + username: remoteUsername + }; + + // 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 { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + socket.emit('offer', { + offer: offer, + target_id: userId + }); + console.log('Offer sent to:', userId); + } catch (error) { + console.error('Error creating offer:', error); + } +} + +async function handleOffer(data) { + try { + const 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 { + const peerConnectionObj = peerConnections[data.target_id] || peerConnections[Object.keys(peerConnections)[0]]; + if (peerConnectionObj && data.candidate) { + await peerConnectionObj.pc.addIceCandidate(data.candidate); + console.log('ICE candidate added'); + } + } catch (error) { + console.error('Error handling ICE candidate:', error); + } +} + +function addRemoteVideo(userId, stream, remoteUsername = null) { + // Remove existing video if any + const existingContainer = document.getElementById(userId); + if (existingContainer) { + existingContainer.remove(); + } + + const remoteVideoContainer = document.createElement('div'); + remoteVideoContainer.className = 'remoteVideoContainer'; + remoteVideoContainer.id = userId; + + const remoteVideo = document.createElement('video'); + remoteVideo.className = 'remoteVideo'; + remoteVideo.autoplay = true; + remoteVideo.srcObject = stream; + remoteVideoContainer.appendChild(remoteVideo); + + const usernameElement = document.createElement('div'); + usernameElement.className = 'remoteUsername'; + usernameElement.textContent = `${remoteUsername || `user_${userId.substring(0, 8)}`}`; + remoteVideoContainer.appendChild(usernameElement); + + remoteVideos.appendChild(remoteVideoContainer); +} + +// 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) { + const videoContainer = document.getElementById(userId); + if (videoContainer) { + videoContainer.remove(); + } + + const peerConnectionObj = peerConnections[userId]; + if (peerConnectionObj) { + peerConnectionObj.pc.close(); + delete peerConnections[userId]; + } +} + +// Button event handlers +muteButton.addEventListener('click', () => { + if (localStream) { + const audioTrack = localStream.getAudioTracks()[0]; + if (audioTrack) { + audioTrack.enabled = !audioTrack.enabled; + isAudioEnabled = audioTrack.enabled; + + const span = muteButton.querySelector('span'); + + if (isAudioEnabled) { + span.textContent = 'mic'; + muteButton.classList.remove('muted'); + } else { + span.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 span = videoButton.querySelector('span'); + + // Update local video display visibility + const localVideoDisplay = document.querySelector('#local-video-display .remoteVideo'); + + if (isVideoEnabled) { + span.textContent = 'cam'; + videoButton.classList.remove('disabled'); + if (localVideoDisplay) { + localVideoDisplay.style.visibility = 'visible'; + } + if (selfVideo && isSelfViewVisible) { + selfVideo.style.visibility = 'visible'; + } + } else { + span.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', () => { + // Close all peer connections + Object.values(peerConnections).forEach(pcObj => pcObj.pc.close()); + peerConnections = {}; + + // Stop local stream + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + localVideo.srcObject = null; + selfVideo.srcObject = null; + } + + // Remove all videos (including local video display) + while (remoteVideos.firstChild) { + remoteVideos.firstChild.remove(); + } + + // Disconnect from server + if (socket) { + socket.disconnect(); + } + + // Reset UI + chatInterface.classList.add('hidden'); + usernameModal.classList.remove('hidden'); + usernameInput.value = ''; + selfViewContainer.classList.add('hidden'); + isSelfViewVisible = false; + + console.log('Left the chat'); +}); + +// Theme toggle functionality +function initTheme() { + const savedTheme = localStorage.getItem('theme') || 'light'; + 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); + +// Focus username input on load and initialize theme +document.addEventListener('DOMContentLoaded', () => { + usernameInput.focus(); + initTheme(); +}); \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1f74efa --- /dev/null +++ b/styles.css @@ -0,0 +1,438 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --text-primary: #212529; + --text-secondary: #6c757d; + --text-muted: #adb5bd; + --border-color: #dee2e6; + --accent-color: #0d6efd; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); +} + +[data-theme="dark"] { + --bg-primary: #212529; + --bg-secondary: #2d3338; + --bg-tertiary: #343a40; + --text-primary: #f8f9fa; + --text-secondary: #adb5bd; + --text-muted: #6c757d; + --border-color: #495057; + --accent-color: #0d6efd; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Utility Classes */ +.hidden { + display: none !important; +} + +/* Username Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 2rem; + text-align: center; + max-width: 420px; + width: 90%; + box-shadow: var(--shadow-lg); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header h2 { + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.modal-header p { + color: var(--text-secondary); + margin-bottom: 1.5rem; + font-size: 0.95rem; +} + +.input-group { + position: relative; + margin-bottom: 1.5rem; +} + +.input-group input { + width: 100%; + padding: 0.875rem 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + font-size: 1rem; + color: var(--text-primary); + transition: all 0.2s ease; + outline: none; +} + +.input-group input:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.1); + background: var(--bg-primary); +} + +.input-group input::placeholder { + color: var(--text-muted); +} + +#joinButton { + width: 100%; + padding: 0.875rem 1rem; + background: var(--accent-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +#joinButton:hover { + background: #0b5ed7; + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +/* Chat Interface */ +#chatInterface { + height: 100vh; + display: flex; + flex-direction: column; + margin: 10px; + height: calc(100vh - 20px); + border-radius: 12px; + border: 1px solid var(--border-color); + overflow: hidden; + background: var(--bg-primary); +} + +/* Header */ +.chat-header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header-left h1 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0; +} + +#participantCount { + color: var(--text-secondary); + font-size: 0.9rem; + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--border-color); +} + +.header-btn { + padding: 0.5rem 1rem; + background: var(--accent-color); + border: none; + border-radius: 6px; + cursor: pointer; + color: white; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.header-btn:hover { + background: #0b5ed7; + transform: translateY(-1px); +} + +/* Main Chat Area */ +.chat-main { + flex: 1; + position: relative; + padding: 1rem; + overflow: hidden; + background: var(--bg-primary); +} + +/* Video Grid */ +.video-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + height: 100%; + justify-content: flex-start; + align-items: flex-start; + padding: 1rem; +} + +.remoteVideoContainer { + position: relative; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + min-width: 200px; + aspect-ratio: 16 / 9; + box-shadow: var(--shadow); + transition: all 0.2s ease; +} + +.remoteVideoContainer:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.remoteVideo { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remoteUsername { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + backdrop-filter: blur(10px); +} + +/* Responsive grid sizing */ +.remoteVideoContainer:only-child { + width: min(70vw, 800px); +} + +.video-grid:has(.remoteVideoContainer:nth-child(2)) .remoteVideoContainer { + width: min(45vw, 500px); +} + +.video-grid:has(.remoteVideoContainer:nth-child(3)) .remoteVideoContainer { + width: min(30vw, 350px); +} + +.video-grid:has(.remoteVideoContainer:nth-child(4)) .remoteVideoContainer { + width: min(22vw, 280px); +} + +/* Self View */ +.self-view { + position: fixed; + bottom: 4rem; + right: 1rem; + width: 200px; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow-lg); + z-index: 200; +} + +.self-view:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +#selfVideo { + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); /* Mirror effect */ +} + +.self-view-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + text-align: center; + backdrop-filter: blur(10px); +} + +/* Chat Controls */ +.chat-controls { + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + padding: 1rem; + display: flex; + justify-content: center; + gap: 1rem; +} + +.control-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; +} + +.control-btn:hover { + background: var(--accent-color); + color: white; + border-color: var(--accent-color); + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.control-btn span { + font-size: 0.875rem; +} + +.mute-btn.muted { + background: #dc3545; + color: white; + border-color: #dc3545; +} + +.mute-btn.muted:hover { + background: #c82333; + transform: translateY(-1px); +} + +.video-btn.disabled { + background: #dc3545; + color: white; + border-color: #dc3545; +} + +.video-btn.disabled:hover { + background: #c82333; + transform: translateY(-1px); +} + +.hangup-btn { + background: #dc3545; + color: white; + border-color: #dc3545; +} + +.hangup-btn:hover { + background: #c82333; + transform: translateY(-1px); +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .chat-header { + padding: 1rem; + } + + .header-left h1 { + font-size: 1.2rem; + } + + .chat-main { + padding: 0.5rem; + } + + .remoteVideoContainer:only-child { + width: 90vw; + } + + .video-grid:has(.remoteVideoContainer:nth-child(2)) .remoteVideoContainer { + width: 45vw; + } + + .video-grid:has(.remoteVideoContainer:nth-child(3)) .remoteVideoContainer { + width: 45vw; + } + + .video-grid:has(.remoteVideoContainer:nth-child(4)) .remoteVideoContainer { + width: 45vw; + } + + .self-view { + width: 120px; + bottom: 6rem; + } + + .chat-controls { + gap: 0.5rem; + padding: 0.75rem; + } + + .control-btn { + padding: 0.75rem; + min-width: 60px; + } + + .control-btn span { + font-size: 0.8rem; + } +} \ No newline at end of file