radchat/static/app.js
2025-08-19 17:43:12 -05:00

1747 lines
52 KiB
JavaScript

// Global state
let ws = null;
let localStream = null;
let peerConnections = {};
let currentUsername = null;
let isMuted = false;
let isDeafened = false;
let isInVoiceChat = false;
let micPermissionGranted = false;
let mutedUsers = new Set();
let userVolumes = new Map();
let currentModalUserId = null;
let isChatVisible = false;
let messageTimeouts = new Map();
let speakingThreshold = 10;
let intentionalDisconnect = false;
let gSampleRate = 96000;
// WebRTC configuration
const rtcConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
// Don't initialize WebSocket immediately - only when joining voice chat
setupEventListeners();
requestMicrophonePermission();
});
function setupEventListeners() {
const usernameInput = document.getElementById('username-input');
const joinChatBtn = document.getElementById('join-chat-btn');
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
joinChatBtn.disabled = usernameInput.value.trim().length === 0;
// Enable/disable join button based on input
usernameInput.addEventListener('input', function() {
joinChatBtn.disabled = this.value.trim().length === 0;
});
// Allow Enter key to join voice chat
usernameInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && this.value.trim().length > 0) {
joinVoiceChat();
}
});
// Enable/disable send button based on chat input
chatInput.addEventListener('input', function() {
sendBtn.disabled = this.value.trim().length === 0;
});
}
function initializeWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log('🔥 Attempting to connect to WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
updateConnectionStatus(true);
console.log('🔥 WebSocket connected successfully');
};
ws.onmessage = function(event) {
console.log('🔴 RAW WebSocket message received:', event.data);
try {
const message = JSON.parse(event.data);
console.log('🟢 PARSED WebSocket message:', message);
// Force call the handler and log every step
if (message.type === 'users_list') {
console.log('🔵 This is a users_list message with data:', message.data);
console.log('🔵 About to call updateUsersList...');
updateUsersList(message.data);
console.log('🔵 updateUsersList call completed');
} else {
console.log('🟠 Non-users_list message, calling handleWebSocketMessage');
handleWebSocketMessage(message);
}
} catch (error) {
console.error('❌ Error parsing WebSocket message:', error, event.data);
}
};
ws.onclose = function(event) {
updateConnectionStatus(false);
console.log('🔥 WebSocket disconnected. Code:', event.code, 'Reason:', event.reason);
// Reset client state since server has lost us
if (isInVoiceChat) {
console.log('🔄 Server connection lost, resetting client state...');
// Clean up peer connections
Object.values(peerConnections).forEach(pc => pc.close());
peerConnections = {};
// Remove all remote audio elements
document.getElementById('audio-container').innerHTML = '';
// Clear user list
document.getElementById('users-list').innerHTML = '<div class="no-users"></div>';
// Reset voice chat state but keep username for reconnection
const savedUsername = currentUsername;
isInVoiceChat = false;
// Attempt to reconnect and rejoin automatically
if (!intentionalDisconnect) {
console.log('🔄 Attempting to reconnect and rejoin voice chat...');
setTimeout(() => {
if (savedUsername && !intentionalDisconnect) {
// Reset currentUsername for rejoin process
currentUsername = savedUsername;
initializeWebSocket();
// Wait for connection then rejoin
const rejoinInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
clearInterval(rejoinInterval);
console.log('🔄 Reconnected, rejoining voice chat...');
// Verify we still have microphone access
if (!localStream || localStream.getTracks().length === 0 || localStream.getAudioTracks().every(track => track.readyState !== 'live')) {
console.log('🔄 Local stream lost or inactive, requesting microphone again...');
// Add timeout to microphone request
const micPromise = requestMicrophonePermission();
const micTimeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => {
console.log('🔄 Microphone request timed out after 8 seconds');
reject(new Error('Microphone request timeout'));
}, 8000);
});
Promise.race([micPromise, micTimeoutPromise]).then(() => {
console.log('🔄 Microphone re-acquired, sending username to rejoin...');
// Send username to rejoin after mic is ready
ws.send(JSON.stringify({
type: 'set_username',
data: savedUsername
}));
}).catch(error => {
console.error('🔄 Failed to re-acquire microphone or timed out:', error);
// Still try to rejoin without mic
console.log('🔄 Proceeding with rejoin without microphone...');
ws.send(JSON.stringify({
type: 'set_username',
data: savedUsername
}));
});
} else {
console.log('🔄 Local stream is still active, checking for browser-muted tracks...');
// Check for and fix browser-muted tracks with timeout
const fixTracksPromise = fixBrowserMutedTracks();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
console.log('🔄 fixBrowserMutedTracks timed out after 5 seconds, proceeding anyway...');
resolve(false);
}, 5000);
});
Promise.race([fixTracksPromise, timeoutPromise]).then(fixedMutedTracks => {
console.log('🔄 Fix tracks completed or timed out, result:', fixedMutedTracks);
if (!fixedMutedTracks) {
// Ensure tracks are enabled according to mute state
if (localStream) {
localStream.getAudioTracks().forEach(track => {
track.enabled = !isMuted;
console.log('🔄 Track enabled state:', track.enabled, 'muted:', isMuted, 'browser-muted:', track.muted);
});
}
}
// Send username to rejoin
console.log('🔄 Sending rejoin request...');
ws.send(JSON.stringify({
type: 'set_username',
data: savedUsername
}));
}).catch(error => {
console.error('🔄 Error in fix tracks process:', error);
// Still try to rejoin even if fixing failed
console.log('🔄 Proceeding with rejoin despite error...');
ws.send(JSON.stringify({
type: 'set_username',
data: savedUsername
}));
});
}
}
}, 100);
}
}, 2000); // Wait 2 seconds before attempting reconnect
}
} else {
// Normal reconnection for non-voice users
if (!intentionalDisconnect) {
console.log('🔄 Attempting to reconnect WebSocket...');
setTimeout(initializeWebSocket, 3000);
}
}
if (intentionalDisconnect) {
console.log('🔴 Intentional disconnect, not reconnecting');
}
};
ws.onerror = function(error) {
console.error('🔥 WebSocket error:', error);
updateConnectionStatus(false);
};
}
function handleWebSocketMessage(message) {
console.log('Received WebSocket message:', message);
switch (message.type) {
case 'users_list':
console.log('Updating users list with:', message.data);
updateUsersList(message.data);
break;
case 'user_speaking':
console.log('User speaking update:', message.userId, message.data);
updateSpeakingStatus(message.userId, message.data);
break;
case 'username_error':
console.log('Username error:', message.error);
handleUsernameError(message.error);
break;
case 'chat_message':
console.log('Chat message received:', message);
handleChatMessage(message);
break;
case 'webrtc_offer':
handleWebRTCOffer(message);
break;
case 'webrtc_answer':
handleWebRTCAnswer(message);
break;
case 'webrtc_ice':
handleWebRTCIce(message);
break;
default:
console.log('Unknown message type:', message.type);
}
}
function updateConnectionStatus(connected) {
const sidebar = document.querySelector('.sidebar');
if (connected) {
sidebar.classList.add('visible');
// Remove any reconnection notices
removeReconnectionNotice();
} else {
sidebar.classList.remove('visible');
// Show reconnection notice if we were in voice chat
if (isInVoiceChat && !intentionalDisconnect) {
showReconnectionNotice();
}
}
}
function showReconnectionNotice() {
// Remove existing notice if any
removeReconnectionNotice();
const notice = document.createElement('div');
notice.id = 'reconnection-notice';
notice.className = 'reconnection-notice';
notice.innerHTML = `
<div class="notice-content">
<div class="spinner"></div>
<div class="notice-text">
<h3>Connection Lost</h3>
<p>Reconnecting to voice chat...</p>
</div>
</div>
`;
document.body.appendChild(notice);
}
function removeReconnectionNotice() {
const notice = document.getElementById('reconnection-notice');
if (notice) {
notice.remove();
}
}
async function requestMicrophonePermission() {
const micStatus = document.getElementById('mic-status');
try {
// If we already have a stream but it's inactive, stop it first
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: gSampleRate || 44100, // Use global sample rate if set, otherwise default to 44100
}
});
localStream = stream;
micPermissionGranted = true;
localStream.getAudioTracks().forEach(track => {
track.enabled = !isMuted; // Respect current mute state
// IMPORTANT: Also unmute at browser level
if (track.muted) {
console.log('🎤 Track was browser-muted, attempting to unmute...');
// The track.muted property is read-only, but we can try to work around it
}
console.log('🎤 Audio track setup - enabled:', track.enabled, 'browser-muted:', track.muted, 'app-muted:', isMuted);
});
if (micStatus) {
micStatus.textContent = '✅ Microphone access granted';
micStatus.className = 'mic-status granted';
// Hide the entire microphone section
document.getElementById('mic-section').style.display = 'none';
}
setupAudioAnalysis();
console.log('🎤 Microphone permission granted and stream ready');
return Promise.resolve();
} catch (error) {
console.error('Microphone permission denied:', error);
if (micStatus) {
micStatus.textContent = '❌ Microphone access denied. Please refresh and allow microphone access.';
micStatus.className = 'mic-status denied';
}
return Promise.reject(error);
}
}
function setupAudioAnalysis() {
if (!localStream) {
console.error('🎤 Cannot setup audio analysis - no local stream');
return;
}
console.log('🎤 Setting up audio analysis...');
// Check if tracks are active
const audioTracks = localStream.getAudioTracks();
console.log('🎤 Audio tracks:', audioTracks.length, 'active tracks:', audioTracks.filter(t => t.readyState === 'live').length);
if (audioTracks.length === 0) {
console.error('🎤 No audio tracks in local stream!');
return;
}
try {
const audioContext = new window.AudioContext();
console.log('🎤 AudioContext created, state:', audioContext.state);
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(localStream);
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.3;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
microphone.connect(analyser);
console.log('🎤 Audio analysis connected successfully');
let speakingTimeout;
let isSpeaking = false;
function checkAudioLevel() {
analyser.getByteFrequencyData(dataArray);
// Calculate average volume
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
// Debug audio levels occasionally
if (Math.random() < 0.01) { // 1% of the time
console.log('🎤 Audio level:', average, 'threshold:', speakingThreshold, 'muted:', isMuted, 'in voice:', isInVoiceChat);
}
if (average > speakingThreshold && !isMuted && isInVoiceChat) {
if (!isSpeaking) {
isSpeaking = true;
console.log('🎤 Started speaking - audio level:', average);
sendSpeakingStatus(true);
}
clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => {
if (isSpeaking) {
isSpeaking = false;
console.log('🎤 Stopped speaking');
sendSpeakingStatus(false);
}
}, 1000);
}
requestAnimationFrame(checkAudioLevel);
}
checkAudioLevel();
} catch (error) {
console.error('🎤 Error setting up audio analysis:', error);
}
}
function sendSpeakingStatus(speaking) {
console.log('Sending speaking status:', speaking);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'speaking',
data: speaking
}));
console.log('Speaking status sent successfully');
} else {
console.error('Cannot send speaking status - WebSocket not connected');
}
}
async function checkUsernameAvailability(username) {
try {
const response = await fetch('/check-username', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: username })
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
return data.available;
} catch (error) {
console.error('Error checking username:', error);
// If check fails, allow attempt (fallback to old behavior)
return true;
}
}
async function joinVoiceChat() {
// Get username from input
const usernameInput = document.getElementById('username-input');
const joinBtn = document.getElementById('join-chat-btn');
const username = usernameInput.value.trim();
intentionalDisconnect = false; // Reset disconnect flag
if (!username) {
alert('Please enter a username.');
return;
}
if (!micPermissionGranted) {
alert('Please grant microphone permission first.');
return;
}
// Show connecting state
joinBtn.disabled = true;
joinBtn.textContent = '🔄 Validating...';
// Check username availability FIRST
const isAvailable = await checkUsernameAvailability(username);
// print debug info for this varialbe
console.log(isAvailable)
if (!isAvailable) {
alert(`Username "${username}" is already taken. Please choose a different username.`);
joinBtn.disabled = false;
joinBtn.textContent = '📞 Join Voice Chat';
usernameInput.focus();
usernameInput.select();
return;
}
// Username is available, proceed with connection
joinBtn.textContent = '🔄 Connecting...';
// Set the username
currentUsername = username;
console.log('Joining voice chat with username:', currentUsername);
// Connect to WebSocket
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.log('Connecting to WebSocket for voice chat...');
initializeWebSocket();
// Wait for connection before proceeding
const maxWait = 5000; // 5 seconds
const startTime = Date.now();
while ((!ws || ws.readyState !== WebSocket.OPEN) && (Date.now() - startTime) < maxWait) {
await new Promise(resolve => setTimeout(resolve, 100));
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Failed to connect to server. Please try again.');
// Reset button state
joinBtn.disabled = false;
joinBtn.textContent = '📞 Join Voice Chat';
return;
}
}
// Send username to server
console.log('Sending username to server:', currentUsername);
ws.send(JSON.stringify({
type: 'set_username',
data: currentUsername
}));
// Note: UI updates will happen when server responds with success or error
}
function updateUsersList(users) {
console.log('🟡 updateUsersList called with:', users);
const usersList = document.getElementById('users-list');
// const userCount = document.getElementById('user-count'); // Commented out since header is commented
// Track previous users to detect joins/leaves
const currentUserIds = users ? users.map(u => u.id) : [];
const previousUserIds = Object.keys(window.previousUsers || {});
// Detect new users (joins)
if (window.previousUsers && isInVoiceChat) {
const newUsers = users ? users.filter(u => !previousUserIds.includes(u.id) && u.username !== currentUsername) : [];
newUsers.forEach(user => {
showJoinAlert(user.username);
});
// Detect users who left
const leftUserIds = previousUserIds.filter(id => !currentUserIds.includes(id));
leftUserIds.forEach(userId => {
const leftUser = window.previousUsers[userId];
if (leftUser && leftUser.username !== currentUsername) {
showLeaveAlert(leftUser.username);
}
});
}
// Store current users for next comparison
window.previousUsers = {};
if (users) {
users.forEach(user => {
window.previousUsers[user.id] = user;
});
}
// If this is the first time we're getting a users list after joining,
// it means our username was accepted
if (currentUsername && !isInVoiceChat) {
console.log('Username accepted, completing join process');
// Hide username section and show voice controls in sidebar
document.getElementById('username-section').style.display = 'none';
document.getElementById('voice-controls-sidebar').style.display = 'block';
document.getElementById('chat-section').style.display = 'flex';
isInVoiceChat = true;
// Start peer connection health monitoring
startPeerHealthCheck();
console.log('Successfully joined voice chat with username:', currentUsername);
}
// Update user count if element exists (commented out in HTML)
// if (userCount) {
// userCount.textContent = users ? users.length : 0;
// }
if (!users || users.length === 0) {
if (usersList) {
usersList.innerHTML = '<div class="no-users"></div>';
}
return;
}
const userCardsHtml = users.map((user) => {
const initial = user.username ? user.username.charAt(0).toUpperCase() : '?';
const isCurrentUser = user.username === currentUsername;
const userClass = isCurrentUser ? 'user-card current-user-card' : 'user-card';
const isMuted = mutedUsers.has(user.id);
const clickHandler = isCurrentUser ? `onclick="openSelfModal()"` : `onclick="openUserModal('${user.id}', '${user.username}')"`;
const muteButtonHtml = !isCurrentUser ? `
<button class="mute-user-btn" onclick="event.stopPropagation(); muteUser('${user.id}')" title="${isMuted ? 'Unmute user' : 'Mute user'}">
${isMuted ? '🔇' : '🔊'}
</button>
` : '';
return `
<div class="${userClass}" data-user-id="${user.id}" ${clickHandler}>
<div class="user-avatar">${initial}</div>
<div class="user-info">
<div class="user-name">${user.username || 'Unknown'}</div>
</div>
${muteButtonHtml}
</div>
`;
}).join('');
if (usersList) {
usersList.innerHTML = userCardsHtml;
// Create peer connections for users if we're in voice chat
if (isInVoiceChat) {
console.log('🔗 Setting up peer connections for voice chat...');
// First, check which users we should have connections to but don't
const expectedPeers = users.filter(user => user.username !== currentUsername).map(user => user.id);
const existingPeers = Object.keys(peerConnections);
const missingPeers = expectedPeers.filter(id => !existingPeers.includes(id));
const extraPeers = existingPeers.filter(id => !expectedPeers.includes(id));
console.log('🔗 Expected peers:', expectedPeers);
console.log('🔗 Existing peers:', existingPeers);
console.log('🔗 Missing peers:', missingPeers);
console.log('🔗 Extra peers:', extraPeers);
// Clean up connections to users who are no longer in the list
extraPeers.forEach(userId => {
console.log('🔗 Cleaning up connection to user who left:', userId);
cleanupPeerConnection(userId);
});
// Create connections to missing users
if (missingPeers.length > 0) {
console.log('🔗 Creating connections to missing peers:', missingPeers);
setTimeout(() => {
missingPeers.forEach(userId => {
console.log('🔗 Creating peer connection for missing user:', userId);
createPeerConnection(userId, true);
});
}, 500);
// Also set up a retry mechanism in case some connections fail
setTimeout(() => {
retryMissingPeerConnections(expectedPeers);
}, 3000);
}
}
}
}
function showJoinAlert(username) {
if (!isInVoiceChat) return;
console.log('👋 User joined:', username);
addChatAlert(`${username} joined the chat`, 'join');
}
function showLeaveAlert(username) {
if (!isInVoiceChat) return;
console.log('👋 User left:', username);
addChatAlert(`${username} left the chat`, 'leave');
}
function addChatAlert(message, type) {
const chatMessages = document.getElementById('chat-messages');
if (!chatMessages) return;
// Create alert element
const alertEl = document.createElement('div');
alertEl.className = `chat-alert chat-alert-${type}`;
const alertId = `alert_${Date.now()}_${Math.random()}`;
alertEl.id = alertId;
alertEl.innerHTML = `
<div class="alert-content">
<span class="alert-text">${escapeHtml(message)}</span>
<span class="alert-time">${formatTime(Date.now())}</span>
</div>
`;
// Add to chat
chatMessages.appendChild(alertEl);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
// No auto-removal - alerts persist
console.log('📢 Added chat alert:', message);
}
// Function to retry peer connections that might have failed or never started
function retryMissingPeerConnections(expectedPeerIds) {
if (!isInVoiceChat) return;
const existingPeers = Object.keys(peerConnections);
const stillMissingPeers = expectedPeerIds.filter(id => !existingPeers.includes(id));
const failedPeers = expectedPeerIds.filter(id => {
const pc = peerConnections[id];
return pc && (pc.connectionState === 'failed' || pc.connectionState === 'disconnected');
});
const peersToFix = [...new Set([...stillMissingPeers, ...failedPeers])];
if (peersToFix.length > 0) {
console.log('🔗 Retrying peer connections for:', peersToFix);
peersToFix.forEach(userId => {
// Clean up any existing failed connection
if (peerConnections[userId]) {
console.log('🔗 Cleaning up failed connection before retry:', userId);
cleanupPeerConnection(userId);
}
// Wait a bit then retry
setTimeout(() => {
console.log('🔗 Retrying peer connection for:', userId);
createPeerConnection(userId, true);
}, Math.random() * 1000); // Random delay to avoid simultaneous attempts
});
} else {
console.log('🔗 All expected peer connections are healthy');
}
}
// Periodic health check for peer connections
let peerHealthCheckInterval = null;
function startPeerHealthCheck() {
if (peerHealthCheckInterval) {
clearInterval(peerHealthCheckInterval);
}
peerHealthCheckInterval = setInterval(() => {
if (!isInVoiceChat) {
stopPeerHealthCheck();
return;
}
console.log('🔗 Running peer connection health check...');
// Get current user list from DOM
const userCards = document.querySelectorAll('.user-card:not(.current-user-card)');
const expectedPeerIds = Array.from(userCards).map(card => card.dataset.userId);
if (expectedPeerIds.length > 0) {
retryMissingPeerConnections(expectedPeerIds);
}
}, 10000); // Check every 10 seconds
}
function stopPeerHealthCheck() {
if (peerHealthCheckInterval) {
clearInterval(peerHealthCheckInterval);
peerHealthCheckInterval = null;
}
}
// Function to fix browser-muted tracks
async function fixBrowserMutedTracks() {
if (!localStream) return false;
const audioTracks = localStream.getAudioTracks();
const hasMutedTracks = audioTracks.some(track => track.muted);
console.log('🎤 Checking tracks - total:', audioTracks.length, 'muted:', audioTracks.filter(t => t.muted).length);
if (hasMutedTracks) {
console.log('🎤 Detected browser-muted tracks, creating fresh stream...');
try {
// Stop current tracks completely
audioTracks.forEach(track => {
console.log('🎤 Stopping track:', track.id, 'state:', track.readyState);
track.stop();
});
// Clear the old stream reference
localStream = null;
// Wait a moment for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Get completely fresh stream with new constraints
const newStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: gSampleRate || 44100, // Use global sample rate if set, otherwise default to 44100
// Add timestamp to force new stream
deviceId: 'default'
}
});
localStream = newStream;
// Set up the new tracks
localStream.getAudioTracks().forEach(track => {
track.enabled = !isMuted;
console.log('🎤 Fresh track setup - id:', track.id, 'enabled:', track.enabled, 'browser-muted:', track.muted);
});
// Restart audio analysis with new stream
setupAudioAnalysis();
// Update existing peer connections with new stream
if (isInVoiceChat && Object.keys(peerConnections).length > 0) {
console.log('🎤 Updating', Object.keys(peerConnections).length, 'peer connections with fresh stream...');
for (const [userId, peerConnection] of Object.entries(peerConnections)) {
try {
const senders = peerConnection.getSenders();
console.log('🎤 Peer', userId, 'has', senders.length, 'senders');
for (const sender of senders) {
if (sender.track && sender.track.kind === 'audio') {
const newTrack = localStream.getAudioTracks()[0];
if (newTrack) {
await sender.replaceTrack(newTrack);
console.log('🎤 Replaced track for peer:', userId);
}
}
}
} catch (error) {
console.error('🎤 Error updating peer connection for', userId, ':', error);
// If replacing track fails, recreate the peer connection
console.log('🎤 Recreating peer connection for', userId);
cleanupPeerConnection(userId);
setTimeout(() => {
createPeerConnection(userId, true);
}, 500);
}
}
}
console.log('🎤 Successfully fixed browser-muted tracks');
return true;
} catch (error) {
console.error('🎤 Failed to fix browser-muted tracks:', error);
return false;
}
}
console.log('🎤 No browser-muted tracks found');
return false;
}
function updateSpeakingStatus(userId, isSpeaking) {
console.log('Updating speaking status for user:', userId, 'speaking:', isSpeaking);
const userCard = document.querySelector(`[data-user-id="${userId}"]`);
console.log('Found user card:', userCard);
if (userCard) {
if (isSpeaking) {
console.log('Adding speaking class to user card');
userCard.classList.add('speaking');
} else {
console.log('Removing speaking class from user card');
userCard.classList.remove('speaking');
}
} else {
console.error('User card not found for userId:', userId);
const allCards = document.querySelectorAll('.user-card');
console.log('All user cards:', Array.from(allCards).map(card => ({
id: card.dataset.userId,
element: card
})));
}
}
function toggleMute() {
if (!localStream) return;
const audioTracks = localStream.getAudioTracks();
const muteBtn = document.getElementById('toggle-mute-btn');
isMuted = !isMuted;
audioTracks.forEach(track => {
track.enabled = !isMuted;
});
if (isMuted) {
muteBtn.innerHTML = '🎤';
muteBtn.style.textDecoration = 'line-through';
muteBtn.title = 'Unmute microphone';
muteBtn.classList.remove('unmuted');
muteBtn.classList.add('muted');
} else {
muteBtn.innerHTML = '🎤';
muteBtn.style.textDecoration = 'none';
muteBtn.title = 'Mute microphone';
muteBtn.classList.remove('muted');
muteBtn.classList.add('unmuted');
}
console.log('Mute toggled:', isMuted);
}
function toggleDeafen() {
isDeafened = !isDeafened;
const deafenBtn = document.getElementById('toggle-deafen-btn');
// Mute/unmute all remote audio elements
const audioElements = document.querySelectorAll('#audio-container audio');
audioElements.forEach(audio => {
audio.muted = isDeafened;
});
if (isDeafened) {
deafenBtn.innerHTML = '🔊';
deafenBtn.style.textDecoration = 'line-through';
deafenBtn.title = 'Undeafen (enable incoming audio)';
deafenBtn.classList.add('muted');
} else {
deafenBtn.innerHTML = '🔊';
deafenBtn.style.textDecoration = 'none';
deafenBtn.title = 'Deafen (mute all incoming audio)';
deafenBtn.classList.remove('muted');
}
console.log('Deafen toggled:', isDeafened);
}
function leaveVoiceChat() {
isInVoiceChat = false;
// Stop peer health monitoring
stopPeerHealthCheck();
// Close all peer connections
Object.values(peerConnections).forEach(pc => pc.close());
peerConnections = {};
// Remove all remote audio elements
document.getElementById('audio-container').innerHTML = '';
// Clear muted users list and volumes
mutedUsers.clear();
userVolumes.clear();
// Close any open user modal
closeUserModal();
intentionalDisconnect = true; // Set flag to prevent reconnection
// Clear chat messages and timeouts
clearAllChatMessages();
// Disconnect from WebSocket to remove from user list
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('Closing WebSocket connection to leave voice chat');
ws.close();
}
// Update connection status
updateConnectionStatus(false);
// Show username section and hide voice controls
document.getElementById('username-section').style.display = 'block';
document.getElementById('voice-controls-sidebar').style.display = 'none';
document.getElementById('chat-section').style.display = 'none';
// Reset the join button properly
const joinBtn = document.getElementById('join-chat-btn');
joinBtn.disabled = false;
joinBtn.textContent = 'Join Voice Chat'; // Fix: Reset button text
// Clear the user list since we're disconnected
document.getElementById('users-list').innerHTML = '<div class="no-users"></div>';
console.log('Left voice chat and disconnected from server');
}
function handleChatInput(event) {
const textarea = event.target;
// Auto-resize textarea
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; // Max 120px height
// Enable/disable send button
document.getElementById('send-btn').disabled = textarea.value.trim().length === 0;
}
function handleChatKeyPress(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendChatMessage();
}
// Shift+Enter allows multiline - do nothing, let default behavior happen
}
function sendChatMessage() {
const chatInput = document.getElementById('chat-input');
const message = chatInput.value.trim();
if (!message || !ws || ws.readyState !== WebSocket.OPEN) {
return;
}
// Send message to server (newlines are preserved in the string)
ws.send(JSON.stringify({
type: 'chat_message',
username: currentUsername,
data: {
message: message // This preserves \n characters
},
timestamp: Date.now()
}));
// Clear input and reset height
chatInput.value = '';
chatInput.style.height = 'auto';
document.getElementById('send-btn').disabled = true;
console.log('Sent chat message:', message);
}
function handleChatMessage(data) {
const chatMessages = document.getElementById('chat-messages');
const isOwnMessage = data.username === currentUsername;
// Extract message and timestamp from data
let message = '';
let timestamp = Date.now();
if (data.data && typeof data.data === 'object') {
message = data.data.message || '';
timestamp = data.data.timestamp || Date.now();
}
if (!message) {
console.log('Received empty chat message');
return;
}
// Remove welcome message if it exists
const welcomeMsg = chatMessages.querySelector('.chat-welcome');
if (welcomeMsg) {
welcomeMsg.remove();
}
// Create message element
const messageEl = document.createElement('div');
messageEl.className = `chat-message ${isOwnMessage ? 'own' : 'other'}`;
const messageId = `msg_${Date.now()}_${Math.random()}`;
messageEl.id = messageId;
messageEl.innerHTML = `
<div class="chat-message-header">
<div class="username">${isOwnMessage ? 'You' : data.username}</div>
<div class="timestamp">${formatTime(timestamp)}</div>
</div>
<div class="content">${escapeHtml(message)}</div>
`;
// Add to chat
chatMessages.appendChild(messageEl);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
console.log('Added chat message from:', data.username, 'message:', message);
}
function deleteMessage(messageId) {
const messageEl = document.getElementById(messageId);
if (messageEl) {
// Add fading animation
messageEl.classList.add('fading');
// Remove after animation
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
messageTimeouts.delete(messageId);
}, 2000);
}
}
function clearAllChatMessages() {
// Clear all timeouts
messageTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
messageTimeouts.clear();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
let html = div.innerHTML.replace(/\n/g, '<br>');
// Process URLs for embedding
html = processMediaEmbeds(html);
return html;
}
function processMediaEmbeds(text) {
// GIF embedding - detects .gif URLs
text = text.replace(/(https?:\/\/[^\s]+\.gif(?:\?[^\s]*)?)/gi,
'<img src="$1" alt="GIF" class="embedded-gif" loading="lazy">');
// Image embedding - detects common image formats
text = text.replace(/(https?:\/\/[^\s]+\.(?:jpg|jpeg|png|webp)(?:\?[^\s]*)?)/gi,
'<img src="$1" alt="Image" class="embedded-image" loading="lazy">');
// YouTube embedding
text = text.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/gi,
'<iframe class="embedded-video" src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen></iframe>');
// Video file embedding
text = text.replace(/(https?:\/\/[^\s]+\.(?:mp4|webm|ogg)(?:\?[^\s]*)?)/gi,
'<video class="embedded-video" controls preload="metadata"><source src="$1" type="video/mp4">Your browser does not support the video tag.</video>');
return text;
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function muteUser(userId) {
if (mutedUsers.has(userId)) {
mutedUsers.delete(userId);
} else {
mutedUsers.add(userId);
}
// Update the audio element
const audioElement = document.getElementById(`audio-${userId}`);
if (audioElement) {
audioElement.muted = mutedUsers.has(userId);
}
// Update the button text
updateUserMuteButton(userId);
console.log('User', userId, mutedUsers.has(userId) ? 'muted' : 'unmuted');
}
function updateUserMuteButton(userId) {
const muteBtn = document.querySelector(`[data-user-id="${userId}"] .mute-user-btn`);
if (muteBtn) {
const isMuted = mutedUsers.has(userId);
muteBtn.textContent = isMuted ? '🔇' : '🔊';
muteBtn.title = isMuted ? 'Unmute user' : 'Mute user';
}
}
async function createPeerConnection(remoteUserId, isInitiator = false) {
if (peerConnections[remoteUserId]) {
console.log('🔗 Peer connection already exists for:', remoteUserId);
return peerConnections[remoteUserId];
}
console.log('🔗 Creating new peer connection for:', remoteUserId, 'as initiator:', isInitiator);
const peerConnection = new RTCPeerConnection(rtcConfiguration);
peerConnections[remoteUserId] = peerConnection;
// Add local stream to peer connection
if (localStream) {
console.log('🔗 Adding local stream tracks to peer connection');
localStream.getTracks().forEach(track => {
console.log('🔗 Adding track:', track.kind, 'enabled:', track.enabled);
peerConnection.addTrack(track, localStream);
});
} else {
console.error('🔗 No local stream available for peer connection!');
}
// Handle incoming remote stream
peerConnection.ontrack = (event) => {
console.log('🔗 Received remote stream from', remoteUserId);
const remoteStream = event.streams[0];
addRemoteAudio(remoteUserId, remoteStream);
};
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate && ws && ws.readyState === WebSocket.OPEN) {
console.log('🔗 Sending ICE candidate to:', remoteUserId);
ws.send(JSON.stringify({
type: 'webrtc_ice',
target: remoteUserId,
ice: event.candidate
}));
}
};
// Handle connection state changes
peerConnection.onconnectionstatechange = () => {
console.log(`🔗 Connection state with ${remoteUserId}:`, peerConnection.connectionState);
if (peerConnection.connectionState === 'failed' ||
peerConnection.connectionState === 'disconnected') {
console.log('🔗 Cleaning up failed connection for:', remoteUserId);
cleanupPeerConnection(remoteUserId);
}
};
// If we're the initiator, create an offer
if (isInitiator) {
try {
console.log('🔗 Creating offer for:', remoteUserId);
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: false
});
await peerConnection.setLocalDescription(offer);
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('🔗 Sending offer to:', remoteUserId);
ws.send(JSON.stringify({
type: 'webrtc_offer',
target: remoteUserId,
offer: offer
}));
} else {
console.error('🔗 Cannot send offer - WebSocket not open');
}
} catch (error) {
console.error('🔗 Error creating offer:', error);
}
}
return peerConnection;
}
async function handleWebRTCOffer(message) {
const remoteUserId = message.userId;
const offer = message.offer;
try {
const peerConnection = await createPeerConnection(remoteUserId, false);
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'webrtc_answer',
target: remoteUserId,
answer: answer
}));
}
} catch (error) {
console.error('Error handling WebRTC offer:', error);
}
}
async function handleWebRTCAnswer(message) {
const remoteUserId = message.userId;
const answer = message.answer;
try {
const peerConnection = peerConnections[remoteUserId];
if (peerConnection) {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
}
} catch (error) {
console.error('Error handling WebRTC answer:', error);
}
}
async function handleWebRTCIce(message) {
const remoteUserId = message.userId;
const iceCandidate = message.ice;
try {
const peerConnection = peerConnections[remoteUserId];
if (peerConnection) {
await peerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}
function addRemoteAudio(userId, stream) {
console.log('🔊 Adding remote audio for user:', userId);
// Remove existing audio element if it exists
const existingAudio = document.getElementById(`audio-${userId}`);
if (existingAudio) {
console.log('🔊 Removing existing audio element for:', userId);
existingAudio.remove();
}
// Check if stream has audio tracks
const audioTracks = stream.getAudioTracks();
console.log('🔊 Remote stream audio tracks:', audioTracks.length);
if (audioTracks.length === 0) {
console.error('🔊 No audio tracks in remote stream for user:', userId);
return;
}
// Create new audio element
const audioElement = document.createElement('audio');
audioElement.id = `audio-${userId}`;
audioElement.srcObject = stream;
audioElement.autoplay = true;
audioElement.playsInline = true; // Important for mobile
// Set initial volume (default 100% unless user has custom setting)
const userVolume = userVolumes.get(userId) || 100;
audioElement.volume = userVolume / 100;
// Apply current mute/deafen settings
audioElement.muted = isDeafened || mutedUsers.has(userId);
// Add event listeners for debugging
audioElement.addEventListener('loadstart', () => console.log('🔊 Audio loadstart for:', userId));
audioElement.addEventListener('canplay', () => console.log('🔊 Audio canplay for:', userId));
audioElement.addEventListener('playing', () => console.log('🔊 Audio playing for:', userId));
audioElement.addEventListener('error', (e) => console.error('🔊 Audio error for:', userId, e));
// Add to audio container
document.getElementById('audio-container').appendChild(audioElement);
// Force play (browsers may require user interaction)
audioElement.play().then(() => {
console.log('🔊 Successfully started playing audio for user:', userId);
}).catch(error => {
console.error('🔊 Failed to play audio for user:', userId, error);
});
console.log(`🔊 Added remote audio for user ${userId} with volume ${userVolume}%`);
}
function cleanupPeerConnection(userId) {
if (peerConnections[userId]) {
peerConnections[userId].close();
delete peerConnections[userId];
}
// Remove audio element
const audioElement = document.getElementById(`audio-${userId}`);
if (audioElement) {
audioElement.remove();
}
console.log(`Cleaned up peer connection for user ${userId}`);
}
// Self modal for mic and headphone volume
function openSelfModal() {
const modal = document.getElementById('user-control-modal');
const modalUsername = document.getElementById('modal-username');
// Set modal title
modalUsername.textContent = 'Audio Settings';
// Update modal content for self controls
const modalBody = modal.querySelector('.modal-body');
modalBody.innerHTML = `
<div class="control-section">
<label>Microphone Volume</label>
<div class="volume-control">
<span>🎤</span>
<input type="range" id="mic-volume-slider" min="0" max="100" value="100" oninput="updateMicVolume()">
<span>🎤</span>
<span id="mic-volume-percentage">100%</span>
</div>
</div>
<div class="control-section">
<label>Headphone Volume (All Incoming)</label>
<div class="volume-control">
<span>🔇</span>
<input type="range" id="headphone-volume-slider" min="0" max="100" value="100" oninput="updateHeadphoneVolume()">
<span>🔊</span>
<span id="headphone-volume-percentage">100%</span>
</div>
</div>
<div class="control-section">
<label>Audio Controls</label>
<div class="audio-controls">
<button onclick="resetAllVolumes()" class="control-btn secondary">
🔄 Reset All Volumes
</button>
</div>
</div>
`;
// Show modal
modal.style.display = 'flex';
console.log('Opened self audio settings modal');
}
// Global volume controls
let micVolume = 100;
let headphoneVolume = 100;
function updateMicVolume() {
const slider = document.getElementById('mic-volume-slider');
const percentage = document.getElementById('mic-volume-percentage');
micVolume = parseInt(slider.value);
percentage.textContent = `${micVolume}%`;
// Apply to local stream
if (localStream) {
localStream.getAudioTracks().forEach(track => {
// Note: We can't directly control mic input volume via WebAPI
// This is more for UI feedback. Actual mic volume control requires OS-level access
console.log('Mic volume set to:', micVolume + '%');
});
}
}
function updateHeadphoneVolume() {
const slider = document.getElementById('headphone-volume-slider');
const percentage = document.getElementById('headphone-volume-percentage');
headphoneVolume = parseInt(slider.value);
percentage.textContent = `${headphoneVolume}%`;
// Apply to all remote audio elements
const audioElements = document.querySelectorAll('#audio-container audio');
audioElements.forEach(audio => {
const userId = audio.id.replace('audio-', '');
const userVolume = userVolumes.get(userId) || 100;
// Combine headphone volume with individual user volume
audio.volume = (headphoneVolume / 100) * (userVolume / 100);
});
console.log('Headphone volume set to:', headphoneVolume + '%');
}
function resetAllVolumes() {
micVolume = 100;
headphoneVolume = 100;
const micSlider = document.getElementById('mic-volume-slider');
const headphoneSlider = document.getElementById('headphone-volume-slider');
const micPercentage = document.getElementById('mic-volume-percentage');
const headphonePercentage = document.getElementById('headphone-volume-percentage');
if (micSlider) {
micSlider.value = 100;
micPercentage.textContent = '100%';
}
if (headphoneSlider) {
headphoneSlider.value = 100;
headphonePercentage.textContent = '100%';
updateHeadphoneVolume();
}
console.log('Reset all volumes to 100%');
}
// Modal functions
function openUserModal(userId, username) {
currentModalUserId = userId;
const modal = document.getElementById('user-control-modal');
const modalUsername = document.getElementById('modal-username');
// Set modal title
modalUsername.textContent = `${username}`;
// Restore original modal content for other users
const modalBody = modal.querySelector('.modal-body');
modalBody.innerHTML = `
<div class="control-section">
<label>Volume Control</label>
<div class="volume-control">
<span>🔇</span>
<input type="range" id="user-volume-slider" min="0" max="100" value="100" oninput="updateUserVolume()">
<span>🔊</span>
<span id="volume-percentage">100%</span>
</div>
</div>
<div class="control-section">
<label>Audio Controls</label>
<div class="audio-controls">
<button id="modal-mute-btn" onclick="toggleUserMuteFromModal()" class="control-btn">
🔇 Mute User
</button>
<button onclick="resetUserVolume()" class="control-btn secondary">
🔄 Reset Volume
</button>
</div>
</div>
`;
// Set current volume
const currentVolume = userVolumes.get(userId) || 100;
const volumeSlider = document.getElementById('user-volume-slider');
const volumePercentage = document.getElementById('volume-percentage');
volumeSlider.value = currentVolume;
volumePercentage.textContent = `${currentVolume}%`;
// Set mute button state
const muteBtn = document.getElementById('modal-mute-btn');
const isMuted = mutedUsers.has(userId);
muteBtn.textContent = isMuted ? '🔇 User Muted' : '🔊 Mute User';
muteBtn.className = isMuted ? 'control-btn muted' : 'control-btn';
// Show modal
modal.style.display = 'flex';
console.log(`Opened modal for user ${username} (${userId})`);
}
function closeUserModal() {
const modal = document.getElementById('user-control-modal');
modal.style.display = 'none';
currentModalUserId = null;
console.log('Closed user modal');
}
function updateUserVolume() {
if (!currentModalUserId) return;
const volumeSlider = document.getElementById('user-volume-slider');
const volumePercentage = document.getElementById('volume-percentage');
const volume = parseInt(volumeSlider.value);
// Update volume display
volumePercentage.textContent = `${volume}%`;
// Store user volume preference
userVolumes.set(currentModalUserId, volume);
// Update audio element volume
const audioElement = document.getElementById(`audio-${currentModalUserId}`);
if (audioElement) {
audioElement.volume = volume / 100;
}
console.log(`Updated volume for user ${currentModalUserId} to ${volume}%`);
}
function toggleUserMuteFromModal() {
if (!currentModalUserId) return;
muteUser(currentModalUserId);
// Update modal button state
const muteBtn = document.getElementById('modal-mute-btn');
const isMuted = mutedUsers.has(currentModalUserId);
muteBtn.textContent = isMuted ? '🔇 User Muted' : '🔊 Mute User';
muteBtn.className = isMuted ? 'control-btn muted' : 'control-btn';
}
function resetUserVolume() {
if (!currentModalUserId) return;
const volumeSlider = document.getElementById('user-volume-slider');
const volumePercentage = document.getElementById('volume-percentage');
// Reset to 100%
volumeSlider.value = 100;
volumePercentage.textContent = '100%';
// Update volume
updateUserVolume();
console.log(`Reset volume for user ${currentModalUserId} to 100%`);
}
function handleUsernameError(errorMessage) {
// Show error to user
alert(errorMessage);
// Reset the connection since username was rejected
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
// Reset UI state
updateConnectionStatus(false);
currentUsername = null;
isInVoiceChat = false;
// Reset join button
const joinBtn = document.getElementById('join-chat-btn');
joinBtn.disabled = false;
joinBtn.textContent = 'Join Voice Chat'; // Fix: Reset button text
// Show username section again and enable input
document.getElementById('username-section').style.display = 'block';
document.getElementById('voice-controls-sidebar').style.display = 'none';
// Focus on username input and clear it or suggest modification
const usernameInput = document.getElementById('username-input');
usernameInput.focus();
usernameInput.select(); // Select current text so user can easily modify it
console.log('Username rejected, reset to initial state');
}
// Debug function to check voice setup
function debugVoiceSetup() {
console.log('🔍 === VOICE DEBUG INFO ===');
console.log('🔍 isInVoiceChat:', isInVoiceChat);
console.log('🔍 micPermissionGranted:', micPermissionGranted);
console.log('🔍 isMuted:', isMuted);
console.log('🔍 isDeafened:', isDeafened);
console.log('🔍 currentUsername:', currentUsername);
if (localStream) {
const audioTracks = localStream.getAudioTracks();
console.log('🔍 Local stream tracks:', audioTracks.length);
audioTracks.forEach((track, i) => {
console.log(`🔍 Track ${i}:`, {
kind: track.kind,
enabled: track.enabled,
readyState: track.readyState,
muted: track.muted
});
});
// Check if we need to fix browser-muted tracks
const hasMutedTracks = audioTracks.some(track => track.muted);
if (hasMutedTracks) {
console.log('🔍 ⚠️ BROWSER-MUTED TRACKS DETECTED! Run fixVoice() to fix this.');
}
} else {
console.log('🔍 No local stream!');
}
console.log('🔍 Peer connections:', Object.keys(peerConnections).length);
Object.entries(peerConnections).forEach(([userId, pc]) => {
console.log(`🔍 Peer ${userId}:`, pc.connectionState);
});
const audioElements = document.querySelectorAll('#audio-container audio');
console.log('🔍 Remote audio elements:', audioElements.length);
audioElements.forEach((audio, i) => {
console.log(`🔍 Audio ${i}:`, {
id: audio.id,
muted: audio.muted,
volume: audio.volume,
paused: audio.paused
});
});
console.log('🔍 WebSocket state:', ws ? ws.readyState : 'null');
console.log('🔍 === END DEBUG INFO ===');
}
// Manual fix function for console use
async function fixVoice() {
console.log('🔧 Manually fixing voice...');
const fixed = await fixBrowserMutedTracks();
if (fixed) {
console.log('🔧 Voice fixed! Try speaking now.');
} else {
console.log('🔧 No browser-muted tracks found, or fix failed.');
}
debugVoiceSetup();
}
// Test WebSocket connection
function testWebSocket() {
console.log('🔌 Testing WebSocket connection...');
console.log('🔌 WebSocket state:', ws ? ws.readyState : 'null');
console.log('🔌 WebSocket URL:', ws ? ws.url : 'null');
console.log('🔌 Current username:', currentUsername);
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('🔌 WebSocket is open, sending test message...');
ws.send(JSON.stringify({
type: 'set_username',
data: currentUsername || 'TestUser' + Date.now()
}));
console.log('🔌 Test message sent');
} else {
console.error('🔌 WebSocket is not open!');
console.log('🔌 Ready states: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED');
}
// Also run voice debug
debugVoiceSetup();
}
// Close modal when clicking outside
document.addEventListener('click', function(event) {
const modal = document.getElementById('user-control-modal');
if (event.target === modal) {
closeUserModal();
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeUserModal();
}
});
// Handle page visibility changes to manage audio context
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
// Resume audio context if needed
const audioElements = document.querySelectorAll('#audio-container audio');
audioElements.forEach(audio => {
if (audio.paused) {
audio.play().catch(console.error);
}
});
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
Object.values(peerConnections).forEach(pc => pc.close());
if (ws) {
ws.close();
}
});