This commit is contained in:
olcxja 2026-08-26 00:11:00 +02:00
commit b2a6c19bc0
4 changed files with 554 additions and 116 deletions

View file

@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) {
} }
async function getAvatarUrl(id, username) { async function getAvatarUrl(id, username) {
//S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
//generowało 1-2 żądania NA wiersz (główny składnik N+1).
//Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
//network itp.) nie mogą przyklejać fallback-SVG na minutę
if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
const cached = window.pfpExistenceCache[id];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
try { try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
if ((await fetchAsync(pfpUrl)) == "") { if ((await fetchAsync(pfpUrl)) == "") {
throw Error(); throw Error();
} }
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
return pfpUrl; return pfpUrl;
} catch (e) { } catch (e) {
return createAvatarSvg(username); return createAvatarSvg(username);
@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) {
} }
async function getGroupAvatarUrl(groupId, groupName) { async function getGroupAvatarUrl(groupId, groupName) {
if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
const cached = window.groupPfpExistenceCache[groupId];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
try { try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`; let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") { if ((await fetchAsync(pfpUrl)) == "") {
throw Error(); throw Error();
} }
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
return pfpUrl; return pfpUrl;
} catch (e) { } catch (e) {
return createAvatarSvg(groupName); return createAvatarSvg(groupName);
@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) {
await delay(200); await delay(200);
roomsTopBarTransition.innerHTML = processBlah(title); roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
let parser = new DOMParser(); let parser = new DOMParser();
@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let clickableContent = showRoomBar ? let clickableContent = showRoomBar ?
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;"> `<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit> ${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</button>` : </button>` :
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;"> `<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit> ${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</inherit>`; </inherit>`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
} }
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : ""; let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`; roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser(); let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html"); let doc = parser.parseFromString(content, "text/html");
@ -2033,7 +2045,9 @@ async function openProfile(accountId) {
}); });
let roleRes = await fetchEncrypted("room/role", payload); let roleRes = await fetchEncrypted("room/role", payload);
if (roleRes && !roleRes.startsWith("error:")) { if (roleRes && !roleRes.startsWith("error:")) {
let mappedRole = "role." + roleRes.replace(" ", "_"); //roleRes trafia do innerHTML - whitelist znaków zamiast surowej
//podstawienia (audyt W6: serwer-controlled string w szablonie)
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`; roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
} }
} }
@ -2275,7 +2289,7 @@ async function renderInvites(res, type) {
} }
let entry = invitesEntry let entry = invitesEntry
.replaceAll("{username}", groupName) .replaceAll("{username}", escapeHtmlText(groupName))
.replaceAll("{pfp}", pfp) .replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`) .replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions) .replaceAll("{actions}", actions)
@ -2644,7 +2658,13 @@ function handleMobileSwipe() {
} }
} }
//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
let dmListRenderGen = 0;
let groupListRenderGen = 0;
async function renderDms(res) { async function renderDms(res) {
const gen = ++dmListRenderGen;
let container = document.getElementById("dms-list"); let container = document.getElementById("dms-list");
if (!container) return; if (!container) return;
@ -2665,8 +2685,13 @@ async function renderDms(res) {
let myId = `${id};${host}`; let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
if (!targetUsername) {
targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
}
let pfp = await getAvatarUrl(targetId, targetUsername); let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]); let displayName = escapeHtml(targetUsername.split(':')[0]);
@ -2694,11 +2719,13 @@ async function renderDms(res) {
count++; count++;
} }
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI(); updateVoicePresenceUI();
} else { } else {
if (gen !== dmListRenderGen) return;
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah"); let blahTags = container.getElementsByTagName("blah");
@ -2716,7 +2743,17 @@ async function renderDms(res) {
} }
} }
//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają,
//a group/info było najdroższym elementem renderu listy grup
function getGroupNameFromCache(groupId) {
if (!window.groupNameCache) return null;
let entry = window.groupNameCache[sanitizeId(groupId)];
if (!entry || Date.now() - entry.ts > 60000) return null;
return entry.name;
}
async function renderGroups(res) { async function renderGroups(res) {
const gen = ++groupListRenderGen;
let container = document.getElementById("groups-list"); let container = document.getElementById("groups-list");
if (!container) return; if (!container) return;
@ -2737,11 +2774,24 @@ async function renderGroups(res) {
seedCallStateFromList(groupId, groupData.call === true); seedCallStateFromList(groupId, groupData.call === true);
let infoRes = await fetchEncrypted("group/info", groupId); //S22: TTL-cache nazw grup - group/info było wołane per grupa per render
if (infoRes && infoRes.startsWith("{")) { let groupName = getGroupNameFromCache(groupId);
let info = JSON.parse(infoRes); if (groupName === null) {
let groupName = info.string2; let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
groupName = JSON.parse(infoRes).string2;
if (!window.groupNameCache) window.groupNameCache = {};
window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
} else {
if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
}
continue;
}
}
{
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName); let pfp = await getGroupAvatarUrl(groupId, groupName);
@ -2760,17 +2810,16 @@ async function renderGroups(res) {
</button> </button>
`; `;
count++; count++;
} else if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
} }
} }
if (gen !== groupListRenderGen) return; //nowszy render przejął listę
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI(); updateVoicePresenceUI();
} else { } else {
if (gen !== groupListRenderGen) return;
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah"); let blahTags = container.getElementsByTagName("blah");

View file

@ -137,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2;
let peerLastSeen = {}; let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
let voiceHadRemotePeers = false;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null; let voiceRosterRetryTimer = null;
let lastVoiceKeyRequestAt = 0; let lastVoiceKeyRequestAt = 0;
@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) {
delete remoteAudio[aid]; delete remoteAudio[aid];
delete audioDecryptChains[aid]; delete audioDecryptChains[aid];
} }
if (remoteOpusDecoders[aid]) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
} }
for (const decoderId of Object.keys(remoteVideos)) { for (const decoderId of Object.keys(remoteVideos)) {
if (decoderId.split('-')[0] === userId) { if (decoderId.split('-')[0] === userId) {
@ -544,8 +551,10 @@ function setVoiceStatus(state) {
updateVoicePing(); updateVoicePing();
} else { } else {
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
voiceConnectedAt = null; //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
//realnym rozłączeniu (disconnected z endVoiceSession)
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
} }
} }
@ -605,7 +614,11 @@ setInterval(() => {
} }
} }
// Ghost peers: still on the room roster but totally silent (pings count as // Ghost peers: still on the room roster but totally silent (pings count as
// life) - drop them locally until the server or the peer proves otherwise. // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
let ghostRemoved = false; let ghostRemoved = false;
for (const u of voiceActiveUsers) { for (const u of voiceActiveUsers) {
if (u === String(window.id)) continue; if (u === String(window.id)) continue;
@ -617,12 +630,18 @@ setInterval(() => {
} }
} }
if (ghostRemoved) { if (ghostRemoved) {
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
// Half-open socket watchdog + backgrounded-tab audio recovery. // Half-open socket watchdog + backgrounded-tab audio recovery.
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
// Bez tego resetu join drugiej osoby natychmiast spełniał warunek
// ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm).
if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now;
voiceHadRemotePeers = others > 0;
// Alive peers ping every 2s regardless of mute, so total inbound // Alive peers ping every 2s regardless of mute, so total inbound
// silence while others are present means the socket is dead. // silence while others are present means the socket is dead.
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
@ -753,6 +772,96 @@ class TaskQueue {
let voiceJoinInFlight = false; let voiceJoinInFlight = false;
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM ===
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu -
// wymaga testów na realnych urządzeniach przed ponownym włączeniem).
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje.
const ENABLE_VOICE_OPUS = false;
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak
// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek
// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują).
let voiceOpusEncoder = null;
let voiceOpusEncoderFailed = false;
let voiceOpusInputTimestampUs = 0;
const remoteOpusDecoders = {};
function getVoiceOpusEncoder() {
if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null;
if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; }
if (voiceOpusEncoder) return voiceOpusEncoder;
try {
voiceOpusEncoder = new AudioEncoder({
output: (chunk) => {
try {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const bytes = new Uint8Array(chunk.byteLength);
chunk.copyTo(bytes);
sendVoiceEncodedFrame(6, bytes);
} catch (e) {}
},
error: () => { voiceOpusEncoderFailed = true; }
});
voiceOpusEncoder.configure({
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 1,
bitrate: 32000
});
return voiceOpusEncoder;
} catch (e) {
voiceOpusEncoderFailed = true;
voiceOpusEncoder = null;
return null;
}
}
async function sendVoiceEncodedFrame(type, plainBytes) {
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik
//unikalności IV bez zmian
const iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes);
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer);
}
function decodeVoiceOpus(audioId, targetNode, opusBuffer) {
try {
if (typeof window.AudioDecoder !== 'function') return;
let dec = remoteOpusDecoders[audioId];
if (!dec) {
dec = new AudioDecoder({
output: (audioData) => {
try {
const frames = audioData.numberOfFrames;
const f32 = new Float32Array(frames);
audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' });
const pcm = new Int16Array(frames);
for (let i = 0; i < frames; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
audioData.close();
if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]);
} catch (e) { try { audioData.close(); } catch (e2) {} }
},
error: () => { delete remoteOpusDecoders[audioId]; }
});
dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 });
remoteOpusDecoders[audioId] = dec;
}
dec.decode(new EncodedAudioChunk({
type: 'key',
timestamp: Math.trunc(performance.now() * 1000),
data: opusBuffer
}));
} catch (err) {
delete remoteOpusDecoders[audioId];
}
}
async function initiateVoiceCall(roomId = null) { async function initiateVoiceCall(roomId = null) {
if (currentVoiceWs || voiceJoinInFlight) { if (currentVoiceWs || voiceJoinInFlight) {
showBlahNotification("error:already.in.call"); showBlahNotification("error:already.in.call");
@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) {
if (joined) { if (joined) {
await startVoiceSession(joined.serverUrl, joined.ticket); await startVoiceSession(joined.serverUrl, joined.ticket);
} else {
//S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/
//Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie
//"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy
currentVoiceCallId = null;
currentVoiceCallType = null;
} }
} finally { } finally {
voiceJoinInFlight = false; voiceJoinInFlight = false;
@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) {
voicePingMs = Math.round(performance.now() - t0); voicePingMs = Math.round(performance.now() - t0);
updateVoicePing(); updateVoicePing();
const rawUsers = usersRaw.map(u => String(u)); const rawUsers = usersRaw.map(u => String(u));
// Peers we locally declared dead stay hidden until either //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
// they send anything again or the server drops them. //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
for (const gone of [...deadPeers]) { for (const gone of [...deadPeers]) {
if (!rawUsers.includes(gone)) deadPeers.delete(gone); if (!rawUsers.includes(gone)) deadPeers.delete(gone);
} }
const users = rawUsers.filter(u => !deadPeers.has(u)); const users = rawUsers;
let changed = (users.length !== voiceActiveUsers.length); let changed = (users.length !== voiceActiveUsers.length);
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
if(changed) { if(changed) {
@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) {
if (voicePingTimer) clearInterval(voicePingTimer); if (voicePingTimer) clearInterval(voicePingTimer);
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
// Reconnects reuse the live mic stream - only capture a new one //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
// when the previous is gone (full teardown or track ended). //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { //"connected" zombie-call ze spalonym single-use ticketem i bez
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); //mikrofonu, bez powiadomienia i bez recovery
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } try {
} // Reconnects reuse the live mic stream - only capture a new one
// when the previous is gone (full teardown or track ended).
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
}
const nativeRouting = getNativeVoiceRouting(); const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) { if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {} try { await nativeRouting.startCall(); } catch (e) {}
} }
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
if (!isAudioWorkletLoaded) { if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
applyVoiceSpeakerSink();
setupVoiceOutput();
if (micSourceNode) {
try { micSourceNode.disconnect(); } catch (e) {}
micSourceNode = null;
}
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
} catch (err) {
console.error("Voice: call setup failed", err);
showBlahNotification("error:call.connect.failed");
endVoiceSession();
return;
} }
if (audioContext.state === 'suspended') await audioContext.resume();
applyVoiceSpeakerSink();
setupVoiceOutput();
if (micSourceNode) {
try { micSourceNode.disconnect(); } catch (e) {}
micSourceNode = null;
}
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
let vadHangover = 0; let vadHangover = 0;
senderNode.port.onmessage = async (e) => { senderNode.port.onmessage = async (e) => {
@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) {
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; if (!voiceMySenderCryptoKey) return;
//S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM);
//każda awaria = spójny fallback na legacy PCM poniżej
const opusEnc = getVoiceOpusEncoder();
if (opusEnc && opusEnc.state === 'configured') {
try {
const i16 = new Int16Array(audioData);
const f32 = new Float32Array(i16.length);
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768;
const ad = new AudioData({
format: 'f32-planar',
sampleRate: 48000,
numberOfFrames: i16.length,
numberOfChannels: 1,
timestamp: voiceOpusInputTimestampUs,
data: f32
});
voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000);
opusEnc.encode(ad);
ad.close();
return;
} catch (e) { /* fallback na PCM */ }
}
const iv = nextVoiceIv(); const iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) {
const baseUrl = currentVoiceWs.url.split('/ws')[0]; const baseUrl = currentVoiceWs.url.split('/ws')[0];
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15)
voiceActiveUsers = usersRaw.map(u => String(u));
updateVoiceUIUsers(); updateVoiceUIUsers();
}).catch(()=>{}); }).catch(()=>{});
} }
@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) {
//podklucz nadawcy - deterministyczny z (root, clientId) //podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId); const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4) { if (type === 0 || type === 4 || type === 6) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
const isOpus = (type === 6);
const audioId = isScreenAudio ? `${clientId}_screen` : clientId; const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
remoteAudio[audioId].lastUpdate = Date.now(); remoteAudio[audioId].lastUpdate = Date.now();
@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) {
const senderKey = await senderKeyPromise; const senderKey = await senderKeyPromise;
if (!senderKey) return; if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); if (isOpus) {
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
} else {
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
}
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {} } catch (err) {}
@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) {
} }
} }
//Ignore updates for rooms we have nothing to do with - they would otherwise //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa
//overwrite the participant list of an ongoing call and wipe its tiles. //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju
let state = incomingState.filter(u => !deadPeers.has(u)); if (roomId !== currentVoiceCallId) return;
let state = incomingState;
voiceActiveUsers = state; voiceActiveUsers = state;
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() {
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
if (!avatarRes && window.getAvatarUrl) { if (!avatarRes && window.getAvatarUrl) {
getAvatarUrl(u, authorName || "User").then(url => { //Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
getAvatarUrl(u, nameForAvatar).then(url => {
if(url) { if(url) {
window.dmPfpCache[u] = url; window.dmPfpCache[u] = url;
let el = document.getElementById(`voice-tile-${u}`); let el = document.getElementById(`voice-tile-${u}`);
@ -2492,6 +2653,15 @@ function endVoiceSession() {
deadPeers.clear(); deadPeers.clear();
lastVoiceKeyRequestAt = 0; lastVoiceKeyRequestAt = 0;
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false;
//S20: teardown enkodera/dekoderów opus
if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; }
voiceOpusInputTimestampUs = 0;
for (const aid of Object.keys(remoteOpusDecoders)) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
for (let id in audioDecoders) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}

View file

@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) {
} }
async function getAvatarUrl(id, username) { async function getAvatarUrl(id, username) {
//S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
//generowało 1-2 żądania NA wiersz (główny składnik N+1).
//Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
//network itp.) nie mogą przyklejać fallback-SVG na minutę
if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
const cached = window.pfpExistenceCache[id];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
try { try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
if ((await fetchAsync(pfpUrl)) == "") { if ((await fetchAsync(pfpUrl)) == "") {
throw Error(); throw Error();
} }
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
return pfpUrl; return pfpUrl;
} catch (e) { } catch (e) {
return createAvatarSvg(username); return createAvatarSvg(username);
@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) {
} }
async function getGroupAvatarUrl(groupId, groupName) { async function getGroupAvatarUrl(groupId, groupName) {
if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
const cached = window.groupPfpExistenceCache[groupId];
if (cached && Date.now() - cached.ts < 60000) return cached.url;
try { try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`; let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") { if ((await fetchAsync(pfpUrl)) == "") {
throw Error(); throw Error();
} }
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
return pfpUrl; return pfpUrl;
} catch (e) { } catch (e) {
return createAvatarSvg(groupName); return createAvatarSvg(groupName);
@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) {
await delay(200); await delay(200);
roomsTopBarTransition.innerHTML = processBlah(title); roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
let parser = new DOMParser(); let parser = new DOMParser();
@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let clickableContent = showRoomBar ? let clickableContent = showRoomBar ?
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;"> `<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit> ${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</button>` : </button>` :
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;"> `<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit> ${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
</inherit>`; </inherit>`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
} }
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : ""; let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`; roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
let parser = new DOMParser(); let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html"); let doc = parser.parseFromString(content, "text/html");
@ -2033,7 +2045,9 @@ async function openProfile(accountId) {
}); });
let roleRes = await fetchEncrypted("room/role", payload); let roleRes = await fetchEncrypted("room/role", payload);
if (roleRes && !roleRes.startsWith("error:")) { if (roleRes && !roleRes.startsWith("error:")) {
let mappedRole = "role." + roleRes.replace(" ", "_"); //roleRes trafia do innerHTML - whitelist znaków zamiast surowej
//podstawienia (audyt W6: serwer-controlled string w szablonie)
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`; roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
} }
} }
@ -2275,7 +2289,7 @@ async function renderInvites(res, type) {
} }
let entry = invitesEntry let entry = invitesEntry
.replaceAll("{username}", groupName) .replaceAll("{username}", escapeHtmlText(groupName))
.replaceAll("{pfp}", pfp) .replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`) .replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions) .replaceAll("{actions}", actions)
@ -2644,7 +2658,13 @@ function handleMobileSwipe() {
} }
} }
//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
let dmListRenderGen = 0;
let groupListRenderGen = 0;
async function renderDms(res) { async function renderDms(res) {
const gen = ++dmListRenderGen;
let container = document.getElementById("dms-list"); let container = document.getElementById("dms-list");
if (!container) return; if (!container) return;
@ -2665,8 +2685,13 @@ async function renderDms(res) {
let myId = `${id};${host}`; let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
if (!targetUsername) {
targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
}
let pfp = await getAvatarUrl(targetId, targetUsername); let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]); let displayName = escapeHtml(targetUsername.split(':')[0]);
@ -2694,11 +2719,13 @@ async function renderDms(res) {
count++; count++;
} }
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI(); updateVoicePresenceUI();
} else { } else {
if (gen !== dmListRenderGen) return;
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah"); let blahTags = container.getElementsByTagName("blah");
@ -2716,7 +2743,17 @@ async function renderDms(res) {
} }
} }
//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają,
//a group/info było najdroższym elementem renderu listy grup
function getGroupNameFromCache(groupId) {
if (!window.groupNameCache) return null;
let entry = window.groupNameCache[sanitizeId(groupId)];
if (!entry || Date.now() - entry.ts > 60000) return null;
return entry.name;
}
async function renderGroups(res) { async function renderGroups(res) {
const gen = ++groupListRenderGen;
let container = document.getElementById("groups-list"); let container = document.getElementById("groups-list");
if (!container) return; if (!container) return;
@ -2737,11 +2774,24 @@ async function renderGroups(res) {
seedCallStateFromList(groupId, groupData.call === true); seedCallStateFromList(groupId, groupData.call === true);
let infoRes = await fetchEncrypted("group/info", groupId); //S22: TTL-cache nazw grup - group/info było wołane per grupa per render
if (infoRes && infoRes.startsWith("{")) { let groupName = getGroupNameFromCache(groupId);
let info = JSON.parse(infoRes); if (groupName === null) {
let groupName = info.string2; let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
groupName = JSON.parse(infoRes).string2;
if (!window.groupNameCache) window.groupNameCache = {};
window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
} else {
if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
}
continue;
}
}
{
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName); let pfp = await getGroupAvatarUrl(groupId, groupName);
@ -2760,17 +2810,16 @@ async function renderGroups(res) {
</button> </button>
`; `;
count++; count++;
} else if (infoRes === "error:group.not.found") {
//grupa została usunięta - wyczyść nieaktualny wpis z listy
removeRoomFromCachedList(groupId, 'groups');
} }
} }
if (gen !== groupListRenderGen) return; //nowszy render przejął listę
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI(); updateVoicePresenceUI();
} else { } else {
if (gen !== groupListRenderGen) return;
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah"); let blahTags = container.getElementsByTagName("blah");

View file

@ -137,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2;
let peerLastSeen = {}; let peerLastSeen = {};
let deadPeers = new Set(); let deadPeers = new Set();
let lastVoiceServerPacketAt = 0; let lastVoiceServerPacketAt = 0;
//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla
//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu)
let voiceHadRemotePeers = false;
let voicePingTimer = null; let voicePingTimer = null;
let voiceRosterRetryTimer = null; let voiceRosterRetryTimer = null;
let lastVoiceKeyRequestAt = 0; let lastVoiceKeyRequestAt = 0;
@ -150,6 +153,10 @@ function cleanupPeerMedia(userId) {
delete remoteAudio[aid]; delete remoteAudio[aid];
delete audioDecryptChains[aid]; delete audioDecryptChains[aid];
} }
if (remoteOpusDecoders[aid]) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
} }
for (const decoderId of Object.keys(remoteVideos)) { for (const decoderId of Object.keys(remoteVideos)) {
if (decoderId.split('-')[0] === userId) { if (decoderId.split('-')[0] === userId) {
@ -544,8 +551,10 @@ function setVoiceStatus(state) {
updateVoicePing(); updateVoicePing();
} else { } else {
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
voiceConnectedAt = null; //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
//realnym rozłączeniu (disconnected z endVoiceSession)
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
} }
} }
@ -605,7 +614,11 @@ setInterval(() => {
} }
} }
// Ghost peers: still on the room roster but totally silent (pings count as // Ghost peers: still on the room roster but totally silent (pings count as
// life) - drop them locally until the server or the peer proves otherwise. // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
let ghostRemoved = false; let ghostRemoved = false;
for (const u of voiceActiveUsers) { for (const u of voiceActiveUsers) {
if (u === String(window.id)) continue; if (u === String(window.id)) continue;
@ -617,12 +630,18 @@ setInterval(() => {
} }
} }
if (ghostRemoved) { if (ghostRemoved) {
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
// Half-open socket watchdog + backgrounded-tab audio recovery. // Half-open socket watchdog + backgrounded-tab audio recovery.
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
// Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w
// rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się
// nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo.
// Bez tego resetu join drugiej osoby natychmiast spełniał warunek
// ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm).
if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now;
voiceHadRemotePeers = others > 0;
// Alive peers ping every 2s regardless of mute, so total inbound // Alive peers ping every 2s regardless of mute, so total inbound
// silence while others are present means the socket is dead. // silence while others are present means the socket is dead.
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
@ -753,6 +772,96 @@ class TaskQueue {
let voiceJoinInFlight = false; let voiceJoinInFlight = false;
// === S20: Opus (WebCodecs) dla mikrofonu z pełnym fallbackiem na legacy PCM ===
// UWAGA: tymczasowo WYŁĄCZONE (raport użytkownika: cisza w VC po wdrożeniu -
// wymaga testów na realnych urządzeniach przed ponownym włączeniem).
// Flagą włącza się całość; ścieżka odbiorcza typu 6 jest bezpieczna i zostaje.
const ENABLE_VOICE_OPUS = false;
// Typ ramki 6 = opus (typ 0 pozostaje PCM). Layout wewnętrzny identyczny jak
// typ 0: [0]=typ, [1]=keyframe, [2..14)=IV, [14..)=ciphertext - serwer i tak
// dokleja clientId przed relayem. Brak AudioEncoder/AudioDecoder lub jakikolwiek
// błąd = powrót na surowe PCM (mieszane wersje klientów się nie wysypują).
let voiceOpusEncoder = null;
let voiceOpusEncoderFailed = false;
let voiceOpusInputTimestampUs = 0;
const remoteOpusDecoders = {};
function getVoiceOpusEncoder() {
if (!ENABLE_VOICE_OPUS || voiceOpusEncoderFailed) return null;
if (typeof window.AudioEncoder !== 'function') { voiceOpusEncoderFailed = true; return null; }
if (voiceOpusEncoder) return voiceOpusEncoder;
try {
voiceOpusEncoder = new AudioEncoder({
output: (chunk) => {
try {
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
const bytes = new Uint8Array(chunk.byteLength);
chunk.copyTo(bytes);
sendVoiceEncodedFrame(6, bytes);
} catch (e) {}
},
error: () => { voiceOpusEncoderFailed = true; }
});
voiceOpusEncoder.configure({
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 1,
bitrate: 32000
});
return voiceOpusEncoder;
} catch (e) {
voiceOpusEncoderFailed = true;
voiceOpusEncoder = null;
return null;
}
}
async function sendVoiceEncodedFrame(type, plainBytes) {
//licznik IV inkrementowany synchronicznie PRZED awaitem - niezmiennik
//unikalności IV bez zmian
const iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, plainBytes);
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN) return;
const payload = new Uint8Array(14 + cipher.byteLength);
payload[0] = type; payload[1] = 1; payload.set(iv, 2); payload.set(new Uint8Array(cipher), 14);
currentVoiceWs.send(payload.buffer);
}
function decodeVoiceOpus(audioId, targetNode, opusBuffer) {
try {
if (typeof window.AudioDecoder !== 'function') return;
let dec = remoteOpusDecoders[audioId];
if (!dec) {
dec = new AudioDecoder({
output: (audioData) => {
try {
const frames = audioData.numberOfFrames;
const f32 = new Float32Array(frames);
audioData.copyTo(f32, { planeIndex: 0, format: 'f32-planar' });
const pcm = new Int16Array(frames);
for (let i = 0; i < frames; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
audioData.close();
if (targetNode) targetNode.port.postMessage({ clientId: audioId, audioData: pcm.buffer }, [pcm.buffer]);
} catch (e) { try { audioData.close(); } catch (e2) {} }
},
error: () => { delete remoteOpusDecoders[audioId]; }
});
dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 });
remoteOpusDecoders[audioId] = dec;
}
dec.decode(new EncodedAudioChunk({
type: 'key',
timestamp: Math.trunc(performance.now() * 1000),
data: opusBuffer
}));
} catch (err) {
delete remoteOpusDecoders[audioId];
}
}
async function initiateVoiceCall(roomId = null) { async function initiateVoiceCall(roomId = null) {
if (currentVoiceWs || voiceJoinInFlight) { if (currentVoiceWs || voiceJoinInFlight) {
showBlahNotification("error:already.in.call"); showBlahNotification("error:already.in.call");
@ -775,6 +884,12 @@ async function initiateVoiceCall(roomId = null) {
if (joined) { if (joined) {
await startVoiceSession(joined.serverUrl, joined.ticket); await startVoiceSession(joined.serverUrl, joined.ticket);
} else {
//S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/
//Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie
//"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy
currentVoiceCallId = null;
currentVoiceCallType = null;
} }
} finally { } finally {
voiceJoinInFlight = false; voiceJoinInFlight = false;
@ -884,12 +999,12 @@ async function startVoiceSession(serverUrl, ticket) {
voicePingMs = Math.round(performance.now() - t0); voicePingMs = Math.round(performance.now() - t0);
updateVoicePing(); updateVoicePing();
const rawUsers = usersRaw.map(u => String(u)); const rawUsers = usersRaw.map(u => String(u));
// Peers we locally declared dead stay hidden until either //Serwer jest źródłem prawdy o składzie - nie filtrujemy po
// they send anything again or the server drops them. //deadPeers (audyt S15: ukryty peer nadal dostaje audio)
for (const gone of [...deadPeers]) { for (const gone of [...deadPeers]) {
if (!rawUsers.includes(gone)) deadPeers.delete(gone); if (!rawUsers.includes(gone)) deadPeers.delete(gone);
} }
const users = rawUsers.filter(u => !deadPeers.has(u)); const users = rawUsers;
let changed = (users.length !== voiceActiveUsers.length); let changed = (users.length !== voiceActiveUsers.length);
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
if(changed) { if(changed) {
@ -920,37 +1035,48 @@ async function startVoiceSession(serverUrl, ticket) {
if (voicePingTimer) clearInterval(voicePingTimer); if (voicePingTimer) clearInterval(voicePingTimer);
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
// Reconnects reuse the live mic stream - only capture a new one //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
// when the previous is gone (full teardown or track ended). //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { //"connected" zombie-call ze spalonym single-use ticketem i bez
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); //mikrofonu, bez powiadomienia i bez recovery
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } try {
} // Reconnects reuse the live mic stream - only capture a new one
// when the previous is gone (full teardown or track ended).
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId));
if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; }
}
const nativeRouting = getNativeVoiceRouting(); const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.startCall) { if (nativeRouting && nativeRouting.startCall) {
try { await nativeRouting.startCall(); } catch (e) {} try { await nativeRouting.startCall(); } catch (e) {}
} }
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000});
if (!isAudioWorkletLoaded) { if (!isAudioWorkletLoaded) {
await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'})));
senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); senderNode = new AudioWorkletNode(audioContext, 'audio-sender');
const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice());
receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}});
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
isAudioWorkletLoaded = true; isAudioWorkletLoaded = true;
}
if (audioContext.state === 'suspended') await audioContext.resume();
applyVoiceSpeakerSink();
setupVoiceOutput();
if (micSourceNode) {
try { micSourceNode.disconnect(); } catch (e) {}
micSourceNode = null;
}
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
} catch (err) {
console.error("Voice: call setup failed", err);
showBlahNotification("error:call.connect.failed");
endVoiceSession();
return;
} }
if (audioContext.state === 'suspended') await audioContext.resume();
applyVoiceSpeakerSink();
setupVoiceOutput();
if (micSourceNode) {
try { micSourceNode.disconnect(); } catch (e) {}
micSourceNode = null;
}
micSourceNode = audioContext.createMediaStreamSource(micStream);
micSourceNode.connect(senderNode);
let vadHangover = 0; let vadHangover = 0;
senderNode.port.onmessage = async (e) => { senderNode.port.onmessage = async (e) => {
@ -981,6 +1107,30 @@ async function startVoiceSession(serverUrl, ticket) {
if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)'; if (vdsLevelFill) vdsLevelFill.style.backgroundColor = 'var(--big-green)';
if (!voiceMySenderCryptoKey) return; if (!voiceMySenderCryptoKey) return;
//S20: Opus gdy WebCodecs dostępne (~30x mniej bajtów niż PCM);
//każda awaria = spójny fallback na legacy PCM poniżej
const opusEnc = getVoiceOpusEncoder();
if (opusEnc && opusEnc.state === 'configured') {
try {
const i16 = new Int16Array(audioData);
const f32 = new Float32Array(i16.length);
for (let i = 0; i < i16.length; i++) f32[i] = i16[i] / 32768;
const ad = new AudioData({
format: 'f32-planar',
sampleRate: 48000,
numberOfFrames: i16.length,
numberOfChannels: 1,
timestamp: voiceOpusInputTimestampUs,
data: f32
});
voiceOpusInputTimestampUs += Math.round(i16.length * 1e6 / 48000);
opusEnc.encode(ad);
ad.close();
return;
} catch (e) { /* fallback na PCM */ }
}
const iv = nextVoiceIv(); const iv = nextVoiceIv();
const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData); const cipher = await crypto.subtle.encrypt({name: "AES-GCM", iv}, voiceMySenderCryptoKey, audioData);
const payload = new Uint8Array(14 + cipher.byteLength); const payload = new Uint8Array(14 + cipher.byteLength);
@ -1010,7 +1160,8 @@ async function startVoiceSession(serverUrl, ticket) {
const baseUrl = currentVoiceWs.url.split('/ws')[0]; const baseUrl = currentVoiceWs.url.split('/ws')[0];
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15)
voiceActiveUsers = usersRaw.map(u => String(u));
updateVoiceUIUsers(); updateVoiceUIUsers();
}).catch(()=>{}); }).catch(()=>{});
} }
@ -1024,8 +1175,9 @@ async function startVoiceSession(serverUrl, ticket) {
//podklucz nadawcy - deterministyczny z (root, clientId) //podklucz nadawcy - deterministyczny z (root, clientId)
const senderKeyPromise = getVoicePeerSenderKey(clientId); const senderKeyPromise = getVoicePeerSenderKey(clientId);
if (type === 0 || type === 4) { if (type === 0 || type === 4 || type === 6) {
const isScreenAudio = (type === 4); const isScreenAudio = (type === 4);
const isOpus = (type === 6);
const audioId = isScreenAudio ? `${clientId}_screen` : clientId; const audioId = isScreenAudio ? `${clientId}_screen` : clientId;
if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() }; if (!remoteAudio[audioId]) remoteAudio[audioId] = { lastUpdate: Date.now() };
remoteAudio[audioId].lastUpdate = Date.now(); remoteAudio[audioId].lastUpdate = Date.now();
@ -1038,7 +1190,12 @@ async function startVoiceSession(serverUrl, ticket) {
const senderKey = await senderKeyPromise; const senderKey = await senderKeyPromise;
if (!senderKey) return; if (!senderKey) return;
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); if (isOpus) {
//S20: opus -> Int16 PCM -> ten sam jitter-buffer workletu
decodeVoiceOpus(audioId, targetReceiver, decryptedBuffer);
} else {
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
}
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
} catch (err) {} } catch (err) {}
@ -1210,10 +1367,11 @@ async function handleCallUpdate(data) {
} }
} }
//Ignore updates for rooms we have nothing to do with - they would otherwise //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa
//overwrite the participant list of an ongoing call and wipe its tiles. //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju
let state = incomingState.filter(u => !deadPeers.has(u)); if (roomId !== currentVoiceCallId) return;
let state = incomingState;
voiceActiveUsers = state; voiceActiveUsers = state;
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
@ -2157,7 +2315,10 @@ function updateVoiceUIUsers() {
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png"; let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
if (!avatarRes && window.getAvatarUrl) { if (!avatarRes && window.getAvatarUrl) {
getAvatarUrl(u, authorName || "User").then(url => { //Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
getAvatarUrl(u, nameForAvatar).then(url => {
if(url) { if(url) {
window.dmPfpCache[u] = url; window.dmPfpCache[u] = url;
let el = document.getElementById(`voice-tile-${u}`); let el = document.getElementById(`voice-tile-${u}`);
@ -2492,6 +2653,15 @@ function endVoiceSession() {
deadPeers.clear(); deadPeers.clear();
lastVoiceKeyRequestAt = 0; lastVoiceKeyRequestAt = 0;
voiceActiveUsers = []; voiceActiveUsers = [];
voiceHadRemotePeers = false;
//S20: teardown enkodera/dekoderów opus
if (voiceOpusEncoder) { try { voiceOpusEncoder.close(); } catch (e) {} voiceOpusEncoder = null; }
voiceOpusInputTimestampUs = 0;
for (const aid of Object.keys(remoteOpusDecoders)) {
try { remoteOpusDecoders[aid].close(); } catch (e) {}
delete remoteOpusDecoders[aid];
}
for (let id in audioDecoders) { for (let id in audioDecoders) {
try { audioDecoders[id].close(); } catch(e){} try { audioDecoders[id].close(); } catch(e){}