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.