radchat/static/app.js

2296 lines
104 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
RadChat Frontend (static/app.js)
Overview:
- Single-page application handling UI, signaling (WebSocket), and media (WebRTC).
- Uses a central CONFIG object (frozen) to define defaults for output, media, and connection.
- Manages:
* WebSocket connection lifecycle (reconnect, pings, presence)
* WebRTC peer connections (offers/answers/ICE, track management)
* UI state: users list, chat pane, emoji picker, file uploads, audio controls
* Permissions and device selection (planned for settings UI)
Key Endpoints (see backend):
- /ws: signaling channel (JSON messages matching message.go)
- /files: POST uploads; /files/{id}: GET downloads
- /check-username and /user-count auxiliary endpoints
Configuration:
- Adjust CONFIG.CONN.RTC_CONFIGURATION.iceServers to set STUN/TURN.
- For relay-only networks, consider setting iceTransportPolicy to "relay".
Notes:
- Keep JSON envelope fields in sync with message.go to avoid wire format drift.
- Avoid heavy DOM queries in hot paths; use cached selectors where possible.
- Large file; consider future modularization by feature (chat, rtc, ui, files).
*/
// ==================== CONFIGURATION ====================
// noinspection HtmlUnknownTarget
function Fz(obj) {
Object.getOwnPropertyNames(obj).forEach(function(prop) {
if (obj[prop] !== null && (typeof obj[prop] === "object" || typeof obj[prop] === "function")) {
Fz(obj[prop]);
}
});
return Object.freeze(obj);
}
const CONFIG = Fz({
OUTPUT: {
ECHO_CANCELLATION: true,
NOISE_SUPPRESSION: true,
LATENCY: 0.01,
LATENCY_HINT: 'interactive',
SAMPLE_RATE: 96000,
DEVICE_ID: 'default',
SPEAKING_THRESHOLD: 10,
SPEAKING_TIMEOUT: 500,
MIC_REQUEST_TIMEOUT: 10000,
},
MEDIA: {
MAX_VOLUME: 100,
TRACK_FIX_TIMEOUT: 5000,
},
CONN: {
RECONNECT_DELAY: 5000,
CONNECTION_TIMEOUT: 5000,
PEER_HEALTH_CHECK_INTERVAL: 10000,
RTC_CONFIGURATION: {
iceServers: [
{
urls:
[
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302'
]
},
// {
// urls: [
// 'turn:my60455.glddns.com:3478',
// 'turn:my60455.glddns.com:3478?transport=tcp',
// ],
// username: "YeahRightLol",
// credential: "IfOnlyYouWereSoLucky",
// },
],
// iceTransportPolicy: "relay",
}
},
APP: {
CONNECTED_USER_LIMIT: 10,
SAVE_RATE_THROTTLE: 1000,
SYSTEM_MSG_DEFAULT_TIMEOUT: 5000,
FILE_UPLOAD_SIZE_LIMIT: 256 * (1024 * 1024),
}
});
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const $id = (sel) => document.getElementById(sel);
const EMOJIS = [
// Smileys & Emotion
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '☺️', '😚', '😙', '🥲', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠️', '💩', '🤡', '👹', '👺', '👻', '👽', '👾', '🤖',
// People & Body
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '👊', '✊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄', '💋', '🩸',
// Animals & Nature
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🦟', '🦗', '🕷️', '🕸️', '🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋', '🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🦧', '🐘', '🦣', '🦏', '🦛', '🐪', '🐫', '🦒', '🦘', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🦙', '🐐', '🦌', '🐕', '🐩', '🦮', '🐕‍🦺', '🐈', '🐈‍⬛', '🐓', '🦃', '🦚', '🦜', '🦢', '🦩', '🕊️', '🐇', '🦝', '🦨', '🦡', '🦦', '🦥', '🐁', '🐀', '🐿️', '🦔',
// Food & Drink
'🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️', '🫑', '🌽', '🥕', '🫒', '🧄', '🧅', '🥔', '🍠', '🥐', '🥯', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴', '🌭', '🍔', '🍟', '🍕', '🫓', '🥪', '🥙', '🧆', '🌮', '🌯', '🫔', '🥗', '🥘', '🫕', '🥫', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯', '🥛', '🍼', '☕', '🫖', '🍵', '🧃', '🥤', '🧋', '🍶', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸', '🍹', '🧉', '🍾',
// Activities & Sports
'⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️‍♀️', '🏋️‍♂️', '🤼‍♀️', '🤼‍♂️', '🤸‍♀️', '🤸‍♂️', '⛹️‍♀️', '⛹️‍♂️', '🤺', '🤾‍♀️', '🤾‍♂️', '🏌️‍♀️', '🏌️‍♂️', '🏇', '🧘‍♀️', '🧘‍♂️', '🏄‍♀️', '🏄‍♂️', '🏊‍♀️', '🏊‍♂️', '🤽‍♀️', '🤽‍♂️', '🚣‍♀️', '🚣‍♂️', '🧗‍♀️', '🧗‍♂️', '🚵‍♀️', '🚵‍♂️', '🚴‍♀️', '🚴‍♂️', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎗️', '🎫', '🎟️', '🎪', '🤹‍♀️', '🤹‍♂️', '🎭', '🩰', '🎨', '🎬', '🎤', '🎧', '🎼', '🎵', '🎶', '🥁', '🪘', '🎹', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻', '🎲', '♠️', '♥️', '♦️', '♣️', '♟️', '🃏', '🀄', '🎴',
// Travel & Places
'🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚', '🚛', '🚜', '🏍️', '🛵', '🚲', '🛴', '🛹', '🛼', '🚁', '🛸', '✈️', '🛩️', '🛫', '🛬', '🪂', '💺', '🚀', '🛰️', '🚢', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚂', '🚃', '🚄', '🚅', '🚆', '🚇', '🚈', '🚉', '🚊', '🚝', '🚞', '🚋', '🚌', '🚍', '🚎', '🚐', '🚑', '🚒', '🚓', '🚔', '🚕', '🚖', '🚗', '🚘', '🚙', '🚚', '🚛', '🚜', '🏎️', '🏍️', '🛵', '🚲', '🛴', '🛹', '🛼', '🚁', '🛸', '✈️', '🛩️', '🛫', '🛬', '💺', '🚀', '🛰️',
// Objects
'⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺', '🔮', '📿', '🧿', '💈', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🩸', '🧬', '🦠', '🧫', '🧪', '🌡️', '🧹', '🧺', '🧻', '🚽', '🚰', '🚿', '🛁', '🛀', '🧼', '🪒', '🧽', '🧴', '🛎️', '🔑', '🗝️', '🚪', '🪑', '🛋️', '🛏️', '🛌', '🧸', '🖼️', '🛍️', '🛒', '🎁', '🎈', '🎏', '🎀', '🎊', '🎉', '🎎', '🏮', '🎐', '🧧', '✉️', '📩', '📨', '📧', '💌', '📥', '📤', '📦', '🏷️', '📪', '📫', '📬', '📭', '📮', '📯', '📜', '📃', '📄', '📑', '🧾', '📊', '📈', '📉', '🗒️', '🗓️', '📆', '📅', '🗑️', '📇', '🗃️', '🗳️', '🗄️', '📋', '📁', '📂', '🗂️', '🗞️', '📰', '📓', '📔', '📒', '📕', '📗', '📘', '📙', '📚', '📖', '🔖', '🧷', '🔗', '📎', '🖇️', '📐', '📏', '🧮', '📌', '📍', '✂️', '🖊️', '🖋️', '✒️', '🖌️', '🖍️', '📝', '✏️', '🔍', '🔎', '🔏', '🔐', '🔒', '🔓',
// Symbols
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️', '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀', '💤', '🏧', '🚾', '♿', '🅿️', '🈳', '🈂️', '🛂', '🛃', '🛄', '🛅', '🚹', '🚺', '🚼', '🚻', '🚮', '🎦', '📶', '🈁', '🔣', '', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓', '0⃣', '1⃣', '2⃣', '3⃣', '4⃣', '5⃣', '6⃣', '7⃣', '8⃣', '9⃣', '🔟',
// Flags (just a few popular ones)
'🏁', '🚩', '🎌', '🏴', '🏳️', '🏳️‍🌈', '🏳️‍⚧️', '🏴‍☠️', '🇺🇸', '🇬🇧', '🇨🇦', '🇫🇷', '🇩🇪', '🇮🇹', '🇪🇸', '🇯🇵', '🇰🇷', '🇨🇳', '🇮🇳', '🇧🇷', '🇷🇺', '🇦🇺'
];
const SVGS = {
// DISCONNECT: { SVG: "./svg/disconnect.svg", CLASS: "disconnect-icon" },
CONNECT: { SVG: "./svg/connect.svg", CLASS: "connect-icon" },
MUTED: { SVG: "./svg/mic-muted.svg", CLASS: "mic-muted-icon" },
UNMUTED: { SVG: "./svg/mic-unmuted.svg", CLASS: "mic-unmuted-icon" },
DEAFENED: { SVG: "./svg/headphones-muted.svg", CLASS: "headphones-muted-icon" },
UNDEAFENED: { SVG: "./svg/headphones-unmuted.svg", CLASS: "headphones-unmuted-icon" },
SETTINGS: { SVG: "./svg/settings.svg", CLASS: "settings-icon" },
};
async function RenderSvg(svg) {
const svgPath = svg.SVG;
const className = svg.CLASS;
try {
const response = await fetch(svgPath);
const svgContent = await response.text();
const container = document.createElement('div');
container.className = className;
container.innerHTML = svgContent;
return container.outerHTML;
} catch (error) {
console.error('Error loading SVG:', error);
return `<div class="${className}">Error loading SVG</div>`;
}
}
// ==================== STATE MANAGEMENT ====================
class AppState {
constructor() {
this.ws = null;
this.localStream = null;
this.processedStream = null; // New: processed stream with gain applied
this.audioContext = null; // New: audio context for processing
this.micGainNode = null; // New: gain node for microphone
this.sourceNode = null; // New: source node from mic
this.destinationNode = null; // New: destination node for output
this.peerConnections = {};
this.lastSaveTime = 0;
this.currentUsername = null;
this.currentId = null;
this.isMuted = false;
this.isDeafened = false;
this.isInVoiceChat = false;
this.micPermissionGranted = false;
this.mutedUsers = new Set();
this.userVolumes = new Map();
this.currentModalUserId = null;
this.messageTimeouts = new Map();
this.intentionalDisconnect = false;
this.peerHealthCheckInterval = null;
this.previousUsers = {};
this.micVolume = 100;
this.headphoneVolume = 100;
}
save() {
const now = Date.now();
const timeSinceLastSave = now - this.lastSaveTime;
const timeUntilNextSaveAllowed = CONFIG.APP.SAVE_RATE_THROTTLE - timeSinceLastSave;
if (timeUntilNextSaveAllowed > 0) {
if (state.saveTimeoutHandler) {
clearTimeout(state.saveTimeoutHandler);
}
state.saveTimeoutHandler = setTimeout(() => {
this.save();
clearTimeout(state.saveTimeoutHandler);
state.saveTimeoutHandler = null;
},
timeUntilNextSaveAllowed
);
return;
}
try {
const saveState = {
currentUsername: this.currentUsername,
currentId: this.currentId,
isMuted: this.isMuted,
isDeafened: this.isDeafened,
mutedUsers: Array.from(this.mutedUsers),
userVolumes: Object.fromEntries(this.userVolumes),
micVolume: this.micVolume,
headphoneVolume: this.headphoneVolume,
}
localStorage.setItem('appState', JSON.stringify(saveState));
this.lastSaveTime = now;
console.log('App state saved successfully:', saveState);
} catch (error) {
console.error('Error saving app state:', error);
}
}
load() {
try {
const savedState = localStorage.getItem('appState');
if (savedState) {
const appState = JSON.parse(savedState);
this.currentUsername = appState.currentUsername || null;
this.currentId = appState.currentId || "";
this.isMuted = appState.isMuted || false;
this.isDeafened = appState.isDeafened || false;
this.micVolume = appState.micVolume || CONFIG.MEDIA.MAX_VOLUME;
this.headphoneVolume = appState.headphoneVolume || CONFIG.MEDIA.MAX_VOLUME;
if (Array.isArray(appState.mutedUsers)) {
this.mutedUsers = new Set(appState.mutedUsers.filter(id => typeof id === 'string'));
} else {
console.log('Error: Invalid mutedUsers in localStorage, resetting to empty set');
}
if (appState.userVolumes && typeof appState.userVolumes === 'object' && !Array.isArray(appState.userVolumes)) {
this.userVolumes = new Map(
Object.entries(appState.userVolumes).map(([key, value]) => [
key,
typeof value === 'number' ? value : parseFloat(value) || CONFIG.MEDIA.MAX_VOLUME
]).filter(([key, value]) => typeof key === 'string' && !isNaN(value))
);
} else {
console.warn('Invalid userVolumes in localStorage, using empty Map');
}
console.log("App state loaded successfully", appState);
}
} catch (error) {
console.error('Error loading app state:', error);
}
}
reset() {
this.isInVoiceChat = false;
this.peerConnections = {};
this.mutedUsers.clear();
this.userVolumes.clear();
this.currentModalUserId = null;
this.messageTimeouts.clear();
this.previousUsers = {};
}
// New: cleanup audio processing resources
cleanupAudioProcessing() {
if (this.sourceNode) {
this.sourceNode.disconnect();
this.sourceNode = null;
}
if (this.micGainNode) {
this.micGainNode.disconnect();
this.micGainNode = null;
}
if (this.destinationNode) {
this.destinationNode = null;
}
if (this.processedStream) {
this.processedStream.getTracks().forEach(track => track.stop());
this.processedStream = null;
}
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close();
this.audioContext = null;
}
}
apparentOutputVolume() {
return state.isDeafened ? 0 : state.headphoneVolume / CONFIG.MEDIA.MAX_VOLUME;
}
// apparentInputVolume() {
// return state.isMuted ? 0 : state.micVolume / CONFIG.MEDIA.MAX_VOLUME;
// }
}
const state = new AppState();
// ==================== UTILITY FUNCTIONS ====================
// noinspection HtmlUnknownTarget
class Utils {
static sleep(ms) {
setTimeout(() => {}, ms);
}
static async getUserCount() {
try {
const response = await fetch('/user-count', {
method: 'GET',
});
const data = await response.json();
return { userCount: data.userCount };
} catch (error) {
console.error('Error checking user count:', error);
return { userCount: 0 }; // Fallback to allowing attempt
}
}
static playSound(filepath, volume) {
const audio = new Audio(filepath);
const defaultVolume = 0.5;
audio.volume = volume !== undefined ? volume : defaultVolume;
audio.play().catch(error => {
console.error('Error playing sound:', error);
});
}
static getWebSocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let clientId = (state.currentId || '').trim();
console.log("🔑 Telling backend to use client id:", clientId);
return `${protocol}//${window.location.host}/ws?client_id=${encodeURIComponent(clientId)}`;
}
static async delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
static escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
let html = div.innerHTML.replace(/\n/g, '<br>');
return Utils.processMediaEmbeds(html);
}
static processMediaEmbeds(text) {
// Images (including GIFs)
text = text.replace(/(https?:\/\/\S+\.(?:jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|avif|jfif)(?:\?\S*)?)/gi,
'<img src="$1" alt="Image" class="embedded-image" loading="lazy">');
// Videos
text = text.replace(/(https?:\/\/\S+\.(?:mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp)(?:\?\S*)?)/gi,
'<video class="embedded-video" controls preload="metadata"><source src="$1">Your browser does not support the video tag.</video>');
// Audio
text = text.replace(/(https?:\/\/\S+\.(?:mp3|wav|ogg|m4a|aac|flac|wma|opus)(?:\?\S*)?)/gi,
'<audio class="embedded-audio" controls preload="metadata"><source src="$1">Your browser does not support the audio tag.</audio>');
// YouTube (multiple formats)
text = text.replace(/(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})(?:\S*)?/gi,
'<iframe class="embedded-video youtube" src="https://www.youtube.com/embed/$1" style="border: 0;" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe>');
// Vimeo
text = text.replace(/(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(?:video\/)?(\d+)(?:\S*)?/gi,
'<iframe class="embedded-video vimeo" src="https://player.vimeo.com/video/$1" style="border: 0;" allowfullscreen allow="autoplay; fullscreen; picture-in-picture"></iframe>');
// Twitch (clips and videos)
text = text.replace(/(?:https?:\/\/)?(?:www\.)?twitch\.tv\/videos\/(\d+)(?:\S*)?/gi,
'<iframe class="embedded-video twitch" src="https://player.twitch.tv/?video=$1&parent=localhost" style="border: 0;" allowfullscreen></iframe>');
text = text.replace(/(?:https?:\/\/)?(?:clips\.twitch\.tv\/|www\.twitch\.tv\/\w+\/clip\/)([a-zA-Z0-9_-]+)(?:\S*)?/gi,
'<iframe class="embedded-video twitch" src="https://clips.twitch.tv/embed?clip=$1&parent=localhost" style="border: 0;" allowfullscreen></iframe>');
// TikTok
text = text.replace(/(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)(?:\S*)?/gi,
'<iframe class="embedded-video tiktok" src="https://www.tiktok.com/embed/$1" style="border: 0;" allowfullscreen></iframe>');
// Twitter/X
text = text.replace(/(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)(?:\S*)?/gi,
'<blockquote class="twitter-tweet"><a href="https://twitter.com/i/status/$1">Loading tweet...</a></blockquote><script async src="https://platform.twitter.com/widgets.js"></script>');
// Instagram
text = text.replace(/(?:https?:\/\/)?(?:www\.)?instagram\.com\/p\/([a-zA-Z0-9_-]+)(?:\S*)?/gi,
'<blockquote class="instagram-media" data-instgrm-permalink="https://www.instagram.com/p/$1/"><a href="https://www.instagram.com/p/$1/">Loading Instagram post...</a></blockquote><script async src="//www.instagram.com/embed.js"></script>');
// Spotify
text = text.replace(/(?:https?:\/\/)?(?:open\.)?spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)(?:\S*)?/gi,
'<iframe class="embedded-audio spotify" src="https://open.spotify.com/embed/$1/$2" style="border: 0;" allowtransparency="true" allow="encrypted-media"></iframe>');
// SoundCloud
text = text.replace(/(?:https?:\/\/)?(?:www\.)?soundcloud\.com\/[\w-]+\/[\w-]+(?:\S*)?/gi, (match) => {
return `<iframe class="embedded-audio soundcloud" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(match)}&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true" style="border: 0;" allow="autoplay"></iframe>`;
});
// Reddit
text = text.replace(/(?:https?:\/\/)?(?:www\.)?reddit\.com\/r\/\w+\/comments\/([a-zA-Z0-9]+)(?:\S*)?/gi,
'<blockquote class="reddit-embed-bq"><a href="https://www.reddit.com/r/posts/comments/$1/">Loading Reddit post...</a></blockquote><script async src="https://embed.redditmedia.com/widgets/platform.js"></script>');
// Discord Message Links (limited embed support)
text = text.replace(/(?:https?:\/\/)?(?:www\.)?discord\.com\/channels\/(\d+)\/(\d+)\/(\d+)(?:\S*)?/gi,
'<div class="discord-embed"><a href="https://discord.com/channels/$1/$2/$3" target="_blank">Discord Message Link</a></div>');
// CodePen
text = text.replace(/(?:https?:\/\/)?codepen\.io\/([\w-]+)\/pen\/([a-zA-Z0-9]+)(?:\S*)?/gi,
'<iframe class="embedded-code codepen" src="https://codepen.io/$1/embed/$2?default-tab=result" style="border: 0;" allowfullscreen></iframe>');
// JSFiddle
text = text.replace(/(?:https?:\/\/)?jsfiddle\.net\/([\w-]+\/)?([a-zA-Z0-9]+)(?:\/\d+)?(?:\S*)?/gi,
'<iframe class="embedded-code jsfiddle" src="https://jsfiddle.net/$1$2/embedded/result/" style="border: 0;" allowfullscreen></iframe>');
// PDF files
text = text.replace(/(https?:\/\/\S+\.pdf(?:\?\S*)?)/gi,
'<iframe class="embedded-document pdf" src="$1" style="border: 0;">Your browser does not support PDFs. <a href="$1">Download PDF</a></iframe>');
// Google Drive files (public)
text = text.replace(/(?:https?:\/\/)?drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)(?:\S*)?/gi,
'<iframe class="embedded-document google-drive" src="https://drive.google.com/file/d/$1/preview" style="border: 0;" allowfullscreen></iframe>');
// Dropbox files
text = text.replace(/(?:https?:\/\/)?(?:www\.)?dropbox\.com\/s\/([a-zA-Z0-9_-]+)\/[^?\s]*(?:\?\S*)?/gi,
'<iframe class="embedded-document dropbox" src="https://www.dropbox.com/s/$1?raw=1" style="border: 0;"></iframe>');
// Generic document types (will be downloaded)
text = text.replace(/(https?:\/\/\S+\.(?:doc|docx|xls|xlsx|ppt|pptx|txt|rtf|odt|ods|odp)(?:\?\S*)?)/gi,
'<div class="embedded-document generic"><a href="$1" download>📄 Download Document</a></div>');
// Convert remaining plain URLs to clickable links (do this last to avoid conflicts)
// First, temporarily replace all existing HTML to protect it
const htmlPlaceholders = [];
text = text.replace(/<[^>]+>/g, (match) => {
const placeholder = `__HTML_PLACEHOLDER_${htmlPlaceholders.length}__`;
htmlPlaceholders.push(match);
return placeholder;
});
// Convert remaining plain URLs to clickable links (only http/https)
text = text.replace(/(^|[\s\n])(https?:\/\/\S+)/gi, (_, leadingChar, url) => {
const cleanUrl = url.trim();
const maxUrlLength = 50;
// Shorten display URL if too long
const displayUrl = cleanUrl.length > maxUrlLength ? cleanUrl.substring(0, maxUrlLength - 3) + '...' : cleanUrl;
return `${leadingChar}<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer" class="auto-link">${displayUrl}</a>`;
});
// Restore HTML placeholders
htmlPlaceholders.forEach((html, index) => {
text = text.replace(`__HTML_PLACEHOLDER_${index}__`, html);
});
return text;
}
static formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], {
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
static async checkUsernameAvailability(username) {
try {
const response = await fetch('/check-username', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username })
});
const data = await response.json();
return { available: data.available, error: data.error };
} catch (error) {
console.error('Error checking username:', error);
return { available: true, error: "" }; // Fallback to allowing attempt
}
}
}
// ==================== UI MANAGEMENT ====================
class UIManager {
static openFilePicker() {
const fileInput = $id('file-input');
fileInput.click();
fileInput.addEventListener('change', async (e) => {
const filesToUpload = e.target.files;
if (!filesToUpload || filesToUpload.length === 0) {
return;
}
const formData = new FormData();
const files = Array.from(filesToUpload);
files.forEach((file, index) => {
if (file.size <= CONFIG.APP.FILE_UPLOAD_SIZE_LIMIT) {
formData.append(`file_${index}`, file);
} else {
console.log(`File: ${file} is too large to upload`);
alert(`File: ${file} is too large to upload`);
}
});
console.log(`Uploading ${files.length} files`);
formData.append('username', state.currentUsername)
formData.append('client_id', state.currentId)
try {
// Uploading files system message
const alertMessage = ChatManager.addChatAlert('Uploading file(s)...', 'upload');
const response = await fetch('/files', {
method: 'POST',
body: formData
});
if (response.ok) {
alertMessage.remove();
const data = await response.json();
console.log('Upload successful:', data);
} else {
alertMessage.remove();
ChatManager.addChatAlert(`Upload failed: ${error}`, 'upload_failure', 3000);
console.error('Upload failed:', response.statusText);
}
} catch (error) {
ChatManager.addChatAlert(`Upload failed: ${error}`, 'upload_failure', 3000);
console.error('Upload error:', error);
}
}, { once: true });
}
static updateConnectionStatus(connected) {
const sidebar = $('.sidebar');
if (connected) {
sidebar.classList.add('visible');
UIManager.removeReconnectionNotice();
} else {
sidebar.classList.remove('visible');
if (state.isInVoiceChat && !state.intentionalDisconnect) {
UIManager.showReconnectionNotice();
}
}
}
static isChatScrolledToBottom() {
const chatMessages = $('.chat-messages');
const scrollTop = chatMessages.scrollTop;
const scrollHeight = chatMessages.scrollHeight;
const clientHeight = chatMessages.clientHeight;
return !(scrollTop + clientHeight < scrollHeight);
}
static scrollChatToBottom() {
const chatMessages = $('.chat-messages');
const atBottom = UIManager.isChatScrolledToBottom(chatMessages);
if (!atBottom) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
static showReconnectionNotice() {
UIManager.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);
}
static removeReconnectionNotice() {
const notice = $id('reconnection-notice');
if (notice) notice.remove();
}
static updateMicrophoneStatus(granted) {
const micStatus = $id('mic-status');
if (!micStatus) return;
if (granted) {
micStatus.textContent = '✅ Microphone access granted';
micStatus.className = 'mic-status granted';
$id('mic-section').style.display = 'none';
} else {
micStatus.textContent = '❌ Microphone access denied. Please refresh and allow microphone access.';
micStatus.innerHTML = micStatus.textContent.replace('. ', '.<br>');
micStatus.className = 'mic-status denied';
}
}
static updateJoinButton(text, disabled = false) {
const joinBtn = $id('join-chat-btn');
joinBtn.textContent = text;
joinBtn.disabled = disabled;
}
static showVoiceChatUI() {
$id('join-section').style.display = 'none';
$id('username-section').style.display = 'none';
$id('voice-controls-sidebar').style.display = 'block';
$id('chat-section').style.display = 'flex';
}
static showUsernameUI() {
$id('join-section').style.display = 'flex';
$id('username-section').style.display = 'block';
$id('voice-controls-sidebar').style.display = 'none';
$id('chat-section').style.display = 'none';
$id('users-list').innerHTML = '<div class="no-users"></div>';
}
static handleClickOutsideEmojiPicker(event) {
const emojiPicker = $id('emoji-picker');
if (!emojiPicker.contains(event.target)) {
UIManager.closeEmojiPicker();
}
}
static closeEmojiPicker() {
const emojiPicker = $id('emoji-picker');
if (emojiPicker) {
emojiPicker.remove();
document.removeEventListener('click', UIManager.handleClickOutsideEmojiPicker);
}
}
static openEmojiPicker() {
if ($id('emoji-picker')) {
return;
}
const chatInputContainer = $('.chat-input-container');
// Create emoji picker container
const emojiPicker = document.createElement('div');
emojiPicker.id = 'emoji-picker';
emojiPicker.className = 'emoji-picker';
emojiPicker.style.borderRadius = "0.5rem";
emojiPicker.style.zIndex = "1000";
emojiPicker.style.margin = "auto";
emojiPicker.style.backgroundColor = "var(--bg-secondary)";
emojiPicker.style.border = "1px solid var(--border)";
emojiPicker.style.display = 'grid';
emojiPicker.style.gridTemplateColumns = 'repeat(auto-fill, minmax(3rem, 1fr))';
emojiPicker.style.gap = '1rem';
emojiPicker.style.position = 'absolute';
emojiPicker.style.bottom = '20vh';
emojiPicker.style.right = '2vw';
emojiPicker.style.width = '50vw';
emojiPicker.style.height = '30vh';
emojiPicker.style.overflowY = 'scroll';
emojiPicker.style.overflowX = 'hidden';
emojiPicker.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.1)";
emojiPicker.style.border = "3px solid var(--border)";
// Add emojis to the picker
EMOJIS.forEach(emoji => {
const emojiSpan = document.createElement('span');
const sendBtn = $id('send-btn');
emojiSpan.className = 'emoji';
emojiSpan.textContent = emoji;
emojiSpan.style.cursor = 'pointer';
emojiSpan.style.hover = 'background-color: var(--bg-hover)';
emojiSpan.style.textAlign = 'center';
emojiSpan.style.lineHeight = '2.5rem';
emojiSpan.style.fontSize = '1.5rem';
emojiSpan.style.borderRadius = '0.5rem';
emojiSpan.onclick = () => {
const messageInput = $id('chat-input');
messageInput.value += emoji;
messageInput.focus();
sendBtn.disabled = messageInput.value.trim() === '';
// close after selecting an emoji, remove or comment out
// if we don't actually want this behavior
toggleEmojiPicker();
};
emojiSpan.addEventListener('mouseover', function () {
this.style.backgroundColor = 'var(--bg-primary)';
});
emojiSpan.addEventListener('mouseout', function () {
this.style.backgroundColor = 'transparent';
})
setTimeout(() => {
document.addEventListener('click', UIManager.handleClickOutsideEmojiPicker);
}, 0);
emojiPicker.appendChild(emojiSpan);
});
chatInputContainer.style.position = 'relative';
chatInputContainer.appendChild(emojiPicker);
}
static toggleEmojiPicker() {
if ($id('emoji-picker')) {
UIManager.closeEmojiPicker();
} else {
UIManager.openEmojiPicker();
}
}
static async loadSettingsButton() {
const settingsBtn = $id('voice-settings-btn');
settingsBtn.innerHTML = await RenderSvg(SVGS.SETTINGS);
settingsBtn.title = 'Settings';
}
static async loadDisconnectButton() {
const disconnectBtn = $id('leave-voice-btn');
disconnectBtn.innerHTML = await RenderSvg(SVGS.CONNECT);
disconnectBtn.title = 'Disconnect';
}
static async updateMuteButton() {
const muteBtn = $id('toggle-mute-btn');
if (state.isMuted) {
muteBtn.innerHTML = await RenderSvg(SVGS.MUTED);
muteBtn.title = 'Unmute (enable outgoing audio)';
muteBtn.classList.remove('unmuted');
muteBtn.classList.add('muted');
} else {
muteBtn.innerHTML = await RenderSvg(SVGS.UNMUTED);
muteBtn.title = 'Mute (mute all outgoing audio)';
muteBtn.classList.remove('muted');
muteBtn.classList.add('unmuted');
}
}
static async updateDeafenButton() {
const deafenBtn = $id('toggle-deafen-btn');
if (state.isDeafened) {
deafenBtn.innerHTML = await RenderSvg(SVGS.DEAFENED);
deafenBtn.title = 'Undeafen (enable incoming audio)';
deafenBtn.classList.remove('unmuted');
deafenBtn.classList.add('muted');
} else {
deafenBtn.innerHTML = await RenderSvg(SVGS.UNDEAFENED);
deafenBtn.title = 'Deafen (mute all incoming audio)';
deafenBtn.classList.add('unmuted');
deafenBtn.classList.remove('muted');
}
}
static async updateUserMuteButton(userId) {
const muteBtn = $(`[data-user-id="${userId}"] .mute-user-btn`);
if (muteBtn) {
const isMuted = state.mutedUsers.has(userId);
if (isMuted) {
muteBtn.textContent = '🔇';
} else {
muteBtn.textContent = '🔊';
}
muteBtn.title = isMuted ? 'Unmute user' : 'Mute user';
}
}
static async updateAllButtons() {
await UIManager.updateMuteButton();
await UIManager.updateDeafenButton();
await UIManager.loadSettingsButton();
await UIManager.loadDisconnectButton();
}
}
// ==================== AUDIO MANAGEMENT ====================
class AudioManager {
static async requestMicrophonePermission() {
try {
if (state.localStream) {
state.localStream.getTracks().forEach(track => track.stop());
}
state.cleanupAudioProcessing();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Microphone request timeout')), CONFIG.OUTPUT.MIC_REQUEST_TIMEOUT);
});
const mediaPromise = navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: CONFIG.OUTPUT.ECHO_CANCELLATION,
noiseSuppression: CONFIG.OUTPUT.NOISE_SUPPRESSION,
latency: CONFIG.OUTPUT.LATENCY,
sampleRate: CONFIG.OUTPUT.SAMPLE_RATE, // Fixed: was using DEVICE_ID
deviceId: CONFIG.OUTPUT.DEVICE_ID,
}
});
state.localStream = await Promise.race([mediaPromise, timeoutPromise]);
state.micPermissionGranted = true;
// Set up audio processing with gain control
await AudioManager.setupAudioProcessing();
UIManager.updateMicrophoneStatus(true);
AudioManager.setupAudioAnalysis();
console.log('🎤 Microphone permission granted and stream ready');
return Promise.resolve();
} catch (error) {
console.error('Microphone permission denied:', error);
UIManager.updateMicrophoneStatus(false);
return Promise.reject(error);
}
}
// New: Set up audio processing with gain control
static async setupAudioProcessing() {
if (!state.localStream) {
console.error('🎤 Cannot setup audio processing - no local stream');
return;
}
try {
// Create audio context
state.audioContext = new (AudioContext)({
sampleRate: CONFIG.OUTPUT.SAMPLE_RATE,
latencyHint: CONFIG.OUTPUT.LATENCY_HINT
});
console.log('🎤 Created AudioContext, state:', state.audioContext.state);
// Create source from microphone
state.sourceNode = state.audioContext.createMediaStreamSource(state.localStream);
// Create gain node for microphone volume control
state.micGainNode = state.audioContext.createGain();
// Set initial gain based on stored volume
const gainValue = state.micVolume / CONFIG.MEDIA.MAX_VOLUME;
state.micGainNode.gain.value = gainValue;
console.log('🎤 Set initial mic gain to:', gainValue);
// Create destination for processed stream
state.destinationNode = state.audioContext.createMediaStreamDestination();
// Connect the audio graph: source -> gain -> destination
state.sourceNode.connect(state.micGainNode);
state.micGainNode.connect(state.destinationNode);
// Get the processed stream
state.processedStream = state.destinationNode.stream;
// Enable/disable tracks based on mute state
state.processedStream.getAudioTracks().forEach(track => {
track.enabled = !state.isMuted;
console.log('🎤 Processed audio track setup - enabled:', track.enabled, 'app-muted:', state.isMuted);
});
if (state.audioContext.state === 'suspended') {
console.log('⚠️⚠️⚠️ Resuming suspended AudioContext');
state.audioContext.resume();
}
console.log('🎤 Audio processing setup complete with gain control');
} catch (error) {
console.error('🎤 Error setting up audio processing:', error);
}
}
// New: Update microphone gain
static updateMicrophoneGain() {
if (state.micGainNode) {
state.micGainNode.gain.value = state.micVolume / CONFIG.MEDIA.MAX_VOLUME;
} else {
console.warn('🎤 Cannot update microphone gain - no gain node available');
}
}
static setupAudioAnalysis() {
if (!state.localStream) {
console.error('🎤 Cannot setup audio analysis - no local stream');
return;
}
console.log('🎤 Setting up audio analysis...');
const audioTracks = state.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 {
// Use existing audio context or create new one for analysis
const audioContext = state.audioContext || new AudioContext();
console.log('🎤 AudioContext for analysis, state:', audioContext.state);
const analyser = audioContext.createAnalyser();
// Connect analyzer to the gain node if available, otherwise directly to source
if (state.micGainNode) {
state.micGainNode.connect(analyser);
console.log('🎤 Connected analyser to gain node');
} else {
const microphone = audioContext.createMediaStreamSource(state.localStream);
microphone.connect(analyser);
console.log('🎤 Connected analyser directly to microphone');
}
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.3;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
console.log('🎤 Audio analysis connected successfully');
AudioManager.startSpeakingDetection(analyser, dataArray, bufferLength);
} catch (error) {
console.error('🎤 Error setting up audio analysis:', error);
}
}
static startSpeakingDetection(analyser, dataArray, bufferLength) {
let speakingTimeout;
let isSpeaking = false;
function checkAudioLevel() {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
if (average > CONFIG.OUTPUT.SPEAKING_THRESHOLD && !state.isMuted && state.isInVoiceChat) {
if (!isSpeaking) {
isSpeaking = true;
WebSocketManager.sendSpeakingStatus(true);
}
clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => {
if (isSpeaking) {
isSpeaking = false;
WebSocketManager.sendSpeakingStatus(false);
}
}, CONFIG.OUTPUT.SPEAKING_TIMEOUT);
}
requestAnimationFrame(checkAudioLevel);
}
checkAudioLevel();
}
static async fixBrowserMutedTracks() {
if (!state.localStream) return false;
const audioTracks = state.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 {
audioTracks.forEach(track => {
console.log('🎤 Stopping track:', track.id, 'state:', track.readyState);
track.stop();
});
// Clean up audio processing
state.cleanupAudioProcessing();
state.localStream = null;
await Utils.delay(100);
state.localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: CONFIG.OUTPUT.ECHO_CANCELLATION,
noiseSuppression: CONFIG.OUTPUT.NOISE_SUPPRESSION,
latency: CONFIG.OUTPUT.LATENCY,
sampleRate: CONFIG.OUTPUT.SAMPLE_RATE,
deviceId: CONFIG.OUTPUT.DEVICE_ID,
}
});
// Set up audio processing again
await AudioManager.setupAudioProcessing();
state.processedStream.getAudioTracks().forEach(track => {
track.enabled = !state.isMuted;
console.log('🎤 Fresh processed track setup - enabled:', track.enabled, 'app-muted:', state.isMuted);
});
AudioManager.setupAudioAnalysis();
await PeerConnectionManager.updateAllPeerConnections();
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;
}
static addRemoteAudio(userId, stream) {
console.log('🔊 Adding remote audio for user:', userId);
const existingAudio = $id(`audio-${userId}`);
if (existingAudio) {
console.log('🔊 Removing existing audio element for:', userId);
existingAudio.remove();
}
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;
}
const audioElement = document.createElement('audio');
audioElement.id = `audio-${userId}`;
audioElement.srcObject = stream;
audioElement.autoplay = true;
audioElement.playsInline = true;
const userVolume = state.userVolumes.get(userId) || CONFIG.MEDIA.MAX_VOLUME;
audioElement.volume = (state.headphoneVolume / CONFIG.MEDIA.MAX_VOLUME) * (userVolume / CONFIG.MEDIA.MAX_VOLUME);
audioElement.muted = state.isDeafened || state.mutedUsers.has(userId);
AudioManager.setupAudioEventListeners(audioElement, userId);
$id('audio-container').appendChild(audioElement);
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}%`);
}
static setupAudioEventListeners(audioElement, userId) {
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));
}
}
// ==================== WEBSOCKET MANAGEMENT ====================
class WebSocketManager {
static initialize() {
const wsUrl = Utils.getWebSocketUrl();
console.log('Attempting to connect to WebSocket:', wsUrl);
state.ws = new WebSocket(wsUrl);
WebSocketManager.setupEventHandlers();
}
static setupEventHandlers() {
state.ws.onopen = () => {
UIManager.updateConnectionStatus(true);
console.log('WebSocket connected successfully');
};
state.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
WebSocketManager.handleMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error, event.data);
}
};
state.ws.onclose = (event) => {
UIManager.updateConnectionStatus(false);
console.log('WebSocket disconnected. Code:', event.code, 'Reason:', event.reason);
WebSocketManager.handleDisconnection();
};
state.ws.onerror = (error) => {
console.error('WebSocket error:', error);
UIManager.updateConnectionStatus(false);
};
}
static sendSystemMessage(message, qualifier, userIdList, type, timeout) {
// qualifiers: "all", "except", "include"
if (state.ws.readyState === WebSocket.OPEN) {
const payload = {
type: 'system_message',
data: message,
dataExt: qualifier,
dataList: userIdList,
dataType: type ? type : 'system_message',
dataTime: timeout ? timeout : 0
};
state.ws.send(JSON.stringify(payload));
} else {
console.error('WebSocket is not open. Cannot send system message.');
}
}
static handleMessage(message) {
switch (message.type) {
case 'system_message':
ChatManager.addChatAlert(message.data, message.dataType, message.dataTime);
break;
case 'client_id':
console.log('🔑 Client ID received:', message.clientId);
state.currentId = message.clientId;
state.save();
break;
case 'users_list':
console.log('🔵 This is a users_list message with data:', message.data);
UserManager.updateUsersList(message.data);
break;
case 'user_speaking':
// console.log('🎤 User speaking update:', message.userId, message.data);
UserManager.updateSpeakingStatus(message.userId, message.data);
break;
case 'username_error':
console.log('❌ Username error:', message.error);
VoiceChatManager.handleUsernameError(message.error);
break;
case 'chat_message':
console.log('💬 Chat message received:', message);
ChatManager.handleChatMessage(message);
break;
case 'webrtc_offer':
console.log('🔗 WebRTC offer:', message);
PeerConnectionManager.handleWebRTCOffer(message);
break;
case 'webrtc_answer':
console.log('🔗 WebRTC answer:', message);
PeerConnectionManager.handleWebRTCAnswer(message);
break;
case 'webrtc_ice':
console.log('🔗 WebRTC ICE candidate:', message);
PeerConnectionManager.handleWebRTCIce(message);
break;
default:
console.log('❓ Unknown message type:', message.type);
}
}
static handleDisconnection() {
if (state.isInVoiceChat) {
console.log('🔄 Server connection lost, resetting client state...');
PeerConnectionManager.cleanupAllConnections();
$id('audio-container').innerHTML = '';
$id('users-list').innerHTML = '<div class="no-users"></div>';
const savedUsername = state.currentUsername;
state.isInVoiceChat = false;
if (!state.intentionalDisconnect) {
WebSocketManager.attemptReconnection(savedUsername);
}
} else if (!state.intentionalDisconnect) {
console.log('🔄 Attempting to reconnect WebSocket...');
setTimeout(WebSocketManager.initialize, CONFIG.CONN.RECONNECT_DELAY);
}
if (state.intentionalDisconnect) {
console.log('🔴 Intentional disconnect, not reconnecting');
}
}
static async attemptReconnection(savedUsername) {
console.log('🔄 Attempting to reconnect and rejoin voice chat...');
setTimeout(async () => {
if (savedUsername && !state.intentionalDisconnect) {
state.currentUsername = savedUsername;
WebSocketManager.initialize();
await WebSocketManager.waitForConnection();
await WebSocketManager.rejoinVoiceChat(savedUsername);
}
}, CONFIG.CONN.RECONNECT_DELAY);
}
static async waitForConnection() {
const maxWait = CONFIG.CONN.CONNECTION_TIMEOUT;
const startTime = Date.now();
while ((!state.ws || state.ws.readyState !== WebSocket.OPEN) && (Date.now() - startTime) < maxWait) {
await Utils.delay(100);
}
}
static async rejoinVoiceChat(savedUsername) {
const rejoinInterval = setInterval(async () => {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
clearInterval(rejoinInterval);
// Force a refresh to load changes
window.location.reload();
console.log('🔄 Reconnected, rejoining voice chat...');
if (!state.localStream || state.localStream.getTracks().length === 0 ||
state.localStream.getAudioTracks().every(track => track.readyState !== 'live')) {
console.log('🔄 Local stream lost or inactive, requesting microphone again...');
await WebSocketManager.handleMicrophoneReacquisition(savedUsername);
} else {
await WebSocketManager.handleStreamReactivation(savedUsername);
}
}
}, 100);
}
static async handleMicrophoneReacquisition(savedUsername) {
const micPromise = AudioManager.requestMicrophonePermission();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
console.log('🔄 Microphone request timed out after 8 seconds');
reject(new Error('Microphone request timeout'));
}, CONFIG.OUTPUT.MIC_REQUEST_TIMEOUT);
});
try {
await Promise.race([micPromise, timeoutPromise]);
console.log('🔄 Microphone re-acquired, sending username to rejoin...');
} catch (error) {
console.error('🔄 Failed to re-acquire microphone or timed out:', error);
console.log('🔄 Proceeding with rejoin without microphone...');
}
WebSocketManager.sendUsername(savedUsername);
}
static async handleStreamReactivation(savedUsername) {
console.log('🔄 Local stream is still active, checking for browser-muted tracks...');
const fixTracksPromise = AudioManager.fixBrowserMutedTracks();
const timeoutPromise = new Promise((resolve) => {
setTimeout(() => {
console.log('🔄 fixBrowserMutedTracks timed out after 5 seconds, proceeding anyway...');
resolve(false);
}, CONFIG.MEDIA.TRACK_FIX_TIMEOUT);
});
try {
const fixedMutedTracks = await Promise.race([fixTracksPromise, timeoutPromise]);
console.log('🔄 Fix tracks completed or timed out, result:', fixedMutedTracks);
if (!fixedMutedTracks && state.processedStream) {
state.processedStream.getAudioTracks().forEach(track => {
track.enabled = !state.isMuted;
console.log('🔄 Processed track enabled state:', track.enabled, 'muted:', state.isMuted);
});
}
} catch (error) {
console.error('🔄 Error in fix tracks process:', error);
}
console.log('🔄 Sending rejoin request...');
WebSocketManager.sendUsername(savedUsername);
}
static sendUsername(username) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
type: 'set_username',
data: username
}));
}
}
static sendSpeakingStatus(speaking) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
type: 'speaking',
data: speaking
}));
} else {
console.error('Cannot send speaking status - WebSocket not connected');
}
}
static sendChatMessage(message) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
type: 'chat_message',
username: state.currentUsername,
data: { message: message },
timestamp: Date.now()
}));
}
}
}
// ==================== PEER CONNECTION MANAGEMENT ====================
class PeerConnectionManager {
static async createPeerConnection(remoteUserId, isInitiator = false) {
if (state.peerConnections[remoteUserId]) {
console.log('🔗 Peer connection already exists for:', remoteUserId);
return state.peerConnections[remoteUserId];
}
console.log('🔗 Creating new peer connection for:', remoteUserId, 'as initiator:', isInitiator);
const peerConnection = new RTCPeerConnection(CONFIG.CONN.RTC_CONFIGURATION);
state.peerConnections[remoteUserId] = peerConnection;
PeerConnectionManager.setupPeerConnection(peerConnection, remoteUserId);
if (isInitiator) {
await PeerConnectionManager.createOffer(peerConnection, remoteUserId);
}
return peerConnection;
}
static setupPeerConnection(peerConnection, remoteUserId) {
// Add processed stream (with gain applied) instead of raw local stream
const streamToUse = state.processedStream || state.localStream;
if (streamToUse) {
console.log('🔗 Adding processed stream tracks to peer connection');
streamToUse.getTracks().forEach(track => {
console.log('🔗 Adding track:', track.kind, 'enabled:', track.enabled);
peerConnection.addTrack(track, streamToUse);
});
} else {
console.error('🔗 No stream available for peer connection!');
}
// Handle incoming remote stream
peerConnection.ontrack = (event) => {
console.log('🔗 Received remote stream from', remoteUserId);
const remoteStream = event.streams[0];
AudioManager.addRemoteAudio(remoteUserId, remoteStream);
};
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate && state.ws && state.ws.readyState === WebSocket.OPEN) {
console.log('🔗 Sending ICE candidate to:', remoteUserId);
state.ws.send(JSON.stringify({
type: 'webrtc_ice',
target: remoteUserId,
ice: event.candidate
}));
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log('🔗 Connection state change:', peerConnection.connectionState);
}
// 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);
PeerConnectionManager.cleanupPeerConnection(remoteUserId);
}
};
}
static async createOffer(peerConnection, remoteUserId) {
try {
console.log('🔗 Creating offer for:', remoteUserId);
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: false
});
await peerConnection.setLocalDescription(offer);
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
console.log('🔗 Sending offer to:', remoteUserId);
state.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);
}
}
static async handleWebRTCOffer(message) {
const remoteUserId = message.userId;
const offer = message.offer;
try {
const peerConnection = await PeerConnectionManager.createPeerConnection(remoteUserId, false);
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
type: 'webrtc_answer',
target: remoteUserId,
answer: answer
}));
}
} catch (error) {
console.error('Error handling WebRTC offer:', error);
}
}
static async handleWebRTCAnswer(message) {
const remoteUserId = message.userId;
const answer = message.answer;
try {
const peerConnection = state.peerConnections[remoteUserId];
if (peerConnection) {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
}
} catch (error) {
console.error('Error handling WebRTC answer:', error);
}
}
static async handleWebRTCIce(message) {
const remoteUserId = message.userId;
const iceCandidate = message.ice;
try {
const peerConnection = state.peerConnections[remoteUserId];
if (peerConnection) {
await peerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}
static cleanupPeerConnection(userId) {
if (state.peerConnections[userId]) {
state.peerConnections[userId].close();
delete state.peerConnections[userId];
}
const audioElement = $id(`audio-${userId}`);
if (audioElement) {
audioElement.remove();
}
console.log(`Cleaned up peer connection for user ${userId}`);
}
static cleanupAllConnections() {
Object.values(state.peerConnections).forEach(pc => pc.close());
state.peerConnections = {};
}
static async updateAllPeerConnections() {
if (state.isInVoiceChat && Object.keys(state.peerConnections).length > 0) {
console.log('🎤 Updating', Object.keys(state.peerConnections).length, 'peer connections with fresh stream...');
const streamToUse = state.processedStream || state.localStream;
for (const [userId, peerConnection] of Object.entries(state.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 = streamToUse.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);
console.log('🎤 Recreating peer connection for', userId);
PeerConnectionManager.cleanupPeerConnection(userId);
setTimeout(() => {
PeerConnectionManager.createPeerConnection(userId, true);
}, 500);
}
}
}
}
static retryMissingPeerConnections(expectedPeerIds) {
if (!state.isInVoiceChat) return;
const existingPeers = Object.keys(state.peerConnections);
const stillMissingPeers = expectedPeerIds.filter(id => !existingPeers.includes(id));
const failedPeers = expectedPeerIds.filter(id => {
const pc = state.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 => {
if (state.peerConnections[userId]) {
console.log('🔗 Cleaning up failed connection before retry:', userId);
PeerConnectionManager.cleanupPeerConnection(userId);
}
setTimeout(() => {
console.log('🔗 Retrying peer connection for:', userId);
PeerConnectionManager.createPeerConnection(userId, true);
}, Math.random() * 1000);
});
}
}
static startPeerHealthCheck() {
if (state.peerHealthCheckInterval) {
clearInterval(state.peerHealthCheckInterval);
}
state.peerHealthCheckInterval = setInterval(() => {
if (!state.isInVoiceChat) {
PeerConnectionManager.stopPeerHealthCheck();
return;
}
const userCards = $$('.user-card:not(.current-user-card)');
const expectedPeerIds = Array.from(userCards).map(card => card.dataset.userId);
if (expectedPeerIds.length > 0) {
PeerConnectionManager.retryMissingPeerConnections(expectedPeerIds);
}
}, CONFIG.CONN.PEER_HEALTH_CHECK_INTERVAL);
}
static stopPeerHealthCheck() {
if (state.peerHealthCheckInterval) {
clearInterval(state.peerHealthCheckInterval);
state.peerHealthCheckInterval = null;
}
}
}
// ==================== USER MANAGEMENT ====================
class UserManager {
static updateUsersList(users) {
if (users && typeof users === 'object' && !Array.isArray(users)) {
users = Object.entries(users).map(([id, name]) => ({
id: id,
name: name
}));
} else if (!Array.isArray(users)) {
users = [];
}
const usersList = $id('users-list');
const currentUserIds = users.map(u => u.id);
const previousUserIds = Object.keys(state.previousUsers);
UserManager.detectUserChanges(users, currentUserIds, previousUserIds);
UserManager.updatePreviousUsers(users);
UserManager.checkFirstJoin();
UserManager.renderUsersList(usersList, users);
UserManager.setupPeerConnections(users);
}
static setUsername() {
const modalUsernameInput = $id('modal-username-input');
const usernameInput = $id('username-input');
const username = modalUsernameInput.value.trim();
if (username === state.currentUsername) {
return;
}
if (!username) {
alert('Please enter a username.');
return;
}
Utils.checkUsernameAvailability(username).then(availableResponse => {
console.log(availableResponse);
const isAvailable = availableResponse.available;
const errorMsg = availableResponse.error;
if (isAvailable) {
WebSocketManager.sendSystemMessage(
`${state.currentUsername} changed their username to ${username}`,
'except',
[state.currentId],
'system_message_username_change',
CONFIG.APP.SYSTEM_MSG_DEFAULT_TIMEOUT,
)
state.currentUsername = username;
usernameInput.value = username;
} else {
alert(errorMsg);
return;
}
console.log('Changed username to:', state.currentUsername);
// change the style of the modal username input field momentarily
const transitionDuration = 750;
modalUsernameInput.style.backgroundColor = '#c8e6c9'; // light green
modalUsernameInput.style.color = '#1b5e20'; // dark green
modalUsernameInput.style.boxShadow = '0 0 10px #c8e6c9';
modalUsernameInput.style.transition = 'background-color ' + transitionDuration + 'ms, box-shadow ' + transitionDuration + 'ms';
setTimeout(() => {
modalUsernameInput.style.backgroundColor = ''; // reset to default
modalUsernameInput.style.color = ''; // reset to default
modalUsernameInput.style.boxShadow = ''; // reset to default
modalUsernameInput.transition = ''; // reset transition
}, transitionDuration);
console.log('Sending username to server:', state.currentUsername);
WebSocketManager.sendUsername(state.currentUsername);
})
}
static onVoiceChatJoin() {
DebugUtils.fixVoice();
UIManager.updateAllButtons();
WebSocketManager.sendSystemMessage(`${state.currentUsername} joined the voice chat`,
'except',
[state.currentId],
'system_message_join',
CONFIG.APP.SYSTEM_MSG_DEFAULT_TIMEOUT,
)
Utils.playSound('/sounds/join.wav', state.apparentOutputVolume());
}
static onVoiceChatLeave() {
// WebSocketManager.sendSystemMessage(`${state.currentUsername} left the voice chat`,
// 'except',
// [state.currentId],
// 'system_message_leave',
// CONFIG.APP.SYSTEM_MSG_DEFAULT_TIMEOUT,
// )
Utils.playSound('/sounds/leave.wav', state.apparentOutputVolume());
}
static detectUserChanges(users, currentUserIds, previousUserIds) {
if (state.previousUsers && state.isInVoiceChat) {
const newUsers = users ? users.filter(u => !previousUserIds.includes(u.id) && u.username !== state.currentUsername) : [];
newUsers.forEach(_ => {
Utils.playSound('/sounds/join.wav', state.apparentOutputVolume());
});
const leftUserIds = previousUserIds.filter(id => !currentUserIds.includes(id));
leftUserIds.forEach(userId => {
const leftUser = state.previousUsers[userId];
if (leftUser && leftUser.username !== state.currentUsername) {
Utils.playSound('/sounds/leave.wav', state.apparentOutputVolume());
}
});
}
}
static updatePreviousUsers(users) {
state.previousUsers = {};
if (users) {
users.forEach(user => {
state.previousUsers[user.id] = user;
});
}
}
static checkFirstJoin() {
if (state.currentUsername && !state.isInVoiceChat) {
UserManager.onVoiceChatJoin();
console.log('Username accepted, completing join process');
UIManager.showVoiceChatUI();
state.isInVoiceChat = true;
PeerConnectionManager.startPeerHealthCheck();
console.log('Successfully joined voice chat with username:', state.currentUsername);
}
}
static renderUsersList(usersList, users) {
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 === state.currentUsername;
const userClass = isCurrentUser ? 'user-card current-user-card' : 'user-card';
const isMuted = state.mutedUsers.has(user.id);
const clickHandler = isCurrentUser ? `onclick="ModalManager.openSelfModal()"` : `onclick="ModalManager.openUserModal('${user.id}', '${user.username}')"`;
const muteButtonHtml = !isCurrentUser ? `
<button class="mute-user-btn" onclick="event.stopPropagation(); VoiceControls.muteUser('${user.id}'); const muteBtn = $id('modal-mute-btn'); const isMuted = state.mutedUsers.has('${user.id}'); muteBtn.textContent = isMuted ? '🔇 User Muted' : '🔊 Mute User'; muteBtn.className = isMuted ? 'control-btn muted' : 'control-btn';" 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;
}
}
static setupPeerConnections(users) {
if (state.isInVoiceChat) {
console.log('🔗 Setting up peer connections for voice chat...');
const expectedPeers = users.filter(user => user.username !== state.currentUsername).map(user => user.id);
const existingPeers = Object.keys(state.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);
extraPeers.forEach(userId => {
console.log('🔗 Cleaning up connection to user who left:', userId);
PeerConnectionManager.cleanupPeerConnection(userId);
});
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);
PeerConnectionManager.createPeerConnection(userId, true);
});
}, 500);
setTimeout(() => {
PeerConnectionManager.retryMissingPeerConnections(expectedPeers);
}, 3000);
}
}
}
static updateSpeakingStatus(userId, isSpeaking) {
const userCard = $(`[data-user-id="${userId}"]`);
if (userCard) {
if (isSpeaking) {
userCard.classList.add('speaking');
} else {
userCard.classList.remove('speaking');
}
} else {
console.log('Error: User card not found for userId:', userId);
const allCards = $$('.user-card');
console.log('Info: All user cards:', Array.from(allCards).map(card => ({
id: card.dataset.userId,
element: card
})));
}
}
}
// ==================== CHAT MANAGEMENT ====================
class ChatManager {
static handleChatMessage(data) {
const chatMessages = $id('chat-messages');
const isOwnMessage = data.username === state.currentUsername;
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;
}
const welcomeMsg = chatMessages.querySelector('.chat-welcome');
if (welcomeMsg) {
welcomeMsg.remove();
}
const messageEl = document.createElement('div');
messageEl.className = `chat-message ${isOwnMessage ? 'own' : 'other'}`;
messageEl.id = `msg_${Date.now()}_${Math.random()}`;
messageEl.innerHTML = `
<div class="chat-message-header">
<div class="username">${isOwnMessage ? 'You' : data.username}</div>
<div class="timestamp">${Utils.formatTime(timestamp)}</div>
</div>
<div class="content">${Utils.escapeHtml(message)}</div>
`;
chatMessages.appendChild(messageEl);
UIManager.scrollChatToBottom();
console.log('Added chat message from:', data.username, 'message:', message);
}
static addChatAlert(message, type, timeout) {
const chatMessages = $id('chat-messages');
if (!chatMessages) return;
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">${Utils.escapeHtml(message)}</span>
<span class="alert-time">${Utils.formatTime(Date.now())}</span>
</div>
`;
if (timeout) {
alertEl.classList.add('timeout');
alertEl.style.setProperty('--timeout-duration', `${timeout}ms`);
state.messageTimeouts.set(alertId, setTimeout(() => {
ChatManager.deleteMessage(alertId);
}, timeout));
}
chatMessages.appendChild(alertEl);
UIManager.scrollChatToBottom();
console.log('📢 Added chat alert:', message);
return alertEl;
}
static deleteMessage(messageId) {
const messageEl = $id(messageId);
if (messageEl) {
messageEl.classList.add('fading');
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
state.messageTimeouts.delete(messageId);
}, 500);
}
}
static clearAllChatMessages() {
state.messageTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
state.messageTimeouts.clear();
}
}
// ==================== VOICE CONTROLS ====================
class VoiceControls {
static toggleMute() {
state.isMuted = !state.isMuted;
// Update both raw and processed streams
if (state.localStream) {
state.localStream.getAudioTracks().forEach(track => {
track.enabled = !state.isMuted;
});
}
if (state.processedStream) {
state.processedStream.getAudioTracks().forEach(track => {
track.enabled = !state.isMuted;
});
}
UIManager.updateMuteButton();
console.log('Mute toggled:', state.isMuted);
state.save();
}
static toggleDeafen() {
state.isDeafened = !state.isDeafened;
const audioElements = $$('#audio-container audio');
audioElements.forEach(audio => {
audio.muted = state.isDeafened;
});
UIManager.updateDeafenButton();
console.log('Deafen toggled:', state.isDeafened);
state.save();
}
static async muteUser(userId) {
if (state.mutedUsers.has(userId)) {
state.mutedUsers.delete(userId);
} else {
state.mutedUsers.add(userId);
}
const audioElement = $id(`audio-${userId}`);
if (audioElement) {
audioElement.muted = state.mutedUsers.has(userId);
}
await UIManager.updateUserMuteButton(userId);
console.log('User', userId, state.mutedUsers.has(userId) ? 'muted' : 'unmuted');
state.save();
}
static updateMicVolume() {
const slider = $id('mic-volume-slider');
const percentage = $id('mic-volume-percentage');
state.micVolume = parseInt(slider.value);
percentage.textContent = `${state.micVolume}%`;
// Apply gain using Web Audio API
AudioManager.updateMicrophoneGain();
}
static updateHeadphoneVolume() {
const slider = $id('headphone-volume-slider');
const percentage = $id('headphone-volume-percentage');
state.headphoneVolume = parseInt(slider.value);
percentage.textContent = `${state.headphoneVolume}%`;
const audioElements = $$('#audio-container audio');
audioElements.forEach(audio => {
const userId = audio.id.replace('audio-', '');
const userVolume = state.userVolumes.get(userId) || 100;
audio.volume = state.apparentOutputVolume() * (userVolume / 100);
});
}
static resetAllVolumes() {
state.micVolume = 100;
state.headphoneVolume = 100;
const micSlider = $id('mic-volume-slider');
const headphoneSlider = $id('headphone-volume-slider');
const micPercentage = $id('mic-volume-percentage');
const headphonePercentage = $id('headphone-volume-percentage');
if (micSlider) {
micSlider.value = 100;
micPercentage.textContent = '100%';
VoiceControls.updateMicVolume();
}
if (headphoneSlider) {
headphoneSlider.value = 100;
headphonePercentage.textContent = '100%';
VoiceControls.updateHeadphoneVolume();
}
console.log('Reset all volumes to 100%');
state.save();
}
}
// ==================== MODAL MANAGEMENT ====================
class ModalManager {
static openSelfModal() {
const modal = $id('user-control-modal');
const modalUsername = $id('modal-username');
modalUsername.textContent = 'Settings';
const percentage = $id('headphone-volume-percentage');
if (percentage) {
percentage.textContent = `${state.headphoneVolume}%`;
}
const modalBody = modal.querySelector('.modal-body');
modalBody.innerHTML = `
<div class="control-section">
<h1>Username</h1>
<div class="modal-username">
<input type="text" id="modal-username-input" placeholder="Enter your username" value="${state.currentUsername}">
<input type="button" value="Apply" class="control-btn secondary" onclick="UserManager.setUsername()">
</div >
</div>
<div class="control-section">
<h1>Microphone Volume</h1>
<div class="volume-control">
<span>🎤</span>
<input type="range" id="mic-volume-slider" min="0" max="100" value="${state.micVolume}" oninput="VoiceControls.updateMicVolume()" onchange="state.save()">
<span>🎤</span>
<span id="mic-volume-percentage">${state.micVolume}%</span>
</div>
</div>
<div class="control-section">
<h1>Headphone Volume (All Incoming)</h1>
<div class="volume-control">
<span>🔇</span>
<input type="range" id="headphone-volume-slider" min="0" max="100" value=${state.headphoneVolume} oninput="VoiceControls.updateHeadphoneVolume()" onchange="state.save()">
<span>🔊</span>
<span id="headphone-volume-percentage">${state.headphoneVolume}%</span>
</div>
</div>
<div class="control-section">
<h1>Audio Controls</h1>
<div class="audio-controls">
<button onclick="VoiceControls.resetAllVolumes()" class="control-btn secondary">
🔄 Reset All Volumes
</button>
</div>
</div>
`;
const modalUsernameInput = $id('modal-username-input');
modalUsernameInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
UserManager.setUsername();
}
});
modal.style.display = 'flex';
console.log('Opened self audio settings modal');
}
// static closeSelfModal() {
// const modal = $id('user-control-modal');
// modal.style.display = 'none';
// state.currentModalUserId = null;
// console.log('Closed self audio settings modal');
// }
static openUserModal(userId, username) {
state.currentModalUserId = userId;
const modal = $id('user-control-modal');
const modalUsername = $id('modal-username');
modalUsername.textContent = `${username} `;
const modalBody = modal.querySelector('.modal-body');
modalBody.innerHTML = `
<div class="control-section">
<h1>Volume Control</h1>
<div class="volume-control">
<span>🔇</span>
<input type="range" id="user-volume-slider" min="0" max="100" value="100" oninput="ModalManager.updateUserVolume()" onchange="state.save()">
<span>🔊</span>
<span id="volume-percentage">100%</span>
</div>
<h1>Audio Controls</h1>
<div class="audio-controls">
<button id="modal-mute-btn" onclick="ModalManager.toggleUserMuteFromModal()" class="control-btn">
🔇 Mute User
</button>
<button onclick="ModalManager.resetUserVolume()" class="control-btn secondary">
🔄 Reset Volume
</button>
</div>
</div>
`;
const currentVolume = state.userVolumes.get(userId) || 100;
const volumeSlider = $id('user-volume-slider');
const volumePercentage = $id('volume-percentage');
volumeSlider.value = currentVolume;
volumePercentage.textContent = `${currentVolume}% `;
const muteBtn = $id('modal-mute-btn');
const isMuted = state.mutedUsers.has(userId);
if (muteBtn) {
muteBtn.textContent = isMuted ? '🔇 User Muted' : '🔊 Mute User';
muteBtn.className = isMuted ? 'control-btn muted' : 'control-btn';
}
modal.style.display = 'flex';
console.log(`Opened modal for user ${username}(${userId})`);
}
static closeUserModal() {
const modal = $id('user-control-modal');
modal.style.display = 'none';
state.currentModalUserId = null;
console.log('Closed user modal');
}
static updateUserVolume() {
if (!state.currentModalUserId) return;
const slider = $id('user-volume-slider');
const volumePercentage = $id('volume-percentage');
const volume = parseInt(slider.value);
volumePercentage.textContent = `${volume}% `;
state.userVolumes.set(state.currentModalUserId, volume);
const audioElement = $id(`audio - ${state.currentModalUserId} `);
if (audioElement) {
audioElement.volume = volume / 100;
}
// now update the audio so the changes actually take effect
const audioElements = $$('#audio-container audio');
audioElements.forEach(audio => {
const userId = audio.id.replace('audio-', '');
const userVolume = state.userVolumes.get(userId) || 100;
audio.volume = state.apparentOutputVolume() * (userVolume / 100);
});
}
static toggleUserMuteFromModal() {
if (!state.currentModalUserId) return;
VoiceControls.muteUser(state.currentModalUserId);
const muteBtn = $id('modal-mute-btn');
const isMuted = state.mutedUsers.has(state.currentModalUserId);
muteBtn.textContent = isMuted ? '🔇 User Muted' : '🔊 Mute User';
muteBtn.className = isMuted ? 'control-btn muted' : 'control-btn';
}
static resetUserVolume() {
if (!state.currentModalUserId) return;
const volumeSlider = $id('user-volume-slider');
const volumePercentage = $id('volume-percentage');
volumeSlider.value = 100;
volumePercentage.textContent = '100%';
ModalManager.updateUserVolume();
console.log(`Reset volume for user ${state.currentModalUserId} to 100 % `);
}
}
// ==================== VOICE CHAT MANAGER ====================
class VoiceChatManager {
static async joinVoiceChat() {
const userCountData = await Utils.getUserCount();
const userCount = userCountData.userCount;
console.log("Users connected:", userCount)
const userLimit = CONFIG.APP.CONNECTED_USER_LIMIT;
if (userCount >= userLimit) {
alert(`The server has reached maximum capacity ${userLimit}`);
return;
}
const usernameInput = $id('username-input');
const username = usernameInput.value.trim();
state.intentionalDisconnect = false;
if (!username) {
alert('Please enter a username.');
return;
}
if (!state.micPermissionGranted) {
alert('Please grant microphone permission first.');
return;
}
UIManager.updateJoinButton('🔄 Validating...', true);
const availableResponse = await Utils.checkUsernameAvailability(username);
// console.log(availableResponse);
const isAvailable = availableResponse.available;
const errorMsg = availableResponse.error;
// console.log(isAvailable);
if (!isAvailable) {
alert(errorMsg);
UIManager.updateJoinButton('Join Voice Chat', false);
usernameInput.focus();
usernameInput.select();
return;
}
UIManager.updateJoinButton('🔄 Connecting...', true);
state.currentUsername = username;
console.log('Joining voice chat with username:', state.currentUsername);
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
console.log('Connecting to WebSocket for voice chat...');
WebSocketManager.initialize();
await WebSocketManager.waitForConnection();
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
alert('Failed to connect to server. Please try again.');
UIManager.updateJoinButton('Join Voice Chat', false);
return;
}
}
console.log('Sending username to server:', state.currentUsername);
WebSocketManager.sendUsername(state.currentUsername);
}
static leaveVoiceChat() {
state.isInVoiceChat = false;
PeerConnectionManager.stopPeerHealthCheck();
PeerConnectionManager.cleanupAllConnections();
UserManager.onVoiceChatLeave();
$id('audio-container').innerHTML = '';
state.reset();
ModalManager.closeUserModal();
state.intentionalDisconnect = true;
ChatManager.clearAllChatMessages();
// Clean up audio processing resources
state.cleanupAudioProcessing();
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
console.log('Closing WebSocket connection to leave voice chat');
state.ws.close();
}
UIManager.updateConnectionStatus(false);
UIManager.showUsernameUI();
UIManager.updateJoinButton('Join Voice Chat', false);
console.log('Left voice chat and disconnected from server');
}
static handleUsernameError(errorMessage) {
alert(errorMessage);
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.close();
}
UIManager.updateConnectionStatus(false);
state.currentUsername = null;
state.isInVoiceChat = false;
UIManager.updateJoinButton('Join Voice Chat', false);
UIManager.showUsernameUI();
const usernameInput = $id('username-input');
usernameInput.focus();
usernameInput.select();
console.log('Username rejected, reset to initial state');
}
}
// ==================== EVENT HANDLERS ====================
class EventHandlers {
static setupEventListeners() {
const usernameInput = $id('username-input');
const joinChatBtn = $id('join-chat-btn');
const chatInput = $id('chat-input');
const sendBtn = $id('send-btn');
const chatMessages = $('.chat-messages');
// const fileBtn = $id('file-btn');
chatMessages.addEventListener('scroll', function() {
const atBottom = UIManager.isChatScrolledToBottom();
if (!atBottom) {
$id('scroll-btn').disabled = false;
}
if (atBottom) {
$id('scroll-btn').disabled = true;
}
});
joinChatBtn.disabled = usernameInput.value.trim().length === 0;
usernameInput.addEventListener('input', function() {
joinChatBtn.disabled = this.value.trim().length === 0;
});
usernameInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && this.value.trim().length > 0) {
VoiceChatManager.joinVoiceChat();
}
});
chatInput.addEventListener('input', function(e) {
sendBtn.disabled = this.value.trim().length === 0;
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight * 0.5) + 'px';
$id('send-btn').disabled = textarea.value.trim().length === 0;
});
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
event.preventDefault();
EventHandlers.sendChatMessage();
}
})
EventHandlers.setupGlobalEventListeners();
}
static setupGlobalEventListeners() {
document.addEventListener('click', function(event) {
const modal = $id('user-control-modal');
if (event.target === modal) {
ModalManager.closeUserModal();
}
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
ModalManager.closeUserModal();
}
});
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
const audioElements = $$('#audio-container audio');
audioElements.forEach(audio => {
if (audio.paused) {
audio.play().catch(console.error);
}
});
}
});
window.addEventListener('beforeunload', function() {
if (state.localStream) {
state.localStream.getTracks().forEach(track => track.stop());
}
state.cleanupAudioProcessing();
Object.values(state.peerConnections).forEach(pc => pc.close());
if (state.ws) {
state.ws.close();
}
});
}
static sendChatMessage() {
const chatInput = $id('chat-input');
const message = chatInput.value.trim();
if (!message || !state.ws || state.ws.readyState !== WebSocket.OPEN) {
return;
}
WebSocketManager.sendChatMessage(message);
chatInput.value = '';
chatInput.style.height = 'auto';
$id('send-btn').disabled = true;
}
}
// ==================== DEBUG UTILITIES ====================
class DebugUtils {
static debugVoiceSetup() {
console.log('🔍 === VOICE DEBUG INFO ===');
console.log('🔍 isInVoiceChat:', state.isInVoiceChat);
console.log('🔍 micPermissionGranted:', state.micPermissionGranted);
console.log('🔍 isMuted:', state.isMuted);
console.log('🔍 isDeafened:', state.isDeafened);
console.log('🔍 currentUsername:', state.currentUsername);
console.log('🔍 micVolume:', state.micVolume);
console.log('🔍 headphoneVolume:', state.headphoneVolume);
if (state.localStream) {
const audioTracks = state.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
});
});
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!');
}
// Debug audio processing chain
console.log('🔍 Audio Context:', state.audioContext ? state.audioContext.state : 'none');
console.log('🔍 Mic Gain Node:', state.micGainNode ? state.micGainNode.gain.value : 'none');
console.log('🔍 Processed Stream:', state.processedStream ? 'available' : 'none');
if (state.processedStream) {
const processedTracks = state.processedStream.getAudioTracks();
console.log('🔍 Processed stream tracks:', processedTracks.length);
processedTracks.forEach((track, i) => {
console.log(`🔍 Processed Track ${i}: `, {
kind: track.kind,
enabled: track.enabled,
readyState: track.readyState,
muted: track.muted
});
});
}
console.log('🔍 Peer connections:', Object.keys(state.peerConnections).length);
Object.entries(state.peerConnections).forEach(([userId, pc]) => {
console.log(`🔍 Peer ${userId}: `, pc.connectionState);
});
const audioElements = $$('#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:', state.ws ? state.ws.readyState : 'null');
console.log('🔍 === END DEBUG INFO ===');
}
static async fixVoice() {
console.log('🔧 Manually fixing voice...');
const fixed = await AudioManager.fixBrowserMutedTracks();
if (fixed) {
console.log('🔧 Voice fixed! Try speaking now.');
} else {
console.log('🔧 No browser-muted tracks found, or fix failed.');
}
// DebugUtils.debugVoiceSetup();
}
}
// ==================== PERIODIC TASKS ====================
// setInterval(() => {
// if (state.isInVoiceChat) {
// console.log('🔄 Performing periodic voice chat maintenance tasks...');
// if (state.audioContext.state === 'suspended') {
// console.log('⚠️⚠️⚠️ Resuming suspended AudioContext');
// state.audioContext.resume();
// }
// }
// }, 5000);
// setInterval(async () => {
// for (const peerConnection of Object.values(state.peerConnections)) {
// const stats = await peerConnection.getStats();
// stats.forEach(stat => {
// if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
// console.log('Active connection type:', stat.localCandidateType, '->', stat.remoteCandidateType);
// console.log('Bytes sent/received:', stat.bytesSent, stat.bytesReceived);
// }
//
// if (stat.type === 'inbound-rtp' && stat.mediaType === 'audio') {
// console.log('Packets lost:', stat.packetsLost);
// console.log('Jitter:', stat.jitter);
// }
// });
// }
// }, 5000);
// ==================== INITIALIZATION ====================
for (let { event, target } of [
{ event: 'DOMContentLoaded', target: document },
{ event: 'online', target: window }
]) {
// Auto-join on load
target.addEventListener(event, async function() {
state.load();
if (state.currentUsername) {
$id('username-input').value = state.currentUsername;
}
EventHandlers.setupEventListeners();
await AudioManager.requestMicrophonePermission();
await VoiceChatManager.joinVoiceChat();
});
}
// ================== DE-INITIALIZATION ===================
for (let { event, target } of [
{ event: 'beforeunload', target: window },
{ event: 'offline', target: window },
{ event: 'blur', target: window },
{ event: 'pagehide', target: window },
]) {
target.addEventListener(event, function() {
state.save();
});
}
// ==================== GLOBAL FUNCTION EXPORTS ====================
// Export functions to global scope for HTML onclick handlers and console debugging
window.joinVoiceChat = VoiceChatManager.joinVoiceChat;
window.leaveVoiceChat = VoiceChatManager.leaveVoiceChat;
window.toggleMute = VoiceControls.toggleMute;
window.toggleDeafen = VoiceControls.toggleDeafen;
window.muteUser = VoiceControls.muteUser;
window.openUserModal = ModalManager.openUserModal;
window.openSelfModal = ModalManager.openSelfModal;
window.closeUserModal = ModalManager.closeUserModal;
window.updateUserVolume = ModalManager.updateUserVolume;
window.toggleUserMuteFromModal = ModalManager.toggleUserMuteFromModal;
window.resetUserVolume = ModalManager.resetUserVolume;
window.updateMicVolume = VoiceControls.updateMicVolume;
window.updateHeadphoneVolume = VoiceControls.updateHeadphoneVolume;
window.resetAllVolumes = VoiceControls.resetAllVolumes;
window.sendChatMessage = EventHandlers.sendChatMessage;
window.requestMicrophonePermission = AudioManager.requestMicrophonePermission;
window.toggleEmojiPicker = UIManager.toggleEmojiPicker;
window.openFilePicker = UIManager.openFilePicker;
// Debug functions
window.debugVoiceSetup = DebugUtils.debugVoiceSetup;
window.fixVoice = DebugUtils.fixVoice;
window.scrollChatToBottom = UIManager.scrollChatToBottom;