Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>WhatsApp Style Chat + Video</title> | |
| <!-- Include Emoji Picker library --> | |
| <script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script> | |
| <style> | |
| /* Basic Reset & Body Styling */ | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { height: 100%; overflow: hidden; } /* Prevent scrolling */ | |
| body { | |
| font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; | |
| background-color: #DADBD3; /* WhatsApp Web-like background */ | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| position: relative; /* Needed for absolute positioning of call container */ | |
| } | |
| /* Container to hold the chat app */ | |
| .app-container { | |
| width: 100%; | |
| max-width: 800px; | |
| height: 100%; /* Use full viewport height */ | |
| max-height: 95vh; /* Max height limit */ | |
| background-color: #E5DDD5; /* Chat background */ | |
| display: flex; | |
| flex-direction: column; | |
| box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.2); | |
| overflow: hidden; /* Important for layout */ | |
| position: relative; /* Context for absolute video container */ | |
| } | |
| /* --- Login Screen --- */ | |
| #login-screen { | |
| display: flex; flex-direction: column; justify-content: center; align-items: center; | |
| text-align: center; padding: 40px; background-color: #f8f9fa; height: 100%; | |
| } | |
| #login-screen h1 { color: #444; margin-bottom: 30px; font-weight: 300; } | |
| /* --- Login Screen Form --- */ | |
| #login-form { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| #login-screen input[type="text"], | |
| #login-screen input[type="password"] { /* Style both inputs */ | |
| padding: 12px 15px; | |
| margin-bottom: 20px; | |
| border: 1px solid #ccc; | |
| border-radius: 20px; | |
| width: 250px; | |
| font-size: 1em; | |
| outline: none; | |
| } | |
| #login-screen input[type="text"]:focus, | |
| #login-screen input[type="password"]:focus { | |
| border-color: #075E54; | |
| box-shadow: 0 0 0 2px rgba(7, 94, 84, 0.2); | |
| } | |
| #login-screen button { | |
| padding: 12px 30px; | |
| background-color: #075E54; | |
| color: white; | |
| border: none; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| font-size: 1em; | |
| transition: background-color 0.2s; | |
| } | |
| #login-screen button:hover { background-color: #128C7E; } | |
| /* --- Login Error Message --- */ | |
| #login-error { | |
| color: #d9534f; /* Red color for errors */ | |
| margin-top: 15px; | |
| font-size: 0.9em; | |
| height: 1.2em; /* Reserve space even when empty */ | |
| text-align: center; | |
| font-weight: bold; | |
| min-width: 250px; /* Match input width roughly */ | |
| } | |
| /* --- Chat Screen --- */ | |
| #chat-screen { | |
| display: none; /* Hidden initially */ | |
| flex-direction: column; | |
| height: 100%; | |
| width: 100%; | |
| } | |
| /* Chat Header */ | |
| .chat-header { | |
| background-color: #075E54; | |
| color: white; | |
| padding: 10px 15px; | |
| display: flex; | |
| align-items: center; | |
| font-size: 1.1em; | |
| min-height: 60px; /* Fixed height */ | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| } | |
| .chat-header h1 { | |
| font-size: 1.2em; | |
| font-weight: 500; | |
| } | |
| /* Messages Area */ | |
| #messages { | |
| flex-grow: 1; /* Takes available space */ | |
| padding: 20px 5%; /* Padding left/right */ | |
| overflow-y: auto; /* Enable scrolling */ | |
| background-color: #E5DDD5; /* Default chat background color */ | |
| /* Optional: Add the WhatsApp background pattern */ | |
| /* background-image: url('https://user-images.githubusercontent.com/15075759/28719144-86dc0f70-73b1-11e7-911d-60d70fcded21.png'); */ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; /* Small gap between elements (messages/separators) */ | |
| } | |
| /* --- Date Separator --- */ | |
| .date-separator { | |
| align-self: center; | |
| background-color: #e1f7fb; /* Light blueish */ | |
| color: #586063; | |
| font-size: 0.75em; | |
| padding: 4px 10px; | |
| border-radius: 8px; | |
| margin: 15px 0 10px 0; /* Space around separator */ | |
| font-weight: 500; | |
| box-shadow: 0 1px 1px rgba(0,0,0,0.05); | |
| } | |
| /* Individual Message Container Styling */ | |
| .message { | |
| display: flex; | |
| max-width: 70%; /* Max width of a message bubble + avatar */ | |
| align-items: flex-end; /* Align avatar and bubble bottom */ | |
| gap: 8px; /* Space between avatar and bubble */ | |
| margin-bottom: 5px; /* Space below each message */ | |
| position: relative; /* Added relative for call button */ | |
| } | |
| /* --- Avatar Styling --- */ | |
| .avatar { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| background-color: #ccc; /* Default bg */ | |
| color: white; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8em; | |
| font-weight: bold; | |
| text-transform: uppercase; | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| } | |
| /* Message Bubble Styling */ | |
| .message-bubble { | |
| padding: 8px 12px; | |
| border-radius: 8px; | |
| position: relative; /* For timestamp positioning */ | |
| word-wrap: break-word; /* Wrap long words */ | |
| box-shadow: 0 1px 1px rgba(0,0,0,0.1); | |
| min-width: 80px; /* Ensure timestamp fits well */ | |
| } | |
| .message .username { | |
| font-weight: bold; | |
| font-size: 0.9em; | |
| margin-bottom: 3px; | |
| color: #075E54; /* Default color, can be adjusted */ | |
| display: block; /* Take its own line if needed */ | |
| } | |
| .message .text { | |
| font-size: 0.95em; | |
| margin-right: 45px; /* Space for timestamp */ | |
| line-height: 1.4; | |
| color: #303030; /* Main text color */ | |
| } | |
| .message .time { | |
| font-size: 0.7em; | |
| color: #999; | |
| position: absolute; /* Position relative to bubble */ | |
| bottom: 5px; | |
| right: 8px; | |
| white-space: nowrap; /* Prevent timestamp wrapping */ | |
| } | |
| /* Incoming Message Styling */ | |
| .message.incoming { | |
| align-self: flex-start; /* Align container to left */ | |
| } | |
| .message.incoming .message-bubble { | |
| background-color: #FFFFFF; /* White bubble */ | |
| border-top-left-radius: 0; /* Flat corner like WA */ | |
| } | |
| /* Optionally assign different colors to incoming usernames */ | |
| .message.incoming .username { | |
| /* Example: Use avatar color logic here if desired */ | |
| color: hsl(var(--user-hue, 0), 50%, 40%); | |
| } | |
| /* Hide avatar placeholder for outgoing messages */ | |
| .message.outgoing .avatar { | |
| display: none; | |
| } | |
| /* Outgoing Message Styling */ | |
| .message.outgoing { | |
| align-self: flex-end; /* Align container to right */ | |
| } | |
| .message.outgoing .message-bubble { | |
| background-color: #DCF8C6; /* Light green bubble */ | |
| border-top-right-radius: 0; /* Flat corner like WA */ | |
| } | |
| /* Hide username for outgoing messages for cleaner look */ | |
| .message.outgoing .username { | |
| display: none; | |
| } | |
| .message.outgoing .time { | |
| color: #6aaa96; /* Slightly different time color */ | |
| } | |
| /* System Message Styling */ | |
| .system-message { | |
| align-self: center; /* Center align */ | |
| background-color: #E1F3FB; /* Light blue background */ | |
| color: #555; | |
| font-size: 0.8em; | |
| padding: 5px 10px; | |
| border-radius: 10px; | |
| margin: 10px 0; | |
| font-style: italic; | |
| box-shadow: 0 1px 1px rgba(0,0,0,0.05); | |
| } | |
| /* --- Call Button on Incoming Messages --- */ | |
| .call-button { | |
| background: none; border: none; cursor: pointer; | |
| font-size: 1.1em; color: #075E54; padding: 2px 5px; | |
| position: absolute; /* Position near the message bubble */ | |
| top: -10px; /* Adjust as needed */ | |
| right: -25px; /* Adjust as needed */ | |
| display: none; /* Hide by default */ | |
| opacity: 0.7; | |
| transition: opacity 0.2s; | |
| z-index: 5; /* Ensure it's clickable over potential bubble margins */ | |
| } | |
| .message.incoming:hover .call-button { | |
| display: inline-block; /* Show on hover of incoming message */ | |
| } | |
| .call-button:hover { | |
| opacity: 1; | |
| color: #128C7E; | |
| } | |
| /* --- Footer Area (Input + Typing Indicator) --- */ | |
| .chat-footer { | |
| background-color: #F0F0F0; /* Light grey background for whole footer */ | |
| padding: 5px 15px 10px 15px; /* Padding top adjusted */ | |
| display: flex; | |
| flex-direction: column; /* Stack input form and typing indicator */ | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| border-top: 1px solid #e0e0e0; /* Subtle top border */ | |
| position: relative; /* Needed for emoji picker positioning context */ | |
| } | |
| /* --- Typing Indicator --- */ | |
| #typing-indicator { | |
| height: 20px; /* Reserve space */ | |
| font-size: 0.8em; | |
| color: #777; | |
| font-style: italic; | |
| padding-left: 5px; /* Align with start of input area roughly */ | |
| min-height: 20px; /* Ensure it takes space even when empty */ | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| visibility: hidden; /* Hide until someone is typing */ | |
| } | |
| /* Message Input Form */ | |
| #message-form { | |
| display: flex; | |
| align-items: center; | |
| width: 100%; /* Take full width within footer */ | |
| } | |
| /* --- Emoji Button --- */ | |
| #emoji-button { | |
| background: none; | |
| border: none; | |
| font-size: 1.6em; /* Larger emoji icon */ | |
| padding: 8px; | |
| cursor: pointer; | |
| color: #54656f; | |
| margin-right: 5px; | |
| flex-shrink: 0; | |
| } | |
| #emoji-button:hover { | |
| color: #3b4a54; | |
| } | |
| /* Input Field */ | |
| #message-input { | |
| flex-grow: 1; | |
| padding: 10px 15px; | |
| border: none; | |
| border-radius: 20px; /* Rounded input field */ | |
| margin-right: 10px; | |
| font-size: 1em; | |
| outline: none; | |
| background-color: #fff; /* White background for input */ | |
| } | |
| /* Send Button */ | |
| #message-form button[type="submit"] { | |
| background-color: #128C7E; /* WhatsApp Send Green */ | |
| color: white; | |
| border: none; | |
| border-radius: 50%; /* Circular button */ | |
| width: 44px; | |
| height: 44px; | |
| cursor: pointer; | |
| font-size: 1.5em; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| transition: background-color 0.2s; | |
| flex-shrink: 0; | |
| /* Simple Send Icon using SVG */ | |
| padding-bottom: 2px; /* Adjust symbol position slightly */ | |
| } | |
| /* Simple SVG Send Icon */ | |
| #message-form button[type="submit"] svg { | |
| width: 24px; | |
| height: 24px; | |
| fill: white; | |
| } | |
| #message-form button[type="submit"]:hover { | |
| background-color: #075E54; /* Darker green on hover */ | |
| } | |
| /* --- Emoji Picker --- */ | |
| emoji-picker { | |
| position: absolute; | |
| bottom: 65px; /* Position above input area footer */ | |
| left: 10px; /* Adjust left positioning */ | |
| z-index: 10; | |
| display: none; /* Hidden by default */ | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.2); | |
| border-radius: 8px; | |
| border: 1px solid #ccc; | |
| } | |
| emoji-picker.visible { | |
| display: block; | |
| } | |
| /* Hide the original separate user list block (if it existed) */ | |
| #user-list { display: none; } | |
| /* --- Video Call Container --- */ | |
| #call-container { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background-color: rgba(0, 0, 0, 0.9); /* Dark overlay */ | |
| display: none; /* Hidden by default */ | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 100; | |
| padding: 20px; | |
| } | |
| #call-container.active { | |
| display: flex; /* Show when call is active */ | |
| } | |
| #video-grid { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 10px; | |
| width: 100%; | |
| max-width: 700px; /* Limit width */ | |
| position: relative; /* For positioning local video */ | |
| flex-grow: 1; /* Take available space */ | |
| min-height: 200px; /* Ensure grid takes some space even before video loads */ | |
| } | |
| #remote-video { | |
| width: 100%; /* Take full width */ | |
| max-height: 80vh; /* Limit height */ | |
| background-color: #222; | |
| border-radius: 8px; | |
| object-fit: contain; /* Scale video nicely */ | |
| } | |
| #local-video { | |
| width: 25%; /* Smaller local video */ | |
| max-width: 150px; | |
| position: absolute; | |
| bottom: 15px; | |
| right: 15px; | |
| border: 2px solid rgba(255, 255, 255, 0.5); | |
| border-radius: 5px; | |
| background-color: #333; | |
| object-fit: cover; /* Fill the small frame */ | |
| z-index: 101; /* Above remote video if overlapping */ | |
| } | |
| #call-controls { | |
| display: flex; | |
| gap: 20px; | |
| margin-top: 15px; | |
| flex-shrink: 0; /* Prevent shrinking */ | |
| } | |
| #call-controls button { | |
| padding: 12px 18px; | |
| border-radius: 50%; /* Circular buttons */ | |
| border: none; | |
| cursor: pointer; | |
| font-size: 1.4em; /* Icon size */ | |
| width: 55px; | |
| height: 55px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| transition: background-color 0.2s; | |
| } | |
| #hangup-button { | |
| background-color: #ff4d4d; /* Red */ | |
| color: white; | |
| } | |
| #hangup-button:hover { | |
| background-color: #e60000; | |
| } | |
| /* --- Incoming Call Notification --- */ | |
| #incoming-call-notification { | |
| position: absolute; | |
| top: 70px; /* Below header */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: #075E54; | |
| color: white; | |
| padding: 15px 25px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.3); | |
| z-index: 110; | |
| display: none; /* Hidden by default */ | |
| text-align: center; | |
| min-width: 280px; | |
| } | |
| #incoming-call-notification p { | |
| margin-bottom: 15px; | |
| font-size: 1.1em; | |
| } | |
| #incoming-call-controls button { | |
| padding: 8px 15px; | |
| margin: 0 10px; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| transition: background-color 0.2s; | |
| } | |
| #accept-call-button { background-color: #25D366; color: white; } | |
| #reject-call-button { background-color: #ff4d4d; color: white; } | |
| #accept-call-button:hover { background-color: #1DAF53; } | |
| #reject-call-button:hover { background-color: #e60000; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Main container for the app --> | |
| <div class="app-container"> | |
| <!-- Login Screen View --> | |
| <div id="login-screen"> | |
| <h1>Login or Register</h1> | |
| <form id="login-form"> | |
| <input type="text" id="username-input" placeholder="Enter your username" required autocomplete="username"> | |
| <input type="password" id="password-input" placeholder="Enter your password" required autocomplete="current-password"> | |
| <button type="submit">Login / Register</button> | |
| </form> | |
| <!-- Area to display login errors --> | |
| <div id="login-error"></div> | |
| </div> | |
| <!-- Chat Screen View (Initially Hidden) --> | |
| <div id="chat-screen"> | |
| <!-- Chat Header --> | |
| <div class="chat-header"> | |
| <h1 id="chat-title">Group Chat</h1> | |
| </div> | |
| <!-- Message Display Area --> | |
| <div id="messages"> | |
| <!-- Messages, Date Separators, Avatars will be added here by JS --> | |
| </div> | |
| <!-- Footer: Typing Indicator + Input Form --> | |
| <div class="chat-footer"> | |
| <div id="typing-indicator"></div> <!-- Typing indicator display --> | |
| <form id="message-form"> | |
| <!-- Emoji toggle button --> | |
| <button type="button" id="emoji-button">😀</button> | |
| <!-- Message text input --> | |
| <input type="text" id="message-input" placeholder="Type a message" required autocomplete="off"> | |
| <!-- Send message button --> | |
| <button type="submit"> | |
| <svg viewBox="0 0 24 24"><path fill="currentColor" d="M1.101 21.757 23.8 12.028 1.101 2.3l.011 7.912 13.623 1.816-13.623 1.817-.011 7.912z"></path></svg> | |
| </button> | |
| </form> | |
| </div> | |
| <!-- Emoji Picker Element (positioned absolutely relative to footer) --> | |
| <emoji-picker class="light"></emoji-picker> | |
| </div> <!-- End #chat-screen --> | |
| <!-- Video Call UI --> | |
| <div id="call-container"> | |
| <div id="video-grid"> | |
| <video id="remote-video" playsinline autoplay></video> | |
| <video id="local-video" playsinline autoplay muted></video> <!-- Muted local video --> | |
| </div> | |
| <div id="call-controls"> | |
| <button id="hangup-button">❌</button> <!-- Simple hangup icon --> | |
| <!-- Add mute/video toggle buttons later if needed --> | |
| </div> | |
| </div> | |
| <!-- Incoming Call Notification --> | |
| <div id="incoming-call-notification"> | |
| <p id="incoming-call-text">Incoming call from User...</p> | |
| <div id="incoming-call-controls"> | |
| <button id="accept-call-button">✔️ Accept</button> | |
| <button id="reject-call-button">✖️ Reject</button> | |
| </div> | |
| </div> | |
| </div> <!-- End .app-container --> | |
| <!-- Include Socket.IO client library --> | |
| <script src="/socket.io/socket.io.js"></script> | |
| <!-- Main application JavaScript --> | |
| <script> | |
| const socket = io(); | |
| let currentUsername = ''; // Stores the username after successful login | |
| let lastMessageDate = null; // Tracks date for separators | |
| let typingTimeout = null; // Timer for typing indicator | |
| // --- DOM Elements --- | |
| const loginScreen = document.getElementById('login-screen'); | |
| const chatScreen = document.getElementById('chat-screen'); | |
| const loginForm = document.getElementById('login-form'); | |
| const usernameInput = document.getElementById('username-input'); | |
| const passwordInput = document.getElementById('password-input'); | |
| const loginError = document.getElementById('login-error'); | |
| const messagesDiv = document.getElementById('messages'); | |
| const messageForm = document.getElementById('message-form'); | |
| const messageInput = document.getElementById('message-input'); | |
| const chatTitle = document.getElementById('chat-title'); | |
| const typingIndicator = document.getElementById('typing-indicator'); | |
| const emojiButton = document.getElementById('emoji-button'); | |
| const emojiPicker = document.querySelector('emoji-picker'); | |
| // --- Video Call DOM Elements & State --- | |
| const callContainer = document.getElementById('call-container'); | |
| const localVideo = document.getElementById('local-video'); | |
| const remoteVideo = document.getElementById('remote-video'); | |
| const hangupButton = document.getElementById('hangup-button'); | |
| const incomingCallNotification = document.getElementById('incoming-call-notification'); | |
| const incomingCallText = document.getElementById('incoming-call-text'); | |
| const acceptCallButton = document.getElementById('accept-call-button'); | |
| const rejectCallButton = document.getElementById('reject-call-button'); | |
| let localStream = null; | |
| let peerConnection = null; | |
| let isCallActive = false; | |
| let otherUserInCall = null; // Stores the username of the person we are calling/is calling us | |
| let pendingCallOffer = null; // Store offer if received before local media ready | |
| // STUN servers configuration (using Google's public servers) | |
| const iceServers = { | |
| iceServers: [ | |
| { urls: 'stun:stun.l.google.com:19302' }, | |
| { urls: 'stun:stun1.l.google.com:19302' }, | |
| // Add TURN servers here if you have them for better reliability | |
| ], | |
| }; | |
| // --- END: Video Call DOM Elements & State --- | |
| // --- Event Listeners (Chat + Call) --- | |
| // Login form submission | |
| loginForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const username = usernameInput.value.trim(); | |
| const password = passwordInput.value; // Get password | |
| loginError.textContent = ''; // Clear previous errors | |
| if (username && password) { | |
| socket.emit('attempt_login', { username, password }); | |
| } else { | |
| loginError.textContent = 'Please enter both username and password.'; | |
| } | |
| }); | |
| // Message form submission | |
| messageForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const text = messageInput.value.trim(); | |
| if (text && currentUsername) { | |
| const timestamp = new Date().toISOString(); // Use ISO string for consistency | |
| // Display own message immediately | |
| addMessage(currentUsername, text, timestamp); | |
| // Send message data to server | |
| socket.emit('message', { text }); | |
| // Clear typing status immediately | |
| if (typingTimeout) { | |
| clearTimeout(typingTimeout); | |
| typingTimeout = null; | |
| socket.emit('stop_typing'); // Inform server typing stopped | |
| } | |
| messageInput.value = ''; // Clear input field | |
| emojiPicker.classList.remove('visible'); // Hide emoji picker | |
| messageInput.focus(); // Keep focus on input | |
| } | |
| }); | |
| // --- Typing Indicator Logic --- | |
| messageInput.addEventListener('input', () => { | |
| if (!currentUsername) return; // Ignore typing if not logged in | |
| if (!typingTimeout) { | |
| socket.emit('typing'); // Send 'typing' event only on the first keypress after pause | |
| } else { | |
| clearTimeout(typingTimeout); // Reset the timeout if already typing | |
| } | |
| // Set a timeout to send 'stop_typing' if no input for 1.5 seconds | |
| typingTimeout = setTimeout(() => { | |
| socket.emit('stop_typing'); | |
| typingTimeout = null; // Reset the timeout ID state | |
| }, 1500); | |
| }); | |
| // --- Emoji Picker Logic --- | |
| emojiButton.addEventListener('click', () => { | |
| emojiPicker.classList.toggle('visible'); | |
| if(emojiPicker.classList.contains('visible')) { | |
| messageInput.focus(); // Keep focus on input when picker opens | |
| } | |
| }); | |
| emojiPicker.addEventListener('emoji-click', event => { | |
| messageInput.value += event.detail.unicode; | |
| emojiPicker.classList.remove('visible'); // Hide picker after selection | |
| messageInput.focus(); // Return focus to input field | |
| }); | |
| // Hide emoji picker if a click occurs outside | |
| document.addEventListener('click', (event) => { | |
| const isClickInsidePicker = emojiPicker.contains(event.target); | |
| const isClickOnEmojiButton = (event.target === emojiButton || emojiButton.contains(event.target)); // Check button itself or icon inside | |
| if (!isClickInsidePicker && !isClickOnEmojiButton && emojiPicker.classList.contains('visible')) { | |
| emojiPicker.classList.remove('visible'); | |
| } | |
| }); | |
| // --- Video Call Event Listeners --- | |
| // Hang Up Button | |
| hangupButton.addEventListener('click', () => { | |
| hangUpCall(); // Cleans up locally and optionally notifies server | |
| }); | |
| // Accept Incoming Call Button | |
| acceptCallButton.addEventListener('click', () => { | |
| const callerUsername = incomingCallNotification.dataset.caller; | |
| if (callerUsername) { | |
| console.log(`Accepting call from ${callerUsername}`); | |
| hideIncomingCallNotification(); | |
| socket.emit('accept-call', { callerUsername }); | |
| // The server will respond with 'prepare-for-offer' or handle errors | |
| } else { | |
| console.error("Cannot accept call, caller username not found in notification data."); | |
| hideIncomingCallNotification(); // Hide inconsistent notification | |
| } | |
| }); | |
| // Reject Incoming Call Button | |
| rejectCallButton.addEventListener('click', () => { | |
| const callerUsername = incomingCallNotification.dataset.caller; | |
| if (callerUsername) { | |
| console.log(`Rejecting call from ${callerUsername}`); | |
| hideIncomingCallNotification(); | |
| socket.emit('reject-call', { callerUsername }); | |
| otherUserInCall = null; // Ensure state is cleared locally | |
| } else { | |
| console.error("Cannot reject call, caller username not found in notification data."); | |
| hideIncomingCallNotification(); // Hide inconsistent notification | |
| } | |
| }); | |
| // --- END: Video Call Event Listeners --- | |
| // --- Socket Event Handlers (Chat + Call) --- | |
| // LOGIN SUCCESS | |
| socket.on('login_success', (username) => { | |
| currentUsername = username; // Store logged-in username | |
| loginScreen.style.display = 'none'; // Hide login screen | |
| chatScreen.style.display = 'flex'; // Show chat screen | |
| chatTitle.textContent = `Chatting as ${currentUsername}`; // Update header | |
| messagesDiv.innerHTML = ''; // Clear any previous messages | |
| lastMessageDate = null; // Reset date separator tracking | |
| messageInput.focus(); // Focus the message input field | |
| requestNotificationPermission(); // Ask for notification permission | |
| resetCallState(); // Ensure call state is clean on login | |
| }); | |
| // LOGIN FAIL | |
| socket.on('login_fail', (errorMessage) => { | |
| loginError.textContent = errorMessage; // Show error message | |
| passwordInput.value = ''; // Clear password field for retry | |
| passwordInput.focus(); // Focus password field after error | |
| }); | |
| // INCOMING MESSAGE | |
| socket.on('message', (data) => { | |
| // Only display if the message is from a different user | |
| if (data.username !== currentUsername) { | |
| addMessage(data.username, data.text, data.timestamp); // Pass ISO timestamp | |
| showNotification(`${data.username}: ${data.text}`); // Show notification if tab not active | |
| } | |
| }); | |
| // USER JOIN/LEAVE/LIST | |
| socket.on('user_join', (username) => { | |
| if (username !== currentUsername && currentUsername) { // Also check if current user is logged in | |
| addSystemMessage(`${username} has joined`); | |
| } | |
| }); | |
| socket.on('user_leave', (username) => { | |
| if (username !== currentUsername && currentUsername) { // Check if current user is logged in | |
| addSystemMessage(`${username} has left`); | |
| // If the user who left was the one we were in a call with | |
| if (isCallActive && otherUserInCall === username) { | |
| console.log("Other user disconnected during call."); | |
| addSystemMessage(`Call with ${username} ended (user disconnected).`); | |
| hangUpCall(false); // Clean up locally, don't emit hangup again | |
| } | |
| } | |
| }); | |
| socket.on('user_list', (users) => { | |
| console.log("Online users:", users); | |
| // Potential future use: update online status indicators, etc. | |
| }); | |
| // TYPING STATUS | |
| socket.on('user_typing_status', (typingUsers) => { | |
| // Filter out the current user from the list of typers | |
| const otherTypingUsers = typingUsers.filter(u => u !== currentUsername); | |
| if (otherTypingUsers.length === 0) { | |
| typingIndicator.textContent = ''; // Clear indicator text | |
| typingIndicator.style.visibility = 'hidden'; // Hide the indicator element | |
| } else { | |
| let text = ''; | |
| if (otherTypingUsers.length === 1) { | |
| text = `${otherTypingUsers[0]} is typing...`; | |
| } else if (otherTypingUsers.length <= 3) { | |
| text = `${otherTypingUsers.join(', ')} are typing...`; | |
| } else { | |
| text = 'Several people are typing...'; | |
| } | |
| typingIndicator.textContent = text; // Set the indicator text | |
| typingIndicator.style.visibility = 'visible'; // Make the indicator element visible | |
| } | |
| }); | |
| // --- START: Video Call Socket Handlers --- | |
| // Notification of an incoming call | |
| socket.on('incoming-call', ({ callerUsername }) => { | |
| if (isCallActive || otherUserInCall || incomingCallNotification.style.display === 'block') { | |
| // Already in a call, processing one, or notification already shown | |
| console.warn("Received incoming call while busy or already notified, rejecting."); | |
| socket.emit('reject-call', { callerUsername }); // Inform server to notify caller | |
| return; | |
| } | |
| console.log(`Incoming call from ${callerUsername}`); | |
| otherUserInCall = callerUsername; // Tentatively set the user we might talk to | |
| showIncomingCallNotification(callerUsername); | |
| }); | |
| // The user we called has accepted | |
| socket.on('call-accepted', ({ acceptorUsername }) => { | |
| if (!otherUserInCall || otherUserInCall !== acceptorUsername) { | |
| console.warn(`Received call-accepted from ${acceptorUsername}, but expected ${otherUserInCall || 'nobody'}.`); | |
| // Maybe the call was cancelled or timed out locally? Reset state just in case. | |
| if (!isCallActive) resetCallState(); // Reset if not actually in call setup | |
| return; | |
| } | |
| console.log(`${acceptorUsername} accepted the call. Starting WebRTC offer.`); | |
| // otherUserInCall should already be set correctly from initiateCallRequest | |
| startCallNegotiation(true); // Start negotiation as the caller (will create offer) | |
| }); | |
| // We accepted a call, now prepare for the offer from the caller | |
| socket.on('prepare-for-offer', ({ callerUsername }) => { | |
| if (!otherUserInCall || otherUserInCall !== callerUsername) { | |
| console.warn(`Received prepare-for-offer from ${callerUsername}, but expected ${otherUserInCall || 'nobody'}.`); | |
| // Maybe the user cancelled/rejected before server processed accept? Reset state. | |
| if (!isCallActive) resetCallState(); | |
| return; | |
| } | |
| console.log(`Call accepted. Preparing to receive offer from ${callerUsername}.`); | |
| // otherUserInCall should be set from 'incoming-call' handler or accept button logic | |
| startCallNegotiation(false); // Start negotiation as the callee (will wait for offer) | |
| }); | |
| // The user we called rejected the call | |
| socket.on('call-rejected', ({ rejectorUsername }) => { | |
| if (otherUserInCall === rejectorUsername) { | |
| console.log(`${rejectorUsername} rejected the call.`); | |
| addSystemMessage(`Call rejected by ${rejectorUsername}.`); | |
| resetCallState(); // Clean up our state since the call attempt failed | |
| } else { | |
| console.warn(`Received rejection from ${rejectorUsername}, but was expecting ${otherUserInCall || 'nobody'}`); | |
| } | |
| }); | |
| // The call could not proceed (user busy, offline, conflict) | |
| socket.on('call-denied', ({ reason }) => { | |
| console.log(`Call denied: ${reason}`); | |
| addSystemMessage(`Call could not be started: ${reason}`); | |
| hideIncomingCallNotification(); // Ensure notification is hidden | |
| resetCallState(); // Clean up our state since the call attempt failed | |
| }); | |
| // Receive WebRTC offer from the caller | |
| socket.on('webrtc-offer', async ({ offer, callerUsername }) => { | |
| if (otherUserInCall !== callerUsername) { | |
| console.warn(`Received offer from ${callerUsername}, but currently expect call with ${otherUserInCall || 'nobody'}. Ignoring.`); | |
| return; | |
| } | |
| if (!peerConnection) { | |
| console.warn("Received offer, but PeerConnection is not ready. Storing offer."); | |
| // Store the offer to handle once the peer connection is ready (in startCallNegotiation(false)) | |
| pendingCallOffer = { offer, callerUsername }; | |
| // If startCallNegotiation hasn't run yet, this will be picked up there. | |
| // If it HAS run but PC isn't ready (unlikely), need more robust handling. | |
| return; | |
| } | |
| console.log("Received WebRTC Offer from", callerUsername); | |
| try { | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); | |
| console.log("Remote description (offer) set. Creating answer..."); | |
| const answer = await peerConnection.createAnswer(); | |
| await peerConnection.setLocalDescription(answer); | |
| console.log("Local description (answer) set. Sending answer..."); | |
| socket.emit('webrtc-answer', { targetUsername: otherUserInCall, answer }); | |
| pendingCallOffer = null; // Clear any pending offer | |
| } catch (error) { | |
| console.error("Error handling offer:", error); | |
| addSystemMessage("Error setting up call connection (offer)."); | |
| hangUpCall(); // Attempt cleanup | |
| } | |
| }); | |
| // Receive WebRTC answer from the acceptor | |
| socket.on('webrtc-answer', async ({ answer, acceptorUsername }) => { | |
| if (!peerConnection || otherUserInCall !== acceptorUsername) { | |
| console.warn(`Received answer from ${acceptorUsername}, but not ready or wrong user (expected ${otherUserInCall || 'nobody'}).`); | |
| return; | |
| } | |
| console.log("Received WebRTC Answer from", acceptorUsername); | |
| try { | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); | |
| console.log("Remote description (answer) set. Connection should establish."); | |
| } catch (error) { | |
| console.error("Error handling answer:", error); | |
| addSystemMessage("Error setting up call connection (answer)."); | |
| hangUpCall(); // Attempt cleanup | |
| } | |
| }); | |
| // Receive ICE candidate from the peer | |
| socket.on('webrtc-ice-candidate', ({ candidate }) => { | |
| if (!peerConnection || !peerConnection.remoteDescription) { | |
| // Wait until remote description is set before adding candidates | |
| console.warn("Received ICE candidate prematurely. Ignoring."); | |
| return; | |
| } | |
| if (!candidate) { | |
| console.log("Received null ICE candidate (end of candidates signal)."); | |
| return; | |
| } | |
| // console.log("Received ICE Candidate"); // Can be very noisy | |
| try { | |
| peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) | |
| .catch(e => console.error("Error adding received ICE candidate", e)); | |
| } catch (error) { | |
| // Ignore errors like "Error processing ICE candidate" if state is wrong | |
| if (!isCallActive) { | |
| console.warn("Ignoring ICE candidate error as call is not active:", error.message); | |
| } else { | |
| console.error("Error processing received ICE candidate:", error); | |
| } | |
| } | |
| }); | |
| // Call ended by the other user (hangup or disconnect) or server cleanup | |
| socket.on('call-ended', () => { | |
| console.log("Received 'call-ended' signal."); | |
| if (isCallActive) { | |
| addSystemMessage(`Call with ${otherUserInCall || 'user'} ended.`); | |
| hangUpCall(false); // Clean up locally, don't emit hangup again | |
| } else { | |
| // Might receive this if call was rejected/denied and server initiated cleanup, | |
| // or if local state was already cleaned up. Ensure UI is reset. | |
| console.log("Call already ended locally or wasn't active. Ensuring cleanup."); | |
| resetCallState(); | |
| hideIncomingCallNotification(); | |
| } | |
| }); | |
| // --- END: Video Call Socket Handlers --- | |
| // --- Helper Functions (Chat + Call) --- | |
| // Add Regular Message (Includes Call Button) | |
| function addMessage(username, text, timestampISO) { | |
| const messageDate = new Date(timestampISO); // Parse ISO string | |
| maybeAddDateSeparator(messageDate); // Check if a date separator is needed | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.classList.add('message'); | |
| const isOutgoing = (username === currentUsername); | |
| messageDiv.classList.add(isOutgoing ? 'outgoing' : 'incoming'); | |
| // Add Avatar only for incoming messages | |
| if (!isOutgoing) { | |
| messageDiv.appendChild(createAvatar(username)); | |
| } | |
| const bubble = document.createElement('div'); | |
| bubble.classList.add('message-bubble'); | |
| // Add username span only for incoming messages | |
| if (!isOutgoing) { | |
| const usernameSpan = document.createElement('span'); | |
| usernameSpan.className = 'username'; | |
| usernameSpan.textContent = username; | |
| // Optional: Apply user-specific color | |
| // const userHue = stringToHslColor(username, 0, 0, true); | |
| // usernameSpan.style.setProperty('--user-hue', userHue); | |
| bubble.appendChild(usernameSpan); | |
| // --- Add Call Button for Incoming Messages --- | |
| const callBtn = document.createElement('button'); | |
| callBtn.className = 'call-button'; | |
| callBtn.innerHTML = '📞'; // Phone icon | |
| callBtn.title = `Call ${username}`; | |
| callBtn.onclick = (e) => { | |
| e.stopPropagation(); // Prevent triggering other click events if nested | |
| initiateCallRequest(username); | |
| }; | |
| // Append near the bubble, but within the message container for positioning | |
| messageDiv.appendChild(callBtn); | |
| // --- End Call Button --- | |
| } | |
| const textSpan = document.createElement('span'); | |
| textSpan.className = 'text'; | |
| textSpan.textContent = text; // Use textContent for security | |
| const timeSpan = document.createElement('span'); | |
| timeSpan.className = 'time'; | |
| // Format time as HH:MM | |
| timeSpan.textContent = `${messageDate.getHours()}:${String(messageDate.getMinutes()).padStart(2, '0')}`; | |
| bubble.appendChild(textSpan); | |
| bubble.appendChild(timeSpan); | |
| messageDiv.appendChild(bubble); // Add bubble after avatar/call button | |
| messagesDiv.appendChild(messageDiv); | |
| scrollToBottom(); // Scroll down after adding message | |
| } | |
| // Add System Message | |
| function addSystemMessage(text) { | |
| maybeAddDateSeparator(new Date()); // Check date for system messages too | |
| const message = document.createElement('div'); | |
| message.className = 'system-message'; | |
| message.textContent = text; | |
| messagesDiv.appendChild(message); | |
| scrollToBottom(); // Scroll down | |
| } | |
| // --- Date Separator Logic --- | |
| function maybeAddDateSeparator(messageDate) { | |
| const messageDay = messageDate.toDateString(); | |
| const lastDay = lastMessageDate ? lastMessageDate.toDateString() : null; | |
| if (messageDay !== lastDay) { | |
| const separator = document.createElement('div'); | |
| separator.className = 'date-separator'; | |
| separator.textContent = formatDateSeparator(messageDate); // Format the date text | |
| messagesDiv.appendChild(separator); | |
| lastMessageDate = messageDate; // Update the date of the last displayed item | |
| } | |
| } | |
| function formatDateSeparator(date) { | |
| const today = new Date(); | |
| const yesterday = new Date(today); | |
| yesterday.setDate(today.getDate() - 1); | |
| if (date.toDateString() === today.toDateString()) { return 'Today'; } | |
| else if (date.toDateString() === yesterday.toDateString()) { return 'Yesterday'; } | |
| else { return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); } | |
| } | |
| // --- Avatar Creation Logic --- | |
| function createAvatar(username) { | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'avatar'; | |
| const initials = username?.substring(0, 2).toUpperCase() || '?'; | |
| avatar.textContent = initials; | |
| avatar.style.backgroundColor = stringToHslColor(username || '', 50, 60); // Saturation 50%, Lightness 60% | |
| return avatar; | |
| } | |
| function stringToHslColor(str, s, l, hueOnly = false) { | |
| if (!str) return hueOnly ? 0 : `hsl(0, ${s}%, ${l}%)`; | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; | |
| } | |
| const h = Math.abs(hash % 360); | |
| return hueOnly ? h : `hsl(${h}, ${s}%, ${l}%)`; | |
| } | |
| // --- Scrolling --- | |
| function scrollToBottom() { | |
| requestAnimationFrame(() => { | |
| messagesDiv.scrollTo({ top: messagesDiv.scrollHeight, behavior: 'smooth' }); | |
| }); | |
| } | |
| // --- Browser Notifications --- | |
| function requestNotificationPermission() { | |
| if ('Notification' in window && Notification.permission !== 'granted' && Notification.permission !== 'denied') { | |
| Notification.requestPermission().then(permission => { | |
| if (permission === 'granted') { console.log('Notification permission granted.'); } | |
| else { console.log('Notification permission denied.'); } | |
| }); | |
| } | |
| } | |
| function showNotification(body) { | |
| if (!('Notification' in window) || Notification.permission !== 'granted') { return; } | |
| if (document.hidden) { // Only show if tab is not active | |
| const notification = new Notification('New Chat Message', { | |
| body: body, | |
| icon: '/favicon.ico' // Optional: place an icon named favicon.ico in your public folder | |
| }); | |
| setTimeout(notification.close.bind(notification), 5000); | |
| notification.onclick = () => { window.focus(); }; | |
| } | |
| } | |
| // --- START: Video Call Helper Functions --- | |
| // 1. Request to start a call with a user | |
| function initiateCallRequest(targetUsername) { | |
| if (isCallActive) { | |
| addSystemMessage("You are already in a call."); | |
| return; | |
| } | |
| if (otherUserInCall || incomingCallNotification.style.display === 'block') { | |
| addSystemMessage("Cannot start a new call while another is pending or incoming."); | |
| return; | |
| } | |
| if (targetUsername === currentUsername) { | |
| addSystemMessage("You cannot call yourself."); | |
| return; | |
| } | |
| console.log(`Requesting call with ${targetUsername}`); | |
| addSystemMessage(`Calling ${targetUsername}...`); | |
| otherUserInCall = targetUsername; // Tentatively set target for this call attempt | |
| socket.emit('request-call', { targetUsername }); | |
| // Now wait for 'call-accepted', 'call-rejected', or 'call-denied' from server | |
| } | |
| // 2. Start media streams and WebRTC negotiation (called after acceptance) | |
| async function startCallNegotiation(isCaller) { | |
| console.log(`Starting negotiation. Is Caller: ${isCaller}`); | |
| if (isCallActive) { | |
| console.warn("Negotiation requested but call is already active."); | |
| return; // Don't start negotiation again if already active | |
| } | |
| if (!otherUserInCall) { | |
| console.error("Cannot start negotiation, other user not set."); | |
| resetCallState(); // Reset if state is inconsistent | |
| return; | |
| } | |
| addSystemMessage(`Starting video call with ${otherUserInCall}...`); | |
| showCallUI(); // Show the video elements and controls | |
| try { | |
| // Get local camera/mic stream | |
| console.log("Requesting user media..."); | |
| localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); | |
| localVideo.srcObject = localStream; | |
| console.log("Local stream obtained."); | |
| // Create PeerConnection | |
| createPeerConnection(); // This sets up the peerConnection object | |
| // Add local tracks to the connection BEFORE creating offer/answer | |
| localStream.getTracks().forEach(track => { | |
| if (peerConnection) { | |
| peerConnection.addTrack(track, localStream); | |
| console.log(`Added local track: ${track.kind}`); | |
| } | |
| }); | |
| isCallActive = true; // Mark call as active *after* getting media/PC setup | |
| if (isCaller) { | |
| // Caller creates the offer | |
| if (!peerConnection) throw new Error("PeerConnection not available for creating offer."); | |
| console.log("Creating offer..."); | |
| const offer = await peerConnection.createOffer(); | |
| await peerConnection.setLocalDescription(offer); | |
| console.log("Local description (offer) set. Sending offer..."); | |
| socket.emit('webrtc-offer', { targetUsername: otherUserInCall, offer }); | |
| } else { | |
| // Callee waits for the offer (handled by 'webrtc-offer' socket event) | |
| console.log("Waiting for offer from caller..."); | |
| // If an offer arrived *before* we were ready (before PC was created), handle it now | |
| if (pendingCallOffer) { | |
| console.log("Handling pending offer received earlier."); | |
| await handlePendingOffer(); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error starting call negotiation:", error); | |
| addSystemMessage(`Error starting call: ${error.message}. Please check camera/mic permissions.`); | |
| hangUpCall(); // Clean up on error | |
| } | |
| } | |
| // 3. Create the RTCPeerConnection object and set up listeners | |
| function createPeerConnection() { | |
| console.log("Creating PeerConnection with ICE servers:", iceServers); | |
| // Clean up any previous connection first | |
| if (peerConnection) { | |
| console.warn("Closing existing PeerConnection before creating new one."); | |
| peerConnection.close(); | |
| } | |
| peerConnection = new RTCPeerConnection(iceServers); | |
| // Listener for ICE candidates generated by the browser | |
| peerConnection.onicecandidate = (event) => { | |
| if (event.candidate && otherUserInCall && isCallActive) { // Send only if call is active and peer known | |
| // console.log("Generated ICE Candidate:", event.candidate); // Noisy | |
| socket.emit('webrtc-ice-candidate', { | |
| targetUsername: otherUserInCall, | |
| candidate: event.candidate, | |
| }); | |
| } else if (!event.candidate) { | |
| console.log("All ICE candidates have been sent."); | |
| } | |
| }; | |
| // Listener for when the remote peer adds a track (video/audio) | |
| peerConnection.ontrack = (event) => { | |
| console.log("Remote track received:", event.track.kind, "Stream count:", event.streams.length); | |
| if (event.streams && event.streams[0]) { | |
| console.log("Attaching remote stream to video element"); | |
| remoteVideo.srcObject = event.streams[0]; | |
| } else { | |
| // Fallback for browsers that might not bundle tracks into streams initially | |
| if (!remoteVideo.srcObject) { | |
| remoteVideo.srcObject = new MediaStream(); | |
| } | |
| if(remoteVideo.srcObject.getVideoTracks().length === 0 && event.track.kind === 'video') { | |
| console.warn("Adding video track to remote stream."); | |
| remoteVideo.srcObject.addTrack(event.track); | |
| } | |
| if(remoteVideo.srcObject.getAudioTracks().length === 0 && event.track.kind === 'audio') { | |
| console.warn("Adding audio track to remote stream."); | |
| remoteVideo.srcObject.addTrack(event.track); | |
| } | |
| } | |
| }; | |
| // Listen for connection state changes | |
| peerConnection.oniceconnectionstatechange = () => { | |
| if (!peerConnection) return; // Connection might be closed already | |
| console.log(`ICE Connection State: ${peerConnection.iceConnectionState}`); | |
| if (peerConnection.iceConnectionState === 'failed' || | |
| peerConnection.iceConnectionState === 'disconnected' || | |
| peerConnection.iceConnectionState === 'closed') { | |
| if(isCallActive) { // Prevent cleanup if already hung up | |
| console.warn(`ICE connection issue: ${peerConnection.iceConnectionState}.`); | |
| // Avoid aggressive hangup on 'disconnected' as it might recover | |
| if (peerConnection.iceConnectionState === 'failed') { | |
| addSystemMessage("Call connection failed."); | |
| hangUpCall(); // Hang up on definite failure | |
| } | |
| } | |
| } | |
| }; | |
| peerConnection.onconnectionstatechange = () => { | |
| if (!peerConnection) return; | |
| console.log(`Connection State: ${peerConnection.connectionState}`); | |
| if (peerConnection.connectionState === 'failed' || peerConnection.connectionState === 'closed') { | |
| if (isCallActive) { | |
| console.warn(`Connection issue: ${peerConnection.connectionState}.`); | |
| addSystemMessage("Call connection lost or closed."); | |
| hangUpCall(false); // Clean up locally if connection definitively fails/closes | |
| } | |
| } else if (peerConnection.connectionState === 'connected') { | |
| console.log("Peers connected successfully!"); | |
| // Call is fully established | |
| } | |
| }; | |
| } | |
| // 4. Handle incoming call notification UI | |
| function showIncomingCallNotification(callerUsername) { | |
| incomingCallText.textContent = `Incoming call from ${callerUsername}`; | |
| incomingCallNotification.dataset.caller = callerUsername; // Store caller name | |
| incomingCallNotification.style.display = 'block'; | |
| } | |
| function hideIncomingCallNotification() { | |
| incomingCallNotification.style.display = 'none'; | |
| if (incomingCallNotification.dataset.caller) { | |
| delete incomingCallNotification.dataset.caller; | |
| } | |
| // If we hide the notification without accepting/rejecting, reset the potential peer | |
| if (!isCallActive && otherUserInCall === incomingCallNotification.dataset.caller) { | |
| otherUserInCall = null; | |
| } | |
| } | |
| // 5. Show/Hide the main video call UI | |
| function showCallUI() { | |
| callContainer.classList.add('active'); | |
| } | |
| function hideCallUI() { | |
| callContainer.classList.remove('active'); | |
| } | |
| // 6. Clean up call state and resources | |
| function hangUpCall(notifyServer = true) { | |
| if (!isCallActive && !otherUserInCall && !localStream && !peerConnection) { | |
| // Already cleaned up or never started | |
| console.log("Hang up called, but no active call or resources found."); | |
| resetCallState(); // Ensure UI is hidden etc. | |
| return; | |
| } | |
| console.log("Hanging up call..."); | |
| if (isCallActive && notifyServer && otherUserInCall) { | |
| // Only notify server if the call was considered active and we know who to notify about | |
| console.log("Notifying server of hangup."); | |
| socket.emit('hangup-call'); // Inform the server/other peer | |
| } else { | |
| console.log("Hangup: Not notifying server (call not active, peer unknown, or notifyServer=false).") | |
| } | |
| // Close peer connection | |
| if (peerConnection) { | |
| peerConnection.close(); | |
| peerConnection = null; | |
| console.log("PeerConnection closed."); | |
| } else { | |
| console.log("Hangup: No PeerConnection to close."); | |
| } | |
| // Stop local media tracks | |
| if (localStream) { | |
| localStream.getTracks().forEach(track => track.stop()); | |
| localStream = null; | |
| console.log("Local stream stopped."); | |
| } else { | |
| console.log("Hangup: No local stream to stop."); | |
| } | |
| // Reset video elements | |
| localVideo.srcObject = null; | |
| remoteVideo.srcObject = null; | |
| // Reset state variables (critical!) | |
| const previouslyActive = isCallActive; | |
| isCallActive = false; | |
| otherUserInCall = null; | |
| pendingCallOffer = null; | |
| // Hide UI elements | |
| hideCallUI(); | |
| hideIncomingCallNotification(); // Ensure notification is hidden too | |
| if (previouslyActive) { | |
| addSystemMessage("Call ended."); // Inform user only if call was active | |
| } | |
| console.log("Call cleanup complete."); | |
| } | |
| // 7. Reset call state completely (use cautiously, e.g., on login) | |
| function resetCallState() { | |
| console.log("Resetting call state completely..."); | |
| hangUpCall(false); // Clean up everything without notifying server | |
| } | |
| // 8. Handle pending offer if received before negotiation started fully | |
| async function handlePendingOffer() { | |
| if (!peerConnection) { | |
| console.error("Cannot handle pending offer: PeerConnection is not ready."); | |
| pendingCallOffer = null; // Discard invalid offer | |
| resetCallState(); | |
| return; | |
| } | |
| if (!pendingCallOffer || !otherUserInCall) { | |
| console.warn("Attempted to handle pending offer, but no offer or peer found."); | |
| return; // No pending offer or state mismatch | |
| } | |
| const { offer, callerUsername } = pendingCallOffer; | |
| if (callerUsername !== otherUserInCall) { | |
| console.warn("Pending offer username mismatch. Ignoring."); | |
| pendingCallOffer = null; | |
| return; | |
| } | |
| console.log("Handling pending WebRTC Offer received earlier."); | |
| try { | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); | |
| console.log("Pending: Remote description (offer) set. Creating answer..."); | |
| const answer = await peerConnection.createAnswer(); | |
| await peerConnection.setLocalDescription(answer); | |
| console.log("Pending: Local description (answer) set. Sending answer..."); | |
| socket.emit('webrtc-answer', { targetUsername: otherUserInCall, answer }); | |
| pendingCallOffer = null; // Clear the pending offer | |
| } catch (error) { | |
| console.error("Error handling pending offer:", error); | |
| addSystemMessage("Error setting up call connection (pending offer)."); | |
| hangUpCall(); // Attempt cleanup | |
| } | |
| } | |
| // --- END: Video Call Helper Functions --- | |
| </script> | |
| </body> | |
| </html> |