2296 lines
104 KiB
JavaScript
2296 lines
104 KiB
JavaScript
/*
|
||
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;
|