1747 lines
52 KiB
JavaScript
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();
|
|
}
|
|
});
|