PageSourceSearch

https://www.40gold.de/assets/scripts/chat/core/webrtc-TGY-OUX.js

js 40gold.de collected 2026-10-04 03:32:43 UTC 27,299 bytes, 598 lines download raw bytes

1/*
2 * WebRTC-Kern: State-Machine + RTCPeerConnection-Handling fuer Audio/Video-
3 * Calls. Signaling-Protokoll (Envelope {type:'wrtc', sender, receiver,
4 * payload}) 1:1 aus dem Legacy-Client (chat/js/app.js) uebernommen, siehe
5 * docs/chat-panel-integration.md. Bewusste Fixes ggue. Legacy: TURN-Server
6 * wird genutzt (statt nur hardcodiertem STUN), Audio wird im Call
7 * angefordert (Legacy forderte nur Video an).
8 *
9 * Freundschafts-Pruefung ist NICHT Teil dieses Moduls - die Fassade
10 * (chat_client.js) prueft das anhand des Partnerlisten-Status, bevor sie
11 * startCall() ueberhaupt aufruft (siehe "call:friend-required"-Event dort).
12 *
13 * Events: 'state-changed' (detail: {state, partnerId}), 'incoming-call'
14 * (detail: {partnerId, username, cacheBreak, video}), 'remote-media-state-changed'
15 * (detail: {kind:'microphone'|'camera', muted}), 'local-stream',
16 * 'remote-stream', 'error', 'call-missed' (detail: {partnerId, video} - eigener
17 * ausgehender Anruf wurde nie angenommen, nur vom Anrufer ausgeloest).
18 */
19import { fetchTurnSettings } from './api.js';
20
21const RING_TIMEOUT_MS = 30000;
22// Deckel fuer die Video-Sendebitrate und Schwelle (ms Wartezeit pro Paket) fuer den Sendestau-Eingriff
23const VIDEO_MAX_BITRATE = 800000;
24const SEND_BACKLOG_MS = 500;
25
26export const CallState = Object.freeze({
27    IDLE: 'idle',
28    DIALING: 'dialing', // wir haben "ring" gesendet, warten auf Annahme
29    RINGING: 'ringing', // wir wurden angerufen, warten auf lokale Entscheidung
30    CONNECTING: 'connecting', // Angenommen, SDP-Austausch laeuft
31    ACTIVE: 'active',
32});
33
34export class WebrtcSession extends EventTarget {
35    #myId;
36    #sendSignal;
37    #state = CallState.IDLE;
38    #partnerId = null;
39    #peerConnection = null;
40    #localStream = null;
41    #remoteStream = null;
42    #ringTimer = null;
43    // Trickle-ICE-Kandidaten treffen ueber einen vom Offer/Answer-Austausch
44    // unabhaengigen Signalisierungspfad ein und koennen daher ankommen,
45    // bevor #peerConnection ueberhaupt existiert oder bevor
46    // setRemoteDescription() gelaufen ist (RTCPeerConnection.addIceCandidate()
47    // wirft in dem Fall) - besonders wahrscheinlich, wenn eine Seite ueber ein
48    // langsameres/hoeher-latentes Netz (z.B. Mobilfunk) verbunden ist. Ohne
49    // Warteschlange gehen solche Kandidaten unwiederbringlich verloren, was
50    // je nach Anruf-Richtung (wer Offer- bzw. Answer-Ersteller ist, siehe
51    // answerCall()/'offer'-Handler) zu inkonsistent fehlschlagenden Anrufen
52    // fuehrt: SDP-Austausch laeuft durch (State wird ACTIVE), aber ohne die
53    // verworfenen Kandidaten findet ICE keinen funktionierenden Pfad, der
54    // Call haengt ein paar Sekunden und wird dann durch den ICE-Timeout
55    // (connectionstatechange -> 'failed' -> hangUp()) wieder beendet.
56    #pendingCandidates = [];
57    // Timer fuer die Gnadenfrist bei connectionState 'disconnected' - siehe
58    // #ensurePeerConnection()/connectionstatechange-Handler unten.
59    #disconnectGraceTimer = null;
60    // Ob innerhalb der aktuellen 'disconnected'-Episode bereits ein ICE-
61    // Restart versucht wurde (siehe #onDisconnectGraceElapsed()) - verhindert
62    // endlose Wiederholversuche, wird bei jeder Erholung/neuen Episode
63    // zurueckgesetzt.
64    #restartAttemptedForCurrentDisconnect = false;
65    // Feste Rollenverteilung fuer die Dauer des Calls (wie beim initialen
66    // Verbindungsaufbau: wer angenommen hat, erzeugt IMMER das Offer, auch
67    // bei einem spaeteren ICE-Restart) - vermeidet "Glare" (beide Seiten
68    // erzeugen gleichzeitig ein Offer), siehe #requestIceRestart() unten.
69    #isOfferer = false;
70    // Ueberwachung des Video-Sendestaus, siehe #checkSendBacklog() - bei
71    // instabilem Uplink (Mobilfunk-Hotspot) schaetzt Chrome die Bandbreite
72    // sehr niedrig, die Video-Pakete stauen sich in der Sende-Warteschlange
73    // (im Dump bis 1,8s) und kommen beim Gegenueber verzoegert/gebuendelt an.
74    #sendMonitorTimer = null;
75    #sendPrev = null;
76    #sendBacklogCount = 0;
77    #lastBacklogFlush = 0;
78    // false = Videoanruf ohne eigene Kamera angenommen ("Ohne Video annehmen")
79    #sendVideo = true;
80    // true = Video angefordert/erwartet, false = reiner Audio-Anruf (docs/chat-todo.md
81    // §22.6) - default true statt eines dritten "unbekannt"-Zustands, da vor dem
82    // ersten 'ring'/startCall() ohnehin niemand danach fragt.
83    #video = true;
84
85    constructor({ myId, sendSignal }) {
86        super();
87        this.#myId = myId;
88        this.#sendSignal = sendSignal;
89    }
90
91    get state() {
92        return this.#state;
93    }
94
95    get localStream() {
96        return this.#localStream;
97    }
98
99    get remoteStream() {
100        return this.#remoteStream;
101    }
102
103    /** false = reiner Audio-Anruf (docs/chat-todo.md §22.6) - nur waehrend eines
104     * laufenden Calls aussagekraeftig, siehe #video-Feld oben. */
105    get isVideoCall() {
106        return this.#video;
107    }
108
109    /** false = Videoanruf, den wir "ohne Video" angenommen haben (nur
110     * Empfang, keine eigene Kamera) bzw. reiner Audio-Anruf. */
111    get isSendingVideo() {
112        return this.#video && this.#sendVideo;
113    }
114
115    #setState(state) {
116        this.#state = state;
117        this.dispatchEvent(new CustomEvent('state-changed', { detail: { state, partnerId: this.#partnerId } }));
118    }
119
120    /** Anruf starten (Zielperson muss bereits als Freund geprueft sein - siehe Modul-Kommentar).
121     * video:false = reiner Audio-Anruf (docs/chat-todo.md §22.6, Telefon- statt
122     * Video-Button) - fordert kein Kamerabild an und zeigt beim Gegenueber
123     * ebenfalls keins an. */
124    startCall(partnerId, { username, cacheBreak, video = true } = {}) {
125        if (this.#state !== CallState.IDLE) {
126            return;
127        }
128        this.#video = video;
129        this.#partnerId = partnerId;
130        this.#setState(CallState.DIALING);
131        this.#sendSignal(partnerId, { type: 'ring', target: partnerId, sender: this.#myId, cacheBreak, username, video });
132        this.#ringTimer = setTimeout(() => {
133            if (this.#state === CallState.DIALING) {
134                this.hangUp();
135            }
136        }, RING_TIMEOUT_MS);
137
138        // Eigene Peer-Connection/Medienzugriff/ICE-Sammlung schon WAEHREND
139        // des Klingelns starten, statt (wie bisher) erst nach Erhalt des
140        // Offers vom Angerufenen (siehe 'offer'-Fall unten). Der Angerufene
141        // startet seinerseits schon bei answerCall() sofort - blieb der
142        // Anrufer bisher rein passiv, hatte GENAU die Seite am wenigsten
143        // Zeit fuers ICE-Gathering (STUN/TURN-Roundtrips), die haeufig
144        // ohnehin schon die langsamere/hoeher-latente ist (z.B. Mobilfunk-
145        // Hotspot) - live nachgestellt: Anruf richtung "schneller Angerufener,
146        // langsamerer Anrufer, der zusaetzlich noch passiv wartet" schlug
147        // wiederholt fehl, die umgekehrte Richtung nicht. Eigene Kandidaten,
148        // die dadurch schon vor der Annahme beim Angerufenen ankommen,
149        // werden dort per #pendingCandidates zwischengespeichert (siehe
150        // dort) bis dessen eigene Remote-Description gesetzt ist -
151        // #ensurePeerConnection() selbst ist ein No-Op, falls beim
152        // spaeteren Empfang des Offers bereits eine Peer-Connection
153        // existiert.
154        this.#ensurePeerConnection().catch((error) => {
155            this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
156            this.hangUp();
157        });
158    }
159
160    /** Vom Gegenueber wird ein Anruf angenommen -> wir (die Angerufenen) erzeugen das Offer. */
161    async answerCall({ withVideo = true } = {}) {
162        if (this.#state !== CallState.RINGING) {
163            return;
164        }
165        clearTimeout(this.#ringTimer);
166        this.#sendVideo = withVideo;
167        this.#setState(CallState.CONNECTING);
168        this.#isOfferer = true;
169        try {
170            await this.#ensurePeerConnection();
171            const offer = await this.#peerConnection.createOffer();
172            await this.#peerConnection.setLocalDescription(offer);
173            this.#sendSignal(this.#partnerId, { type: 'offer', target: this.#partnerId, offer });
174        } catch (error) {
175            this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
176            this.hangUp();
177        }
178    }
179
180    rejectCall() {
181        clearTimeout(this.#ringTimer);
182        if (this.#partnerId !== null) {
183            this.#sendSignal(this.#partnerId, { type: 'reject', target: this.#partnerId });
184        }
185        this.#reset();
186    }
187
188    hangUp() {
189        clearTimeout(this.#ringTimer);
190        if (this.#partnerId !== null && this.#state !== CallState.IDLE) {
191            this.#sendSignal(this.#partnerId, { type: 'bye', target: this.#partnerId });
192        }
193        this.#reset();
194    }
195
196    setMicrophoneMuted(muted) {
197        this.#localStream?.getAudioTracks().forEach((track) => { track.enabled = !muted; });
198        if (this.#partnerId !== null) {
199            this.#sendSignal(this.#partnerId, { type: 'microphone-state', target: this.#partnerId, state: muted });
200        }
201    }
202
203    setCameraDisabled(disabled) {
204        this.#localStream?.getVideoTracks().forEach((track) => { track.enabled = !disabled; });
205        if (this.#partnerId !== null) {
206            this.#sendSignal(this.#partnerId, { type: 'camera-state', target: this.#partnerId, state: disabled });
207        }
208    }
209
210    /** Eine andere Session desselben Users (anderer Browser/Tab/Geraet) hat den
211     * Anruf angenommen oder abgelehnt (Server: wrtc_handled_elsewhere) - hier
212     * nur noch das Klingeln beenden. */
213    handleHandledElsewhere(callerId) {
214        if (this.#state === CallState.RINGING && this.#partnerId === callerId) {
215            this.#reset();
216        }
217    }
218
219    /** Vom ChatClient bei jedem eingehenden wrtc-Envelope aufzurufen. */
220    async handleSignal(senderId, payload) {
221        switch (payload.type) {
222            case 'ring':
223                if (this.#state !== CallState.IDLE) {
224                    // bereits in einem Call/Ring-Vorgang - schlicht ablehnen
225                    this.#sendSignal(senderId, { type: 'reject', target: senderId });
226                    return;
227                }
228                // payload.video fehlt bei Gegenstellen mit aelterem Stand (vor
229                // §22.6) nie einfach "undefined statt false" - dann wie bisher
230                // als Videoanruf behandeln (!== false statt === true).
231                this.#video = payload.video !== false;
232                this.#partnerId = senderId;
233                this.#setState(CallState.RINGING);
234                this.#ringTimer = setTimeout(() => {
235                    if (this.#state === CallState.RINGING) {
236                        this.rejectCall();
237                    }
238                }, RING_TIMEOUT_MS);
239                this.dispatchEvent(new CustomEvent('incoming-call', {
240                    detail: { partnerId: senderId, username: payload.username, cacheBreak: payload.cacheBreak, video: this.#video },
241                }));
242                break;
243
244            case 'offer':
245                if (senderId !== this.#partnerId) return;
246                clearTimeout(this.#ringTimer);
247                this.#setState(CallState.CONNECTING);
248                // Wer ein Offer EMPFAENGT, ist der SDP-Answerer - gilt genauso
249                // fuer ein spaeteres Restart-Offer (siehe #requestIceRestart()),
250                // die Rollenverteilung bleibt fuer die ganze Call-Dauer fest.
251                this.#isOfferer = false;
252                try {
253                    await this.#ensurePeerConnection();
254                    await this.#peerConnection.setRemoteDescription(new RTCSessionDescription(payload.offer));
255                    await this.#flushPendingCandidates();
256                    const answer = await this.#peerConnection.createAnswer();
257                    await this.#peerConnection.setLocalDescription(answer);
258                    this.#sendSignal(senderId, { type: 'answer', target: senderId, answer });
259                    this.#setState(CallState.ACTIVE);
260                } catch (error) {
261                    this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
262                    this.hangUp();
263                }
264                break;
265
266            case 'answer':
267                if (senderId !== this.#partnerId || !this.#peerConnection) return;
268                await this.#peerConnection.setRemoteDescription(new RTCSessionDescription(payload.answer));
269                await this.#flushPendingCandidates();
270                this.#setState(CallState.ACTIVE);
271                break;
272
273            case 'candidate':
274                if (senderId !== this.#partnerId) return;
275                // Solange die Peer-Connection noch nicht existiert oder die
276                // Remote-Description noch nicht gesetzt ist, in die
277                // Warteschlange stellen statt zu verwerfen (siehe
278                // #pendingCandidates oben) - wird nach setRemoteDescription()
279                // im 'offer'/'answer'-Zweig durch #flushPendingCandidates()
280                // nachgeholt.
281                if (!this.#peerConnection || !this.#peerConnection.remoteDescription) {
282                    this.#pendingCandidates.push(payload.candidate);
283                    return;
284                }
285                try {
286                    await this.#peerConnection.addIceCandidate(new RTCIceCandidate(payload.candidate));
287                } catch (e) {
288                    // Einzelner kaputter/veralteter Kandidat - restliche Verbindung
289                    // nicht deswegen abbrechen.
290                }
291                break;
292
293            case 'reject':
294                if (senderId === this.#partnerId) {
295                    this.#reset();
296                }
297                break;
298
299            case 'bye':
300                if (senderId === this.#partnerId) {
301                    this.#reset();
302                }
303                break;
304
305            // Von der Gegenseite geschickt, wenn SIE eine dauerhaft gestoerte
306            // Verbindung erkennt, selbst aber nicht Offerer ist und deshalb
307            // kein eigenes Offer erzeugen darf (siehe #requestIceRestart()) -
308            // nur der Offerer fuehrt den tatsaechlichen Restart aus.
309            case 'restart-ice-request':
310                if (senderId === this.#partnerId && this.#isOfferer) {
311                    this.#performIceRestart();
312                }
313                break;
314
315            case 'microphone-state':
316                if (senderId === this.#partnerId) {
317                    this.dispatchEvent(new CustomEvent('remote-media-state-changed', {
318                        detail: { kind: 'microphone', muted: payload.state === true },
319                    }));
320                }
321                break;
322
323            case 'camera-state':
324                if (senderId === this.#partnerId) {
325                    this.dispatchEvent(new CustomEvent('remote-media-state-changed', {
326                        detail: { kind: 'camera', muted: payload.state === true },
327                    }));
328                }
329                break;
330        }
331    }
332
333    /** Traegt Kandidaten nach, die eintrafen bevor die Remote-Description gesetzt war
334     * (siehe #pendingCandidates oben) - nach dem jeweiligen setRemoteDescription()-Aufruf
335     * im 'offer'/'answer'-Zweig aufzurufen. */
336    async #flushPendingCandidates() {
337        const candidates = this.#pendingCandidates;
338        this.#pendingCandidates = [];
339        for (const candidate of candidates) {
340            try {
341                await this.#peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
342            } catch (e) {
343                // Einzelner kaputter/veralteter Kandidat - restliche Verbindung
344                // nicht deswegen abbrechen.
345            }
346        }
347    }
348
349    async #ensurePeerConnection() {
350        if (this.#peerConnection) {
351            return;
352        }
353        let iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
354        try {
355            const turnSettings = await fetchTurnSettings();
356            iceServers = [{
357                urls: turnSettings.urls,
358                username: turnSettings.username,
359                credential: turnSettings.credential,
360            }];
361        } catch (e) {
362            // TURN-Abruf fehlgeschlagen - mit dem STUN-Fallback weitermachen statt den Call abzubrechen
363        }
364
365        this.#localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: this.#video && this.#sendVideo });
366        this.dispatchEvent(new CustomEvent('local-stream', { detail: { stream: this.#localStream } }));
367
368        const peerConnection = new RTCPeerConnection({ iceServers });
369        this.#localStream.getTracks().forEach((track) => peerConnection.addTrack(track, this.#localStream));
370        if (this.#video && !this.#sendVideo) {
371            // Video des Gegenuebers trotzdem empfangen
372            peerConnection.addTransceiver('video', { direction: 'recvonly' });
373        }
374
375        peerConnection.addEventListener('icecandidate', (event) => {
376            if (event.candidate && this.#partnerId !== null) {
377                this.#sendSignal(this.#partnerId, { type: 'candidate', target: this.#partnerId, candidate: event.candidate });
378            }
379        });
380
381        peerConnection.addEventListener('track', (event) => {
382            this.#remoteStream = event.streams[0];
383            this.dispatchEvent(new CustomEvent('remote-stream', { detail: { stream: this.#remoteStream } }));
384        });
385
386        peerConnection.addEventListener('connectionstatechange', () => {
387            const state = peerConnection.connectionState;
388
389            if (state === 'failed' || state === 'closed') {
390                clearTimeout(this.#disconnectGraceTimer);
391                this.hangUp();
392                return;
393            }
394
395            if (state === 'disconnected') {
396                // 'disconnected' ist laut WebRTC-Spezifikation kein
397                // endgueltiger Fehlerzustand - nur "aktuell kein
398                // funktionierender Transport", z.B. durch einen kurzen
399                // Netzwerk-Haenger oder Paketverlust. Der ICE-Agent versucht
400                // selbststaendig, DASSELBE Kandidatenpaar wiederherzustellen,
401                // und schafft das bei einem kurzen Wackler oft innerhalb
402                // weniger Sekunden - ohne Gnadenfrist wurde der Call beim
403                // ALLERERSTEN kurzen Aussetzer sofort beendet (Nutzer-
404                // Beobachtung: kurz Tonuebertragung, dann Abbruch). Reicht
405                // das nicht (siehe #onDisconnectGraceElapsed() - echtes,
406                // per RTCStats-Dump verifiziertes NAT-Rebinding auf
407                // Mobilfunkverbindungen: das ALTE Kandidatenpaar bleibt dabei
408                // dauerhaft tot, kein Abwarten der Welt bringt es zurueck),
409                // wird dort zusaetzlich ein ICE-Restart versucht, bevor
410                // wirklich aufgelegt wird.
411                clearTimeout(this.#disconnectGraceTimer);
412                this.#disconnectGraceTimer = setTimeout(() => this.#onDisconnectGraceElapsed(), 8000);
413                return;
414            }
415
416            // Jeder andere Zustand (u.a. 'connected'/'completed') zaehlt als
417            // Erholung - eine noch laufende Gnadenfrist ist hinfaellig, die
418            // naechste 'disconnected'-Episode (z.B. ein erneutes Rebinding)
419            // bekommt wieder einen frischen Restart-Versuch.
420            clearTimeout(this.#disconnectGraceTimer);
421            this.#restartAttemptedForCurrentDisconnect = false;
422            if (state === 'connected') {
423                if (this.#video && !this.#sendVideo && this.#partnerId !== null) {
424                    // Gegenueber zeigt "Kamera aus" statt eines leeren Bildes
425                    this.#sendSignal(this.#partnerId, { type: 'camera-state', target: this.#partnerId, state: true });
426                }
427                this.#applyVideoSenderLimits();
428                this.#startSendMonitor();
429            }
430        });
431
432        this.#peerConnection = peerConnection;
433    }
434
435    /** Nach der ersten Gnadenfrist bei anhaltendem 'disconnected' - erster
436     * Aufruf versucht einen ICE-Restart (siehe #requestIceRestart()) und gibt
437     * dem eine zweite, kuerzere Gnadenfrist; hilft das auch nicht, wird
438     * tatsaechlich aufgelegt. */
439    #onDisconnectGraceElapsed() {
440        if (this.#peerConnection?.connectionState !== 'disconnected') {
441            return;
442        }
443
444        if (!this.#restartAttemptedForCurrentDisconnect) {
445            this.#restartAttemptedForCurrentDisconnect = true;
446            this.#requestIceRestart();
447            this.#disconnectGraceTimer = setTimeout(() => this.#onDisconnectGraceElapsed(), 8000);
448            return;
449        }
450
451        this.hangUp();
452    }
453
454    /** Stoesst eine ICE-Neuverhandlung an (frische Kandidaten/ICE-Credentials
455     * ueber die bestehende Verbindung, ohne den Call zu beenden) - fuer ein
456     * per NAT-Rebinding dauerhaft ungueltig gewordenes Kandidatenpaar, das
457     * sich nie von selbst erholen wuerde. Nur der urspruengliche SDP-Offerer
458     * darf ein neues Offer erzeugen (feste Rollenverteilung wie beim
459     * initialen Verbindungsaufbau, siehe #isOfferer) - sonst wuerden bei
460     * einem beidseitig erkannten Abbruch ggf. beide Seiten gleichzeitig ein
461     * Offer erzeugen ("Glare"). Ist man selbst nicht Offerer, wird die
462     * Gegenseite per eigenem Signal darum gebeten. */
463    #requestIceRestart() {
464        if (this.#partnerId === null) return;
465        if (this.#isOfferer) {
466            this.#performIceRestart();
467        } else {
468            this.#sendSignal(this.#partnerId, { type: 'restart-ice-request', target: this.#partnerId });
469        }
470    }
471
472    async #performIceRestart() {
473        if (!this.#peerConnection || this.#partnerId === null) return;
474        try {
475            this.#peerConnection.restartIce();
476            const offer = await this.#peerConnection.createOffer();
477            await this.#peerConnection.setLocalDescription(offer);
478            this.#sendSignal(this.#partnerId, { type: 'offer', target: this.#partnerId, offer });
479        } catch (error) {
480            // Best effort - schlaegt der Neuaufbau fehl, greift die zweite
481            // Gnadenfrist in #onDisconnectGraceElapsed() ohnehin und legt
482            // danach regulaer auf.
483        }
484    }
485
486    /** Deckelt die Video-Bitrate und bevorzugt Bildrate vor Aufloesung: ein
487     * zu hohes Ziel bei schlechtem Uplink fuellt nur die Sende-Warteschlange. */
488    async #applyVideoSenderLimits() {
489        const sender = this.#peerConnection?.getSenders().find((candidate) => candidate.track?.kind === 'video');
490        if (!sender) return;
491        try {
492            const parameters = sender.getParameters();
493            if (!parameters.encodings || parameters.encodings.length === 0) return;
494            parameters.encodings[0].maxBitrate = VIDEO_MAX_BITRATE;
495            parameters.degradationPreference = 'maintain-framerate';
496            await sender.setParameters(parameters);
497        } catch (e) {
498            // Best effort - ohne Deckel funktioniert der Call trotzdem
499        }
500    }
501
502    #startSendMonitor() {
503        if (this.#sendMonitorTimer !== null) return;
504        this.#sendMonitorTimer = setInterval(() => this.#checkSendBacklog(), 2000);
505    }
506
507    #stopSendMonitor() {
508        clearInterval(this.#sendMonitorTimer);
509        this.#sendMonitorTimer = null;
510        this.#sendPrev = null;
511        this.#sendBacklogCount = 0;
512    }
513
514    /** Misst alle 2s die durchschnittliche Wartezeit der zuletzt gesendeten
515     * Video-Pakete in der Sende-Warteschlange (Delta von totalPacketSendDelay
516     * / packetsSent). Bleibt sie ueber ~6s > 500ms, wird der Video-Encoder
517     * kurz abgeschaltet und wieder angeschaltet: die veralteten Frames in der
518     * Warteschlange fallen weg, das Gegenueber sieht wieder ein aktuelles
519     * Bild. Mindestens 15s Abstand zwischen zwei Eingriffen. */
520    async #checkSendBacklog() {
521        const peerConnection = this.#peerConnection;
522        const sender = peerConnection?.getSenders().find((candidate) => candidate.track?.kind === 'video');
523        if (!sender) return;
524
525        let report = null;
526        try {
527            (await sender.getStats()).forEach((entry) => {
528                if (entry.type === 'outbound-rtp' && entry.kind === 'video') report = entry;
529            });
530        } catch (e) {
531            return;
532        }
533        if (!report || report.totalPacketSendDelay === undefined) return;
534
535        const previous = this.#sendPrev;
536        this.#sendPrev = { delay: report.totalPacketSendDelay, packets: report.packetsSent };
537        if (!previous) return;
538        const packets = report.packetsSent - previous.packets;
539        if (packets <= 0) return;
540        const ms = (report.totalPacketSendDelay - previous.delay) / packets * 1000;
541
542        this.#sendBacklogCount = ms > SEND_BACKLOG_MS ? this.#sendBacklogCount + 1 : 0;
543        if (this.#sendBacklogCount < 3 || Date.now() - this.#lastBacklogFlush < 15000) return;
544
545        this.#sendBacklogCount = 0;
546        this.#lastBacklogFlush = Date.now();
547        console.info(`[webrtc] Video-Sendestau ${Math.round(ms)}ms - Encoder wird kurz neu gestartet`);
548        try {
549            const parameters = sender.getParameters();
550            if (!parameters.encodings || parameters.encodings.length === 0) return;
551            parameters.encodings[0].active = false;
552            await sender.setParameters(parameters);
553            setTimeout(async () => {
554                try {
555                    const again = sender.getParameters();
556                    again.encodings[0].active = true;
557                    await sender.setParameters(again);
558                } catch (e) {
559                    // Sender evtl. schon geschlossen (Call beendet)
560                }
561            }, 200);
562        } catch (e) {
563            // Best effort
564        }
565    }
566
567    #reset() {
568        // War der Zustand vor dem Reset DIALING, wurde der ausgehende Anruf nie
569        // angenommen (Timeout, expliziter Reject des Gegenuebers oder eigenes
570        // Auflegen waehrend des Klingelns) - aus Sicht des Anrufers ein
571        // verpasster Anruf. Nur diese Seite meldet das (nicht der
572        // Angerufene), damit nicht doppelt gemeldet wird - siehe
573        // "call:missed"-Verwendung in chat_client.js/chat_conversation_controller.js.
574        const wasDialing = this.#state === CallState.DIALING;
575        const missedPartnerId = this.#partnerId;
576        const missedVideo = this.#video;
577
578        clearTimeout(this.#ringTimer);
579        clearTimeout(this.#disconnectGraceTimer);
580        this.#stopSendMonitor();
581        this.#peerConnection?.close();
582        this.#peerConnection = null;
583        this.#localStream?.getTracks().forEach((track) => track.stop());
584        this.#localStream = null;
585        this.#remoteStream = null;
586        this.#partnerId = null;
587        this.#video = true;
588        this.#sendVideo = true;
589        this.#pendingCandidates = [];
590        this.#restartAttemptedForCurrentDisconnect = false;
591        this.#isOfferer = false;
592        this.#setState(CallState.IDLE);
593
594        if (wasDialing && missedPartnerId !== null) {
595            this.dispatchEvent(new CustomEvent('call-missed', { detail: { partnerId: missedPartnerId, video: missedVideo } }));
596        }
597    }
598}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.