fix vc desync
This commit is contained in:
parent
e00bfdce39
commit
6a14104143
4 changed files with 314 additions and 82 deletions
|
|
@ -4206,6 +4206,55 @@ function handleChatInputKey(event) {
|
|||
let dmMessagePollInterval = null;
|
||||
let appWebSocket = null;
|
||||
let dmMessageLoadTimeout = null;
|
||||
let appWsPingTimer = null;
|
||||
let callStatesResyncTimer = null;
|
||||
|
||||
//Keepalive app-WS: tekstowy "ping" co 20 s. Serwer zamyka połączenie po 75 s
|
||||
//bezczynności (AppWsIdleTimeout) - bez tego half-open TCP po zabiciu aplikacji /
|
||||
//zmianie sieci wisiał na serwerze godzinami, cleanup ActiveCalls nie biegł i
|
||||
//wskaźniki rozmów pokazywały uczestników, którzy dawno wyszli. Ping utrzymuje
|
||||
//też mapping NAT przy danych komórkowych, więc eventy (call_update!) nie giną.
|
||||
function startAppWsPing() {
|
||||
if (appWsPingTimer) clearInterval(appWsPingTimer);
|
||||
appWsPingTimer = setInterval(() => {
|
||||
if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) {
|
||||
try { appWebSocket.send("ping"); } catch (e) {}
|
||||
}
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function stopAppWsPing() {
|
||||
if (appWsPingTimer) { clearInterval(appWsPingTimer); appWsPingTimer = null; }
|
||||
}
|
||||
|
||||
//Pełna resynchronizacja wskaźników rozmów: zgubiony podczas przerwy w app-WS
|
||||
//call_update NIGDY nie jest odtwarzany przez serwer, a seedCallStateFromList
|
||||
//tylko dodaje wpisy - bez tego wskaźniki "znikały dopiero po ręcznym odświeżeniu".
|
||||
//Dla każdego pokoju znanego jako aktywny (+ otwartego) pyta room/voice/state
|
||||
//i koryguje mapę oraz UI. Uruchamiane: po (re)otwarciu WS, co 30 s, przy powrocie
|
||||
//karty/aplikacji na wierzch.
|
||||
async function resyncAllCallStates() {
|
||||
if (!window.id || typeof fetchEncrypted !== "function" || typeof ensureActiveCallsMap !== "function") return;
|
||||
let map = ensureActiveCallsMap();
|
||||
let keys = Object.keys(map);
|
||||
let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
|
||||
if (currentKey && !keys.includes(currentKey)) keys.push(currentKey);
|
||||
if (keys.length === 0) return;
|
||||
|
||||
for (let key of keys) {
|
||||
let roomType = window.currentDmId === key && window.currentRoomType ? window.currentRoomType
|
||||
: (String(key).includes("_") ? "dms" : "groups");
|
||||
try {
|
||||
let res = await fetchEncrypted("room/voice/state", JSON.stringify({ string1: roomType, string2: key }));
|
||||
if (!res || !res.startsWith("[")) continue;
|
||||
let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
|
||||
if (participants.length > 0) map[key] = participants;
|
||||
else delete map[key];
|
||||
} catch (e) {}
|
||||
}
|
||||
//Po serii poprawek pełne przerysowanie wskaźników (listy + top-bary + details)
|
||||
if (typeof updateVoicePresenceUI === "function") updateVoicePresenceUI();
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
||||
|
|
@ -4228,6 +4277,17 @@ function setupWebSocket() {
|
|||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
startAppWsPing();
|
||||
//Po każdym (re)połączeniu odtwarzamy stan wskaźników - eventy call_update
|
||||
//z czasu rozłączenia są bezpowrotnie stracone
|
||||
resyncAllCallStates();
|
||||
if (!callStatesResyncTimer) {
|
||||
callStatesResyncTimer = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
resyncAllCallStates();
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
appWebSocket.onmessage = (event) => {
|
||||
|
|
@ -4273,10 +4333,22 @@ function setupWebSocket() {
|
|||
};
|
||||
|
||||
appWebSocket.onclose = () => {
|
||||
stopAppWsPing();
|
||||
setTimeout(setupWebSocket, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
//Powrót karty/aplikacji na wierzch: mobilne WebView potrafią trzymać martwy
|
||||
//socket odkryty bardzo długo (brak ramek = brak detekcji). Wymuszamy próbę
|
||||
//połączenia i od razu resynchronizujemy wskaźniki rozmów.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (appWebSocket && appWebSocket.readyState !== WebSocket.OPEN && appWebSocket.readyState !== WebSocket.CONNECTING) {
|
||||
setupWebSocket();
|
||||
}
|
||||
resyncAllCallStates();
|
||||
});
|
||||
|
||||
let currentContextMenuMsgId = null;
|
||||
let touchContextMenuFired = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -130,10 +130,14 @@ const SPEAKING_HANGOVER_MS = 400;
|
|||
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
|
||||
// user" apart from "crashed/half-open socket". Peers silent for too long are
|
||||
// dropped locally; a dead server socket triggers an automatic rejoin.
|
||||
// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone):
|
||||
// ghost z rosteru (martwy TCP po stronie serwera, zamiata go idle-sweeper do
|
||||
// 15 s) nie może prowokować pętli fałszywych reconnectów = migające "Connecting".
|
||||
const VOICE_PEER_TIMEOUT_MS = 7000;
|
||||
const VOICE_PING_INTERVAL_MS = 2000;
|
||||
const VOICE_SERVER_TIMEOUT_MS = 10000;
|
||||
const VOICE_MAX_RECONNECTS = 2;
|
||||
const VOICE_SERVER_TIMEOUT_MS = 15000;
|
||||
const VOICE_MAX_RECONNECTS = 4;
|
||||
const VOICE_ROSTER_POLL_MS = 5000;
|
||||
let peerLastSeen = {};
|
||||
let deadPeers = new Set();
|
||||
let lastVoiceServerPacketAt = 0;
|
||||
|
|
@ -142,6 +146,7 @@ let lastVoiceServerPacketAt = 0;
|
|||
let voiceHadRemotePeers = false;
|
||||
let voicePingTimer = null;
|
||||
let voiceRosterRetryTimer = null;
|
||||
let voiceRosterPollTimer = null;
|
||||
let lastVoiceKeyRequestAt = 0;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let voiceSocketOpenedAt = 0;
|
||||
|
|
@ -630,7 +635,11 @@ setInterval(() => {
|
|||
}
|
||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||
//deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP
|
||||
//jeszcze nie zsynchronizowany przez serwer) nigdy nic nie wyśle, więc
|
||||
//zliczany jak żywy wymuszał reconnecty w pętli - migające "Connecting"
|
||||
//w zdrowej rozmowie.
|
||||
const others = voiceActiveUsers.filter(u => u !== String(window.id) && !deadPeers.has(u)).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.
|
||||
|
|
@ -693,8 +702,8 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
||||
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
||||
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
||||
// Backlog slightly above target is drained by a gentle trim (1 sample per
|
||||
// quantum) instead of letting latency creep up to the hard limit.
|
||||
// Backlog above target is drained adaptively (proportional to excess, see
|
||||
// process()) so latency returns to ~43 ms quickly after network bursts.
|
||||
constructor(options) {
|
||||
super();
|
||||
this.users = {};
|
||||
|
|
@ -732,7 +741,17 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
if (available < output.length) { user.buffering = true; }
|
||||
else {
|
||||
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
||||
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
|
||||
else if (available > 2049) {
|
||||
//Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany
|
||||
//proporcjonalnie (1 + excess/256 próbek na kwant), max ~17
|
||||
//= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale
|
||||
//"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało,
|
||||
//by po każdym burstu sieciowym latency wisiał pod twardym
|
||||
//limitem przez kilkanaście sekund - odczuwalne jako "dziwnie
|
||||
//duże opóźnienie" na sieci komórkowej.
|
||||
const excess = available - 2048;
|
||||
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 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;
|
||||
|
|
@ -890,6 +909,37 @@ function bytesToB64u(a) {
|
|||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje
|
||||
//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia
|
||||
//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co
|
||||
//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po
|
||||
//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków.
|
||||
async function fetchVoiceRoster() {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
|
||||
try {
|
||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`);
|
||||
const usersRaw = await res.json();
|
||||
voicePingMs = Math.round(performance.now() - t0);
|
||||
updateVoicePing();
|
||||
const rawUsers = usersRaw.map(u => String(u));
|
||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||
for (const gone of [...deadPeers]) {
|
||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||
}
|
||||
const users = rawUsers;
|
||||
let changed = (users.length !== voiceActiveUsers.length);
|
||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||
if(changed) {
|
||||
voiceActiveUsers = users;
|
||||
updateVoiceUIUsers();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl, ticket) {
|
||||
if (!ticket) throw new Error("missing voice ticket");
|
||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||
|
|
@ -932,32 +982,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
else btn.classList.remove("voice-btn-danger");
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
|
||||
try {
|
||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
|
||||
const usersRaw = await res.json();
|
||||
voicePingMs = Math.round(performance.now() - t0);
|
||||
updateVoicePing();
|
||||
const rawUsers = usersRaw.map(u => String(u));
|
||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||
for (const gone of [...deadPeers]) {
|
||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||
}
|
||||
const users = rawUsers;
|
||||
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();
|
||||
fetchVoiceRoster();
|
||||
//The voice-server roster is joined only AFTER ticket validation (an
|
||||
//HTTP roundtrip to the main server), so the immediate fetch can still
|
||||
//miss us - retry quickly until our own tile shows up instead of
|
||||
|
|
@ -971,13 +996,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
voiceRosterRetryTimer = null;
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
fetchVoiceRoster();
|
||||
}, 400);
|
||||
|
||||
voiceReconnectAttempts = 0;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||
//Steady roster poll - utrzymuje kafelki zgodne z prawdą serwera
|
||||
//(ghost-sweep, leave bez call_update) przez całą rozmowę.
|
||||
if (voiceRosterPollTimer) clearInterval(voiceRosterPollTimer);
|
||||
voiceRosterPollTimer = setInterval(fetchVoiceRoster, VOICE_ROSTER_POLL_MS);
|
||||
|
||||
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||
|
|
@ -1165,6 +1194,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
frame.close();
|
||||
},
|
||||
error: e => {
|
||||
voiceLogError('videodecoder[' + decoderId + ']', e);
|
||||
if (remoteVideos[decoderId]) {
|
||||
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
||||
remoteVideos[decoderId].decoder = null;
|
||||
|
|
@ -1190,11 +1220,14 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
try {
|
||||
streamData.decoder.decode(chunk);
|
||||
} catch(err) {
|
||||
voiceLogError('videodecode[' + decoderId + ']', err);
|
||||
try { streamData.decoder.close(); } catch(e){}
|
||||
streamData.decoder = null;
|
||||
streamData.hasKeyFrame = false;
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
voiceLogError('vdecrypt[' + decoderId + ']', err);
|
||||
}
|
||||
});
|
||||
} else if (type === 3) {
|
||||
(async () => {
|
||||
|
|
@ -2507,6 +2540,7 @@ function teardownVoiceTransport() {
|
|||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
//Stale participants from the previous attempt must not linger in the UI
|
||||
voiceActiveUsers = [];
|
||||
|
|
@ -2543,7 +2577,7 @@ function scheduleVoiceReconnect() {
|
|||
console.warn("Voice reconnect failed", err);
|
||||
scheduleVoiceReconnect();
|
||||
}
|
||||
}, 800);
|
||||
}, Math.min(4000, 800 * voiceReconnectAttempts));
|
||||
}
|
||||
|
||||
function endVoiceSession() {
|
||||
|
|
@ -2588,9 +2622,11 @@ function endVoiceSession() {
|
|||
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
voiceReconnectAttempts = 0;
|
||||
voiceActiveUsers = [];
|
||||
voiceHadRemotePeers = false;
|
||||
|
||||
|
|
@ -2703,7 +2739,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
return { width, height, fps, bitrate };
|
||||
};
|
||||
|
||||
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0;
|
||||
const encoder = new VideoEncoder({
|
||||
output: async (chunk) => {
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||
|
|
@ -2713,7 +2749,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
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(); }
|
||||
}, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); }
|
||||
});
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
|
||||
|
|
@ -2733,7 +2769,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
pendingCfg = newConfig;
|
||||
pendingAt = performance.now();
|
||||
} else if (performance.now() - pendingAt > 300) {
|
||||
currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null;
|
||||
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
}
|
||||
} else if (pendingCfg) {
|
||||
|
|
@ -2743,9 +2779,17 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && 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});
|
||||
const nowMs = performance.now();
|
||||
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
|
||||
//Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych
|
||||
//klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał
|
||||
//się z realnym tempem i keyframes stawały się rzadkie. Widz
|
||||
//dołączający w trakcie udostępniania musi zaczekać na keyframe -
|
||||
//z licznikiem potrafiło to trwać bardzo długo (efekt "kamery
|
||||
//nie widać"), z zegarem to maksymalnie ~1 s.
|
||||
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
|
||||
encoder.encode(frame, {keyFrame: wantKeyframe});
|
||||
if (wantKeyframe) lastKeyframeAt = nowMs;
|
||||
} catch (err) { } finally { if (frame) frame.close(); }
|
||||
}
|
||||
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
||||
|
|
|
|||
|
|
@ -4206,6 +4206,55 @@ function handleChatInputKey(event) {
|
|||
let dmMessagePollInterval = null;
|
||||
let appWebSocket = null;
|
||||
let dmMessageLoadTimeout = null;
|
||||
let appWsPingTimer = null;
|
||||
let callStatesResyncTimer = null;
|
||||
|
||||
//Keepalive app-WS: tekstowy "ping" co 20 s. Serwer zamyka połączenie po 75 s
|
||||
//bezczynności (AppWsIdleTimeout) - bez tego half-open TCP po zabiciu aplikacji /
|
||||
//zmianie sieci wisiał na serwerze godzinami, cleanup ActiveCalls nie biegł i
|
||||
//wskaźniki rozmów pokazywały uczestników, którzy dawno wyszli. Ping utrzymuje
|
||||
//też mapping NAT przy danych komórkowych, więc eventy (call_update!) nie giną.
|
||||
function startAppWsPing() {
|
||||
if (appWsPingTimer) clearInterval(appWsPingTimer);
|
||||
appWsPingTimer = setInterval(() => {
|
||||
if (appWebSocket && appWebSocket.readyState === WebSocket.OPEN) {
|
||||
try { appWebSocket.send("ping"); } catch (e) {}
|
||||
}
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
function stopAppWsPing() {
|
||||
if (appWsPingTimer) { clearInterval(appWsPingTimer); appWsPingTimer = null; }
|
||||
}
|
||||
|
||||
//Pełna resynchronizacja wskaźników rozmów: zgubiony podczas przerwy w app-WS
|
||||
//call_update NIGDY nie jest odtwarzany przez serwer, a seedCallStateFromList
|
||||
//tylko dodaje wpisy - bez tego wskaźniki "znikały dopiero po ręcznym odświeżeniu".
|
||||
//Dla każdego pokoju znanego jako aktywny (+ otwartego) pyta room/voice/state
|
||||
//i koryguje mapę oraz UI. Uruchamiane: po (re)otwarciu WS, co 30 s, przy powrocie
|
||||
//karty/aplikacji na wierzch.
|
||||
async function resyncAllCallStates() {
|
||||
if (!window.id || typeof fetchEncrypted !== "function" || typeof ensureActiveCallsMap !== "function") return;
|
||||
let map = ensureActiveCallsMap();
|
||||
let keys = Object.keys(map);
|
||||
let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
|
||||
if (currentKey && !keys.includes(currentKey)) keys.push(currentKey);
|
||||
if (keys.length === 0) return;
|
||||
|
||||
for (let key of keys) {
|
||||
let roomType = window.currentDmId === key && window.currentRoomType ? window.currentRoomType
|
||||
: (String(key).includes("_") ? "dms" : "groups");
|
||||
try {
|
||||
let res = await fetchEncrypted("room/voice/state", JSON.stringify({ string1: roomType, string2: key }));
|
||||
if (!res || !res.startsWith("[")) continue;
|
||||
let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
|
||||
if (participants.length > 0) map[key] = participants;
|
||||
else delete map[key];
|
||||
} catch (e) {}
|
||||
}
|
||||
//Po serii poprawek pełne przerysowanie wskaźników (listy + top-bary + details)
|
||||
if (typeof updateVoicePresenceUI === "function") updateVoicePresenceUI();
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
if (appWebSocket && (appWebSocket.readyState === WebSocket.OPEN || appWebSocket.readyState === WebSocket.CONNECTING)) return;
|
||||
|
|
@ -4228,6 +4277,17 @@ function setupWebSocket() {
|
|||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
startAppWsPing();
|
||||
//Po każdym (re)połączeniu odtwarzamy stan wskaźników - eventy call_update
|
||||
//z czasu rozłączenia są bezpowrotnie stracone
|
||||
resyncAllCallStates();
|
||||
if (!callStatesResyncTimer) {
|
||||
callStatesResyncTimer = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
resyncAllCallStates();
|
||||
}, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
appWebSocket.onmessage = (event) => {
|
||||
|
|
@ -4273,10 +4333,22 @@ function setupWebSocket() {
|
|||
};
|
||||
|
||||
appWebSocket.onclose = () => {
|
||||
stopAppWsPing();
|
||||
setTimeout(setupWebSocket, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
//Powrót karty/aplikacji na wierzch: mobilne WebView potrafią trzymać martwy
|
||||
//socket odkryty bardzo długo (brak ramek = brak detekcji). Wymuszamy próbę
|
||||
//połączenia i od razu resynchronizujemy wskaźniki rozmów.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (appWebSocket && appWebSocket.readyState !== WebSocket.OPEN && appWebSocket.readyState !== WebSocket.CONNECTING) {
|
||||
setupWebSocket();
|
||||
}
|
||||
resyncAllCallStates();
|
||||
});
|
||||
|
||||
let currentContextMenuMsgId = null;
|
||||
let touchContextMenuFired = false;
|
||||
|
||||
|
|
|
|||
126
webroot/voice.js
126
webroot/voice.js
|
|
@ -130,10 +130,14 @@ const SPEAKING_HANGOVER_MS = 400;
|
|||
// Every client broadcasts a tiny encrypted ping so peers can tell "quiet
|
||||
// user" apart from "crashed/half-open socket". Peers silent for too long are
|
||||
// dropped locally; a dead server socket triggers an automatic rejoin.
|
||||
// VOICE_SERVER_TIMEOUT_MS liczy tylko ŻYWYCH peerów (deadPeers są wykluczone):
|
||||
// ghost z rosteru (martwy TCP po stronie serwera, zamiata go idle-sweeper do
|
||||
// 15 s) nie może prowokować pętli fałszywych reconnectów = migające "Connecting".
|
||||
const VOICE_PEER_TIMEOUT_MS = 7000;
|
||||
const VOICE_PING_INTERVAL_MS = 2000;
|
||||
const VOICE_SERVER_TIMEOUT_MS = 10000;
|
||||
const VOICE_MAX_RECONNECTS = 2;
|
||||
const VOICE_SERVER_TIMEOUT_MS = 15000;
|
||||
const VOICE_MAX_RECONNECTS = 4;
|
||||
const VOICE_ROSTER_POLL_MS = 5000;
|
||||
let peerLastSeen = {};
|
||||
let deadPeers = new Set();
|
||||
let lastVoiceServerPacketAt = 0;
|
||||
|
|
@ -142,6 +146,7 @@ let lastVoiceServerPacketAt = 0;
|
|||
let voiceHadRemotePeers = false;
|
||||
let voicePingTimer = null;
|
||||
let voiceRosterRetryTimer = null;
|
||||
let voiceRosterPollTimer = null;
|
||||
let lastVoiceKeyRequestAt = 0;
|
||||
let voiceReconnectAttempts = 0;
|
||||
let voiceSocketOpenedAt = 0;
|
||||
|
|
@ -630,7 +635,11 @@ setInterval(() => {
|
|||
}
|
||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
||||
//deadPeers NIE liczą się do "others": ghost na rosterze (martwy TCP
|
||||
//jeszcze nie zsynchronizowany przez serwer) nigdy nic nie wyśle, więc
|
||||
//zliczany jak żywy wymuszał reconnecty w pętli - migające "Connecting"
|
||||
//w zdrowej rozmowie.
|
||||
const others = voiceActiveUsers.filter(u => u !== String(window.id) && !deadPeers.has(u)).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.
|
||||
|
|
@ -693,8 +702,8 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
// PREBUF_START - initial prebuffer before first playback (~43 ms)
|
||||
// PREBUF_RESUME - re-prebuffer after a starvation event (~32 ms)
|
||||
// HARD_LIMIT - burst ceiling (~128 ms): snap back to target immediately
|
||||
// Backlog slightly above target is drained by a gentle trim (1 sample per
|
||||
// quantum) instead of letting latency creep up to the hard limit.
|
||||
// Backlog above target is drained adaptively (proportional to excess, see
|
||||
// process()) so latency returns to ~43 ms quickly after network bursts.
|
||||
constructor(options) {
|
||||
super();
|
||||
this.users = {};
|
||||
|
|
@ -732,7 +741,17 @@ class AudioReceiver extends AudioWorkletProcessor {
|
|||
if (available < output.length) { user.buffering = true; }
|
||||
else {
|
||||
if (available > 6144) user.readPtr = (user.writePtr - 2048 + 65536) % 65536;
|
||||
else if (available > 2049) user.readPtr = (user.readPtr + 1) % 65536;
|
||||
else if (available > 2049) {
|
||||
//Adaptacyjny odzysk latencji: nadmiar nad target odprowadzany
|
||||
//proporcjonalnie (1 + excess/256 próbek na kwant), max ~17
|
||||
//= pełny powrót z 128 ms do ~43 ms w ułamku sekundy. Stale
|
||||
//"1 próbka/kwant" (~8 ms opóźnienia na sekundę) pozwalało,
|
||||
//by po każdym burstu sieciowym latency wisiał pod twardym
|
||||
//limitem przez kilkanaście sekund - odczuwalne jako "dziwnie
|
||||
//duże opóźnienie" na sieci komórkowej.
|
||||
const excess = available - 2048;
|
||||
user.readPtr = (user.readPtr + 1 + (excess >> 8)) % 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;
|
||||
|
|
@ -890,6 +909,37 @@ function bytesToB64u(a) {
|
|||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
//Pobiera skład pokoju z voice-servera (źródło prawdy o rosterze) i synchronizuje
|
||||
//UI. Wywoływana: na otwarciu socketu (plus szybkie retrye do czasu pojawienia
|
||||
//sie własnego kafelka), przy pakiecie od nieznanego clientId ORAZ cyklicznie co
|
||||
//VOICE_ROSTER_POLL_MS - bez steady-pollu zniknięcie ghosta z rosteru (sweep po
|
||||
//stronie serwera / leave bez call_update) nigdy nie docierało do naszych kafelków.
|
||||
async function fetchVoiceRoster() {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
|
||||
try {
|
||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${httpUrl}/room/users?room=${encodeURIComponent(currentVoiceCallId)}`);
|
||||
const usersRaw = await res.json();
|
||||
voicePingMs = Math.round(performance.now() - t0);
|
||||
updateVoicePing();
|
||||
const rawUsers = usersRaw.map(u => String(u));
|
||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||
for (const gone of [...deadPeers]) {
|
||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||
}
|
||||
const users = rawUsers;
|
||||
let changed = (users.length !== voiceActiveUsers.length);
|
||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||
if(changed) {
|
||||
voiceActiveUsers = users;
|
||||
updateVoiceUIUsers();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function startVoiceSession(serverUrl, ticket) {
|
||||
if (!ticket) throw new Error("missing voice ticket");
|
||||
// Single-session invariant: a stale socket (e.g. a double-fired join)
|
||||
|
|
@ -932,32 +982,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
else btn.classList.remove("voice-btn-danger");
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (!currentVoiceWs || currentVoiceWs.readyState !== WebSocket.OPEN || !currentVoiceCallId) return;
|
||||
try {
|
||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`);
|
||||
const usersRaw = await res.json();
|
||||
voicePingMs = Math.round(performance.now() - t0);
|
||||
updateVoicePing();
|
||||
const rawUsers = usersRaw.map(u => String(u));
|
||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
||||
for (const gone of [...deadPeers]) {
|
||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||
}
|
||||
const users = rawUsers;
|
||||
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();
|
||||
fetchVoiceRoster();
|
||||
//The voice-server roster is joined only AFTER ticket validation (an
|
||||
//HTTP roundtrip to the main server), so the immediate fetch can still
|
||||
//miss us - retry quickly until our own tile shows up instead of
|
||||
|
|
@ -971,13 +996,17 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
voiceRosterRetryTimer = null;
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
fetchVoiceRoster();
|
||||
}, 400);
|
||||
|
||||
voiceReconnectAttempts = 0;
|
||||
lastVoiceServerPacketAt = Date.now();
|
||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
||||
//Steady roster poll - utrzymuje kafelki zgodne z prawdą serwera
|
||||
//(ghost-sweep, leave bez call_update) przez całą rozmowę.
|
||||
if (voiceRosterPollTimer) clearInterval(voiceRosterPollTimer);
|
||||
voiceRosterPollTimer = setInterval(fetchVoiceRoster, VOICE_ROSTER_POLL_MS);
|
||||
|
||||
//S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić
|
||||
//(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał
|
||||
|
|
@ -1165,6 +1194,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
frame.close();
|
||||
},
|
||||
error: e => {
|
||||
voiceLogError('videodecoder[' + decoderId + ']', e);
|
||||
if (remoteVideos[decoderId]) {
|
||||
try { remoteVideos[decoderId].decoder.close(); } catch(err) {}
|
||||
remoteVideos[decoderId].decoder = null;
|
||||
|
|
@ -1190,11 +1220,14 @@ async function startVoiceSession(serverUrl, ticket) {
|
|||
try {
|
||||
streamData.decoder.decode(chunk);
|
||||
} catch(err) {
|
||||
voiceLogError('videodecode[' + decoderId + ']', err);
|
||||
try { streamData.decoder.close(); } catch(e){}
|
||||
streamData.decoder = null;
|
||||
streamData.hasKeyFrame = false;
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
voiceLogError('vdecrypt[' + decoderId + ']', err);
|
||||
}
|
||||
});
|
||||
} else if (type === 3) {
|
||||
(async () => {
|
||||
|
|
@ -2507,6 +2540,7 @@ function teardownVoiceTransport() {
|
|||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
//Stale participants from the previous attempt must not linger in the UI
|
||||
voiceActiveUsers = [];
|
||||
|
|
@ -2543,7 +2577,7 @@ function scheduleVoiceReconnect() {
|
|||
console.warn("Voice reconnect failed", err);
|
||||
scheduleVoiceReconnect();
|
||||
}
|
||||
}, 800);
|
||||
}, Math.min(4000, 800 * voiceReconnectAttempts));
|
||||
}
|
||||
|
||||
function endVoiceSession() {
|
||||
|
|
@ -2588,9 +2622,11 @@ function endVoiceSession() {
|
|||
if (voiceWsHeartbeat) { clearInterval(voiceWsHeartbeat); voiceWsHeartbeat = null; }
|
||||
if (voicePingTimer) { clearInterval(voicePingTimer); voicePingTimer = null; }
|
||||
if (voiceRosterRetryTimer) { clearInterval(voiceRosterRetryTimer); voiceRosterRetryTimer = null; }
|
||||
if (voiceRosterPollTimer) { clearInterval(voiceRosterPollTimer); voiceRosterPollTimer = null; }
|
||||
peerLastSeen = {};
|
||||
deadPeers.clear();
|
||||
lastVoiceKeyRequestAt = 0;
|
||||
voiceReconnectAttempts = 0;
|
||||
voiceActiveUsers = [];
|
||||
voiceHadRemotePeers = false;
|
||||
|
||||
|
|
@ -2703,7 +2739,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
return { width, height, fps, bitrate };
|
||||
};
|
||||
|
||||
let currentConfig = getCurrentConfig(), isRunning = true, frameCount = 0;
|
||||
let currentConfig = getCurrentConfig(), isRunning = true, lastKeyframeAt = 0;
|
||||
const encoder = new VideoEncoder({
|
||||
output: async (chunk) => {
|
||||
if (currentVoiceWs?.readyState !== WebSocket.OPEN || !voiceMySenderCryptoKey) return;
|
||||
|
|
@ -2713,7 +2749,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
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(); }
|
||||
}, error: (e) => { voiceLogError('videoencoder', e); stopFunction(); }
|
||||
});
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
|
||||
|
|
@ -2733,7 +2769,7 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
pendingCfg = newConfig;
|
||||
pendingAt = performance.now();
|
||||
} else if (performance.now() - pendingAt > 300) {
|
||||
currentConfig = newConfig; forceKeyframe = true; frameCount = 0; pendingCfg = null;
|
||||
currentConfig = newConfig; forceKeyframe = true; lastKeyframeAt = 0; pendingCfg = null;
|
||||
encoder.configure({codec: 'av01.0.04M.08', width: currentConfig.width, height: currentConfig.height, framerate: currentConfig.fps, bitrate: currentConfig.bitrate, latencyMode: 'realtime'});
|
||||
}
|
||||
} else if (pendingCfg) {
|
||||
|
|
@ -2743,9 +2779,17 @@ async function startHardwareVideo(btnEl, streamPromise, typeLabel, typeId) {
|
|||
if (encoder.state === 'configured' && encoder.encodeQueueSize <= 2 && currentVoiceWs.bufferedAmount < 1024 * 256 && 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});
|
||||
const nowMs = performance.now();
|
||||
frame = new VideoFrame(videoEl, {timestamp: Math.trunc(nowMs * 1000)});
|
||||
//Klatka kluczowa co ~1 s według ZEGARA, nie licznika zakodowanych
|
||||
//klatek: zawór congestion pomija klatki, więc licznik rozjeżdżał
|
||||
//się z realnym tempem i keyframes stawały się rzadkie. Widz
|
||||
//dołączający w trakcie udostępniania musi zaczekać na keyframe -
|
||||
//z licznikiem potrafiło to trwać bardzo długo (efekt "kamery
|
||||
//nie widać"), z zegarem to maksymalnie ~1 s.
|
||||
const wantKeyframe = forceKeyframe || (nowMs - lastKeyframeAt >= 1000);
|
||||
encoder.encode(frame, {keyFrame: wantKeyframe});
|
||||
if (wantKeyframe) lastKeyframeAt = nowMs;
|
||||
} catch (err) { } finally { if (frame) frame.close(); }
|
||||
}
|
||||
setTimeout(encodeLoop, 1000 / currentConfig.fps);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue