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;
let screenReceiverNode = null;
let voiceOutputAudio = null;
let voiceLoopSendPc = null, voiceLoopRecvPc = null;
let voicePlayoutDest = null;
let voiceLoopWatchdog = null;
let voiceRemoteStream = null;
let voiceLoopRestarts = 0;
let voiceDeviceChangeHandler = null;
let voiceDeviceChangeTimer = null;
let isVoiceSpeakerMode = false;
const SPEAKING_HANGOVER_MS = 400;
let voiceDragState = false;
let voiceResizeAbort = null;
function clampVoiceWindowToViewport(popupEl) {
const r = popupEl.getBoundingClientRect();
const W = window.innerWidth, H = window.innerHeight;
const w = Math.min(r.width, W);
const h = Math.min(r.height, H);
const l = Math.min(Math.max(r.left, 0), W - w);
const t = Math.min(Math.max(r.top, 0), H - h);
if (l !== r.left || t !== r.top || w !== r.width || h !== r.height) {
popupEl.style.right = "auto";
popupEl.style.bottom = "auto";
popupEl.style.width = w + "px";
popupEl.style.height = h + "px";
popupEl.style.left = l + "px";
popupEl.style.top = t + "px";
}
}
let voiceVolumeState = {};
function getStoredVolume(type, clientId) {
let v = voiceVolumeState[`${type}:${clientId}`];
return v === undefined ? 100 : v;
}
let voiceConnectedAt = null;
let voiceTimerInterval = null;
let voicePingMs = null;
function formatVoiceDuration(ms) {
let totalSec = Math.floor(ms / 1000);
let h = Math.floor(totalSec / 3600);
let m = Math.floor((totalSec % 3600) / 60);
let s = totalSec % 60;
let mm = String(m).padStart(2, "0"), ss = String(s).padStart(2, "0");
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
}
function updateVoicePing() {
let pingEl = document.getElementById("voice-ping");
if (pingEl) pingEl.innerText = voicePingMs !== null ? `${voicePingMs} ms` : "";
}
function isMobileVoiceDevice() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) && !!window.RTCPeerConnection;
}
function getNativeVoiceRouting() {
try {
return window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.VoiceRouting ? window.Capacitor.Plugins.VoiceRouting : null;
} catch (e) {
return null;
}
}
function teardownVoiceLoopback() {
if (voiceLoopWatchdog) { clearTimeout(voiceLoopWatchdog); voiceLoopWatchdog = null; }
document.removeEventListener("click", voicePlayRetry, true);
window.removeEventListener("voicemodechanged", voiceModeChangedHandler);
if (voiceDeviceChangeHandler && navigator.mediaDevices && navigator.mediaDevices.removeEventListener) {
navigator.mediaDevices.removeEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceDeviceChangeHandler = null;
clearTimeout(voiceDeviceChangeTimer);
if (voiceLoopSendPc) { try { voiceLoopSendPc.close(); } catch (e) {} voiceLoopSendPc = null; }
if (voiceLoopRecvPc) { try { voiceLoopRecvPc.close(); } catch (e) {} voiceLoopRecvPc = null; }
if (voicePlayoutDest) { try { voicePlayoutDest.disconnect(); } catch (e) {} voicePlayoutDest = null; }
if (voiceOutputAudio) {
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.remove();
voiceOutputAudio = null;
}
voiceRemoteStream = null;
}
function voicePlayRetry() {
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
}
function int16ToBase64(view) {
let bin = '';
const u8 = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const STEP = 8192;
for (let i = 0; i < u8.length; i += STEP) {
bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + STEP, u8.length)));
}
return btoa(bin);
}
function handlePcmChunk(ev) {
const native = getNativeVoiceRouting();
if (!native || !native.nativeAudioFeed || !ev.data) return;
try {
native.nativeAudioFeed({ data: int16ToBase64(new Int16Array(ev.data)) }).catch(() => {});
} catch (e) {}
}
function stopNativePlayback() {
const native = getNativeVoiceRouting();
if (native && native.nativeAudioStop) native.nativeAudioStop().catch(() => {});
}
// Fired natively when Android reverted MODE_IN_COMMUNICATION and the plugin
// restored it - Chromium's playout track must be re-initialized afterwards.
function voiceModeChangedHandler() {
console.debug("Voice: native mode restore detected, replugging playout");
setTimeout(() => replugVoiceOutputElement(), 150);
setTimeout(() => replugVoiceOutputElement(), 700);
}
function voicePlayoutOk() {
let connected = voiceLoopRecvPc && voiceLoopRecvPc.connectionState === "connected";
let playing = voiceOutputAudio && !voiceOutputAudio.paused;
return !!connected && !!playing;
}
// Re-attaching the stream forces the browser audio stack to re-initialize its
// output track - needed after communication device changes on Android.
async function replugVoiceOutputElement() {
if (!voiceOutputAudio || !voiceRemoteStream) return;
console.debug("Voice: replugging WebRTC playout element");
try { voiceOutputAudio.pause(); } catch (e) {}
voiceOutputAudio.srcObject = null;
voiceOutputAudio.srcObject = voiceRemoteStream;
try { await voiceOutputAudio.play(); } catch (e) {}
}
function fallbackVoiceToMediaOutput() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
console.warn("Voice: WebRTC playout unavailable, falling back to media output");
teardownVoiceLoopback();
try { receiverNode.disconnect(); } catch (e) {}
try { receiverNode.connect(audioContext.destination); } catch (e) {}
}
function recoverVoiceLoopback() {
if (!receiverNode || !audioContext || !voicePlayoutDest) return;
const native = getNativeVoiceRouting();
// First aid: re-plug the playout element (fixes stuck routes after device switches)
replugVoiceOutputElement().then(() => {
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
if (native && voiceLoopRestarts < 2) {
voiceLoopRestarts++;
console.warn("Voice: restarting WebRTC loopback (attempt " + voiceLoopRestarts + ")");
const wasSpeaker = isVoiceSpeakerMode;
teardownVoiceLoopback();
isVoiceSpeakerMode = wasSpeaker;
setupVoiceOutput();
} else {
fallbackVoiceToMediaOutput();
}
}, 800);
});
}
function setupVoiceOutput() {
if (!audioContext || !receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (screenReceiverNode) {
try { screenReceiverNode.disconnect(); } catch (e) {}
screenReceiverNode.connect(audioContext.destination);
}
const native = getNativeVoiceRouting();
// App + native plugin: the mixed call audio is played by a native AudioTrack
// (USAGE_VOICE_COMMUNICATION), so it follows the call volume slider and the
// earpiece/speaker switch. Nothing is output through the WebAudio graph.
// The receiver worklet forwards mixed PCM straight here (~20 ms packets).
if (native && isMobileVoiceDevice()) {
stopNativePlayback();
receiverNode.port.onmessage = handlePcmChunk;
if (native.nativeAudioStart) native.nativeAudioStart({ sampleRate: audioContext.sampleRate }).catch(() => {});
return;
}
if (!isMobileVoiceDevice()) {
receiverNode.connect(audioContext.destination);
return;
}
// Browser-only speaker mode plays through plain media output.
// With the native plugin present, routing stays on the call path
// and the speaker switch is handled natively.
if (isVoiceSpeakerMode) {
receiverNode.connect(audioContext.destination);
return;
}
voiceLoopRestarts = 0;
teardownVoiceLoopback();
voicePlayoutDest = audioContext.createMediaStreamDestination();
receiverNode.connect(voicePlayoutDest);
voiceLoopSendPc = new RTCPeerConnection();
voiceLoopRecvPc = new RTCPeerConnection();
voiceLoopSendPc.onicecandidate = e => { if (e.candidate) try { voiceLoopRecvPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onicecandidate = e => { if (e.candidate) try { voiceLoopSendPc.addIceCandidate(e.candidate); } catch (err) {} };
voiceLoopRecvPc.onconnectionstatechange = () => {
const st = voiceLoopRecvPc && voiceLoopRecvPc.connectionState;
if (st === "failed") recoverVoiceLoopback();
};
voiceLoopRecvPc.ontrack = e => {
voiceRemoteStream = e.streams[0];
if (!voiceOutputAudio) {
voiceOutputAudio = document.createElement("audio");
voiceOutputAudio.autoplay = true;
voiceOutputAudio.setAttribute("playsinline", "");
document.body.appendChild(voiceOutputAudio);
}
voiceOutputAudio.srcObject = voiceRemoteStream;
voiceOutputAudio.play().catch(() => {});
};
document.addEventListener("click", voicePlayRetry, true);
window.addEventListener("voicemodechanged", voiceModeChangedHandler);
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
voiceDeviceChangeHandler = () => {
clearTimeout(voiceDeviceChangeTimer);
// Audio route changed (BT/headset/speaker) - re-init the playout track
voiceDeviceChangeTimer = setTimeout(() => replugVoiceOutputElement(), 400);
};
navigator.mediaDevices.addEventListener("devicechange", voiceDeviceChangeHandler);
}
voiceLoopSendPc.addTrack(voicePlayoutDest.stream.getAudioTracks()[0], voicePlayoutDest.stream);
voiceLoopSendPc.createOffer()
.then(offer => voiceLoopSendPc.setLocalDescription(offer))
.then(() => voiceLoopRecvPc.setRemoteDescription(voiceLoopSendPc.localDescription))
.then(() => voiceLoopRecvPc.createAnswer())
.then(answer => voiceLoopRecvPc.setLocalDescription(answer))
.then(() => voiceLoopSendPc.setRemoteDescription(voiceLoopRecvPc.localDescription))
.catch(() => {
recoverVoiceLoopback();
});
// Safety net: verify the WebRTC playout actually produces sound and repair it if not
if (voiceLoopWatchdog) clearTimeout(voiceLoopWatchdog);
voiceLoopWatchdog = setTimeout(() => {
voiceLoopWatchdog = null;
if (!voicePlayoutDest) return;
voicePlayRetry();
setTimeout(() => {
if (!voicePlayoutDest || voicePlayoutOk()) return;
recoverVoiceLoopback();
}, 600);
}, 4000);
}
window.toggleVoiceSpeakerMode = async function() {
isVoiceSpeakerMode = !isVoiceSpeakerMode;
let btn = document.getElementById("voice-btn-speaker");
if (btn) btn.classList.toggle("voice-btn-active", isVoiceSpeakerMode);
const native = getNativeVoiceRouting();
if (native && native.setSpeaker) {
// Native routing keeps playback on the call volume stream - only the
// physical output (earpiece <-> loudspeaker) changes, like a phone call.
// The native AudioTrack follows the communication device automatically.
try { await native.setSpeaker({ enabled: isVoiceSpeakerMode }); } catch (e) {}
setTimeout(() => replugVoiceOutputElement(), 250);
setTimeout(() => replugVoiceOutputElement(), 900);
setTimeout(() => { if (voicePlayoutDest && !voicePlayoutOk()) recoverVoiceLoopback(); }, 1700);
return;
}
if (!receiverNode) return;
try { receiverNode.disconnect(); } catch (e) {}
if (!isVoiceSpeakerMode && voicePlayoutDest) {
receiverNode.connect(voicePlayoutDest);
if (voiceOutputAudio) voiceOutputAudio.play().catch(() => {});
} else {
receiverNode.connect(audioContext.destination);
if (voiceOutputAudio) { try { voiceOutputAudio.pause(); } catch (e) {} }
}
};
function setVoiceStatus(state) {
let el = document.getElementById("voice-status");
if (!el) return;
el.classList.toggle("connected", state === "connected");
el.classList.toggle("connecting", state === "connecting");
el.classList.toggle("disconnected", state === "disconnected");
let textEl = el.querySelector(".voice-status-text");
if (state === "connected") {
if (voiceConnectedAt === null) voiceConnectedAt = Date.now();
if (!voiceTimerInterval) {
voiceTimerInterval = setInterval(() => {
let tickEl = document.querySelector("#voice-status .voice-status-text");
if (tickEl && voiceConnectedAt !== null) tickEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt);
}, 1000);
}
if (textEl) textEl.innerText = formatVoiceDuration(Date.now() - voiceConnectedAt);
updateVoicePing();
} else {
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
voiceConnectedAt = null;
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
}
}
function setTileSpeaking(clientId, speaking, streamDecoderId = null) {
let tile = document.getElementById(`voice-tile-${clientId}`);
if (streamDecoderId) {
let streamTile = document.getElementById(`voice-tile-stream-${streamDecoderId}`);
if (streamTile) tile = streamTile;
}
if (!tile) return;
if (tile._speakingTimer) {
clearTimeout(tile._speakingTimer);
tile._speakingTimer = null;
}
if (speaking) {
tile.classList.add("speaking");
tile._speakingTimer = setTimeout(() => {
tile._speakingTimer = null;
tile.classList.remove("speaking");
}, SPEAKING_HANGOVER_MS);
} else {
tile.classList.remove("speaking");
}
}
function clearAllSpeaking() {
for (const el of document.querySelectorAll(".voice-tile.speaking")) {
if (el._speakingTimer) { clearTimeout(el._speakingTimer); el._speakingTimer = null; }
el.classList.remove("speaking");
}
}
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 {
// Jitter buffer targets in samples (@48 kHz):
// PREBUF_START - initial prebuffer before first playback (~43 ms)
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
// Backlog slightly above target is drained by a gentle trim (1 sample per
// quantum) instead of letting latency creep up to the hard limit.
constructor(options) {
super();
this.users = {};
this.volumes = {};
const opts = (options && options.processorOptions) || {};
this.forward = !!opts.forward;
this.acc = new Int16Array(960); // ~20 ms @ 48 kHz
this.accLen = 0;
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, started: false };
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 >= (user.started ? 1536 : 2048)) { user.buffering = false; user.started = true; }
if (!user.buffering) {
if (available < output.length) { user.buffering = true; }
else {
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 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;
}
}
}
}
if (this.forward && this.port) {
for (let i = 0; i < output.length; i++) {
const s = Math.max(-1, Math.min(1, output[i]));
this.acc[this.accLen++] = s < 0 ? s * 0x8000 : s * 0x7FFF;
if (this.accLen >= this.acc.length) {
this.port.postMessage(this.acc.buffer, [this.acc.buffer]);
this.acc = new Int16Array(960);
this.accLen = 0;
}
}
}
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();
setVoiceStatus("connecting");
updateVoiceChatTitle();
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 () => {
setVoiceStatus("connected");
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 t0 = performance.now();
const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
const usersRaw = await res.json();
voicePingMs = Math.round(performance.now() - t0);
updateVoicePing();
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);
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {}
}
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');
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
setupVoiceOutput();
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;
let isSpeaking = vadHangover > 0 && !isMicMuted;
setTileSpeaking(String(window.id), isSpeaking);
if (vadHangover > 0 && !isMicMuted) {
vadHangover--;
if (micLevelBar) micLevelBar.style.backgroundColor = 'var(--big-green)';
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 = 'rgba(255, 255, 255, 0.15)';
}
};
};
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();
const targetReceiver = isScreenAudio ? screenReceiverNode : receiverNode;
audioDecryptChains[audioId].enqueue(async () => {
try {
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, voiceAesCryptoKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} 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(screenReceiverNode) screenReceiverNode.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];
const rn = u.endsWith("_screen") ? screenReceiverNode : receiverNode;
if(rn) rn.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) {
dismissIncomingCall();
}
}
async function resolveCallerInfo(roomId) {
if (window.fetchEncrypted) {
try {
let infoPayload = await fetchEncrypted("group/info", roomId);
if (infoPayload && infoPayload.startsWith("{")) {
let info = JSON.parse(infoPayload);
if (info.string2) {
let pfpUrl = null;
if (window.getGroupAvatarUrl) {
try { pfpUrl = await getGroupAvatarUrl(roomId, info.string2); } catch (e) {}
}
return { name: info.string2, pfpUrl };
}
}
} catch (e) {}
}
let targetId = String(roomId);
let parts = targetId.split('_');
let myId = `${window.id};${window.host}`;
if (parts.length === 2 && (parts[0] === myId || parts[1] === myId)) {
targetId = parts[0] === myId ? parts[1] : parts[0];
}
let name = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
if (!name && window.fetchAsync) {
try {
let fetched = await window.fetchAsync(`${window.url}/idtoname?id=${targetId}`);
if (fetched && !fetched.startsWith("error:")) {
name = fetched;
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = fetched;
}
} catch (e) {}
}
let pfpUrl = window.dmPfpCache ? window.dmPfpCache[targetId] : null;
if (!pfpUrl && window.getAvatarUrl) {
try { pfpUrl = await window.getAvatarUrl(targetId, name || "User"); } catch (e) {}
if (pfpUrl && window.dmPfpCache) window.dmPfpCache[targetId] = pfpUrl;
}
return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl };
}
function showIncomingCallUI(roomId) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null;
let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup";
incomingUi.innerHTML = `