chat / public /index.html
sameernotes's picture
Upload 6 files
47a00d3 verified
<!DOCTYPE html>
<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>