/* ===================================================================== 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 = `

Live-Kameras

WebRTC-Spectate

📹
Die Live-Kameras sind deaktiviert oder der Media-Server ist nicht konfiguriert.
Siehe media-server/README.md und die Convar aether_admin_media_secret.
`; return; } let spieler = []; try { spieler = (await App.aufruf("spieler", "liste")).spieler || []; } catch (e) {} main.innerHTML = `

Live-Kameras

WebRTC-Spectate · Einzel- & Multi-Cam

${aktiveView ? '' : ""}
Spieler wählen
${spieler.map((p) => `
${App.sicher(App.initialen(p.name))}
${App.sicher(p.name)} · ${App.sicher(p.region)}
`).join("") || '
Niemand online.
'}
${aktiveView ? "" : '
📹
Spieler links wählen und „View öffnen".
'}
`; 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 = `
LIVE
${App.sicher(aktiveView.titel || "Kamera")}
`; 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) }; })();