aether-framework/resources/[aether]/aether-admin/html/js/webrtc.js
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

248 lines
12 KiB
JavaScript

/* =====================================================================
aether-admin — Live-Kameras (echtes WebRTC)
Zwei Rollen laufen in dieser Datei, beide über eine WebSocket-
Signalisierung direkt zum Media-Server (SFU) — NICHT über FiveM:
• VIEWER — die geöffnete Panel-Ansicht. Empfängt die Streams der
beobachteten Spieler und zeigt sie als Video-Kacheln.
• PUBLISHER — läuft im Hintergrund auf dem Ziel-Client (auch ohne
geöffnetes Panel). Sendet seinen Aufnahme-Stream in den
Raum.
Die EHRLICHE Stelle: Die NUI/CEF kann das Spielbild NICHT selbst
abgreifen. Der Publisher nutzt daher `window.__aetherCapture` (die
Anbindung einer externen Quelle wie OBS-Virtual-Cam). Ist keine da,
sendet er ein deutlich beschriftetes Platzhalterbild — so ist die
ganze WebRTC-Strecke live und verifizierbar, und man sieht genau,
wo die echte Quelle angeklemmt wird.
===================================================================== */
"use strict";
(() => {
const EIS = [{ urls: "stun:stun.l.google.com:19302" }];
/* ================= PUBLISHER (Hintergrund, Ziel-Client) ============ */
const Publisher = (() => {
const raeume = {}; // [raum] = { ws, pc, stream }
/**
* Besorgt den zu sendenden MediaStream.
* Primärweg ist die Bildschirmfreigabe der NUI (getDisplayMedia) — echtes,
* flüssiges Video des Spielfensters, ganz ohne externes Werkzeug. Klappt sie
* (abhängig davon, ob das FiveM-CEF Bildschirmaufnahme erlaubt), sieht der
* Admin das Live-Bild. Sonst ein markierter Platzhalter.
* @returns {Promise<{stream:MediaStream, typ:string, fehler?:string}>}
*/
async function holeQuelle() {
// 1) Optionaler Override für Fortgeschrittene (z. B. bestimmtes Aufnahmegerät)
if (typeof window.__aetherCapture === "function") {
try { const s = await window.__aetherCapture(); if (s) return { stream: s, typ: "extern" }; } catch (e) {}
}
// 2) Bildschirmfreigabe — der eigentliche „keine externe Quelle"-Weg
try {
if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) {
const s = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 30, max: 60 } }, audio: false,
});
return { stream: s, typ: "screen" };
}
return { stream: platzhalterStream(), typ: "platzhalter", fehler: "getDisplayMedia nicht verfügbar" };
} catch (e) {
// Häufig: das FiveM-CEF verweigert die Freigabe (kein Nutzergesten-Kontext)
return { stream: platzhalterStream(), typ: "platzhalter", fehler: String(e && e.name || e) };
}
}
function platzhalterStream() {
const c = document.createElement("canvas");
c.width = 640; c.height = 360;
const ctx = c.getContext("2d");
(function zeichne() {
ctx.fillStyle = "#0b0f16"; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = "#f0506e"; ctx.font = "bold 22px sans-serif";
ctx.fillText("Bildschirmfreigabe blockiert", 40, 150);
ctx.fillStyle = "#7e879b"; ctx.font = "14px sans-serif";
ctx.fillText("Das FiveM-CEF dieses Clients erlaubt keine", 40, 185);
ctx.fillText("Bildschirmaufnahme (getDisplayMedia).", 40, 208);
ctx.fillStyle = "#34d39a"; ctx.font = "13px monospace";
ctx.fillText(new Date().toLocaleTimeString("de-DE"), 40, 250);
requestAnimationFrame(zeichne);
})();
return c.captureStream(12);
}
async function starte(raum, url, token) {
if (raeume[raum]) return; // schon aktiv
const quelle = await holeQuelle();
const stream = quelle.stream;
// Dem Admin zurückmelden, ob echte Bildschirmfreigabe oder Platzhalter
try { App.anClient("kameraStatus", { raum: raum, typ: quelle.typ, fehler: quelle.fehler || "" }); } catch (e) {}
const pc = new RTCPeerConnection({ iceServers: EIS });
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
const ws = new WebSocket(url);
raeume[raum] = { ws, pc, stream };
pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); };
ws.onopen = () => sende(ws, { t: "join", token: token });
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "ready") {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sende(ws, { t: "offer", sdp: pc.localDescription });
} else if (m.t === "answer") {
await pc.setRemoteDescription(m.sdp);
} else if (m.t === "ice" && m.candidate) {
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
}
};
ws.onclose = () => stoppe(raum);
}
function stoppe(raum) {
const r = raeume[raum]; if (!r) return;
try { r.pc.close(); } catch (e) {}
try { r.ws.close(); } catch (e) {}
try { r.stream.getTracks().forEach((t) => t.stop()); } catch (e) {}
delete raeume[raum];
}
function stoppeAlle() { Object.keys(raeume).forEach(stoppe); }
return { starte, stoppe, stoppeAlle };
})();
/* ================= VIEWER (Panel-Ansicht) ========================== */
const Viewer = (() => {
let ws = null, pc = null, aktuellerRaum = null;
let aufTrack = null; // Rückruf, wenn ein Video-Track eintrifft
function verbinde(url, token, raum, onTrack) {
trenne();
aktuellerRaum = raum; aufTrack = onTrack;
pc = new RTCPeerConnection({ iceServers: EIS });
pc.ontrack = (e) => { if (aufTrack) aufTrack(e.streams[0], e.track); };
pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); };
ws = new WebSocket(url);
ws.onopen = () => sende(ws, { t: "join", token: token });
ws.onmessage = async (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "offer") {
await pc.setRemoteDescription(m.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sende(ws, { t: "answer", sdp: pc.localDescription });
} else if (m.t === "ice" && m.candidate) {
try { await pc.addIceCandidate(m.candidate); } catch (e) {}
}
};
}
function trenne() {
try { if (pc) pc.close(); } catch (e) {}
try { if (ws) ws.close(); } catch (e) {}
pc = null; ws = null; aktuellerRaum = null;
}
return { verbinde, trenne, raum: () => aktuellerRaum };
})();
function sende(ws, obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
/* ================= Nachrichten vom Client (Publisher steuern) ====== */
window.addEventListener("message", (e) => {
const d = e.data || {};
if (d.aktion === "webrtcPublish") Publisher.starte(d.raum, d.url, d.token);
else if (d.aktion === "webrtcStop") Publisher.stoppe(d.raum);
else if (d.aktion === "webrtcStopAll") Publisher.stoppeAlle();
else if (d.aktion === "kameraStatus") {
// Rückmeldung eines Ziel-Clients an den beobachtenden Admin
if (d.typ === "screen" || d.typ === "extern") App.toast(`📹 ${d.quelle || "Ziel"}: Bild live`, "good");
else App.toast(`📹 ${d.quelle || "Ziel"}: Bildschirmfreigabe blockiert (${d.fehler || "CEF"})`, "warn");
}
});
/* ================= Kamera-Sektion (Viewer-UI) ====================== */
let aktiveView = null; // { raum, ziele:[{id,name}] }
App.registriere("cams", async (main) => {
if (!App.zustand.media || !App.zustand.media.aktiv) {
main.innerHTML = `<div class="phead"><div><h2>Live-Kameras</h2><p>WebRTC-Spectate</p></div></div>
<div class="card"><div class="empty"><div class="big">📹</div>
Die Live-Kameras sind deaktiviert oder der Media-Server ist nicht konfiguriert.<br>
Siehe <b>media-server/README.md</b> und die Convar <b>aether_admin_media_secret</b>.</div></div>`;
return;
}
let spieler = [];
try { spieler = (await App.aufruf("spieler", "liste")).spieler || []; } catch (e) {}
main.innerHTML = `<div class="phead"><div><h2>Live-Kameras</h2><p>WebRTC-Spectate · Einzel- & Multi-Cam</p></div>
${aktiveView ? '<button class="btn red" id="cam-zu">View schließen</button>' : ""}</div>
<div style="display:grid;grid-template-columns:230px 1fr;gap:12px;align-items:start">
<div class="card"><div class="list-h">Spieler wählen</div><div id="cam-picker" style="max-height:420px;overflow-y:auto">
${spieler.map((p) => `<div class="chk" data-pv="${p.id}"><span class="av" style="background:${App.avColor(p.name)}">${App.sicher(App.initialen(p.name))}</span><div class="grow" style="font-weight:700;font-size:13px">${App.sicher(p.name)}<span style="color:var(--muted);font-weight:500"> · ${App.sicher(p.region)}</span></div></div>`).join("") || '<div class="empty">Niemand online.</div>'}
</div><button class="btn primary" id="cam-start" style="width:100%;margin-top:10px" disabled>View öffnen</button></div>
<div><div class="camgrid" id="cam-grid" style="grid-template-columns:repeat(2,1fr)">${aktiveView ? "" : '<div class="empty" style="padding:60px"><div class="big">📹</div>Spieler links wählen und „View öffnen".</div>'}</div></div></div>`;
const gewaehlt = new Set();
main.querySelectorAll("[data-pv]").forEach((el) => el.addEventListener("click", () => {
const id = +el.dataset.pv;
if (gewaehlt.has(id)) { gewaehlt.delete(id); el.classList.remove("sel"); }
else { gewaehlt.add(id); el.classList.add("sel"); }
$("cam-start").disabled = gewaehlt.size === 0;
}));
const $ = App.$;
$("cam-start").addEventListener("click", () => starteView([...gewaehlt],
spieler.filter((p) => gewaehlt.has(p.id)).map((p) => p.name).join(", ")));
const zu = $("cam-zu"); if (zu) zu.addEventListener("click", schliesseView);
if (aktiveView) baueKacheln(spieler);
});
async function starteView(ids, titel) {
if (!ids.length) return;
let antwort;
try { antwort = await App.aufruf("kamera", "oeffnen", { ziele: ids }); }
catch (f) { return App.toast(f.message, "warn"); }
aktiveView = { raum: antwort.raum, ziele: ids, titel: titel };
App.oeffneSektion("cams"); // neu rendern mit aktiver View
App.toast(`View geöffnet · ${antwort.ziele} Ziel(e)`, "good");
Viewer.verbinde(antwort.url, antwort.token, antwort.raum, (stream) => {
// Neuen Stream als Kachel einhängen
const grid = App.$("cam-grid"); if (!grid) return;
const platz = grid.querySelector(".empty"); if (platz) platz.remove();
const id = "cam_" + Math.random().toString(36).slice(2, 8);
const tile = document.createElement("div");
tile.className = "camtile";
tile.innerHTML = `<video autoplay playsinline muted></video>
<div class="cam-hud"><div class="rec"><i></i>LIVE</div><div class="who">${App.sicher(aktiveView.titel || "Kamera")}</div></div>`;
tile.querySelector("video").srcObject = stream;
grid.appendChild(tile);
});
}
async function schliesseView() {
if (!aktiveView) return;
Viewer.trenne();
try { await App.aufruf("kamera", "schliessen", { raum: aktiveView.raum }); } catch (e) {}
aktiveView = null;
App.oeffneSektion("cams");
}
function baueKacheln() {
// Bereits laufende View nach einem Re-Render: die Video-Tracks kommen
// erneut über pc.ontrack; hier wird nur der leere Zustand entfernt.
}
// Aus dem Spieler-Dialog aufrufbar: direkt eine View auf einen Spieler öffnen
window.Kameras = { starte: (ids, name) => starteView(ids, name) };
})();