- 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>
248 lines
12 KiB
JavaScript
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) };
|
|
})();
|