From dd08cc88ae0c197cf7d56e58ce7d68ced621f5a9 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sat, 22 Aug 2026 12:37:19 +0200 Subject: [PATCH] voice chat [TEST] --- android/app/src/main/assets/public/icons.js | 2 +- android/app/src/main/assets/public/index.html | 1 + android/app/src/main/assets/public/main.js | 10 + android/app/src/main/assets/public/screens.js | 2 +- android/app/src/main/assets/public/style.css | 173 +++- android/app/src/main/assets/public/voice.js | 924 ++++++++++++++++++ webroot/icons.js | 2 +- webroot/index.html | 1 + webroot/main.js | 10 + webroot/screens.js | 2 +- webroot/style.css | 173 +++- webroot/voice.js | 924 ++++++++++++++++++ 12 files changed, 2218 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/assets/public/voice.js create mode 100644 webroot/voice.js 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 = ` +
+
+ Voice Chat +
+
Connecting...
+
+
+
+
+
+ + `; + + 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 = ` +
+
+ Voice Chat +
+
Connecting...
+
+
+
+
+
+ + `; + + 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)); + } +}