diff --git a/android/app/src/main/assets/public/icons.js b/android/app/src/main/assets/public/icons.js
index ae3b562c..b734da1f 100644
--- a/android/app/src/main/assets/public/icons.js
+++ b/android/app/src/main/assets/public/icons.js
@@ -48,8 +48,8 @@ const ICONS = {
spinner: (props) => ICONS._create(``, { viewBox: "0 0 50 50", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none", className: "spinner" }, props),
video: (props) => ICONS._feather(``, props),
file: (props) => ICONS._feather(``, props),
+ mic: (props) => ICONS._feather(``, props),
-
home: (props) => ICONS._create(``, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
inbox: (props) => ICONS._material(``, props),
diff --git a/android/app/src/main/assets/public/index.html b/android/app/src/main/assets/public/index.html
index 013bd422..b24f8c40 100644
--- a/android/app/src/main/assets/public/index.html
+++ b/android/app/src/main/assets/public/index.html
@@ -279,5 +279,6 @@
}
+
\ No newline at end of file
diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js
index 2c1d5d29..ecd51413 100644
--- a/android/app/src/main/assets/public/main.js
+++ b/android/app/src/main/assets/public/main.js
@@ -3976,6 +3976,16 @@ function setupWebSocket() {
}, 300);
}
}
+ else if (data.startsWith("call_update:")) {
+ if (typeof handleCallUpdate === "function") {
+ handleCallUpdate(data);
+ }
+ }
+ else if (data.startsWith("signal:")) {
+ if (typeof handleRoomSignal === "function") {
+ handleRoomSignal(data);
+ }
+ }
};
appWebSocket.onclose = () => {
diff --git a/android/app/src/main/assets/public/screens.js b/android/app/src/main/assets/public/screens.js
index 93af3aa4..63c3484f 100644
--- a/android/app/src/main/assets/public/screens.js
+++ b/android/app/src/main/assets/public/screens.js
@@ -289,7 +289,7 @@ var messageContextMenu = `
//elements
var backBtnHtml = ``;
var pinBtnHtml = ``;
-var callBtnHtml = ``;
+var callBtnHtml = ``;
var groupMenuBtnHtml = ``;
var settingsBar = `
diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css
index 4b36fdf8..a254bfcc 100644
--- a/android/app/src/main/assets/public/style.css
+++ b/android/app/src/main/assets/public/style.css
@@ -198,7 +198,6 @@ roomcontent {
height: 100%;
flex-grow: 1;
min-width: 0;
- border-right: var(--border-width) solid var(--light-border-color);
}
roomcontent2 {
display: flex;
@@ -1274,3 +1273,175 @@ input:checked + .toggle-slider:before {
background-color: rgba(255, 255, 255, 0.15);
font-weight: bold;
}
+
+/* Voice UI Styles */
+#voice-call-popup {
+ position: fixed;
+ width: 600px;
+ height: 450px;
+ background-color: var(--main-bg-color);
+ border: var(--border-width) solid var(--light-border-color);
+ border-radius: 0.9rem;
+ box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ resize: both;
+}
+
+#voice-header {
+ height: 3rem;
+ background: var(--light-border-color);
+ display: flex;
+ align-items: center;
+ padding: 0 15px;
+ cursor: grab;
+ font-weight: bold;
+ user-select: none;
+ border-bottom: var(--border-width) solid rgba(255,255,255,0.05);
+}
+
+#voice-users {
+ padding: 15px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+ gap: 12px;
+ overflow-y: auto;
+ flex-grow: 1;
+}
+
+.voice-tile {
+ position: relative;
+ background: var(--light-border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 4px 10px rgba(0,0,0,0.3);
+ cursor: pointer;
+ border: 2px solid transparent;
+}
+.voice-tile.speaking {
+ border-color: #4cd137;
+ box-shadow: 0 0 8px 2px #4cd137;
+}
+
+.voice-tile-name {
+ position: absolute;
+ bottom: 6px;
+ left: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 6px;
+ background: rgba(0,0,0,0.6);
+ border-radius: 4px;
+ color: #fff;
+ max-width: calc(100% - 12px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.voice-tile-icons {
+ position: absolute;
+ bottom: 6px;
+ right: 6px;
+ display: flex;
+ gap: 4px;
+}
+
+.voice-tile video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ background: #000;
+}
+
+.voice-controls {
+ height: 4rem;
+ background: var(--main-bg-color);
+ border-top: var(--border-width) solid var(--light-border-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+}
+
+.voice-btn {
+ border: var(--border-width) solid var(--light-border-color);
+ background: var(--main-bg-color);
+ color: var(--fg-1);
+ border-radius: 0.6rem;
+ padding: 0.6rem 1rem;
+ cursor: pointer;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: all 0.2s;
+}
+
+.voice-btn:hover {
+ background: var(--light-border-color);
+}
+
+.voice-btn.voice-btn-active {
+ background: #27ae60;
+ color: #fff;
+ border-color: #27ae60;
+}
+
+.voice-btn-danger {
+ background: #e74c3c !important;
+ color: white !important;
+ border-color: #e74c3c !important;
+}
+
+.voice-btn-danger:hover {
+ background: #c0392b !important;
+ border-color: #c0392b !important;
+}
+
+/* Voice User Settings Popup */
+#voice-user-settings {
+ position: fixed;
+ background: var(--main-bg-color);
+ border: var(--border-width) solid var(--light-border-color);
+ padding: 15px;
+ border-radius: 8px;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
+ z-index: 10000;
+}
+
+.voice-setting-row {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 10px;
+}
+
+.voice-setting-row label {
+ font-size: 11px;
+ color: var(--fg-1);
+ display: flex;
+ justify-content: space-between;
+}
+
+.voice-setting-row input[type="range"] {
+ width: 100%;
+ height: 4px;
+ appearance: none;
+ background: var(--bg-2);
+ outline: none;
+ border-radius: 2px;
+}
+
+.voice-setting-row input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ width: 12px;
+ height: 12px;
+ background: var(--accent);
+ border-radius: 50%;
+ cursor: pointer;
+}
diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js
new file mode 100644
index 00000000..58c9a6c9
--- /dev/null
+++ b/android/app/src/main/assets/public/voice.js
@@ -0,0 +1,924 @@
+let currentVoiceWs = null;
+let currentVoiceCallId = null;
+let currentVoiceCallType = null;
+let voiceWsHeartbeat = null;
+let voiceActiveUsers = [];
+let voiceAesKeyBytes = null;
+let voiceAesCryptoKey = null;
+let voiceUiContainer = null;
+
+let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
+let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
+let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
+let knownVoiceUsers = new Set();
+let isMicMuted = false;
+let remoteVideos = {}, videoDecryptChains = {};
+let camControl = null, screenControl = null;
+
+setInterval(() => {
+ const now = Date.now();
+ let changed = false;
+ for (const [decoderId, data] of Object.entries(remoteVideos)) {
+ if (now - data.lastUpdate > 3000) {
+ if (data.decoder) { try { data.decoder.close(); } catch (e) {} }
+ if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId];
+ delete videoDecryptChains[decoderId];
+ changed = true;
+ }
+ }
+ if (changed) updateVoiceUIUsers();
+}, 2000);
+
+const voiceWorkletCode = `
+class AudioProcessor extends AudioWorkletProcessor {
+ constructor() { super(); this.buffer = new Int16Array(1024); this.pointer = 0; }
+ process(inputs) {
+ const input = inputs[0][0];
+ if (!input) return true;
+ let sumSquares = 0;
+ for (let i = 0; i < input.length; i++) {
+ let s = Math.max(-1, Math.min(1, input[i]));
+ sumSquares += s * s;
+ this.buffer[this.pointer++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
+ if (this.pointer >= 1024) {
+ const rms = Math.sqrt(sumSquares / 1024);
+ const db = 20 * Math.log10(rms || 0.0001);
+ this.port.postMessage({ audioData: this.buffer.buffer, db: db },[this.buffer.buffer]);
+ this.buffer = new Int16Array(1024);
+ this.pointer = 0;
+ sumSquares = 0;
+ }
+ } return true;
+ }
+}
+class AudioReceiver extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.users = {};
+ this.volumes = {};
+
+ this.port.onmessage = (e) => {
+ if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
+ if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; return; }
+ if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
+
+ const { clientId, audioData } = e.data;
+ if (!this.users[clientId]) this.users[clientId] = { buffer: new Int16Array(65536), readPtr: 0, writePtr: 0, buffering: true };
+ const user = this.users[clientId];
+ const intData = new Int16Array(audioData);
+ for (let i = 0; i < intData.length; i++) {
+ user.buffer[user.writePtr] = intData[i];
+ user.writePtr = (user.writePtr + 1) % 65536;
+ }
+ };
+ }
+ process(inputs, outputs) {
+ const output = outputs[0][0];
+ if (!output) return true;
+ output.fill(0);
+ for (const id in this.users) {
+ const user = this.users[id];
+ let available = (user.writePtr - user.readPtr + 65536) % 65536;
+ if (user.buffering && available >= 4096) user.buffering = false;
+
+ if (!user.buffering) {
+ if (available < output.length) { user.buffering = true; }
+ else {
+ if (available > 24576) user.readPtr = (user.writePtr - 8192 + 65536) % 65536;
+ let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
+ for (let i = 0; i < output.length; i++) {
+ output[i] += (user.buffer[user.readPtr] / 32768) * vol;
+ user.readPtr = (user.readPtr + 1) % 65536;
+ }
+ }
+ }
+ } return true;
+ }
+}
+registerProcessor('audio-sender', AudioProcessor);
+registerProcessor('audio-receiver', AudioReceiver);`;
+
+class TaskQueue {
+ constructor() { this.queue = []; this.isProcessing = false; }
+ enqueue(task) { this.queue.push(task); this.processNext(); }
+ async processNext() {
+ if (this.isProcessing || this.queue.length === 0) return;
+ this.isProcessing = true;
+ try { await this.queue.shift()(); } catch (e) { }
+ this.isProcessing = false;
+ this.processNext();
+ }
+ clear() { this.queue = []; }
+}
+
+async function initiateVoiceCall(roomId = null) {
+ if (currentVoiceWs) {
+ showBlahNotification("error:already.in.call");
+ return;
+ }
+ currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
+ currentVoiceCallId = roomId || window.currentDmId;
+
+ showAction("action.call.init", "callinit");
+ let res = await fetchEncrypted("room/voice/join", JSON.stringify({
+ string1: currentVoiceCallType,
+ string2: currentVoiceCallId
+ }));
+ clearAction("callinit");
+
+ if (res.startsWith("success:")) {
+ let bestIp = res.split("success:")[1];
+ if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
+ bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
+ }
+ await startVoiceSession(bestIp);
+ } else {
+ showBlahNotification(res);
+ }
+}
+
+async function startVoiceSession(serverUrl) {
+ showVoiceUI();
+ updateVoiceUIUsers();
+ document.getElementById("voice-status").innerText = "Connecting...";
+
+ try {
+ const wsUrl = new URL(serverUrl);
+ if (!wsUrl.pathname.endsWith("/ws")) {
+ wsUrl.pathname = (wsUrl.pathname === "/" ? "" : wsUrl.pathname) + "/ws";
+ }
+ wsUrl.searchParams.set("room", currentVoiceCallId);
+ wsUrl.searchParams.set("userId", window.id);
+ wsUrl.searchParams.set("secret", "none");
+
+ currentVoiceWs = new WebSocket(wsUrl.toString());
+ currentVoiceWs.binaryType = 'arraybuffer';
+
+ currentVoiceWs.onopen = async () => {
+ document.getElementById("voice-status").innerText = "Connected";
+ document.getElementById("voice-status").style.color = "#4cd137";
+ let btn = document.getElementById("voice-btn-mic");
+ if (btn) {
+ if (isMicMuted) btn.classList.add("voice-btn-danger");
+ else btn.classList.remove("voice-btn-danger");
+ }
+
+ const fetchUsers = async () => {
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
+ try {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
+ const usersRaw = await res.json();
+ const users = usersRaw.map(u => String(u));
+ let changed = (users.length !== voiceActiveUsers.length);
+ for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
+ if(changed) {
+ voiceActiveUsers = users;
+ updateVoiceUIUsers();
+ }
+ } catch (e) {}
+ };
+ fetchUsers();
+ voiceWsHeartbeat = setInterval(fetchUsers, 5000);
+
+ const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
+ micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
+
+ if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
+ if (!isAudioWorkletLoaded) {
+ await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
+ senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
+ receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
+ receiverNode.connect(audioContext.destination);
+ isAudioWorkletLoaded = true;
+ }
+ if (audioContext.state === 'suspended') await audioContext.resume();
+
+ micSourceNode = audioContext.createMediaStreamSource(micStream);
+ micSourceNode.connect(senderNode);
+
+ let vadHangover = 0;
+ senderNode.port.onmessage = async (e) => {
+ if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
+ const {audioData, db} = e.data;
+ const sensitivity = -65;
+ const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100));
+
+ let micLevelBar = document.getElementById("voice-mic-level");
+ if (micLevelBar) micLevelBar.style.width = percent + '%';
+
+ if (db >= sensitivity) vadHangover = 75;
+ if (vadHangover > 0 && !isMicMuted) {
+ vadHangover--;
+ if (micLevelBar) micLevelBar.style.backgroundColor = '#27ae60';
+
+ if (!voiceAesCryptoKey) return;
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ } else {
+ if (micLevelBar) micLevelBar.style.backgroundColor = '#555';
+ }
+ };
+ };
+
+ currentVoiceWs.onmessage = async (e) => {
+ const arrayBuffer = e.data;
+ const view = new DataView(arrayBuffer);
+ let clientId = view.getBigUint64(0, true).toString();
+
+ if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
+ voiceActiveUsers = usersRaw.map(u => String(u));
+ updateVoiceUIUsers();
+ }).catch(()=>{});
+ }
+
+ const type = view.getUint8(8);
+ const isKeyFrame = view.getUint8(9) === 1;
+ const iv = new Uint8Array(arrayBuffer, 10, 12);
+ const ciphertext = new Uint8Array(arrayBuffer, 22);
+
+ if (!voiceAesCryptoKey) return;
+
+ if (type === 0 || type === 4) {
+ const isScreenAudio = (type === 4);
+ const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
+ if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
+ remoteAudio[audioId].lastUpdate = Date.now();
+
+ if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
+
+ audioDecryptChains[audioId].enqueue(async () => {
+ try {
+ const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
+ if(receiverNode) receiverNode.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
+
+ let userItem = document.getElementById(`voice-tile-${clientId}`);
+ if (userItem) {
+ userItem.classList.add("speaking");
+ if (userItem.timer) clearTimeout(userItem.timer);
+ userItem.timer = setTimeout(() => { userItem.classList.remove("speaking"); }, 500);
+ }
+ } catch (err) {}
+ });
+ } else if (type === 1 || type === 2) {
+ const decoderId = `${clientId}-${type}`;
+ if (!videoDecryptChains[decoderId]) videoDecryptChains[decoderId] = new TaskQueue();
+
+ videoDecryptChains[decoderId].enqueue(async () => {
+ try {
+ const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
+
+ if (!remoteVideos[decoderId]) {
+ const canvas = document.createElement('canvas');
+ remoteVideos[decoderId] = {
+ lastUpdate: Date.now(),
+ hasKeyFrame: false,
+ decoder: null,
+ canvas: canvas
+ };
+ updateVoiceUIUsers();
+ }
+
+ const streamData = remoteVideos[decoderId];
+ streamData.lastUpdate = Date.now();
+
+ if (!streamData.decoder) {
+ if (!isKeyFrame) return;
+ const canvas = streamData.canvas;
+ const ctx = canvas.getContext('2d');
+ streamData.decoder = new VideoDecoder({
+ output: (frame) => {
+ canvas.width = frame.displayWidth;
+ canvas.height = frame.displayHeight;
+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
+ frame.close();
+ },
+ error: e => {
+ if (remoteVideos[decoderId]) {
+ try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
+ remoteVideos[decoderId].decoder = null;
+ remoteVideos[decoderId].hasKeyFrame = false;
+ }
+ }
+ });
+ streamData.decoder.configure({codec: 'av01.0.04M.08'});
+ streamData.hasKeyFrame = true;
+ }
+
+ if (!streamData.hasKeyFrame) {
+ if (isKeyFrame) streamData.hasKeyFrame = true;
+ else return;
+ }
+
+ const chunk = new EncodedVideoChunk({
+ type: isKeyFrame ? 'key' : 'delta',
+ timestamp: Math.trunc(performance.now() * 1000),
+ data: decryptedBuffer
+ });
+
+ try {
+ streamData.decoder.decode(chunk);
+ } catch(err) {
+ try { streamData.decoder.close(); } catch(e){}
+ streamData.decoder = null;
+ streamData.hasKeyFrame = false;
+ }
+ } catch (err) {}
+ });
+ } else if (type === 3) {
+ crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
+ const stoppedTypeId = new Uint8Array(decrypted)[0];
+ if (stoppedTypeId === 4) {
+ const audioId = `${clientId}_screen`;
+ if (remoteAudio[audioId]) {
+ if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
+ delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
+ if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: audioId });
+ }
+ } else {
+ const decoderId = `${clientId}-${stoppedTypeId}`;
+ if (remoteVideos[decoderId]) {
+ try { if(remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
+ if(videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId]; delete videoDecryptChains[decoderId];
+ updateVoiceUIUsers();
+ }
+ }
+ }).catch(() => {});
+ }
+ };
+
+ currentVoiceWs.onclose = () => {
+ endVoiceSession();
+ };
+
+ } catch(err) {
+ console.error(err);
+ showBlahNotification("error:call.connect.failed");
+ endVoiceSession();
+ }
+}
+
+function handleCallUpdate(data) {
+ let parts = data.split(":");
+ let roomId = parts[1];
+ let state = [];
+ if(parts.slice(2).join(":") !== "") {
+ let stateRaw = JSON.parse(parts.slice(2).join(":"));
+ state = stateRaw.map(u => String(u));
+ }
+ voiceActiveUsers = state;
+
+ if (currentVoiceCallId === roomId) {
+ let myIdStr = String(window.id);
+ if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
+ voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
+ crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
+ .then(k => voiceAesCryptoKey = k);
+ }
+ if (voiceAesKeyBytes) {
+ for (let u of state) {
+ if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
+ }
+ }
+ updateVoiceUIUsers();
+
+ for (let u in remoteAudio) {
+ let pureU = u.replace("_screen", "");
+ if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
+ if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
+ delete audioDecoders[u];
+ delete remoteAudio[u];
+ if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: u });
+ }
+ }
+
+ for (let decoderId in remoteVideos) {
+ let pureU = decoderId.split('-')[0];
+ if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
+ if (remoteVideos[decoderId].decoder) { try { remoteVideos[decoderId].decoder.close(); } catch(e){} }
+ if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId];
+ delete videoDecryptChains[decoderId];
+ updateVoiceUIUsers();
+ }
+ }
+ } else if (state.length > 0 && !state.includes(String(window.id))) {
+ // Someone is in a call, and we are not. Show incoming call banner if it's the current room.
+ if (window.currentDmId === roomId) {
+ showIncomingCallUI(roomId);
+ }
+ } else if (state.length === 0) {
+ let incomingUi = document.getElementById("incoming-call-popup");
+ if (incomingUi) incomingUi.remove();
+ }
+}
+
+function showIncomingCallUI(roomId) {
+ if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
+
+ let incomingUi = document.createElement("div");
+ incomingUi.id = "incoming-call-popup";
+ incomingUi.style.position = "fixed";
+ incomingUi.style.top = "80px";
+ incomingUi.style.left = "50%";
+ incomingUi.style.transform = "translateX(-50%)";
+ incomingUi.style.backgroundColor = "var(--main-bg-color, #1a1a1a)";
+ incomingUi.style.padding = "15px 25px";
+ incomingUi.style.borderRadius = "0.9rem";
+ incomingUi.style.boxShadow = "0 1rem 3rem rgba(0, 0, 0, 0.5)";
+ incomingUi.style.display = "flex";
+ incomingUi.style.alignItems = "center";
+ incomingUi.style.gap = "20px";
+ incomingUi.style.zIndex = "9999";
+ incomingUi.style.border = "var(--border-width, 1px) solid var(--light-border-color, #333)";
+
+ incomingUi.innerHTML = `
+
+ ${processBlah("title.incoming.call") || "Incoming Voice Chat"}
+ ${voiceActiveUsers.length} in chat
+
+
+
+
+
+ `;
+ document.body.appendChild(incomingUi);
+}
+
+async function sendVoiceSignalToUser(targetUserId, roomId) {
+ try {
+ let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
+ let publicKeys = JSON.parse(publicKeysRaw);
+ let hybrid = await deriveHybridKeyToUser(publicKeys);
+
+ let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
+ let setupPayloadJson = JSON.stringify({
+ v: 1,
+ ctMlKem: hybrid.ciphertextMlKemBase64,
+ enc: encForMember
+ });
+
+ let payload = JSON.stringify({
+ string1: currentVoiceCallType,
+ string2: roomId,
+ string3: JSON.stringify({ to: targetUserId, from: window.id, data: setupPayloadJson })
+ });
+ await fetchEncrypted("room/signal", payload);
+ } catch(e) {
+ console.error("Failed to send voice signal", e);
+ }
+}
+
+async function handleRoomSignal(data) {
+ let parts = data.split(":");
+ let roomId = parts[1];
+ let signalStr = parts.slice(2).join(":");
+ let signalObj = JSON.parse(signalStr);
+
+ if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) {
+ try {
+ let setupData = JSON.parse(signalObj.data);
+ let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
+ let otherPublicKeysObj = JSON.parse(publicKeysRaw);
+ let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
+ let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
+ voiceAesKeyBytes = base64ToUint8(decRoomKeyB64);
+ voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
+ } catch(e) {
+ console.error("Signal decryption failed", e);
+ }
+ }
+}
+
+function showVoiceUI() {
+ if (voiceUiContainer) return;
+
+ voiceUiContainer = document.createElement("div");
+ voiceUiContainer.id = "voice-call-popup";
+
+ voiceUiContainer.innerHTML = `
+
+
+
+
+ `;
+
+ document.body.appendChild(voiceUiContainer);
+
+ let header = document.getElementById("voice-header");
+ let isDragging = false;
+ let startX, startY, initialLeft, initialTop;
+
+ header.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ let rect = voiceUiContainer.getBoundingClientRect();
+ initialLeft = rect.left;
+ initialTop = rect.top;
+ startX = e.clientX;
+ startY = e.clientY;
+ voiceUiContainer.style.right = "auto";
+ voiceUiContainer.style.bottom = "auto";
+ voiceUiContainer.style.left = initialLeft + "px";
+ voiceUiContainer.style.top = initialTop + "px";
+ });
+
+ window.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ let dx = e.clientX - startX;
+ let dy = e.clientY - startY;
+ voiceUiContainer.style.left = (initialLeft + dx) + "px";
+ voiceUiContainer.style.top = (initialTop + dy) + "px";
+ });
+
+ window.addEventListener('mouseup', () => { isDragging = false; });
+}
+
+function updateVoiceUIUsers() {
+ let container = document.getElementById("voice-users");
+ if (!container) return;
+
+ for (let u of voiceActiveUsers) {
+ let div = document.getElementById(`voice-tile-${u}`);
+ if (!div) {
+ div = document.createElement("div");
+ div.className = "voice-tile";
+ div.id = `voice-tile-${u}`;
+ div.onclick = (e) => showVoiceUserSettings(u, e);
+
+ div.innerHTML = `
+ ${u}
+
+ `;
+ container.appendChild(div);
+ }
+
+ let authorName = window.dmUsernameCache ? window.dmUsernameCache[u] : null;
+ if (!authorName && window.fetchAsync) {
+ authorName = u;
+ window.fetchAsync(`${window.url}/idtoname?id=${u}`).then(async fullUsername => {
+ if (fullUsername && !fullUsername.startsWith("error:")) {
+ if (window.dmUsernameCache) window.dmUsernameCache[u] = fullUsername;
+ if (window.dmPfpCache && window.getAvatarUrl) window.dmPfpCache[u] = await window.getAvatarUrl(u, fullUsername);
+ updateVoiceUIUsers();
+ }
+ });
+ }
+ if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0];
+ if (!authorName && u === String(window.id)) authorName = window.username;
+
+ let avatarRes = window.dmPfpCache ? window.dmPfpCache[u] : null;
+ let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
+
+ if (!avatarRes && window.getAvatarUrl) {
+ getAvatarUrl(u, authorName || "User").then(url => {
+ if(url) {
+ window.dmPfpCache[u] = url;
+ let el = document.getElementById(`voice-tile-${u}`);
+ if (el) el.style.backgroundImage = `url('${url.replace(/'/g, "\\'")}')`;
+ }
+ });
+ }
+
+ div.style.backgroundImage = `url('${avatarUrl.replace(/'/g, "\\'")}')`;
+ let nameEl = document.getElementById(`voice-name-${u}`);
+ if (nameEl) nameEl.innerText = authorName || u;
+ }
+
+ Array.from(container.children).forEach(child => {
+ if (child.id.startsWith("voice-tile-") && !child.id.startsWith("voice-tile-stream-")) {
+ let u = child.id.replace("voice-tile-", "");
+ if (!voiceActiveUsers.includes(u)) {
+ child.remove();
+ }
+ }
+ });
+
+ for (let decoderId in remoteVideos) {
+ let streamData = remoteVideos[decoderId];
+ let clientId = decoderId.split('-')[0];
+ let type = decoderId.split('-')[1];
+
+ let div = document.getElementById(`voice-tile-stream-${decoderId}`);
+ if (!div) {
+ div = document.createElement("div");
+ div.className = "voice-tile";
+ div.id = `voice-tile-stream-${decoderId}`;
+
+ let mediaEl = streamData.canvas || streamData.video;
+ if (mediaEl) {
+ mediaEl.style.width = "100%";
+ mediaEl.style.height = "100%";
+ mediaEl.style.objectFit = "cover";
+ mediaEl.style.background = "#000";
+ div.appendChild(mediaEl);
+ }
+
+ let nameDiv = document.createElement("div");
+ nameDiv.className = "voice-tile-name";
+ nameDiv.id = `voice-stream-name-${decoderId}`;
+ div.appendChild(nameDiv);
+ container.appendChild(div);
+ }
+
+ let authorName = window.dmUsernameCache ? window.dmUsernameCache[clientId] : null;
+ if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0];
+
+ let nameEl = document.getElementById(`voice-stream-name-${decoderId}`);
+ if (nameEl) nameEl.innerText = `${authorName || clientId} (${type == '1' ? 'Cam' : 'Screen'})`;
+ }
+
+ Array.from(container.children).forEach(child => {
+ if (child.id.startsWith("voice-tile-stream-")) {
+ let decoderId = child.id.replace("voice-tile-stream-", "");
+ if (!remoteVideos[decoderId]) {
+ child.remove();
+ }
+ }
+ });
+}
+
+function showVoiceUserSettings(clientId, e) {
+ e.stopPropagation();
+ let existing = document.getElementById("voice-user-settings");
+ if (existing) existing.remove();
+
+ let popup = document.createElement("div");
+ popup.id = "voice-user-settings";
+ popup.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(popup);
+ let rect = popup.getBoundingClientRect();
+ let left = e.clientX;
+ let top = e.clientY;
+ if (left + 200 > window.innerWidth) left = window.innerWidth - 210;
+ if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 10;
+ popup.style.left = left + "px";
+ popup.style.top = top + "px";
+
+ const closePopup = (ev) => {
+ if (!popup.contains(ev.target)) {
+ popup.remove();
+ document.removeEventListener('mousedown', closePopup);
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', closePopup), 10);
+}
+
+window.updateVoiceVol = function(clientId, val, type) {
+ let el = document.getElementById(`val-${type}-${clientId}`);
+ if (el) el.innerText = val + '%';
+ const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
+ const volumeMultiplier = parseInt(val) / 100.0;
+ if (receiverNode) {
+ receiverNode.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
+ }
+};
+
+function toggleVoiceMic() {
+ isMicMuted = !isMicMuted;
+ let btn = document.getElementById("voice-btn-mic");
+ if (btn) {
+ if (isMicMuted) {
+ btn.classList.add("voice-btn-danger");
+ } else {
+ btn.classList.remove("voice-btn-danger");
+ }
+ }
+}
+
+function endVoiceSession() {
+ if (currentVoiceCallId) {
+ let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
+ if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
+ }
+
+ if (currentVoiceWs) {
+ try { currentVoiceWs.close(); } catch(e){}
+ currentVoiceWs = null;
+ }
+ if (micStream) {
+ micStream.getTracks().forEach(t => t.stop());
+ micStream = null;
+ }
+ if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
+ if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
+
+ if (window.fetchUsersInterval) {
+ clearInterval(window.fetchUsersInterval);
+ window.fetchUsersInterval = null;
+ }
+
+ for (let id in audioDecoders) {
+ try { audioDecoders[id].close(); } catch(e){}
+ }
+ for (let id in audioDecryptChains) {
+ if(audioDecryptChains[id]) audioDecryptChains[id].clear();
+ }
+ audioDecoders = {};
+ audioDecryptChains = {};
+ remoteAudio = {};
+
+ if (receiverNode) {
+ receiverNode.port.postMessage({ action: 'clear' });
+ }
+
+ if (camControl) { camControl.stop(); camControl = null; }
+ if (screenControl) { screenControl.stop(); screenControl = null; }
+
+ for (let id in remoteVideos) {
+ if (remoteVideos[id].decoder) { try { remoteVideos[id].decoder.close(); } catch(e){} }
+ }
+ remoteVideos = {};
+ videoDecryptChains = {};
+
+ voiceAesKeyBytes = null;
+ voiceAesCryptoKey = null;
+ currentVoiceWs = null;
+ currentVoiceCallType = null;
+ currentVoiceCallId = null;
+
+ if (voiceUiContainer) {
+ voiceUiContainer.remove();
+ voiceUiContainer = null;
+ }
+}
+
+async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
+ try {
+ const stream = await streamPromise;
+ const videoTrack = stream.getVideoTracks()[0];
+ const videoEl = document.createElement('video');
+ videoEl.srcObject = stream; videoEl.muted = true; videoEl.playsInline = true; videoEl.autoplay = true; videoEl.play();
+
+ const decoderId = window.id + '-' + typeId;
+ remoteVideos[decoderId] = { video: videoEl };
+ updateVoiceUIUsers();
+
+ btnEl.classList.add('voice-btn-active');
+ let screenAudioEncoder = null, screenAudioSourceNode = null, screenSenderNode = null;
+
+ if (typeId === 2) {
+ const audioTracks = stream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ screenAudioSourceNode = audioContext.createMediaStreamSource(stream);
+ screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
+ screenSenderNode.port.onmessage = async (e) => {
+ if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ };
+ screenAudioSourceNode.connect(screenSenderNode);
+ }
+ }
+
+ const getCurrentConfig = () => {
+ const fps = typeId === 1 ? 24 : 30;
+ const resVal = typeId === 1 ? "1280x720" : "1920x1080";
+ const settings = videoTrack.getSettings();
+ let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height;
+ if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else {
+ const [targetW, targetH] = resVal.split('x').map(Number);
+ const ratio = Math.min(targetW / sourceWidth, targetH / sourceHeight);
+ if (ratio < 1) { width = sourceWidth * ratio; height = sourceHeight * ratio; } else { width = sourceWidth; height = sourceHeight; }
+ }
+ width = Math.floor(width / 2) * 2; height = Math.floor(height / 2) * 2;
+ return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) };
+ };
+
+ let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
+ const encoder = new VideoEncoder({
+ output: async (chunk) => {
+ if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
+ const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ }, error: (e) => { stopFunction(); }
+ });
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+
+ const encodeLoop = () => {
+ if (!isRunning) return;
+ const newConfig = getCurrentConfig();
+ let forceKeyframe = false;
+ if (newConfig.width !== currentConfig.width || newConfig.height !== currentConfig.height || newConfig.fps !== currentConfig.fps) {
+ currentConfig = newConfig; forceKeyframe = true; frameCount = 0;
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+ }
+
+ if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) {
+ let frame = null;
+ try {
+ frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
+ frameCount++;
+ encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
+ } catch (err) { } finally { if (frame) frame.close(); }
+ }
+ setTimeout(encodeLoop, 1000 / currentConfig.fps);
+ };
+ setTimeout(encodeLoop, 500);
+
+ videoTrack.onended = () => { if(isRunning) stopFunction(); };
+ const stopFunction = async () => {
+ if (!isRunning) return;
+ isRunning = false; try { encoder.close(); } catch (e) {} stream.getTracks().forEach(t => t.stop());
+
+ delete remoteVideos[decoderId];
+ updateVoiceUIUsers();
+ btnEl.classList.remove('voice-btn-active');
+
+ if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
+ const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer);
+ }
+
+ if (typeId === 2) {
+ if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; }
+ if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; }
+ if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; }
+ if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
+ const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12));
+ const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo);
+ const payloadA = new Uint8Array(14 + cipherA.byteLength);
+ payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer);
+ }
+ screenControl = null;
+ } else if (typeId === 1) { camControl = null; }
+ };
+ return { stop: stopFunction };
+ } catch (e) { console.error(e); alert(`No permission or feature not supported: ${typeLabel}`); return null; }
+}
+
+function toggleVoiceCam() {
+ let btn = document.getElementById("voice-btn-cam");
+ if (camControl) {
+ camControl.stop();
+ } else {
+ navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => {
+ startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl);
+ }).catch(err => alert("Camera error: " + err.message));
+ }
+}
+
+function toggleVoiceScreen() {
+ let btn = document.getElementById("voice-btn-screen");
+ if (screenControl) {
+ screenControl.stop();
+ } else {
+ if (!navigator.mediaDevices.getDisplayMedia) { alert("Screen sharing is not supported on this browser."); return; }
+ navigator.mediaDevices.getDisplayMedia({
+ video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude"
+ }).then(stream => {
+ startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl);
+ }).catch(err => alert("Screen error: " + err.message));
+ }
+}
diff --git a/webroot/icons.js b/webroot/icons.js
index ae3b562c..b734da1f 100644
--- a/webroot/icons.js
+++ b/webroot/icons.js
@@ -48,8 +48,8 @@ const ICONS = {
spinner: (props) => ICONS._create(``, { viewBox: "0 0 50 50", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none", className: "spinner" }, props),
video: (props) => ICONS._feather(``, props),
file: (props) => ICONS._feather(``, props),
+ mic: (props) => ICONS._feather(``, props),
-
home: (props) => ICONS._create(``, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
inbox: (props) => ICONS._material(``, props),
diff --git a/webroot/index.html b/webroot/index.html
index 013bd422..b24f8c40 100644
--- a/webroot/index.html
+++ b/webroot/index.html
@@ -279,5 +279,6 @@
}
+
\ No newline at end of file
diff --git a/webroot/main.js b/webroot/main.js
index 2c1d5d29..ecd51413 100644
--- a/webroot/main.js
+++ b/webroot/main.js
@@ -3976,6 +3976,16 @@ function setupWebSocket() {
}, 300);
}
}
+ else if (data.startsWith("call_update:")) {
+ if (typeof handleCallUpdate === "function") {
+ handleCallUpdate(data);
+ }
+ }
+ else if (data.startsWith("signal:")) {
+ if (typeof handleRoomSignal === "function") {
+ handleRoomSignal(data);
+ }
+ }
};
appWebSocket.onclose = () => {
diff --git a/webroot/screens.js b/webroot/screens.js
index 93af3aa4..63c3484f 100644
--- a/webroot/screens.js
+++ b/webroot/screens.js
@@ -289,7 +289,7 @@ var messageContextMenu = `
//elements
var backBtnHtml = ``;
var pinBtnHtml = ``;
-var callBtnHtml = ``;
+var callBtnHtml = ``;
var groupMenuBtnHtml = ``;
var settingsBar = `
diff --git a/webroot/style.css b/webroot/style.css
index 4b36fdf8..a254bfcc 100644
--- a/webroot/style.css
+++ b/webroot/style.css
@@ -198,7 +198,6 @@ roomcontent {
height: 100%;
flex-grow: 1;
min-width: 0;
- border-right: var(--border-width) solid var(--light-border-color);
}
roomcontent2 {
display: flex;
@@ -1274,3 +1273,175 @@ input:checked + .toggle-slider:before {
background-color: rgba(255, 255, 255, 0.15);
font-weight: bold;
}
+
+/* Voice UI Styles */
+#voice-call-popup {
+ position: fixed;
+ width: 600px;
+ height: 450px;
+ background-color: var(--main-bg-color);
+ border: var(--border-width) solid var(--light-border-color);
+ border-radius: 0.9rem;
+ box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ resize: both;
+}
+
+#voice-header {
+ height: 3rem;
+ background: var(--light-border-color);
+ display: flex;
+ align-items: center;
+ padding: 0 15px;
+ cursor: grab;
+ font-weight: bold;
+ user-select: none;
+ border-bottom: var(--border-width) solid rgba(255,255,255,0.05);
+}
+
+#voice-users {
+ padding: 15px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+ gap: 12px;
+ overflow-y: auto;
+ flex-grow: 1;
+}
+
+.voice-tile {
+ position: relative;
+ background: var(--light-border-color);
+ border-radius: 8px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 4px 10px rgba(0,0,0,0.3);
+ cursor: pointer;
+ border: 2px solid transparent;
+}
+.voice-tile.speaking {
+ border-color: #4cd137;
+ box-shadow: 0 0 8px 2px #4cd137;
+}
+
+.voice-tile-name {
+ position: absolute;
+ bottom: 6px;
+ left: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 6px;
+ background: rgba(0,0,0,0.6);
+ border-radius: 4px;
+ color: #fff;
+ max-width: calc(100% - 12px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.voice-tile-icons {
+ position: absolute;
+ bottom: 6px;
+ right: 6px;
+ display: flex;
+ gap: 4px;
+}
+
+.voice-tile video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ background: #000;
+}
+
+.voice-controls {
+ height: 4rem;
+ background: var(--main-bg-color);
+ border-top: var(--border-width) solid var(--light-border-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 15px;
+}
+
+.voice-btn {
+ border: var(--border-width) solid var(--light-border-color);
+ background: var(--main-bg-color);
+ color: var(--fg-1);
+ border-radius: 0.6rem;
+ padding: 0.6rem 1rem;
+ cursor: pointer;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: all 0.2s;
+}
+
+.voice-btn:hover {
+ background: var(--light-border-color);
+}
+
+.voice-btn.voice-btn-active {
+ background: #27ae60;
+ color: #fff;
+ border-color: #27ae60;
+}
+
+.voice-btn-danger {
+ background: #e74c3c !important;
+ color: white !important;
+ border-color: #e74c3c !important;
+}
+
+.voice-btn-danger:hover {
+ background: #c0392b !important;
+ border-color: #c0392b !important;
+}
+
+/* Voice User Settings Popup */
+#voice-user-settings {
+ position: fixed;
+ background: var(--main-bg-color);
+ border: var(--border-width) solid var(--light-border-color);
+ padding: 15px;
+ border-radius: 8px;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
+ z-index: 10000;
+}
+
+.voice-setting-row {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 10px;
+}
+
+.voice-setting-row label {
+ font-size: 11px;
+ color: var(--fg-1);
+ display: flex;
+ justify-content: space-between;
+}
+
+.voice-setting-row input[type="range"] {
+ width: 100%;
+ height: 4px;
+ appearance: none;
+ background: var(--bg-2);
+ outline: none;
+ border-radius: 2px;
+}
+
+.voice-setting-row input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ width: 12px;
+ height: 12px;
+ background: var(--accent);
+ border-radius: 50%;
+ cursor: pointer;
+}
diff --git a/webroot/voice.js b/webroot/voice.js
new file mode 100644
index 00000000..58c9a6c9
--- /dev/null
+++ b/webroot/voice.js
@@ -0,0 +1,924 @@
+let currentVoiceWs = null;
+let currentVoiceCallId = null;
+let currentVoiceCallType = null;
+let voiceWsHeartbeat = null;
+let voiceActiveUsers = [];
+let voiceAesKeyBytes = null;
+let voiceAesCryptoKey = null;
+let voiceUiContainer = null;
+
+let micStream = null, audioContext = null, isAudioWorkletLoaded = false;
+let micSourceNode = null, senderNode = null, receiverNode = null, audioEncoder = null;
+let remoteAudio = {}, audioDecoders = {}, audioDecryptChains = {};
+let knownVoiceUsers = new Set();
+let isMicMuted = false;
+let remoteVideos = {}, videoDecryptChains = {};
+let camControl = null, screenControl = null;
+
+setInterval(() => {
+ const now = Date.now();
+ let changed = false;
+ for (const [decoderId, data] of Object.entries(remoteVideos)) {
+ if (now - data.lastUpdate > 3000) {
+ if (data.decoder) { try { data.decoder.close(); } catch (e) {} }
+ if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId];
+ delete videoDecryptChains[decoderId];
+ changed = true;
+ }
+ }
+ if (changed) updateVoiceUIUsers();
+}, 2000);
+
+const voiceWorkletCode = `
+class AudioProcessor extends AudioWorkletProcessor {
+ constructor() { super(); this.buffer = new Int16Array(1024); this.pointer = 0; }
+ process(inputs) {
+ const input = inputs[0][0];
+ if (!input) return true;
+ let sumSquares = 0;
+ for (let i = 0; i < input.length; i++) {
+ let s = Math.max(-1, Math.min(1, input[i]));
+ sumSquares += s * s;
+ this.buffer[this.pointer++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
+ if (this.pointer >= 1024) {
+ const rms = Math.sqrt(sumSquares / 1024);
+ const db = 20 * Math.log10(rms || 0.0001);
+ this.port.postMessage({ audioData: this.buffer.buffer, db: db },[this.buffer.buffer]);
+ this.buffer = new Int16Array(1024);
+ this.pointer = 0;
+ sumSquares = 0;
+ }
+ } return true;
+ }
+}
+class AudioReceiver extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.users = {};
+ this.volumes = {};
+
+ this.port.onmessage = (e) => {
+ if (e.data.action === 'clear') { this.users = {}; this.volumes = {}; return; }
+ if (e.data.action === 'setVolume') { this.volumes[e.data.clientId] = e.data.volume; return; }
+ if (e.data.action === 'remove') { delete this.users[e.data.clientId]; return; }
+
+ const { clientId, audioData } = e.data;
+ if (!this.users[clientId]) this.users[clientId] = { buffer: new Int16Array(65536), readPtr: 0, writePtr: 0, buffering: true };
+ const user = this.users[clientId];
+ const intData = new Int16Array(audioData);
+ for (let i = 0; i < intData.length; i++) {
+ user.buffer[user.writePtr] = intData[i];
+ user.writePtr = (user.writePtr + 1) % 65536;
+ }
+ };
+ }
+ process(inputs, outputs) {
+ const output = outputs[0][0];
+ if (!output) return true;
+ output.fill(0);
+ for (const id in this.users) {
+ const user = this.users[id];
+ let available = (user.writePtr - user.readPtr + 65536) % 65536;
+ if (user.buffering && available >= 4096) user.buffering = false;
+
+ if (!user.buffering) {
+ if (available < output.length) { user.buffering = true; }
+ else {
+ if (available > 24576) user.readPtr = (user.writePtr - 8192 + 65536) % 65536;
+ let vol = this.volumes[id] !== undefined ? this.volumes[id] : 1.0;
+ for (let i = 0; i < output.length; i++) {
+ output[i] += (user.buffer[user.readPtr] / 32768) * vol;
+ user.readPtr = (user.readPtr + 1) % 65536;
+ }
+ }
+ }
+ } return true;
+ }
+}
+registerProcessor('audio-sender', AudioProcessor);
+registerProcessor('audio-receiver', AudioReceiver);`;
+
+class TaskQueue {
+ constructor() { this.queue = []; this.isProcessing = false; }
+ enqueue(task) { this.queue.push(task); this.processNext(); }
+ async processNext() {
+ if (this.isProcessing || this.queue.length === 0) return;
+ this.isProcessing = true;
+ try { await this.queue.shift()(); } catch (e) { }
+ this.isProcessing = false;
+ this.processNext();
+ }
+ clear() { this.queue = []; }
+}
+
+async function initiateVoiceCall(roomId = null) {
+ if (currentVoiceWs) {
+ showBlahNotification("error:already.in.call");
+ return;
+ }
+ currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
+ currentVoiceCallId = roomId || window.currentDmId;
+
+ showAction("action.call.init", "callinit");
+ let res = await fetchEncrypted("room/voice/join", JSON.stringify({
+ string1: currentVoiceCallType,
+ string2: currentVoiceCallId
+ }));
+ clearAction("callinit");
+
+ if (res.startsWith("success:")) {
+ let bestIp = res.split("success:")[1];
+ if (!bestIp.startsWith("ws://") && !bestIp.startsWith("wss://")) {
+ bestIp = bestIp.includes("localhost") || bestIp.includes("127.0.0.1") ? "ws://" + bestIp : "wss://" + bestIp;
+ }
+ await startVoiceSession(bestIp);
+ } else {
+ showBlahNotification(res);
+ }
+}
+
+async function startVoiceSession(serverUrl) {
+ showVoiceUI();
+ updateVoiceUIUsers();
+ document.getElementById("voice-status").innerText = "Connecting...";
+
+ try {
+ const wsUrl = new URL(serverUrl);
+ if (!wsUrl.pathname.endsWith("/ws")) {
+ wsUrl.pathname = (wsUrl.pathname === "/" ? "" : wsUrl.pathname) + "/ws";
+ }
+ wsUrl.searchParams.set("room", currentVoiceCallId);
+ wsUrl.searchParams.set("userId", window.id);
+ wsUrl.searchParams.set("secret", "none");
+
+ currentVoiceWs = new WebSocket(wsUrl.toString());
+ currentVoiceWs.binaryType = 'arraybuffer';
+
+ currentVoiceWs.onopen = async () => {
+ document.getElementById("voice-status").innerText = "Connected";
+ document.getElementById("voice-status").style.color = "#4cd137";
+ let btn = document.getElementById("voice-btn-mic");
+ if (btn) {
+ if (isMicMuted) btn.classList.add("voice-btn-danger");
+ else btn.classList.remove("voice-btn-danger");
+ }
+
+ const fetchUsers = async () => {
+ if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
+ try {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
+ const usersRaw = await res.json();
+ const users = usersRaw.map(u => String(u));
+ let changed = (users.length !== voiceActiveUsers.length);
+ for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
+ if(changed) {
+ voiceActiveUsers = users;
+ updateVoiceUIUsers();
+ }
+ } catch (e) {}
+ };
+ fetchUsers();
+ voiceWsHeartbeat = setInterval(fetchUsers, 5000);
+
+ const micConstraints = {audio: {echoCancellation: false, noiseSuppression: false, autoGainControl: false}};
+ micStream = await navigator.mediaDevices.getUserMedia(micConstraints);
+
+ if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
+ if (!isAudioWorkletLoaded) {
+ await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
+ senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
+ receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
+ receiverNode.connect(audioContext.destination);
+ isAudioWorkletLoaded = true;
+ }
+ if (audioContext.state === 'suspended') await audioContext.resume();
+
+ micSourceNode = audioContext.createMediaStreamSource(micStream);
+ micSourceNode.connect(senderNode);
+
+ let vadHangover = 0;
+ senderNode.port.onmessage = async (e) => {
+ if (currentVoiceWs.readyState !== WebSocket.OPEN) return;
+ const {audioData, db} = e.data;
+ const sensitivity = -65;
+ const percent = Math.max(0, Math.min(100, (db + 80) / 80 * 100));
+
+ let micLevelBar = document.getElementById("voice-mic-level");
+ if (micLevelBar) micLevelBar.style.width = percent + '%';
+
+ if (db >= sensitivity) vadHangover = 75;
+ if (vadHangover > 0 && !isMicMuted) {
+ vadHangover--;
+ if (micLevelBar) micLevelBar.style.backgroundColor = '#27ae60';
+
+ if (!voiceAesCryptoKey) return;
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, audioData);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 0; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ } else {
+ if (micLevelBar) micLevelBar.style.backgroundColor = '#555';
+ }
+ };
+ };
+
+ currentVoiceWs.onmessage = async (e) => {
+ const arrayBuffer = e.data;
+ const view = new DataView(arrayBuffer);
+ let clientId = view.getBigUint64(0, true).toString();
+
+ if (!voiceActiveUsers.includes(clientId) && clientId !== String(window.id)) {
+ const baseUrl = currentVoiceWs.url.split('/ws')[0];
+ const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
+ fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
+ voiceActiveUsers = usersRaw.map(u => String(u));
+ updateVoiceUIUsers();
+ }).catch(()=>{});
+ }
+
+ const type = view.getUint8(8);
+ const isKeyFrame = view.getUint8(9) === 1;
+ const iv = new Uint8Array(arrayBuffer, 10, 12);
+ const ciphertext = new Uint8Array(arrayBuffer, 22);
+
+ if (!voiceAesCryptoKey) return;
+
+ if (type === 0 || type === 4) {
+ const isScreenAudio = (type === 4);
+ const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
+ if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
+ remoteAudio[audioId].lastUpdate = Date.now();
+
+ if (!audioDecryptChains[audioId]) audioDecryptChains[audioId] = new TaskQueue();
+
+ audioDecryptChains[audioId].enqueue(async () => {
+ try {
+ const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
+ if(receiverNode) receiverNode.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
+
+ let userItem = document.getElementById(`voice-tile-${clientId}`);
+ if (userItem) {
+ userItem.classList.add("speaking");
+ if (userItem.timer) clearTimeout(userItem.timer);
+ userItem.timer = setTimeout(() => { userItem.classList.remove("speaking"); }, 500);
+ }
+ } catch (err) {}
+ });
+ } else if (type === 1 || type === 2) {
+ const decoderId = `${clientId}-${type}`;
+ if (!videoDecryptChains[decoderId]) videoDecryptChains[decoderId] = new TaskQueue();
+
+ videoDecryptChains[decoderId].enqueue(async () => {
+ try {
+ const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
+
+ if (!remoteVideos[decoderId]) {
+ const canvas = document.createElement('canvas');
+ remoteVideos[decoderId] = {
+ lastUpdate: Date.now(),
+ hasKeyFrame: false,
+ decoder: null,
+ canvas: canvas
+ };
+ updateVoiceUIUsers();
+ }
+
+ const streamData = remoteVideos[decoderId];
+ streamData.lastUpdate = Date.now();
+
+ if (!streamData.decoder) {
+ if (!isKeyFrame) return;
+ const canvas = streamData.canvas;
+ const ctx = canvas.getContext('2d');
+ streamData.decoder = new VideoDecoder({
+ output: (frame) => {
+ canvas.width = frame.displayWidth;
+ canvas.height = frame.displayHeight;
+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
+ frame.close();
+ },
+ error: e => {
+ if (remoteVideos[decoderId]) {
+ try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
+ remoteVideos[decoderId].decoder = null;
+ remoteVideos[decoderId].hasKeyFrame = false;
+ }
+ }
+ });
+ streamData.decoder.configure({codec: 'av01.0.04M.08'});
+ streamData.hasKeyFrame = true;
+ }
+
+ if (!streamData.hasKeyFrame) {
+ if (isKeyFrame) streamData.hasKeyFrame = true;
+ else return;
+ }
+
+ const chunk = new EncodedVideoChunk({
+ type: isKeyFrame ? 'key' : 'delta',
+ timestamp: Math.trunc(performance.now() * 1000),
+ data: decryptedBuffer
+ });
+
+ try {
+ streamData.decoder.decode(chunk);
+ } catch(err) {
+ try { streamData.decoder.close(); } catch(e){}
+ streamData.decoder = null;
+ streamData.hasKeyFrame = false;
+ }
+ } catch (err) {}
+ });
+ } else if (type === 3) {
+ crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext).then(decrypted => {
+ const stoppedTypeId = new Uint8Array(decrypted)[0];
+ if (stoppedTypeId === 4) {
+ const audioId = `${clientId}_screen`;
+ if (remoteAudio[audioId]) {
+ if(audioDecryptChains[audioId]) audioDecryptChains[audioId].clear();
+ delete audioDecryptChains[audioId]; delete remoteAudio[audioId];
+ if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: audioId });
+ }
+ } else {
+ const decoderId = `${clientId}-${stoppedTypeId}`;
+ if (remoteVideos[decoderId]) {
+ try { if(remoteVideos[decoderId].decoder) remoteVideos[decoderId].decoder.close(); } catch (e) {}
+ if(videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId]; delete videoDecryptChains[decoderId];
+ updateVoiceUIUsers();
+ }
+ }
+ }).catch(() => {});
+ }
+ };
+
+ currentVoiceWs.onclose = () => {
+ endVoiceSession();
+ };
+
+ } catch(err) {
+ console.error(err);
+ showBlahNotification("error:call.connect.failed");
+ endVoiceSession();
+ }
+}
+
+function handleCallUpdate(data) {
+ let parts = data.split(":");
+ let roomId = parts[1];
+ let state = [];
+ if(parts.slice(2).join(":") !== "") {
+ let stateRaw = JSON.parse(parts.slice(2).join(":"));
+ state = stateRaw.map(u => String(u));
+ }
+ voiceActiveUsers = state;
+
+ if (currentVoiceCallId === roomId) {
+ let myIdStr = String(window.id);
+ if (!voiceAesKeyBytes && state.length === 1 && state[0] === myIdStr) {
+ voiceAesKeyBytes = crypto.getRandomValues(new Uint8Array(32));
+ crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"])
+ .then(k => voiceAesCryptoKey = k);
+ }
+ if (voiceAesKeyBytes) {
+ for (let u of state) {
+ if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
+ }
+ }
+ updateVoiceUIUsers();
+
+ for (let u in remoteAudio) {
+ let pureU = u.replace("_screen", "");
+ if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
+ if (audioDecoders[u]) try { audioDecoders[u].close(); } catch(e){}
+ delete audioDecoders[u];
+ delete remoteAudio[u];
+ if(receiverNode) receiverNode.port.postMessage({ action: 'remove', clientId: u });
+ }
+ }
+
+ for (let decoderId in remoteVideos) {
+ let pureU = decoderId.split('-')[0];
+ if (!voiceActiveUsers.includes(pureU) && pureU !== myIdStr) {
+ if (remoteVideos[decoderId].decoder) { try { remoteVideos[decoderId].decoder.close(); } catch(e){} }
+ if (videoDecryptChains[decoderId]) videoDecryptChains[decoderId].clear();
+ delete remoteVideos[decoderId];
+ delete videoDecryptChains[decoderId];
+ updateVoiceUIUsers();
+ }
+ }
+ } else if (state.length > 0 && !state.includes(String(window.id))) {
+ // Someone is in a call, and we are not. Show incoming call banner if it's the current room.
+ if (window.currentDmId === roomId) {
+ showIncomingCallUI(roomId);
+ }
+ } else if (state.length === 0) {
+ let incomingUi = document.getElementById("incoming-call-popup");
+ if (incomingUi) incomingUi.remove();
+ }
+}
+
+function showIncomingCallUI(roomId) {
+ if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
+
+ let incomingUi = document.createElement("div");
+ incomingUi.id = "incoming-call-popup";
+ incomingUi.style.position = "fixed";
+ incomingUi.style.top = "80px";
+ incomingUi.style.left = "50%";
+ incomingUi.style.transform = "translateX(-50%)";
+ incomingUi.style.backgroundColor = "var(--main-bg-color, #1a1a1a)";
+ incomingUi.style.padding = "15px 25px";
+ incomingUi.style.borderRadius = "0.9rem";
+ incomingUi.style.boxShadow = "0 1rem 3rem rgba(0, 0, 0, 0.5)";
+ incomingUi.style.display = "flex";
+ incomingUi.style.alignItems = "center";
+ incomingUi.style.gap = "20px";
+ incomingUi.style.zIndex = "9999";
+ incomingUi.style.border = "var(--border-width, 1px) solid var(--light-border-color, #333)";
+
+ incomingUi.innerHTML = `
+
+ ${processBlah("title.incoming.call") || "Incoming Voice Chat"}
+ ${voiceActiveUsers.length} in chat
+
+
+
+
+
+ `;
+ document.body.appendChild(incomingUi);
+}
+
+async function sendVoiceSignalToUser(targetUserId, roomId) {
+ try {
+ let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetUserId}`);
+ let publicKeys = JSON.parse(publicKeysRaw);
+ let hybrid = await deriveHybridKeyToUser(publicKeys);
+
+ let encForMember = await encryptAesGcmToBase64(uint8ToBase64(voiceAesKeyBytes), hybrid.aesKeyBytes);
+ let setupPayloadJson = JSON.stringify({
+ v: 1,
+ ctMlKem: hybrid.ciphertextMlKemBase64,
+ enc: encForMember
+ });
+
+ let payload = JSON.stringify({
+ string1: currentVoiceCallType,
+ string2: roomId,
+ string3: JSON.stringify({ to: targetUserId, from: window.id, data: setupPayloadJson })
+ });
+ await fetchEncrypted("room/signal", payload);
+ } catch(e) {
+ console.error("Failed to send voice signal", e);
+ }
+}
+
+async function handleRoomSignal(data) {
+ let parts = data.split(":");
+ let roomId = parts[1];
+ let signalStr = parts.slice(2).join(":");
+ let signalObj = JSON.parse(signalStr);
+
+ if (String(signalObj.to) === String(window.id) && roomId === currentVoiceCallId && !voiceAesKeyBytes) {
+ try {
+ let setupData = JSON.parse(signalObj.data);
+ let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${signalObj.from}`);
+ let otherPublicKeysObj = JSON.parse(publicKeysRaw);
+ let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
+ let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
+ voiceAesKeyBytes = base64ToUint8(decRoomKeyB64);
+ voiceAesCryptoKey = await crypto.subtle.importKey("raw", voiceAesKeyBytes, {name: "AES-GCM"}, false, ["encrypt", "decrypt"]);
+ } catch(e) {
+ console.error("Signal decryption failed", e);
+ }
+ }
+}
+
+function showVoiceUI() {
+ if (voiceUiContainer) return;
+
+ voiceUiContainer = document.createElement("div");
+ voiceUiContainer.id = "voice-call-popup";
+
+ voiceUiContainer.innerHTML = `
+
+
+
+
+ `;
+
+ document.body.appendChild(voiceUiContainer);
+
+ let header = document.getElementById("voice-header");
+ let isDragging = false;
+ let startX, startY, initialLeft, initialTop;
+
+ header.addEventListener('mousedown', (e) => {
+ isDragging = true;
+ let rect = voiceUiContainer.getBoundingClientRect();
+ initialLeft = rect.left;
+ initialTop = rect.top;
+ startX = e.clientX;
+ startY = e.clientY;
+ voiceUiContainer.style.right = "auto";
+ voiceUiContainer.style.bottom = "auto";
+ voiceUiContainer.style.left = initialLeft + "px";
+ voiceUiContainer.style.top = initialTop + "px";
+ });
+
+ window.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ let dx = e.clientX - startX;
+ let dy = e.clientY - startY;
+ voiceUiContainer.style.left = (initialLeft + dx) + "px";
+ voiceUiContainer.style.top = (initialTop + dy) + "px";
+ });
+
+ window.addEventListener('mouseup', () => { isDragging = false; });
+}
+
+function updateVoiceUIUsers() {
+ let container = document.getElementById("voice-users");
+ if (!container) return;
+
+ for (let u of voiceActiveUsers) {
+ let div = document.getElementById(`voice-tile-${u}`);
+ if (!div) {
+ div = document.createElement("div");
+ div.className = "voice-tile";
+ div.id = `voice-tile-${u}`;
+ div.onclick = (e) => showVoiceUserSettings(u, e);
+
+ div.innerHTML = `
+ ${u}
+
+ `;
+ container.appendChild(div);
+ }
+
+ let authorName = window.dmUsernameCache ? window.dmUsernameCache[u] : null;
+ if (!authorName && window.fetchAsync) {
+ authorName = u;
+ window.fetchAsync(`${window.url}/idtoname?id=${u}`).then(async fullUsername => {
+ if (fullUsername && !fullUsername.startsWith("error:")) {
+ if (window.dmUsernameCache) window.dmUsernameCache[u] = fullUsername;
+ if (window.dmPfpCache && window.getAvatarUrl) window.dmPfpCache[u] = await window.getAvatarUrl(u, fullUsername);
+ updateVoiceUIUsers();
+ }
+ });
+ }
+ if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0];
+ if (!authorName && u === String(window.id)) authorName = window.username;
+
+ let avatarRes = window.dmPfpCache ? window.dmPfpCache[u] : null;
+ let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
+
+ if (!avatarRes && window.getAvatarUrl) {
+ getAvatarUrl(u, authorName || "User").then(url => {
+ if(url) {
+ window.dmPfpCache[u] = url;
+ let el = document.getElementById(`voice-tile-${u}`);
+ if (el) el.style.backgroundImage = `url('${url.replace(/'/g, "\\'")}')`;
+ }
+ });
+ }
+
+ div.style.backgroundImage = `url('${avatarUrl.replace(/'/g, "\\'")}')`;
+ let nameEl = document.getElementById(`voice-name-${u}`);
+ if (nameEl) nameEl.innerText = authorName || u;
+ }
+
+ Array.from(container.children).forEach(child => {
+ if (child.id.startsWith("voice-tile-") && !child.id.startsWith("voice-tile-stream-")) {
+ let u = child.id.replace("voice-tile-", "");
+ if (!voiceActiveUsers.includes(u)) {
+ child.remove();
+ }
+ }
+ });
+
+ for (let decoderId in remoteVideos) {
+ let streamData = remoteVideos[decoderId];
+ let clientId = decoderId.split('-')[0];
+ let type = decoderId.split('-')[1];
+
+ let div = document.getElementById(`voice-tile-stream-${decoderId}`);
+ if (!div) {
+ div = document.createElement("div");
+ div.className = "voice-tile";
+ div.id = `voice-tile-stream-${decoderId}`;
+
+ let mediaEl = streamData.canvas || streamData.video;
+ if (mediaEl) {
+ mediaEl.style.width = "100%";
+ mediaEl.style.height = "100%";
+ mediaEl.style.objectFit = "cover";
+ mediaEl.style.background = "#000";
+ div.appendChild(mediaEl);
+ }
+
+ let nameDiv = document.createElement("div");
+ nameDiv.className = "voice-tile-name";
+ nameDiv.id = `voice-stream-name-${decoderId}`;
+ div.appendChild(nameDiv);
+ container.appendChild(div);
+ }
+
+ let authorName = window.dmUsernameCache ? window.dmUsernameCache[clientId] : null;
+ if (authorName && authorName.includes(':')) authorName = authorName.split(':')[0];
+
+ let nameEl = document.getElementById(`voice-stream-name-${decoderId}`);
+ if (nameEl) nameEl.innerText = `${authorName || clientId} (${type == '1' ? 'Cam' : 'Screen'})`;
+ }
+
+ Array.from(container.children).forEach(child => {
+ if (child.id.startsWith("voice-tile-stream-")) {
+ let decoderId = child.id.replace("voice-tile-stream-", "");
+ if (!remoteVideos[decoderId]) {
+ child.remove();
+ }
+ }
+ });
+}
+
+function showVoiceUserSettings(clientId, e) {
+ e.stopPropagation();
+ let existing = document.getElementById("voice-user-settings");
+ if (existing) existing.remove();
+
+ let popup = document.createElement("div");
+ popup.id = "voice-user-settings";
+ popup.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(popup);
+ let rect = popup.getBoundingClientRect();
+ let left = e.clientX;
+ let top = e.clientY;
+ if (left + 200 > window.innerWidth) left = window.innerWidth - 210;
+ if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 10;
+ popup.style.left = left + "px";
+ popup.style.top = top + "px";
+
+ const closePopup = (ev) => {
+ if (!popup.contains(ev.target)) {
+ popup.remove();
+ document.removeEventListener('mousedown', closePopup);
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', closePopup), 10);
+}
+
+window.updateVoiceVol = function(clientId, val, type) {
+ let el = document.getElementById(`val-${type}-${clientId}`);
+ if (el) el.innerText = val + '%';
+ const audioId = type === 'screen' ? `${clientId}_screen` : clientId;
+ const volumeMultiplier = parseInt(val) / 100.0;
+ if (receiverNode) {
+ receiverNode.port.postMessage({ action: 'setVolume', clientId: audioId, volume: volumeMultiplier });
+ }
+};
+
+function toggleVoiceMic() {
+ isMicMuted = !isMicMuted;
+ let btn = document.getElementById("voice-btn-mic");
+ if (btn) {
+ if (isMicMuted) {
+ btn.classList.add("voice-btn-danger");
+ } else {
+ btn.classList.remove("voice-btn-danger");
+ }
+ }
+}
+
+function endVoiceSession() {
+ if (currentVoiceCallId) {
+ let payload = JSON.stringify({ string1: currentVoiceCallType, string2: currentVoiceCallId });
+ if (window.fetchEncrypted) window.fetchEncrypted("room/voice/leave", payload).catch(()=>{});
+ }
+
+ if (currentVoiceWs) {
+ try { currentVoiceWs.close(); } catch(e){}
+ currentVoiceWs = null;
+ }
+ if (micStream) {
+ micStream.getTracks().forEach(t => t.stop());
+ micStream = null;
+ }
+ if (micSourceNode) { micSourceNode.disconnect(); micSourceNode = null; }
+ if (audioEncoder) { try { audioEncoder.close(); } catch(e){} audioEncoder = null; }
+
+ if (window.fetchUsersInterval) {
+ clearInterval(window.fetchUsersInterval);
+ window.fetchUsersInterval = null;
+ }
+
+ for (let id in audioDecoders) {
+ try { audioDecoders[id].close(); } catch(e){}
+ }
+ for (let id in audioDecryptChains) {
+ if(audioDecryptChains[id]) audioDecryptChains[id].clear();
+ }
+ audioDecoders = {};
+ audioDecryptChains = {};
+ remoteAudio = {};
+
+ if (receiverNode) {
+ receiverNode.port.postMessage({ action: 'clear' });
+ }
+
+ if (camControl) { camControl.stop(); camControl = null; }
+ if (screenControl) { screenControl.stop(); screenControl = null; }
+
+ for (let id in remoteVideos) {
+ if (remoteVideos[id].decoder) { try { remoteVideos[id].decoder.close(); } catch(e){} }
+ }
+ remoteVideos = {};
+ videoDecryptChains = {};
+
+ voiceAesKeyBytes = null;
+ voiceAesCryptoKey = null;
+ currentVoiceWs = null;
+ currentVoiceCallType = null;
+ currentVoiceCallId = null;
+
+ if (voiceUiContainer) {
+ voiceUiContainer.remove();
+ voiceUiContainer = null;
+ }
+}
+
+async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
+ try {
+ const stream = await streamPromise;
+ const videoTrack = stream.getVideoTracks()[0];
+ const videoEl = document.createElement('video');
+ videoEl.srcObject = stream; videoEl.muted = true; videoEl.playsInline = true; videoEl.autoplay = true; videoEl.play();
+
+ const decoderId = window.id + '-' + typeId;
+ remoteVideos[decoderId] = { video: videoEl };
+ updateVoiceUIUsers();
+
+ btnEl.classList.add('voice-btn-active');
+ let screenAudioEncoder = null, screenAudioSourceNode = null, screenSenderNode = null;
+
+ if (typeId === 2) {
+ const audioTracks = stream.getAudioTracks();
+ if (audioTracks.length > 0) {
+ screenAudioSourceNode = audioContext.createMediaStreamSource(stream);
+ screenSenderNode = new AudioWorkletNode(audioContext, 'audio-sender');
+ screenSenderNode.port.onmessage = async (e) => {
+ if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, e.data.audioData);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 4; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ };
+ screenAudioSourceNode.connect(screenSenderNode);
+ }
+ }
+
+ const getCurrentConfig = () => {
+ const fps = typeId === 1 ? 24 : 30;
+ const resVal = typeId === 1 ? "1280x720" : "1920x1080";
+ const settings = videoTrack.getSettings();
+ let sourceWidth = settings.width || 1280, sourceHeight = settings.height || 720, width, height;
+ if (resVal === "source") { width = sourceWidth; height = sourceHeight; } else {
+ const [targetW, targetH] = resVal.split('x').map(Number);
+ const ratio = Math.min(targetW / sourceWidth, targetH / sourceHeight);
+ if (ratio < 1) { width = sourceWidth * ratio; height = sourceHeight * ratio; } else { width = sourceWidth; height = sourceHeight; }
+ }
+ width = Math.floor(width / 2) * 2; height = Math.floor(height / 2) * 2;
+ return { width, height, fps, bitrate: Math.floor(width * height * fps * 0.15) };
+ };
+
+ let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
+ const encoder = new VideoEncoder({
+ output: async (chunk) => {
+ if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceAesCryptoKey) return;
+ const buffer = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buffer);
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = typeId; payload[1] = chunk.type === 'key' ? 1 : 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
+ currentVoiceWs.send(payload.buffer);
+ }, error: (e) => { stopFunction(); }
+ });
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+
+ const encodeLoop = () => {
+ if (!isRunning) return;
+ const newConfig = getCurrentConfig();
+ let forceKeyframe = false;
+ if (newConfig.width !== currentConfig.width || newConfig.height !== currentConfig.height || newConfig.fps !== currentConfig.fps) {
+ currentConfig = newConfig; forceKeyframe = true; frameCount = 0;
+ encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
+ }
+
+ if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 512 && videoEl.readyState >= 2) {
+ let frame = null;
+ try {
+ frame = new VideoFrame(videoEl, {timestamp: Math.trunc(performance.now() * 1000)});
+ frameCount++;
+ encoder.encode(frame, {keyFrame: forceKeyframe || (frameCount % (currentConfig.fps * 2)) === 0});
+ } catch (err) { } finally { if (frame) frame.close(); }
+ }
+ setTimeout(encodeLoop, 1000 / currentConfig.fps);
+ };
+ setTimeout(encodeLoop, 500);
+
+ videoTrack.onended = () => { if(isRunning) stopFunction(); };
+ const stopFunction = async () => {
+ if (!isRunning) return;
+ isRunning = false; try { encoder.close(); } catch (e) {} stream.getTracks().forEach(t => t.stop());
+
+ delete remoteVideos[decoderId];
+ updateVoiceUIUsers();
+ btnEl.classList.remove('voice-btn-active');
+
+ if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
+ const buffer = new Uint8Array([typeId]); const iv = crypto.getRandomValues(new Uint8Array(12));
+ const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, buffer);
+ const payload = new Uint8Array(14 + cipher.byteLength);
+ payload[0] = 3; payload[1] = 0; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14); currentVoiceWs.send(payload.buffer);
+ }
+
+ if (typeId === 2) {
+ if (screenAudioSourceNode) { screenAudioSourceNode.disconnect(); screenAudioSourceNode = null; }
+ if (screenSenderNode) { screenSenderNode.disconnect(); screenSenderNode = null; }
+ if (screenAudioEncoder) { try { screenAudioEncoder.close(); } catch (e) {} screenAudioEncoder = null; }
+ if (currentVoiceWs?.readyState === WebSocket.OPEN && voiceAesCryptoKey) {
+ const bufferAudioInfo = new Uint8Array([4]); const ivA = crypto.getRandomValues(new Uint8Array(12));
+ const cipherA = await crypto.subtle.encrypt({name: "AES-GCM", iv: ivA}, voiceAesCryptoKey, bufferAudioInfo);
+ const payloadA = new Uint8Array(14 + cipherA.byteLength);
+ payloadA[0] = 3; payloadA[1] = 0; payloadA.set(ivA, 2); payloadA.set(new Uint8Array(cipherA), 14); currentVoiceWs.send(payloadA.buffer);
+ }
+ screenControl = null;
+ } else if (typeId === 1) { camControl = null; }
+ };
+ return { stop: stopFunction };
+ } catch (e) { console.error(e); alert(`No permission or feature not supported: ${typeLabel}`); return null; }
+}
+
+function toggleVoiceCam() {
+ let btn = document.getElementById("voice-btn-cam");
+ if (camControl) {
+ camControl.stop();
+ } else {
+ navigator.mediaDevices.getUserMedia({video: {width: {ideal: 1280}, height: {ideal: 720}}}).then(stream => {
+ startHardwareVideo(btn, Promise.resolve(stream), "Camera", 1).then(ctrl => camControl = ctrl);
+ }).catch(err => alert("Camera error: " + err.message));
+ }
+}
+
+function toggleVoiceScreen() {
+ let btn = document.getElementById("voice-btn-screen");
+ if (screenControl) {
+ screenControl.stop();
+ } else {
+ if (!navigator.mediaDevices.getDisplayMedia) { alert("Screen sharing is not supported on this browser."); return; }
+ navigator.mediaDevices.getDisplayMedia({
+ video: true, audio: { autoGainControl: false, echoCancellation: false, noiseSuppression: false }, systemAudio: "include", selfBrowserSurface: "exclude"
+ }).then(stream => {
+ startHardwareVideo(btn, Promise.resolve(stream), "Screen", 2).then(ctrl => screenControl = ctrl);
+ }).catch(err => alert("Screen error: " + err.message));
+ }
+}