/* ===================================================================== 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 = `
WebRTC-Spectate
WebRTC-Spectate · Einzel- & Multi-Cam