Damit Anrufe auch in Netzen zustande kommen, die keine direkte Verbindung zulassen (15-25 % der Faelle). coturn ist installiert, steht aber still, bis die Konfiguration liegt -- ein coturn mit Werkseinstellung ist ein offenes Relais. KEIN FESTES PASSWORT. Es laege dauerhaft im Browser jedes Team-Mitglieds und liesse sich nie entziehen. Der Server rechnet stattdessen bei jeder Abfrage Zugangsdaten, die nach zwoelf Stunden verfallen (server/workspace-turn.js, coturns `use-auth-secret`). Das Geheimnis liegt in einer Datei, nicht in der Datenbank: einstellung- Setzen() schreibt Werte ins Protokoll, und coturn braucht denselben Wert ohnehin in /etc. Die Oberflaeche frischt die Daten vor jedem Anruf auf. Der Chat ist eine App, die tagelang offen bleibt -- wer nur beim Laden holt, telefoniert am zweiten Tag ohne Vermittlung, und es faellt nicht auf: Es scheitern nur die, die sie gebraucht haetten. DIE WICHTIGSTE ZEILE DER KONFIGURATION ist die Sperrliste. Gemessen: dreizehn Dienste lauschen auf diesem Server nur oertlich, darunter Caddys Verwaltung auf 127.0.0.1:2019 -- wer sie erreicht, kann jede Website umleiten. Ohne Sperrliste waere der Vermittlungsserver die Tuer dorthin, und die Anfrage saehe fuer Caddy aus wie von localhost. Geprueft: pruef-anruf.mjs 51 -> 73 Pruefungen. Gegenprobe (Geheimnis als Passwort ausliefern + fremde Zugangsdaten ueberschreiben) macht genau 5 rot, darunter "das Geheimnis steht NIRGENDS in der Antwort". tools/turn-probelauf.sh beweist am echten coturn, was ein fester Vergleichswert nicht kann: dass coturn unsere Rechnung akzeptiert. Beide Seiten koennten sonst konsequent falsch rechnen und jede Pruefung waere gruen. Seine eigene Gegenprobe war zweimal zu Recht rot -- der erste Aufbau mass wegen `-y` gar nicht das Ziel, das er zu messen behauptete, sondern coturns eingebauten Loopback-Schutz. Co-Authored-By: Claude Opus 5 <[email protected]>
580 lines
22 KiB
JavaScript
580 lines
22 KiB
JavaScript
/* =====================================================================
|
||
TELEFONIEREN IM CHAT (18.09.2026)
|
||
|
||
Filipe: „ich will dass du das nimmst was man selbst in die app
|
||
reinsetzten kann, nicht meinen pc belastet und trotzdem vielleicht
|
||
in gruppe, mit video oder einzelnd."
|
||
|
||
DER TON GEHT NICHT ÜBER DEN SERVER. Er geht direkt von Browser zu
|
||
Browser. Der Server reicht nur die Verbindungsdaten weiter -- ein
|
||
paar Kilobyte je Anruf, danach ist er aus dem Spiel. Das ist die
|
||
Antwort auf „nicht meinen PC belasten": Bei zwei Teilnehmern kodiert
|
||
jedes Gerät genau einen Strom und dekodiert einen, wie bei jedem
|
||
gewöhnlichen Videoanruf.
|
||
|
||
---------------------------------------------------------------------
|
||
WARUM EINE VERBINDUNG JE GEGENÜBER
|
||
|
||
Bei drei Leuten baut jeder mit jedem eine eigene Verbindung auf --
|
||
`verbindungen` ist deshalb eine Karte von Personennummer auf
|
||
RTCPeerConnection und keine einzelne Variable. Mit einer einzelnen
|
||
ginge zu zweit alles gut, und beim dritten Teilnehmer wäre die erste
|
||
Verbindung stillschweigend überschrieben: Man sieht sich zu dritt im
|
||
Kasten und hört nur einen.
|
||
|
||
---------------------------------------------------------------------
|
||
WER RUFT AN, WER ANTWORTET
|
||
|
||
Bei WebRTC muss genau EINER das Angebot machen, sonst reden beide
|
||
gleichzeitig („glare"): Zwei Angebote heben sich auf, und die
|
||
Verbindung kommt nie zustande.
|
||
|
||
DIE REGEL IST DIE KLEINERE NUMMER. Wer die niedrigere Personennummer
|
||
hat, macht das Angebot; der andere wartet. Das ist willkürlich, aber
|
||
es ist auf beiden Seiten OHNE ABSPRACHE dieselbe Entscheidung -- und
|
||
genau das braucht man hier. Eine Absprache darüber wäre wieder eine
|
||
Nachricht, die verlorengehen kann.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
if (!window.RTCPeerConnection) return; // sehr alter Browser
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/* Was der Server über die Verbindungsadressen sagt. */
|
||
let adressen = [];
|
||
let nurGleichesNetz = false;
|
||
let gruppeMax = 4;
|
||
|
||
/* Der laufende Anruf. `null`, wenn keiner läuft. */
|
||
let anruf = null; // { raumId, video, verbindungen: Map, stroeme: Map, eigener }
|
||
|
||
/* =====================================================================
|
||
SIGNALE, DIE ZU FRÜH KOMMEN (18.09.2026)
|
||
|
||
GEFUNDEN IM DREIER-TEST, und es hätte jeden Anruf getroffen:
|
||
|
||
Wer rangeht, meldet dem Server „dabei". Der Anrufer bekommt das
|
||
sofort und schickt sein Angebot los. Der Angerufene braucht aber
|
||
noch ein bis zwei Sekunden, bis das Mikrofon offen ist -- und in
|
||
dieser Zeit ist `anruf` hier noch `null`.
|
||
|
||
Vorher stand in `signalVerarbeiten` ein `if (!anruf) return;`. Das
|
||
Angebot wurde damit STILL WEGGEWORFEN. Danach kommt keins mehr:
|
||
Der Anrufer hat seins geschickt und wartet. Beide Seiten zeigten
|
||
„Verbindung …", und es passierte nie wieder etwas.
|
||
|
||
In der Spur war es unübersehbar, sobald man hinsah:
|
||
|
||
ereignis:signal inhalt=angebot <- kommt an
|
||
ereignis:signal inhalt=weg
|
||
verbindung angelegt <- erst JETZT
|
||
|
||
Was zu früh kommt, wird deshalb aufgehoben statt verworfen -- und
|
||
abgearbeitet, sobald das Mikrofon steht. Ein Signal ist keine
|
||
Nachricht, die man wiederholen kann; wer es wegwirft, hat den
|
||
Anruf verloren. */
|
||
const wartend = []; // { von, inhalt }
|
||
|
||
async function wartendeAbarbeiten() {
|
||
const liste = wartend.splice(0, wartend.length);
|
||
for (const w of liste) {
|
||
/* Was zu einem anderen Gespräch gehört, wird verworfen -- und
|
||
zwar hier, wo `anruf` bekannt ist. */
|
||
if (!anruf || (w.raumId && w.raumId !== anruf.raumId)) continue;
|
||
/* NACHEINANDER, NICHT GLEICHZEITIG. Ein Verbindungsweg, der vor
|
||
der Beschreibung ankommt, wird abgewiesen -- und beim
|
||
parallelen Abarbeiten gewinnt oft der Weg. Die Reihenfolge,
|
||
in der sie eingetroffen sind, ist die richtige. */
|
||
await signalVerarbeiten(w.von, w.inhalt);
|
||
}
|
||
}
|
||
|
||
let ichId = null;
|
||
|
||
const el = (art, klasse, text) => {
|
||
const k = document.createElement(art);
|
||
if (klasse) k.className = klasse;
|
||
if (text != null) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
/* ---------- Der Kasten ------------------------------------------------ */
|
||
|
||
function kastenBauen() {
|
||
let k = $('anruf');
|
||
if (k) return k;
|
||
k = el('div', 'anruf');
|
||
k.id = 'anruf';
|
||
k.hidden = true;
|
||
k.setAttribute('role', 'dialog');
|
||
k.setAttribute('aria-label', 'Anruf');
|
||
k.innerHTML = `
|
||
<div class="anruf__kopf">
|
||
<span class="anruf__lage" id="anruf-lage">Verbindung …</span>
|
||
<span class="anruf__zeit" id="anruf-zeit"></span>
|
||
</div>
|
||
<div class="anruf__leute" id="anruf-leute"></div>
|
||
<div class="anruf__knoepfe">
|
||
<button type="button" class="anruf__knopf anruf__knopf--ton" id="anruf-ton"
|
||
aria-pressed="true">Mikro an</button>
|
||
<button type="button" class="anruf__knopf anruf__knopf--video" id="anruf-video"
|
||
aria-pressed="false" hidden>Kamera an</button>
|
||
<button type="button" class="anruf__knopf anruf__knopf--auf" id="anruf-auflegen">Auflegen</button>
|
||
</div>`;
|
||
document.body.append(k);
|
||
$('anruf-ton').addEventListener('click', tonSchalten);
|
||
$('anruf-video').addEventListener('click', videoSchalten);
|
||
$('anruf-auflegen').addEventListener('click', () => auflegen(true));
|
||
return k;
|
||
}
|
||
|
||
const lage = (text) => { const l = $('anruf-lage'); if (l) l.textContent = text; };
|
||
|
||
/* ---------- Die Gegenstelle ------------------------------------------- */
|
||
|
||
function verbindungFuer(wer) {
|
||
if (anruf.verbindungen.has(wer)) return anruf.verbindungen.get(wer);
|
||
|
||
const v = new RTCPeerConnection({ iceServers: adressen });
|
||
anruf.verbindungen.set(wer, v);
|
||
|
||
/* Den eigenen Ton (und Bild) hineingeben. */
|
||
for (const spur of anruf.eigener.getTracks()) v.addTrack(spur, anruf.eigener);
|
||
|
||
/* DIE VERBINDUNGSWEGE. Jeder gefundene Weg wird einzeln geschickt,
|
||
sobald er da ist -- nicht gesammelt am Ende. Wer wartet, bis alle
|
||
gefunden sind, wartet auf den langsamsten, und das sind mehrere
|
||
Sekunden Stille am Anfang jedes Gesprächs. */
|
||
v.onicecandidate = (e) => {
|
||
if (e.candidate) schicken(wer, { weg: e.candidate });
|
||
};
|
||
|
||
v.ontrack = (e) => {
|
||
anruf.stroeme.set(wer, e.streams[0]);
|
||
leuteZeichnen();
|
||
};
|
||
|
||
v.onconnectionstatechange = () => {
|
||
if (v.connectionState === 'connected') lage('Verbunden');
|
||
/* `failed` heisst: Es kommt nichts mehr. Das gehoert gesagt --
|
||
ein Kasten, der ewig „Verbindung …" zeigt, laesst einen warten,
|
||
wo nichts mehr kommt. */
|
||
if (v.connectionState === 'failed') {
|
||
lage(nurGleichesNetz
|
||
? 'Keine Verbindung – dafür fehlt noch ein Vermittlungsserver.'
|
||
: 'Keine Verbindung.');
|
||
}
|
||
};
|
||
return v;
|
||
}
|
||
|
||
async function angebotMachen(wer) {
|
||
const v = verbindungFuer(wer);
|
||
const angebot = await v.createOffer();
|
||
await v.setLocalDescription(angebot);
|
||
schicken(wer, { angebot: v.localDescription });
|
||
}
|
||
|
||
async function signalVerarbeiten(von, inhalt) {
|
||
/* NOCH NICHT BEREIT? AUFHEBEN, NICHT WEGWERFEN. Siehe oben --
|
||
genau hier ging jedes Angebot verloren, das ankam, während das
|
||
Mikrofon noch geöffnet wurde. */
|
||
if (!anruf) {
|
||
wartend.push({ von, inhalt });
|
||
console.info('[anruf] zu frueh, aufgehoben:', Object.keys(inhalt || {}).join('+'));
|
||
return;
|
||
}
|
||
const v = verbindungFuer(von);
|
||
try {
|
||
if (inhalt.angebot) {
|
||
await v.setRemoteDescription(inhalt.angebot);
|
||
const antwort = await v.createAnswer();
|
||
await v.setLocalDescription(antwort);
|
||
schicken(von, { antwort: v.localDescription });
|
||
} else if (inhalt.antwort) {
|
||
await v.setRemoteDescription(inhalt.antwort);
|
||
} else if (inhalt.weg) {
|
||
/* Ein Weg kann eintreffen, bevor die Beschreibung da ist --
|
||
dann wirft addIceCandidate. Das ist kein Fehler, sondern
|
||
Reihenfolge; er wird beim nächsten Versuch ohnehin wieder
|
||
angeboten. */
|
||
await v.addIceCandidate(inhalt.weg).catch(() => {});
|
||
}
|
||
} catch (f) {
|
||
/* LAUT GENUG, UM ETWAS DAMIT ANZUFANGEN. Ein „Signal:
|
||
irgendwas" sagt nicht, WELCHER Schritt gescheitert ist -- und
|
||
bei WebRTC sind es immer dieselben drei, die in Frage kommen.
|
||
Die Art des Signals gehört dazu, sonst sucht man im
|
||
falschen. */
|
||
console.warn('[anruf] Signal von', von,
|
||
Object.keys(inhalt || {}).join('+'), '->', f?.name, f?.message);
|
||
}
|
||
}
|
||
|
||
const schicken = (an, inhalt) =>
|
||
fetch(`/workspace/api/anruf/${anruf.raumId}/signal`, {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ an, inhalt }),
|
||
}).catch(() => {});
|
||
|
||
/* ---------- Anfangen und Aufhören -------------------------------------- */
|
||
|
||
async function eigenenStromHolen(mitVideo) {
|
||
try {
|
||
return await navigator.mediaDevices.getUserMedia({
|
||
audio: { echoCancellation: true, noiseSuppression: true },
|
||
video: mitVideo ? { width: { ideal: 640 }, height: { ideal: 480 } } : false,
|
||
});
|
||
} catch (f) {
|
||
/* DER HÄUFIGSTE FALL IST NICHT „KAPUTT", SONDERN „ABGELEHNT".
|
||
Ein „ging nicht" wäre hier die unbrauchbarste Auskunft von
|
||
allen -- man sucht dann am Gerät statt an der Berechtigung. */
|
||
if (f?.name === 'NotAllowedError') {
|
||
throw new Error('Der Browser lässt das Mikrofon nicht zu. '
|
||
+ 'Im Schloss-Symbol neben der Adresse freigeben.');
|
||
}
|
||
if (f?.name === 'NotFoundError') {
|
||
throw new Error('Kein Mikrofon gefunden.');
|
||
}
|
||
throw new Error('Das Mikrofon ließ sich nicht öffnen.');
|
||
}
|
||
}
|
||
|
||
async function starten(raumId, mitVideo) {
|
||
if (anruf) return;
|
||
kastenBauen();
|
||
$('anruf').hidden = false;
|
||
lage('Mikrofon …');
|
||
/* Läuft NEBEN der Mikrofonfrage, nicht davor: Das Auffrischen
|
||
kostet sonst Zeit, in der nichts passiert -- und die Frage nach
|
||
dem Mikrofon dauert ohnehin länger als der kleine Abruf. */
|
||
const adressenFertig = adressenAuffrischen();
|
||
let eigener;
|
||
try {
|
||
eigener = await eigenenStromHolen(mitVideo);
|
||
} catch (f) {
|
||
lage(f.message);
|
||
setTimeout(() => { if (!anruf) $('anruf').hidden = true; }, 6000);
|
||
return;
|
||
}
|
||
await adressenFertig;
|
||
anruf = { raumId, video: mitVideo, verbindungen: new Map(),
|
||
stroeme: new Map(), eigener, beginn: Date.now() };
|
||
$('anruf-video').hidden = !mitVideo;
|
||
if (mitVideo) $('anruf-video').setAttribute('aria-pressed', 'true');
|
||
lage('Es klingelt …');
|
||
uhrLaufen();
|
||
|
||
const a = await fetch(`/workspace/api/anruf/${raumId}/start`, {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video: mitVideo }),
|
||
}).catch(() => null);
|
||
if (!a || !a.ok) {
|
||
const d = a ? await a.json().catch(() => ({})) : {};
|
||
lage(d.fehler || 'Der Anruf ließ sich nicht starten.');
|
||
setTimeout(() => auflegen(false), 5000);
|
||
return;
|
||
}
|
||
await wartendeAbarbeiten();
|
||
leuteZeichnen();
|
||
}
|
||
|
||
async function annehmen(raumId) {
|
||
/* Auch hier auffrischen -- wer angerufen WIRD, braucht die
|
||
Vermittlung genauso. Beide Wege einzeln abgesichert, nicht nur
|
||
der, an den man zuerst denkt. */
|
||
const adressenFertig = adressenAuffrischen();
|
||
const a = await fetch(`/workspace/api/anruf/${raumId}/dabei`, {
|
||
method: 'POST', credentials: 'same-origin',
|
||
}).catch(() => null);
|
||
if (!a || !a.ok) {
|
||
const d = a ? await a.json().catch(() => ({})) : {};
|
||
window.anrufMeldung?.(d.fehler || 'Der Anruf ist schon vorbei.');
|
||
klingelnAus();
|
||
return;
|
||
}
|
||
const d = await a.json();
|
||
await adressenFertig;
|
||
kastenBauen();
|
||
$('anruf').hidden = false;
|
||
let eigener;
|
||
try {
|
||
eigener = await eigenenStromHolen(d.video);
|
||
} catch (f) {
|
||
lage(f.message);
|
||
await fetch(`/workspace/api/anruf/${raumId}/ende`,
|
||
{ method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||
setTimeout(() => { $('anruf').hidden = true; }, 6000);
|
||
return;
|
||
}
|
||
anruf = { raumId, video: d.video, verbindungen: new Map(),
|
||
stroeme: new Map(), eigener, beginn: Date.now() };
|
||
$('anruf-video').hidden = !d.video;
|
||
if (d.video) $('anruf-video').setAttribute('aria-pressed', 'true');
|
||
klingelnAus();
|
||
lage('Verbindung …');
|
||
uhrLaufen();
|
||
|
||
/* Mit allen, die schon dabei sind, eine Verbindung aufbauen --
|
||
aber das ANGEBOT macht nur, wer die kleinere Nummer hat. */
|
||
for (const p of d.wer || []) {
|
||
if (p.id === ichId) continue;
|
||
if (ichId < p.id) angebotMachen(p.id);
|
||
else verbindungFuer(p.id);
|
||
}
|
||
/* Und jetzt das, was währenddessen schon eingetroffen ist. */
|
||
await wartendeAbarbeiten();
|
||
leuteZeichnen();
|
||
}
|
||
|
||
async function auflegen(melden) {
|
||
klingelnAus();
|
||
if (!anruf) { const k = $('anruf'); if (k) k.hidden = true; return; }
|
||
const raumId = anruf.raumId;
|
||
for (const v of anruf.verbindungen.values()) { try { v.close(); } catch { /* egal */ } }
|
||
for (const s of anruf.eigener.getTracks()) s.stop();
|
||
anruf = null;
|
||
/* Was noch wartet, gehoert zu einem Gespraech, das es nicht mehr
|
||
gibt. Bliebe es liegen, waere es beim naechsten Anruf das erste,
|
||
was verarbeitet wird -- mit einer Beschreibung, die zu niemandem
|
||
passt. */
|
||
wartend.length = 0;
|
||
const k = $('anruf');
|
||
if (k) k.hidden = true;
|
||
uhrStoppen();
|
||
if (melden) {
|
||
await fetch(`/workspace/api/anruf/${raumId}/ende`,
|
||
{ method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
/* ---------- Was man sieht --------------------------------------------- */
|
||
|
||
function leuteZeichnen() {
|
||
const ziel = $('anruf-leute');
|
||
if (!ziel || !anruf) return;
|
||
ziel.textContent = '';
|
||
/* Das eigene Bild zuerst und stumm -- sich selbst zu hören ist ein
|
||
Echo, das jedes Gespräch unbrauchbar macht. */
|
||
if (anruf.video) {
|
||
const eigen = el('video', 'anruf__bild anruf__bild--ich');
|
||
eigen.autoplay = true; eigen.playsInline = true; eigen.muted = true;
|
||
eigen.srcObject = anruf.eigener;
|
||
ziel.append(eigen);
|
||
}
|
||
for (const [wer, strom] of anruf.stroeme) {
|
||
if (anruf.video) {
|
||
const v = el('video', 'anruf__bild');
|
||
v.autoplay = true; v.playsInline = true;
|
||
v.srcObject = strom;
|
||
ziel.append(v);
|
||
} else {
|
||
const t = el('audio');
|
||
t.autoplay = true;
|
||
t.srcObject = strom;
|
||
ziel.append(t);
|
||
ziel.append(el('span', 'anruf__name', 'Verbunden'));
|
||
}
|
||
void wer;
|
||
}
|
||
}
|
||
|
||
let uhr = null;
|
||
function uhrLaufen() {
|
||
uhrStoppen();
|
||
uhr = setInterval(() => {
|
||
if (!anruf) return uhrStoppen();
|
||
const s = Math.floor((Date.now() - anruf.beginn) / 1000);
|
||
const z = $('anruf-zeit');
|
||
if (z) z.textContent = `${String(Math.floor(s / 60)).padStart(2, '0')}:`
|
||
+ `${String(s % 60).padStart(2, '0')}`;
|
||
}, 1000);
|
||
}
|
||
function uhrStoppen() { if (uhr) { clearInterval(uhr); uhr = null; } }
|
||
|
||
function tonSchalten() {
|
||
if (!anruf) return;
|
||
const an = anruf.eigener.getAudioTracks().some((s) => s.enabled);
|
||
for (const s of anruf.eigener.getAudioTracks()) s.enabled = !an;
|
||
const k = $('anruf-ton');
|
||
k.textContent = an ? 'Mikro aus' : 'Mikro an';
|
||
k.setAttribute('aria-pressed', String(!an));
|
||
}
|
||
|
||
function videoSchalten() {
|
||
if (!anruf) return;
|
||
const an = anruf.eigener.getVideoTracks().some((s) => s.enabled);
|
||
for (const s of anruf.eigener.getVideoTracks()) s.enabled = !an;
|
||
const k = $('anruf-video');
|
||
k.textContent = an ? 'Kamera aus' : 'Kamera an';
|
||
k.setAttribute('aria-pressed', String(!an));
|
||
}
|
||
|
||
/* ---------- Es klingelt ------------------------------------------------ */
|
||
|
||
function klingelnZeigen(raumId, von, mitVideo) {
|
||
klingelnAus();
|
||
const k = el('div', 'anruf-klingelt');
|
||
k.id = 'anruf-klingelt';
|
||
k.setAttribute('role', 'alertdialog');
|
||
k.append(el('p', 'anruf-klingelt__wer',
|
||
`${von?.name || 'Jemand'} ruft an${mitVideo ? ' – mit Video' : ''}`));
|
||
const reihe = el('div', 'anruf-klingelt__knoepfe');
|
||
const ja = el('button', 'anruf-klingelt__ja', 'Rangehen');
|
||
ja.type = 'button';
|
||
ja.addEventListener('click', () => annehmen(raumId));
|
||
const nein = el('button', 'anruf-klingelt__nein', 'Ablehnen');
|
||
nein.type = 'button';
|
||
nein.addEventListener('click', async () => {
|
||
klingelnAus();
|
||
await fetch(`/workspace/api/anruf/${raumId}/ende`,
|
||
{ method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||
});
|
||
reihe.append(ja, nein);
|
||
k.append(reihe);
|
||
document.body.append(k);
|
||
/* Nach der Klingelzeit von selbst weg -- sonst steht die Einladung
|
||
zu einem Gespräch da, an dem längst niemand mehr teilnimmt. */
|
||
setTimeout(klingelnAus, 46000);
|
||
}
|
||
function klingelnAus() { $('anruf-klingelt')?.remove(); }
|
||
|
||
/* ---------- Was vom Server kommt --------------------------------------- */
|
||
|
||
async function ereignis(d) {
|
||
if (!d || typeof d !== 'object') return;
|
||
if (d.art === 'klingelt') {
|
||
if (d.von?.id === ichId) return; // der eigene Anruf
|
||
if (anruf) return; // telefoniert schon
|
||
klingelnZeigen(d.raum_id, d.von, d.video);
|
||
return;
|
||
}
|
||
if (d.art === 'dabei') {
|
||
if (!anruf || d.raum_id !== anruf.raumId) return;
|
||
if (d.person?.id === ichId) return;
|
||
/* NICHT „Verbunden" MELDEN, SOLANGE NICHTS VERBUNDEN IST.
|
||
|
||
Hier stand `lage('Verbunden')` -- gesetzt in dem Moment, in
|
||
dem jemand RANGEHT. Die Verbindung braucht danach aber noch
|
||
Sekunden, und bis dahin behauptete der Kasten etwas, das nicht
|
||
stimmte. Im Dreier-Test war das sofort zu sehen: „Verbunden"
|
||
stand da, und es kam kein Ton.
|
||
|
||
Den richtigen Zeitpunkt kennt nur die Verbindung selbst --
|
||
`onconnectionstatechange` meldet ihn (siehe oben). */
|
||
lage('Baut auf …');
|
||
/* Der Neue: Angebot macht, wer die kleinere Nummer hat. */
|
||
if (ichId < d.person.id) angebotMachen(d.person.id);
|
||
else verbindungFuer(d.person.id);
|
||
return;
|
||
}
|
||
if (d.art === 'signal') {
|
||
if (d.an !== ichId) return;
|
||
/* HIER STAND `if (!anruf ...) return;` -- UND DAS WAR DER FEHLER.
|
||
|
||
Die Warteschlange eine Ebene tiefer nützte nichts, weil das
|
||
Signal hier schon verworfen wurde. Gefunden erst, als die
|
||
Diagnose zeigte, dass `signalVerarbeiten` NIE aufgerufen wird:
|
||
keine einzige Konsolenzeile, obwohl beide Zweige dort etwas
|
||
melden.
|
||
|
||
Der Raum lässt sich ohne `anruf` nicht prüfen -- deshalb
|
||
wandert er mit in die Warteschlange und wird beim Abarbeiten
|
||
geprüft. */
|
||
if (!anruf) {
|
||
wartend.push({ von: d.von, inhalt: d.inhalt, raumId: d.raum_id });
|
||
return;
|
||
}
|
||
if (d.raum_id !== anruf.raumId) return;
|
||
await signalVerarbeiten(d.von, d.inhalt);
|
||
return;
|
||
}
|
||
if (d.art === 'weg' || d.art === 'ende') {
|
||
if (!anruf || d.raum_id !== anruf.raumId) {
|
||
if (d.art === 'ende') klingelnAus();
|
||
return;
|
||
}
|
||
if (d.person?.id === ichId) return;
|
||
const v = anruf.verbindungen.get(d.person?.id);
|
||
if (v) { try { v.close(); } catch { /* egal */ } }
|
||
anruf.verbindungen.delete(d.person?.id);
|
||
anruf.stroeme.delete(d.person?.id);
|
||
leuteZeichnen();
|
||
if (d.art === 'ende' || !anruf.stroeme.size) {
|
||
lage('Gespräch beendet');
|
||
setTimeout(() => auflegen(false), 1200);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- Verbindungsadressen ----------------------------------------
|
||
Die Zugangsdaten zum Vermittlungsserver VERFALLEN (sie werden vom
|
||
Server gerechnet, nicht gespeichert -- siehe server/workspace-turn.js).
|
||
|
||
Diese Seite bleibt als App oft tagelang offen. Wer sie nur beim
|
||
Laden holt, telefoniert am zweiten Tag ohne Vermittlung -- und das
|
||
fällt nicht auf: Es scheitern nur die Anrufe derer, die sie
|
||
gebraucht hätten. Genau dafür ist sie gebaut.
|
||
|
||
Deshalb vor jedem Anruf nachsehen. Der Ablaufzeitpunkt steht im
|
||
Benutzernamen (`<ablauf>:<kennung>`) -- er lässt sich hier ablesen,
|
||
ohne dass der Server ein zusätzliches Feld schicken muss. */
|
||
|
||
/* Fünf Minuten Vorlauf: Ein Anruf, der in 30 Sekunden beginnt, soll
|
||
nicht mit Daten starten, die in 40 ablaufen. */
|
||
const ZUGANG_VORLAUF = 300;
|
||
let adressenGeholt = false;
|
||
|
||
function adressenFrisch() {
|
||
if (!adressenGeholt) return false;
|
||
const jetzt = Math.floor(Date.now() / 1000);
|
||
return adressen.every((a) => {
|
||
const m = /^(\d{10,}):/.exec(a && a.username || '');
|
||
return !m || Number(m[1]) > jetzt + ZUGANG_VORLAUF;
|
||
});
|
||
}
|
||
|
||
async function adressenAuffrischen(erzwingen) {
|
||
if (!erzwingen && adressenFrisch()) return;
|
||
try {
|
||
const a = await fetch('/workspace/api/anruf/adressen', { credentials: 'same-origin' });
|
||
if (a.ok) {
|
||
const d = await a.json();
|
||
adressen = d.adressen || [];
|
||
nurGleichesNetz = !!d.nur_gleiches_netz;
|
||
gruppeMax = d.gruppe_max || 4;
|
||
adressenGeholt = true;
|
||
/* Ein eingetragener Vermittlungsserver, der niemanden
|
||
durchlässt, ist schlimmer als keiner -- er sieht aus, als
|
||
wäre alles eingerichtet. Deshalb steht er in der Konsole. */
|
||
if (d.turn_grund) {
|
||
console.warn('[anruf] Vermittlungsserver ohne Zugangsdaten:', d.turn_grund);
|
||
}
|
||
}
|
||
} catch { /* dann eben ohne -- im selben Netz geht es trotzdem */ }
|
||
}
|
||
|
||
/* ---------- Nach außen -------------------------------------------------- */
|
||
|
||
window.anrufStarten = starten;
|
||
window.anrufEreignis = ereignis;
|
||
window.anrufLaeuft = () => !!anruf;
|
||
window.anrufBereit = () => adressen.length > 0 || nurGleichesNetz;
|
||
|
||
(async () => {
|
||
await adressenAuffrischen();
|
||
try {
|
||
const i = await fetch('/workspace/api/ich', { credentials: 'same-origin' });
|
||
if (i.ok) ichId = (await i.json())?.id ?? null;
|
||
} catch { /* der Chat holt es ohnehin auch */ }
|
||
void gruppeMax;
|
||
})();
|
||
|
||
/* Wer die Seite verlässt, legt auf. Ohne das bleibt er für die
|
||
anderen „dabei", und sie warten auf jemanden, der weg ist. */
|
||
window.addEventListener('pagehide', () => { if (anruf) auflegen(true); });
|
||
})();
|