Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
1422 lines
61 KiB
JavaScript
1422 lines
61 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 = `
|
||
<!-- ============================================================
|
||
NEU GEBAUT AM 19.09.2026.
|
||
|
||
Filipe: „das soll viel besser und geiler aussehen bitte."
|
||
|
||
Was vorher nicht stimmte, der Reihe nach:
|
||
|
||
1. MAN SAH NICHT, MIT WEM. Oben stand „Es klingelt …" und
|
||
sonst nichts. Der wichtigste Satz eines Telefonats ist,
|
||
wen man am Apparat hat.
|
||
|
||
2. „MIKRO AN" WAR ZWEIDEUTIG. Ein Knopf mit dieser
|
||
Aufschrift kann heißen „das Mikro IST an" oder „drück
|
||
hier, um es anzuschalten". Beides ist plausibel, und wer
|
||
falsch rät, schaltet sich stumm, ohne es zu merken.
|
||
Jetzt trägt jeder Knopf ein Zeichen für den Zustand UND
|
||
ein Wort für die Sache — „Mikro" mit durchgestrichenem
|
||
Zeichen heißt aus, fertig.
|
||
|
||
3. DIE GERÄTEAUSWAHL WAR DAS GRÖSSTE ELEMENT im Kasten,
|
||
obwohl man sie einmal im Jahr braucht. Sie liegt jetzt
|
||
hinter einem kleinen Knopf.
|
||
|
||
4. ES GAB KEINE ORDNUNG. Vier gleich aussehende Pillen
|
||
nebeneinander, darunter „Auflegen" in derselben Form.
|
||
Jetzt: die beiden Schalter links, Auflegen rechts und
|
||
als einziger Knopf mit Farbe.
|
||
============================================================= -->
|
||
<div class="anruf__kopf">
|
||
<span class="anruf__puls" id="anruf-puls" aria-hidden="true"></span>
|
||
<span class="anruf__kopftext">
|
||
<span class="anruf__wer" id="anruf-wer">Anruf</span>
|
||
<span class="anruf__lage" id="anruf-lage">Verbindung …</span>
|
||
</span>
|
||
<span class="anruf__zeit" id="anruf-zeit"></span>
|
||
</div>
|
||
<!-- TON AUS IST EIN ZUSTAND, DEN MAN SEHEN MUSS (20.09.2026).
|
||
|
||
Filipe: „es soll ohne lautsprecher anfangen das gespraech und
|
||
nur wenn man drauf klickt soll es laut werden."
|
||
|
||
Wortwoertlich umgesetzt -- aber ein Gespraech, in dem man
|
||
nichts hoert und nicht weiss warum, ist die naechste
|
||
Sackgasse. Deshalb steht es gross da, solange es gilt, und
|
||
verschwindet in dem Moment, in dem man den Ton anmacht.
|
||
Der Balken ist ANTIPPBAR: Wer den Satz liest, will genau das
|
||
tun, was dort steht -- ihn dann erst den passenden Knopf
|
||
suchen zu lassen, waere ein unnoetiger Schritt. -->
|
||
<button type="button" class="anruf__stumm" id="anruf-stumm" hidden>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>
|
||
<path d="M4 4l16 16"/>
|
||
</svg>
|
||
<span>Du hörst noch nichts — tippen, um den Ton anzumachen</span>
|
||
</button>
|
||
<div class="anruf__leute" id="anruf-leute"></div>
|
||
<div class="anruf__knoepfe">
|
||
<button type="button" class="anruf__schalter" id="anruf-ton" aria-pressed="true">
|
||
<svg class="anruf__zeichen" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 3.5a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0v-5a3 3 0 0 1 3-3z"/>
|
||
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>
|
||
<path class="anruf__strich" d="M4 4l16 16"/>
|
||
</svg>
|
||
<span class="anruf__schalter-text">Mikro</span>
|
||
</button>
|
||
<button type="button" class="anruf__schalter" id="anruf-hoeren" aria-pressed="false">
|
||
<svg class="anruf__zeichen" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z"/>
|
||
<path d="M16 9a4.5 4.5 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"/>
|
||
<path class="anruf__strich" d="M4 4l16 16"/>
|
||
</svg>
|
||
<span class="anruf__schalter-text">Ton</span>
|
||
</button>
|
||
<button type="button" class="anruf__schalter" id="anruf-video"
|
||
aria-pressed="false" hidden>
|
||
<svg class="anruf__zeichen" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M3.5 7h11v10h-11z"/><path d="M14.5 11l6-3.5v9l-6-3.5z"/>
|
||
<path class="anruf__strich" d="M4 4l16 16"/>
|
||
</svg>
|
||
<span class="anruf__schalter-text">Kamera</span>
|
||
</button>
|
||
<button type="button" class="anruf__mehr" id="anruf-mehr"
|
||
aria-expanded="false" aria-label="Toneinstellungen">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/>
|
||
<circle cx="12" cy="19" r="1.6"/>
|
||
</svg>
|
||
</button>
|
||
<button type="button" class="anruf__auf" id="anruf-auflegen">
|
||
<svg class="anruf__zeichen" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M3.2 10.2c5-3.4 12.6-3.4 17.6 0l-2 3.2-4.2-1.1-.4-2.6a13 13 0 0 0-4.4 0l-.4 2.6-4.2 1.1z"/>
|
||
</svg>
|
||
<span>Auflegen</span>
|
||
</button>
|
||
</div>
|
||
<!-- Die Geräteauswahl wird erst gebaut, wenn es mehr als ein
|
||
Ausgabegerät gibt (siehe ausgabeAnbieten). Sie liegt hier in
|
||
einer eigenen Zeile, damit sie die Knopfreihe nicht sprengt. -->
|
||
<div class="anruf__mehrzeile" id="anruf-mehrzeile" hidden></div>
|
||
<!-- DER WEG ZUR PROBE (19.09.2026).
|
||
|
||
Filipe: „geht es nicht auf der app?" -- Nein, und das war
|
||
mein Fehler: Ich hatte eine Seite gebaut, die sagt, warum es
|
||
nicht klingelt, und sie war nur ueber die Adresszeile
|
||
erreichbar. In der installierten App gibt es keine.
|
||
|
||
Sie steht deshalb dort, wo man ist, wenn etwas nicht
|
||
stimmt: im Anrufkasten selbst. Klein und ohne Farbe -- wer
|
||
telefoniert, soll sie uebersehen; wer ratlos dasitzt,
|
||
findet sie. -->
|
||
<a class="anruf__probe" id="anruf-probe" href="anruf-probe.html">Klingelt nichts?</a>`;
|
||
document.body.append(k);
|
||
/* Die Toneinstellungen liegen hinter einem kleinen Knopf: Man
|
||
braucht sie einmal im Jahr, und vorher war die Geraeteauswahl
|
||
das groesste Element im Kasten. */
|
||
$('anruf-mehr').hidden = true;
|
||
$('anruf-mehr').addEventListener('click', () => {
|
||
const z = $('anruf-mehrzeile');
|
||
const auf = z.hidden;
|
||
z.hidden = !auf;
|
||
$('anruf-mehr').setAttribute('aria-expanded', String(auf));
|
||
});
|
||
$('anruf-ton').addEventListener('click', tonSchalten);
|
||
$('anruf-hoeren').addEventListener('click', hoerenSchalten);
|
||
/* DER HINWEISBALKEN IST SELBST DER KNOPF. Wer liest „tippen, um
|
||
den Ton anzumachen", will genau das tun -- ihn danach noch den
|
||
richtigen Schalter suchen zu lassen, waere ein Schritt zu viel. */
|
||
$('anruf-stumm')?.addEventListener('click', hoerenSchalten);
|
||
$('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; };
|
||
|
||
/** Wen habe ich am Apparat? Steht ganz oben und gross.
|
||
*
|
||
* DER WICHTIGSTE SATZ EINES TELEFONATS. Vorher stand dort nur „Es
|
||
* klingelt …" -- man sah nicht, wen man anruft. */
|
||
const wer = (text) => { const w = $('anruf-wer'); if (w) w.textContent = text; };
|
||
|
||
/* =====================================================================
|
||
WER IST DRAN -- MIT NAMEN UND GESICHT (20.09.2026)
|
||
|
||
Filipe, zur Anrufbox: „die soll auch viel besser geiler und
|
||
spezieller aussehen, aber nicht so raushaengen."
|
||
|
||
DER GROESSTE MANGEL WAR KEIN SCHOENHEITSFEHLER. In einem Anruf
|
||
ohne Video stand unter dem Kasten das Wort „Verbunden" -- einmal
|
||
je Person. Bei dreien stand es dreimal, und man sah nicht, WER
|
||
verbunden war. In einer Gruppe ist genau das die Frage: Ist Luna
|
||
schon drin? Hoert der Modi mit?
|
||
|
||
Es gibt keine Profilbilder im Anruf -- die Sitzung liefert sie
|
||
nicht mit, und sie dafuer durch eine Abfrage zu holen, die auf
|
||
JEDER Seite laeuft, waere an der falschen Stelle gespart und an
|
||
der falschen teuer. Stattdessen dasselbe Mittel wie im Chat: der
|
||
Anfangsbuchstabe in einem Kreis. Er ist sofort da, kostet nichts
|
||
und unterscheidet in einer Gruppe von drei bis fuenf Leuten
|
||
zuverlaessig.
|
||
|
||
WAEHREND ES KLINGELT steht dort, WEN man anruft -- gross, mit
|
||
einem ruhigen Puls. Vorher war die Flaeche in diesem Moment leer,
|
||
und ein leerer Kasten mit „Es klingelt ..." sieht aus wie einer,
|
||
der haengt. Es ist die Sekunde, in der man am unsichersten ist.
|
||
===================================================================== */
|
||
|
||
/** Die Namen aller, die im Anruf sind -- id -> Name.
|
||
*
|
||
* Auf dem Anruf selbst und nicht daneben: Endet er, ist die Karte
|
||
* mit ihm weg. Eine Karte, die einen Anruf ueberlebt, zeigt beim
|
||
* naechsten Namen von gestern. */
|
||
function namenMerken(liste) {
|
||
if (!anruf || !Array.isArray(liste)) return;
|
||
if (!anruf.namen) anruf.namen = new Map();
|
||
for (const w of liste) if (w && w.id != null) anruf.namen.set(w.id, w.name || '');
|
||
}
|
||
|
||
/** Ein bis zwei Buchstaben aus einem Namen -- dieselbe Regel wie im
|
||
* Chat: bei zwei Wörtern die Anfangsbuchstaben beider, sonst der
|
||
* erste. „Luna Sanchez" -> LS, „Dogi" -> D. */
|
||
function zeichenVon(name) {
|
||
const teile = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||
if (!teile.length) return '?';
|
||
if (teile.length === 1) return teile[0][0].toUpperCase();
|
||
return (teile[0][0] + teile[teile.length - 1][0]).toUpperCase();
|
||
}
|
||
|
||
/** Ein Gesichtsfeld: Kreis mit Buchstaben, Name darunter.
|
||
*
|
||
* `lage` ist 'da' (verbunden) oder 'wartet' (klingelt noch) -- das
|
||
* Stilblatt macht daraus den Unterschied zwischen ruhig und
|
||
* pulsierend. Der Zustand steht auch im Text darunter: Ein Puls
|
||
* allein ist nichts, was ein Vorleseprogramm ansagen kann. */
|
||
function gesichtsfeld(name, lage, stumm) {
|
||
const k = el('div', 'anruf__gesicht');
|
||
k.dataset.lage = lage;
|
||
const kreis = el('span', 'anruf__kreis', zeichenVon(name));
|
||
/* Der Kreis traegt zwei Buchstaben -- als Bild, nicht als Text.
|
||
Ein Vorleseprogramm soll „Luna Sanchez, klingelt" sagen und
|
||
nicht „L S". */
|
||
kreis.setAttribute('aria-hidden', 'true');
|
||
k.append(kreis);
|
||
if (stumm) {
|
||
/* KEINE WIEDERHOLUNG DESSEN, WAS DIREKT DARUEBER STEHT.
|
||
Beim Anruf zu zweit sagt die Kopfzeile schon „Luna Sanchez" und
|
||
„Es klingelt ..." -- beides zwei Zeilen tiefer noch einmal
|
||
hinzuschreiben macht den Kasten hoeher und sagt nichts dazu.
|
||
Fuer Vorleseprogramme bleibt der Name trotzdem am Element. */
|
||
k.setAttribute('aria-label', `${name || 'Jemand'} – klingelt`);
|
||
return k;
|
||
}
|
||
k.append(el('span', 'anruf__name', name || 'Jemand'));
|
||
k.append(el('span', 'anruf__stand', lage === 'da' ? 'ist dabei' : 'klingelt …'));
|
||
return k;
|
||
}
|
||
|
||
/** Einen Schalter auf an/aus stellen.
|
||
*
|
||
* Die Aufschrift bleibt IMMER dieselbe (die Sache), der Zustand
|
||
* steht in `aria-pressed` und wird vom Zeichen getragen -- ein
|
||
* durchgestrichenes Mikrofon heisst aus. Fuer Vorleseprogramme
|
||
* kommt der Zustand ueber aria-label mit, denn ein Strich im Bild
|
||
* ist nichts, was man hoeren kann. */
|
||
function schalterSetzen(k, an, sache) {
|
||
if (!k) return;
|
||
k.setAttribute('aria-pressed', String(!!an));
|
||
k.setAttribute('aria-label', `${sache} ${an ? 'an' : 'aus'} – zum Umschalten antippen`);
|
||
}
|
||
|
||
/* ---------- 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') {
|
||
/* Ab hier redet man miteinander -- ein Freizeichen daneben
|
||
waere das, was man an einer Telefonanlage hasst. */
|
||
tonAus();
|
||
klingelWache(); // niemand-geht-ran-Wecker abbestellen
|
||
nachfrageStoppen(); // und die Nachfrage braucht es nicht mehr
|
||
if (anruf && !anruf.beginn) anruf.beginn = Date.now();
|
||
/* Der Puls hoert auf zu atmen, sobald die Verbindung steht --
|
||
eine Bewegung, die nie aufhoert, wird nach zwei Minuten zur
|
||
Belaestigung. */
|
||
$('anruf')?.setAttribute('data-steht', 'ja');
|
||
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') {
|
||
/* TON AUS. Bis heute piepte das Freizeichen weiter, waehrend
|
||
daneben "Keine Verbindung." stand -- der Ton sagte "es
|
||
klingelt noch", der Text "vorbei". Man weiss dann nicht,
|
||
welchem man glauben soll, und legt vorsichtshalber nicht auf. */
|
||
tonAus();
|
||
klingelWache();
|
||
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') {
|
||
/* NICHT "im Schloss-Symbol neben der Adresse" (geaendert
|
||
19.09.2026). In der installierten App gibt es weder
|
||
Adresszeile noch Schloss -- die Anweisung fuehrte genau dort
|
||
ins Leere, wo die meisten sie lesen. Das Haus kennt diesen
|
||
Fehler und hat ihn beim Weg zur Anruf-Probe schon einmal
|
||
behoben; hier stand er noch.
|
||
|
||
Der neue Satz traegt in beiden Faellen, weil er den Ort
|
||
nennt, den es immer gibt: die Geraeteeinstellungen. */
|
||
throw new Error('Das Mikrofon ist gesperrt. Erlaube es in den '
|
||
+ 'Einstellungen deines Geräts (Apps → Team Dogi → Mikrofon) '
|
||
+ 'und versuch es noch einmal. Unter „Klingelt nichts?“ steht, '
|
||
+ 'wie das geht.');
|
||
}
|
||
if (f?.name === 'NotFoundError') {
|
||
throw new Error('Kein Mikrofon gefunden. Steckt ein Headset '
|
||
+ 'halb drin oder ist es anderweitig in Benutzung?');
|
||
}
|
||
throw new Error('Das Mikrofon ließ sich nicht öffnen. Schließ '
|
||
+ 'andere Programme, die es benutzen könnten, und versuch es '
|
||
+ 'noch einmal.');
|
||
}
|
||
}
|
||
|
||
async function starten(raumId, mitVideo, raumName) {
|
||
if (anruf) return;
|
||
kastenBauen();
|
||
$('anruf').hidden = false;
|
||
/* WEN RUFE ICH AN? Der Chat weiss es und gibt es mit. Faellt
|
||
der Name aus (aelterer Aufruf), bleibt es bei „Anruf" --
|
||
das ist immer noch richtig, nur weniger.
|
||
|
||
NACH `kastenBauen()`, UND DAS IST DER PUNKT (20.09.2026):
|
||
Vorher stand diese Zeile DAVOR. Da gibt es das Element
|
||
`#anruf-wer` noch gar nicht -- `wer()` fand nichts, tat nichts,
|
||
und der Name war weg. Oben stand deshalb bei JEDEM ersten Anruf
|
||
„Anruf" statt „Luna Sanchez", obwohl der Chat ihn korrekt
|
||
mitgab. Der Kommentar daneben beschrieb die Absicht und nicht,
|
||
was passierte -- ein Kommentar, der eine Absicht beschreibt,
|
||
erfuellt sie nicht.
|
||
|
||
Gefunden hat es ein Bildschirmfoto, nicht das Lesen: Der Code
|
||
liest sich richtig, und der Rueckfall „Anruf" ist plausibel
|
||
genug, um nicht aufzufallen.
|
||
|
||
Der frueher hier stehende `if (anruf) return;` ist mit nach oben
|
||
gewandert. Das ist auch sachlich besser: Laeuft schon ein
|
||
Gespraech, soll ein zweiter Aufruf es nicht umbenennen. */
|
||
wer(raumName || 'Anruf');
|
||
/* JEDES GESPRAECH FAENGT LEISE AN (20.09.2026) -- an BEIDEN
|
||
Wegen, Anrufen und Rangehen. Einer allein waere die
|
||
Haelfte, und die andere Haelfte faellt niemandem auf,
|
||
bis es einmal zu laut war. */
|
||
tonZuruecksetzen();
|
||
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;
|
||
/* `beginn: null` UND NICHT Date.now() (19.09.2026).
|
||
|
||
Die Uhr zaehlte ab dem Waehlen. Wer 30 Sekunden klingeln liess
|
||
und dann 12 Sekunden sprach, las im Kasten "00:42" -- und danach
|
||
im Chat "Anruf . 12 Sekunden". Zwei Zahlen fuer dieselbe Sache,
|
||
eine halbe Minute auseinander. Der Server rechnet es richtig
|
||
(gespraechSeit); der Kasten hat es wieder kaputtgemacht.
|
||
|
||
Bis jemand rangeht steht jetzt "Es klingelt ..." statt einer
|
||
Uhr, die etwas misst, das noch nicht stattfindet. */
|
||
anruf = { raumId, video: mitVideo, verbindungen: new Map(),
|
||
stroeme: new Map(), eigener, beginn: null };
|
||
$('anruf-video').hidden = !mitVideo;
|
||
if (mitVideo) $('anruf-video').setAttribute('aria-pressed', 'true');
|
||
lage('Es klingelt …');
|
||
/* JETZT ZEICHNEN, nicht erst wenn jemand rangeht: Dies ist der
|
||
Moment, in dem die Flaeche bisher leer war -- und ein leerer
|
||
Kasten mit „Es klingelt ..." sieht aus wie einer, der haengt. */
|
||
leuteZeichnen();
|
||
/* DAS FREIZEICHEN. Ein stiller Kasten mit laufender Uhr sagt nur,
|
||
dass etwas passiert -- nicht, was. */
|
||
freiTon();
|
||
uhrLaufen();
|
||
/* UND EIN ENDE (19.09.2026). Bis heute lief das Freizeichen
|
||
UNENDLICH. Es gab keinen Zeitgeber, der aufhoert: Wer niemanden
|
||
erreichte, sass vor einem piependen Kasten mit "02:47" darauf,
|
||
als telefoniere er laengst. Genau das wird als "geht einfach
|
||
nicht" gemeldet.
|
||
|
||
Der Server laesst einen Anruf nach KLINGELT_SEKUNDEN verfallen
|
||
und schickt den Zeitpunkt sogar mit (`laeuft_bis`) -- benutzt
|
||
hat ihn nie jemand. */
|
||
klingelWache(KLINGELT_MS);
|
||
/* Und der zweite Weg, falls das Ereignis nicht ankommt. */
|
||
nachfrageStarten(raumId);
|
||
|
||
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(sagWas(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?.(sagWas(d.fehler, 'Der Anruf ist schon vorbei.'));
|
||
klingelnAus();
|
||
return;
|
||
}
|
||
const d = await a.json();
|
||
await adressenFertig;
|
||
kastenBauen();
|
||
$('anruf').hidden = false;
|
||
/* JEDES GESPRAECH FAENGT LEISE AN (20.09.2026) -- an BEIDEN
|
||
Wegen, Anrufen und Rangehen. Einer allein waere die
|
||
Haelfte, und die andere Haelfte faellt niemandem auf,
|
||
bis es einmal zu laut war. */
|
||
tonZuruecksetzen();
|
||
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();
|
||
/* Wer angerufen hat, steht im Klingel-Ereignis und wurde dort
|
||
schon gemerkt -- sonst staende beim Angerufenen „Anruf" und
|
||
beim Anrufer der Name, also zweierlei fuer dasselbe Gespraech. */
|
||
wer(letzterAnrufer || 'Anruf');
|
||
lage('Verbindung …');
|
||
uhrLaufen();
|
||
|
||
/* Mit allen, die schon dabei sind, eine Verbindung aufbauen --
|
||
aber das ANGEBOT macht nur, wer die kleinere Nummer hat. */
|
||
namenMerken(d.wer);
|
||
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) {
|
||
tonAus();
|
||
klingelWache();
|
||
nachfrageStoppen();
|
||
$('anruf')?.removeAttribute('data-steht');
|
||
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;
|
||
/* Der Hinweis gehoert zum laufenden Gespraech. Bleibt er stehen,
|
||
sagt er etwas ueber ein Gespraech, das es nicht mehr gibt. */
|
||
stummBalkenZeigen();
|
||
/* 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 [werId, 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 {
|
||
/* Der Ton muss abgespielt werden -- das Element bleibt, nur
|
||
unsichtbar. Es traegt die Verbindung, nicht die Anzeige. */
|
||
const t = el('audio');
|
||
t.autoplay = true;
|
||
t.srcObject = strom;
|
||
ziel.append(t);
|
||
/* HIER STAND „Verbunden" -- einmal je Person, ohne Namen.
|
||
Bei dreien dreimal dasselbe Wort, und man sah nicht, wer. */
|
||
ziel.append(gesichtsfeld(anruf.namen?.get(werId), 'da'));
|
||
}
|
||
}
|
||
|
||
/* ---- NOCH NIEMAND DRAN: zeigen, WEN man anruft ----------------
|
||
Die Flaeche war in diesem Moment leer, und ein leerer Kasten mit
|
||
„Es klingelt ..." sieht aus wie einer, der haengt -- ausgerechnet
|
||
in der Sekunde, in der man am unsichersten ist. */
|
||
if (!anruf.video && !anruf.stroeme.size) {
|
||
const gegenueber = $('anruf-wer')?.textContent?.trim();
|
||
/* „Anruf" ist der Rueckfalltext, kein Name -- dann lieber gar
|
||
kein Gesicht als ein Kreis mit „A" darin. */
|
||
if (gegenueber && gegenueber !== 'Anruf') {
|
||
/* `true`: Name und Zustand stehen schon in der Kopfzeile --
|
||
siehe die Begruendung in `gesichtsfeld`. */
|
||
ziel.append(gesichtsfeld(gegenueber, 'wartet', true));
|
||
}
|
||
}
|
||
/* Der Zustand gilt auch fuer die Elemente, die es gerade eben noch
|
||
nicht gab -- und die Geraeteauswahl kann erst hier entstehen,
|
||
weil die Geraetenamen die Mikrofonerlaubnis voraussetzen. */
|
||
hoerenAnwenden();
|
||
ausgabeAnbieten();
|
||
}
|
||
|
||
let uhr = null;
|
||
function uhrLaufen() {
|
||
uhrStoppen();
|
||
uhr = setInterval(() => {
|
||
if (!anruf) return uhrStoppen();
|
||
/* Solange niemand rangegangen ist, gibt es nichts zu zaehlen. */
|
||
if (!anruf.beginn) return;
|
||
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;
|
||
/* DER ZUSTAND STEHT IN aria-pressed, NICHT IM TEXT (19.09.2026).
|
||
Ein Knopf mit der Aufschrift „Mikro an" kann zweierlei heissen --
|
||
„ist an" oder „schalt es an". Wer falsch raet, sitzt stumm da.
|
||
Jetzt sagt das durchgestrichene Zeichen den Zustand, und das
|
||
Wort sagt nur, worum es geht. */
|
||
schalterSetzen($('anruf-ton'), !an, 'Mikrofon');
|
||
}
|
||
|
||
/* ---------- DER LAUTSPRECHER (18.09.2026) ------------------------------
|
||
|
||
Filipe: „die funktion lautsprecher fehlt."
|
||
|
||
Er ist das Gegenstueck zum Mikrofon: „Mikro aus" heisst, die
|
||
anderen hoeren MICH nicht -- „Lautsprecher aus" heisst, ICH hoere
|
||
sie nicht. Beides braucht man, aus verschiedenen Gruenden: Das
|
||
Mikro macht man aus, wenn es bei einem selbst laut ist; den
|
||
Lautsprecher, wenn nebenher etwas anderes laeuft oder jemand
|
||
ins Zimmer kommt.
|
||
|
||
WAS HIER BEWUSST NICHT STEHT: ein Umschalter zwischen Hoermuschel
|
||
und Freisprechen, wie ihn ein Telefon hat. Den gibt es im Browser
|
||
nicht. Auf dem iPhone entscheidet Safari selbst, wohin der Ton
|
||
geht; auf Android laesst das Betriebssystem einzelne Toene gar
|
||
nicht auf ein anderes Geraet legen. Ein Knopf, der dort nichts
|
||
tut, waere schlimmer als keiner -- man drueckt ihn und sucht den
|
||
Fehler dann bei sich.
|
||
|
||
Wo es GEHT (Rechner mit Chrome, Edge oder Safari), steht dafuer
|
||
eine echte Geraeteauswahl daneben -- und nur dort. */
|
||
|
||
/** Hoeren wir die anderen?
|
||
*
|
||
* STARTET AUS, UND ZWAR BEI JEDEM ANRUF (20.09.2026).
|
||
*
|
||
* Filipe: „es soll ohne lautsprecher anfangen das gespraech und nur
|
||
* wenn man drauf klickt soll es laut werden ... es soll nicht
|
||
* raushaengen oder so, das ist nicht cool."
|
||
*
|
||
* Der Grund ist der Stream: Ein Anruf, der beim Annehmen sofort aus
|
||
* den Lautsprechern kommt, ist im schlimmsten Fall fuer alle
|
||
* Zuschauer zu hoeren, bevor irgendjemand reagieren kann. Das laesst
|
||
* sich nicht zurueckholen.
|
||
*
|
||
* HIER STAND FRUEHER `true` mit der Begruendung: „Wer den Ton
|
||
* abgestellt hat, will ihn nicht beim naechsten Gespraech wieder
|
||
* an." Das war richtig gedacht und ist jetzt umgedreht -- aus
|
||
* demselben Grund, nur in die andere Richtung: Der Zustand soll
|
||
* NICHT ueberdauern, sondern jedes Gespraech faengt leise an. Eine
|
||
* Entscheidung, die man einmal getroffen hat, darf nicht fuer ein
|
||
* Gespraech gelten, von dem man noch nichts wusste. */
|
||
let hoeren = false;
|
||
|
||
/** Auf welches Geraet der Ton geht. Leer = das des Systems. */
|
||
let ausgabe = '';
|
||
|
||
/** Setzt Stummschaltung und Geraet auf ALLE Toenelemente.
|
||
*
|
||
* Muss nach jedem `leuteZeichnen()` laufen: Das baut die Elemente
|
||
* neu, und neue Elemente wissen nichts von einer Einstellung, die
|
||
* vorher getroffen wurde. Genau daran scheitern solche Schalter
|
||
* sonst -- sie wirken, bis der Naechste dazukommt. */
|
||
function hoerenAnwenden() {
|
||
const ziel = $('anruf-leute');
|
||
if (!ziel) return;
|
||
for (const m of ziel.querySelectorAll('audio, video')) {
|
||
/* Das eigene Bild bleibt stumm, immer. Sich selbst zu hoeren ist
|
||
ein Echo, das jedes Gespraech unbrauchbar macht. */
|
||
if (m.classList.contains('anruf__bild--ich')) continue;
|
||
m.muted = !hoeren;
|
||
if (ausgabe && typeof m.setSinkId === 'function') {
|
||
m.setSinkId(ausgabe).catch(() => { /* Geraet weg -> Standardgeraet */ });
|
||
}
|
||
}
|
||
}
|
||
|
||
function hoerenSchalten() {
|
||
hoeren = !hoeren;
|
||
schalterSetzen($('anruf-hoeren'), hoeren, 'Ton der anderen');
|
||
hoerenAnwenden();
|
||
stummBalkenZeigen();
|
||
}
|
||
|
||
/** Den Hinweis "du hoerst noch nichts" ein- oder ausblenden.
|
||
*
|
||
* An EINER Stelle, damit er nicht an dreien vergessen wird: beim
|
||
* Anfangen, beim Umschalten und beim Auflegen. */
|
||
function stummBalkenZeigen() {
|
||
const b = $('anruf-stumm');
|
||
if (!b) return;
|
||
/* AM SICHTBAREN KASTEN, NICHT AN `anruf` (berichtigt 20.09.2026).
|
||
|
||
Der erste Anlauf fragte `!anruf` -- und die Variable wird erst
|
||
gesetzt, NACHDEM der Kasten aufgeht. Beim Aufbauen war sie also
|
||
noch leer, der Hinweis blieb versteckt, und man sass in einem
|
||
stummen Gespraech ohne einen Satz dazu. Genau der Zustand, den
|
||
er verhindern soll.
|
||
|
||
Der Kasten selbst ist die ehrlichere Frage: Ist er zu, gibt es
|
||
nichts zu hoeren; ist er auf, gilt der Hinweis. */
|
||
b.hidden = hoeren || $('anruf')?.hidden !== false;
|
||
}
|
||
|
||
/** Jedes Gespraech faengt leise an -- siehe die Begruendung bei
|
||
* `hoeren`. Wird beim Aufbauen eines Anrufs gerufen, nicht beim
|
||
* Laden der Seite: Sonst gaelte die Entscheidung von vorhin
|
||
* weiter. */
|
||
function tonZuruecksetzen() {
|
||
hoeren = false;
|
||
schalterSetzen($('anruf-hoeren'), false, 'Ton der anderen');
|
||
hoerenAnwenden();
|
||
stummBalkenZeigen();
|
||
}
|
||
|
||
/** Baut die Geraeteauswahl -- oder eben nicht.
|
||
*
|
||
* SIE ERSCHEINT NUR, WENN ES ETWAS ZU WAEHLEN GIBT: wenn der Browser
|
||
* `setSinkId` kann UND mehr als ein Ausgabegeraet da ist. Eine
|
||
* Auswahl mit einem einzigen Eintrag ist keine Auswahl, sondern eine
|
||
* Behauptung -- und auf dem Handy waere sie eine falsche. */
|
||
async function ausgabeAnbieten() {
|
||
const kasten = $('anruf');
|
||
if (!kasten || $('anruf-ausgabe')) return;
|
||
if (typeof document.createElement('audio').setSinkId !== 'function') return;
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
|
||
let geraete = [];
|
||
try {
|
||
geraete = (await navigator.mediaDevices.enumerateDevices())
|
||
.filter((g) => g.kind === 'audiooutput');
|
||
} catch { return; }
|
||
/* Namen gibt es erst, seit das Mikrofon erlaubt wurde -- im Anruf
|
||
ist das der Fall. Ohne Namen waere die Liste eine Reihe von
|
||
Nummern, und niemand wuesste, was er waehlt. */
|
||
if (geraete.length < 2 || !geraete.some((g) => g.label)) return;
|
||
|
||
const w = el('select', 'anruf__ausgabe');
|
||
w.id = 'anruf-ausgabe';
|
||
w.setAttribute('aria-label', 'Wohin der Ton geht');
|
||
for (const g of geraete) {
|
||
const o = document.createElement('option');
|
||
o.value = g.deviceId;
|
||
o.textContent = g.label || 'Gerät';
|
||
w.append(o);
|
||
}
|
||
w.value = ausgabe || geraete[0].deviceId;
|
||
w.addEventListener('change', () => { ausgabe = w.value; hoerenAnwenden(); });
|
||
/* IN DIE EIGENE ZEILE, NICHT IN DIE KNOPFREIHE (19.09.2026).
|
||
Dort war sie das breiteste Element des Kastens -- fuer etwas,
|
||
das man einmal im Jahr braucht. Jetzt liegt sie hinter dem
|
||
Knopf mit den drei Punkten und ist erst da, wenn man sie sucht.
|
||
Der Knopf selbst erscheint nur, wenn es hier auch etwas zu
|
||
waehlen gibt. */
|
||
const zeile = $('anruf-mehrzeile');
|
||
if (!zeile) return;
|
||
zeile.append(el('span', 'anruf__mehrschild', 'Ton geht an'));
|
||
zeile.append(w);
|
||
const mehr = $('anruf-mehr');
|
||
if (mehr) mehr.hidden = false;
|
||
}
|
||
|
||
function videoSchalten() {
|
||
if (!anruf) return;
|
||
const an = anruf.eigener.getVideoTracks().some((s) => s.enabled);
|
||
for (const s of anruf.eigener.getVideoTracks()) s.enabled = !an;
|
||
schalterSetzen($('anruf-video'), !an, 'Kamera');
|
||
}
|
||
|
||
/* ---------- Es klingelt ------------------------------------------------ */
|
||
|
||
/* Wer zuletzt angerufen hat -- fuer die Kopfzeile des Kastens, wenn
|
||
man rangeht. */
|
||
let letzterAnrufer = '';
|
||
|
||
function klingelnZeigen(raumId, von, mitVideo) {
|
||
klingelnAus();
|
||
letzterAnrufer = von?.name || '';
|
||
/* UND ES KLINGELT WIRKLICH. Vorher stand hier nur ein Kasten --
|
||
wer nicht zufaellig hinsah, merkte nichts. */
|
||
klingelTon();
|
||
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.
|
||
|
||
ABER SO LANGE WIE DER ANRUF WIRKLICH LAEUFT (19.09.2026). Hier
|
||
standen 46 Sekunden, waehrend der Server 120 gibt. Wer beim
|
||
Klingeln das Handy aus der Tasche holt und in Sekunde 50
|
||
hinsieht, fand nichts mehr -- kein Kasten, keine Knoepfe --,
|
||
waehrend der Anrufer noch siebzig Sekunden das Freizeichen
|
||
hoerte. Die 120 Sekunden sind im Server ausdruecklich damit
|
||
begruendet, dass man Zeit zum Entsperren braucht; diese Zeile
|
||
hat genau diese Entscheidung wieder aufgehoben. */
|
||
setTimeout(klingelnAus, KLINGELT_MS);
|
||
}
|
||
/* ---------- DER KLINGELTON (18.09.2026) --------------------------
|
||
|
||
Filipe: „ist das normal dass es nicht mal klingelt wenn man
|
||
anruft?"
|
||
|
||
Nein. Es gab gar keinen Ton -- weder beim Anrufer noch beim
|
||
Angerufenen. Ein Anruf zeigte einen Kasten auf dem Bildschirm,
|
||
sonst nichts. Wer nicht zufaellig hinsah, merkte nichts; und wer
|
||
anrief, wusste nie, ob ueberhaupt etwas passiert. Genau das
|
||
fuehlt sich an wie „geht nicht".
|
||
|
||
OHNE TONDATEI. Ein Klingelton als .mp3 waere eine Datei mehr im
|
||
Netz, eine Lizenzfrage und ein Ladefehler, der genau dann
|
||
auffaellt, wenn man ihn braucht. Zwei Sinustoene aus dem Browser
|
||
reichen -- das ist, was ein Telefon auch tut.
|
||
|
||
LEISE UND KURZ. Kein durchdringendes Schrillen: zwei weiche
|
||
Toene, dann Pause. Das traegt durch einen Raum, ohne jemanden zu
|
||
erschrecken -- dieselbe Ueberlegung wie bei allem Sichtbaren hier.
|
||
|
||
WARUM ES TROTZDEM STUMM BLEIBEN KANN: Browser lassen Ton erst zu,
|
||
wenn jemand auf der Seite etwas angeklickt hat. Wer die Seite
|
||
gerade erst geoeffnet hat, hoert deshalb unter Umstaenden nichts
|
||
-- dann bleibt die Benachrichtigung des Geraets der Weg. Das ist
|
||
eine Regel des Browsers, keine Entscheidung von uns. */
|
||
|
||
let tonWerk = null; /* der Taktgeber, solange es klingelt */
|
||
let tonRaum = null; /* AudioContext -- erst bei Bedarf */
|
||
|
||
function tonAn(hoehe, laenge, lautstaerke) {
|
||
try {
|
||
if (!tonRaum) {
|
||
const K = window.AudioContext || window.webkitAudioContext;
|
||
if (!K) return;
|
||
tonRaum = new K();
|
||
}
|
||
if (tonRaum.state === 'suspended') tonRaum.resume().catch(() => {});
|
||
const o = tonRaum.createOscillator();
|
||
const v = tonRaum.createGain();
|
||
o.type = 'sine';
|
||
o.frequency.value = hoehe;
|
||
/* Sanft ein und aus: Ein hart geschalteter Sinus knackt, und
|
||
genau das Knacken ist das Unangenehme daran. */
|
||
const t = tonRaum.currentTime;
|
||
v.gain.setValueAtTime(0, t);
|
||
v.gain.linearRampToValueAtTime(lautstaerke, t + 0.03);
|
||
v.gain.linearRampToValueAtTime(0, t + laenge);
|
||
o.connect(v).connect(tonRaum.destination);
|
||
o.start(t);
|
||
o.stop(t + laenge + 0.05);
|
||
} catch { /* kein Ton -- die Benachrichtigung bleibt der Weg */ }
|
||
}
|
||
|
||
/** Es klingelt bei dem, der angerufen wird: zwei Toene, dann Pause. */
|
||
function klingelTon() {
|
||
tonAus();
|
||
const takt = () => { tonAn(880, 0.22, 0.16); setTimeout(() => tonAn(660, 0.26, 0.16), 300); };
|
||
takt();
|
||
tonWerk = setInterval(takt, 2400);
|
||
/* Und das Geraet darf mitvibrieren, wo es das kann. */
|
||
try { navigator.vibrate?.([250, 200, 250]); } catch { /* egal */ }
|
||
}
|
||
|
||
/** Das Freizeichen bei dem, der anruft -- leiser und langsamer.
|
||
*
|
||
* Damit er weiss, DASS es laeuft. Ein stiller Kasten mit einer
|
||
* laufenden Uhr sagt nur, dass etwas passiert; ein Freizeichen
|
||
* sagt, WAS. */
|
||
function freiTon() {
|
||
tonAus();
|
||
const takt = () => tonAn(440, 0.5, 0.07);
|
||
takt();
|
||
tonWerk = setInterval(takt, 3000);
|
||
}
|
||
|
||
/* Wie lange es hoechstens klingelt, bevor der Browser selbst
|
||
aufgibt. Der Server schickt `laeuft_bis` mit; solange das nicht
|
||
ueberall ankommt, ist dies der Rueckfall -- dieselbe Zahl wie
|
||
KLINGELT_SEKUNDEN in workspace-anruf.js, plus zwei Sekunden Luft,
|
||
damit der Server zuerst dran ist. */
|
||
const KLINGELT_MS = 122000;
|
||
let klingelWecker = null;
|
||
|
||
/* =====================================================================
|
||
DER ZWEITE WEG: SELBST NACHFRAGEN — 19.09.2026
|
||
|
||
Filipe: „wenn vanvan rangeht und redet klingelt es immer noch bei
|
||
mir weiter, der anruf verbindet nicht richtig."
|
||
|
||
NACHGEMESSEN IM PROTOKOLL DES SERVERS:
|
||
|
||
17:04:52 anruf_start Person 1 (Filipe)
|
||
17:05:25 anruf_ende Person 4 (VanVan) 24 s
|
||
17:05:31 anruf_ende Person 1 (Filipe) 39 s
|
||
|
||
Sie WAR im Gespräch -- der Server hat sie 24 Sekunden lang als
|
||
Teilnehmerin geführt. Das Ereignis „dabei" ist also verschickt
|
||
worden. Bei Filipe kam es nicht an: `tonAus()` ist das Erste im
|
||
`dabei`-Zweig, noch vor jeder Prüfung, und das Tuten lief weiter.
|
||
|
||
Geprüft und ausgeschlossen: Raumzugehörigkeit (beide drin, niemand
|
||
`raus_am`), die Ereignis-Verkabelung (Server sendet mit der
|
||
richtigen Art, chat.js reicht weiter, anruf.js nimmt entgegen),
|
||
die Tonsteuerung (ein einziger Taktgeber, `tonAus` räumt ihn),
|
||
ein zwischenspeichernder Service Worker (es gibt keinen).
|
||
|
||
Es geht also unterwegs verloren -- auf einem Weg, den ich von hier
|
||
aus nicht messen kann: Ereignisstrom über Cloudflare, ein
|
||
schlafender Reiter, ein Neustart des Servers im falschen Moment.
|
||
|
||
DARUM WIRD DIE URSACHE NICHT WEITER GESUCHT, SONDERN DIE
|
||
ABHÄNGIGKEIT BESEITIGT. Ein klingelndes Telefon darf nicht an
|
||
einem einzigen, zerbrechlichen Kanal hängen. Solange es klingelt,
|
||
fragt der Anrufer jetzt SELBST nach: „ist schon jemand dran?"
|
||
|
||
Das ist kein Ersatz für den Ereignisstrom -- der ist schneller und
|
||
bleibt der erste Weg. Es ist das Netz darunter. Kommt das Ereignis
|
||
an, hat die Nachfrage nichts mehr zu tun (sie prüft `anruf.beginn`
|
||
und die bekannten Teilnehmer und hält von selbst an).
|
||
|
||
ALLE ZWEI SEKUNDEN, NICHT JEDE HALBE: Es klingelt höchstens zwei
|
||
Minuten, das sind sechzig Abrufe. Häufiger zu fragen kostet Akku
|
||
für eine Sekunde, die niemand merkt. */
|
||
let nachfrage = null;
|
||
|
||
function nachfrageStarten(raumId) {
|
||
nachfrageStoppen();
|
||
nachfrage = setInterval(async () => {
|
||
if (!anruf || anruf.raumId !== raumId) return nachfrageStoppen();
|
||
/* Steht die Verbindung schon, gibt es nichts nachzufragen. */
|
||
if (anruf.beginn) return nachfrageStoppen();
|
||
let d;
|
||
try {
|
||
const a = await fetch(`/workspace/api/anruf/${raumId}`,
|
||
{ credentials: 'same-origin' });
|
||
if (!a.ok) return;
|
||
d = await a.json();
|
||
} catch { return; } /* ein Aussetzer ist kein Ergebnis */
|
||
if (!anruf || anruf.raumId !== raumId) return nachfrageStoppen();
|
||
|
||
/* Der Anruf ist vorbei, ohne dass wir es erfahren haben. */
|
||
if (!d.laeuft) { nachfrageStoppen(); tonAus(); lage('Der Anruf ist vorbei.');
|
||
setTimeout(() => { if (anruf) auflegen(); }, 1500); return; }
|
||
|
||
namenMerken(d.wer);
|
||
const andere = (d.wer || []).filter((w) => w.id !== ichId);
|
||
if (!andere.length) return; /* es klingelt weiter, alles richtig */
|
||
|
||
/* JEMAND IST DRAN. Ab hier dasselbe wie beim Ereignis „dabei" --
|
||
und es schadet nicht, wenn beides passiert: `verbindungFuer`
|
||
gibt eine bestehende Verbindung zurück, und ein zweites
|
||
Angebot an dieselbe Gegenstelle entsteht gar nicht erst. */
|
||
nachfrageStoppen();
|
||
tonAus();
|
||
klingelWache();
|
||
lage('Baut auf …');
|
||
for (const w of andere) {
|
||
if (anruf.verbindungen.has(w.id)) continue;
|
||
if (ichId < w.id) angebotMachen(w.id);
|
||
else verbindungFuer(w.id);
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function nachfrageStoppen() {
|
||
if (nachfrage) { clearInterval(nachfrage); nachfrage = null; }
|
||
}
|
||
|
||
/** Ohne Zahl: abbestellen. Mit Zahl: nach so vielen Millisekunden
|
||
* aufgeben, falls bis dahin niemand rangegangen ist. */
|
||
function klingelWache(ms) {
|
||
if (klingelWecker) { clearTimeout(klingelWecker); klingelWecker = null; }
|
||
if (!ms) return;
|
||
klingelWecker = setTimeout(() => {
|
||
klingelWecker = null;
|
||
/* Ist inzwischen jemand dabei, war es kein vergeblicher Anruf. */
|
||
if (!anruf || anruf.beginn) return;
|
||
tonAus();
|
||
lage('Niemand geht ran.');
|
||
setTimeout(() => { if (anruf && !anruf.beginn) auflegen(); }, 2500);
|
||
}, ms);
|
||
}
|
||
|
||
function tonAus() {
|
||
if (tonWerk) { clearInterval(tonWerk); tonWerk = null; }
|
||
try { navigator.vibrate?.(0); } catch { /* egal */ }
|
||
}
|
||
|
||
function klingelnAus() { tonAus(); $('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') {
|
||
/* Jemand ist rangegangen -- auch wenn die Verbindung noch ein
|
||
paar Sekunden braucht, klingelt hier nichts mehr. */
|
||
tonAus();
|
||
nachfrageStoppen();
|
||
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 …');
|
||
/* DEN NAMEN MITNEHMEN (20.09.2026).
|
||
|
||
Hier fehlte er, und das Loch war genau umgekehrt zu dem, was
|
||
man erwartet: Wer ANGERUFEN wird, erfaehrt die Namen ueber
|
||
`dazustossen` (die Antwort des Servers enthaelt alle). Wer
|
||
ANRUFT, bekommt beim Start nur sich selbst -- alle weiteren
|
||
kommen ausschliesslich ueber dieses Ereignis. Ohne diese Zeile
|
||
stand bei ihm unter jedem Kreis „Jemand", und ausgerechnet der
|
||
Anrufer sah als Einziger keine Namen.
|
||
|
||
Gefunden hat es die Pruefung, aber erst, nachdem sie
|
||
verschaerft wurde: Der erste Anlauf verlangte nur „Name laenger
|
||
als ein Zeichen" -- und „Jemand" ist sechs Zeichen lang. Eine
|
||
Pruefung, die den Rueckfalltext durchlaesst, prueft nichts. */
|
||
namenMerken([d.person]);
|
||
/* 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;
|
||
/* ==== TELEFONIERT DIESE PERSON UEBERHAUPT? (19.09.2026) ============
|
||
|
||
Seit die Community einen Chat hat, gibt es Leute, fuer die es die
|
||
Anruffunktion nicht gibt -- der Riegel sitzt am Anruf-Router und
|
||
antwortet mit 404.
|
||
|
||
OHNE DIESE MERKUNG WAERE DAS LAUT: Die Chatseite holt beim Laden
|
||
die Verbindungsadressen und danach die offenen Anrufe. Beides
|
||
ergab fuer einen Gast einen 404 in der Konsole -- zweimal, bei
|
||
jedem Seitenaufruf. Gefunden hat das die Pruefung im Browser,
|
||
nicht das Lesen.
|
||
|
||
Ein Fehler in der Konsole, der bei jedem Aufruf kommt und nichts
|
||
bedeutet, ist genau die Sorte Rauschen, die den naechsten ECHTEN
|
||
Fehler unsichtbar macht. Dieselbe Regel wie bei der Warnung, die
|
||
immer kommt. */
|
||
let telefonGehtHier = true;
|
||
|
||
/* Der Grund, den der Server zum Vermittlungsserver mitschickt --
|
||
leer heisst "alles in Ordnung". Er steht hier oben, weil ihn
|
||
`adressenFrisch()` braucht. */
|
||
let turnGrund = '';
|
||
|
||
function adressenFrisch() {
|
||
if (!adressenGeholt) return false;
|
||
/* FEHLT DAS GEHEIMNIS, GILT DIE LISTE NIE ALS FRISCH.
|
||
|
||
Sonst waere sie es fuer immer: Ohne Zugangsdaten bleibt nach dem
|
||
Aussortieren nur STUN uebrig, und ein STUN-Eintrag hat keinen
|
||
Ablaufzeitpunkt -- `every()` sagt dann "alles noch gueltig" und
|
||
es wird nie wieder gefragt. Waere die Datei auf dem Server
|
||
zwischenzeitlich repariert, telefonierte trotzdem niemand mehr
|
||
mit Vermittlung, bis er die Seite neu laedt.
|
||
|
||
(Nebenbei die zweite Falle derselben Zeile: `every()` auf einer
|
||
LEEREN Liste ist `true`. Auch dagegen hilft diese Abfrage.) */
|
||
if (turnGrund) 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' });
|
||
/* 404 heisst hier nicht "kaputt", sondern "gibt es fuer dich
|
||
nicht". Ab da wird gar nicht mehr gefragt. */
|
||
if (a.status === 404) { telefonGehtHier = false; return; }
|
||
if (a.ok) {
|
||
const d = await a.json();
|
||
/* ═══════════════════════════════════════════════════════════
|
||
EIN turn: OHNE ZUGANGSDATEN DARF NICHT IN DIE LISTE.
|
||
|
||
Am 25.09.2026 im echten Browser nachgemessen (beim Bau des
|
||
Podcast-Studios, das dieselbe Rechnung benutzt):
|
||
|
||
Failed to construct 'RTCPeerConnection': Both username and
|
||
credential are required when the URL scheme is "turn" or
|
||
"turns".
|
||
|
||
`new RTCPeerConnection(...)` WIRFT. Die Folge ist nicht etwa
|
||
"kein Vermittlungsserver", sondern KEIN ANRUF -- auch nicht
|
||
der, der direkt zustande gekommen waere. Aus einem
|
||
Randproblem (etwa jeder fuenfte braucht Vermittlung) wuerde
|
||
ein Totalausfall fuer alle.
|
||
|
||
Der Server laesst solche Eintraege bewusst stehen, damit der
|
||
Grund nicht verschwindet (server/workspace-turn.js, Absatz
|
||
"KEIN STILLES WEGLASSEN"). Das ist dort richtig -- aber es
|
||
heisst, dass HIER aussortiert werden muss, bevor gebaut wird.
|
||
Der Grund geht nicht verloren: Er steht weiter unten in der
|
||
Konsole.
|
||
|
||
STUN bleibt selbstverstaendlich drin -- STUN kennt gar keine
|
||
Anmeldung, und es ist der Weg, der in den meisten Faellen
|
||
reicht.
|
||
═══════════════════════════════════════════════════════════ */
|
||
adressen = (d.adressen || []).filter((e) => {
|
||
if (!/^turns?:/.test(String(e && e.urls))) return true;
|
||
return !!(e.username && e.credential);
|
||
});
|
||
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. */
|
||
turnGrund = d.turn_grund || '';
|
||
if (turnGrund) {
|
||
console.warn('[anruf] Vermittlungsserver ohne Zugangsdaten:', turnGrund,
|
||
'- Anrufe gehen nur noch direkt. Auf dem Server nachsehen: /etc/coturn-workspace.geheimnis');
|
||
}
|
||
}
|
||
} catch { /* dann eben ohne -- im selben Netz geht es trotzdem */ }
|
||
}
|
||
|
||
/* ---------- Nach außen -------------------------------------------------- */
|
||
|
||
/* Wer hier nicht telefoniert, bekommt die Funktion gar nicht erst.
|
||
Der Chat prueft `window.anrufStarten`, bevor er die Knoepfe baut --
|
||
damit faellt der zweite Weg zu denselben Knoepfen von selbst weg.
|
||
GESETZT WIRD ERST NACH DER ANTWORT, deshalb weiter unten. */
|
||
window.anrufStarten = starten;
|
||
window.anrufEreignis = ereignis;
|
||
window.anrufLaeuft = () => !!anruf;
|
||
window.anrufBereit = () => adressen.length > 0 || nurGleichesNetz;
|
||
|
||
(async () => {
|
||
/* ==== ERST FRAGEN, WER MAN IST -- DANN ERST ANRUFEN =============
|
||
|
||
DIE REIHENFOLGE WAR DER FEHLER (19.09.2026). Hier stand
|
||
`adressenAuffrischen()` VOR der Frage nach der eigenen Person.
|
||
Fuer die Community antwortet dieser Weg seit heute mit 404, und
|
||
der Browser schreibt das in die Konsole, BEVOR JavaScript
|
||
irgendetwas abfangen kann -- ein roter Eintrag bei jedem
|
||
Seitenaufruf, der nichts bedeutet.
|
||
|
||
Ein Fehler, der immer kommt, macht den naechsten ECHTEN Fehler
|
||
unsichtbar. Dieselbe Regel wie bei der Warnung, die immer kommt.
|
||
|
||
Jetzt sagt `/api/ich` vorher, ob es hier ueberhaupt ein Telefon
|
||
gibt -- und wenn nicht, wird gar nicht erst gefragt. Die Angabe
|
||
kommt aus derselben Funktion wie der Riegel im Server. */
|
||
try {
|
||
const i = await fetch('/workspace/api/ich', { credentials: 'same-origin' });
|
||
if (i.ok) {
|
||
const d = await i.json();
|
||
ichId = d?.id ?? null;
|
||
if (d && d.darf_anrufen === false) telefonGehtHier = false;
|
||
}
|
||
} catch { /* der Chat holt es ohnehin auch */ }
|
||
|
||
if (!telefonGehtHier) {
|
||
/* Und die Funktion gar nicht erst anbieten: Der Chat baut seine
|
||
Knoepfe nur, wenn es sie gibt. */
|
||
delete window.anrufStarten;
|
||
return;
|
||
}
|
||
await adressenAuffrischen();
|
||
|
||
/* ---- KLINGELT GERADE ETWAS? (18.09.2026) ----------------------
|
||
|
||
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine
|
||
benarichtigung aber keinen anruf."
|
||
|
||
Genau so war es: Das Klingeln kam ausschliesslich ueber den
|
||
offenen Ereignisstrom. Wer die Seite nicht offen hatte, bekam
|
||
die Benachrichtigung, tippte darauf -- und landete auf einer
|
||
stillen Seite. Das Ereignis war vorbei, bevor sie geladen war.
|
||
|
||
Der Anruf funktionierte damit fuer alle ausser denen, fuer die
|
||
die Benachrichtigung ueberhaupt gedacht ist.
|
||
|
||
ERST NACH `ichId`: Die Klingel prueft, ob der Anruf vom eigenen
|
||
Konto kommt. Ohne die Nummer wuerde sie beim eigenen Anruf in
|
||
einem zweiten Fenster mitlaeuten. */
|
||
if (!telefonGehtHier) { void gruppeMax; return; }
|
||
try {
|
||
const a = await fetch('/workspace/api/anruf/offen', { credentials: 'same-origin' });
|
||
if (a.ok) {
|
||
for (const r of ((await a.json())?.anrufe || [])) {
|
||
if (!r.von || r.von.id === ichId) continue;
|
||
if (anruf) break; /* telefoniert schon */
|
||
klingelnZeigen(r.raum_id, r.von, r.video);
|
||
break; /* einer reicht */
|
||
}
|
||
}
|
||
} catch { /* dann eben nur ueber den Ereignisstrom */ }
|
||
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); });
|
||
})();
|