// 直播 SFU 分流 Demo 前端逻辑。控制面走 JSON 网关(protojson),媒体面走反向代理直连 SFU。 // 已集成登录与 Casbin 权限:自动附加 Authorization header,未登录重定向到 /login window.LiveSFU = (function () { const ENUM = { cloudflare: "BACKEND_KIND_CLOUDFLARE", srs: "BACKEND_KIND_SRS" }; const NAME = { BACKEND_KIND_CLOUDFLARE: "Cloudflare Realtime", BACKEND_KIND_SRS: "SRS" }; function getRoom() { const p = new URLSearchParams(location.search).get("room"); return (p && p.trim()) || "demo"; } function shortName(enumStr) { return NAME[enumStr] || enumStr; } // ----- Auth helpers ----- function getToken() { return localStorage.getItem("token") || ""; } function setToken(t) { if (t) localStorage.setItem("token", t); } function clearToken() { localStorage.removeItem("token"); } function authHeaders() { const h = {}; const tok = getToken(); if (tok) h["Authorization"] = "Bearer " + tok; return h; } function handleAuthError(status) { if (status === 401) { const next = encodeURIComponent(location.pathname + location.search); if (!location.pathname.startsWith("/login")) { location.href = "/login?next=" + next; } } } async function apiGet(path) { const r = await fetch(path, { headers: { ...authHeaders() } }); if (!r.ok) { handleAuthError(r.status); throw new Error(path + " -> " + r.status + " " + (await r.text())); } return r.json(); } async function apiGetWithAuth(path) { const r = await fetch(path, { headers: { ...authHeaders() } }); const text = await r.text(); if (!r.ok) { handleAuthError(r.status); throw new Error(text || (path + " -> " + r.status)); } return text ? JSON.parse(text) : {}; } async function apiPost(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), }); const text = await r.text(); if (!r.ok) { handleAuthError(r.status); throw new Error((text || r.status)); } return text ? JSON.parse(text) : {}; } async function apiPostWithAuth(path, body) { return apiPost(path, body); } async function login(username, password) { const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const text = await r.text(); if (!r.ok) throw new Error(text || ("login -> " + r.status)); const data = text ? JSON.parse(text) : {}; if (data.token) setToken(data.token); return data; } async function register(username, password, role) { const r = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ username, password, role }), }); const text = await r.text(); if (!r.ok) throw new Error(text || ("register -> " + r.status)); const data = text ? JSON.parse(text) : {}; if (data.token) setToken(data.token); return data; } async function getMe() { return apiGetWithAuth("/api/auth/me"); } async function authCheck() { try { const r = await fetch("/api/auth/check", { headers: { ...authHeaders() } }); return await r.json(); } catch(e) { return { enabled: false }; } } async function loadConfig(el) { try { const cfg = await apiGet("/api/config"); el.innerHTML = ""; (cfg.backends || []).forEach((b) => { const span = document.createElement("span"); span.className = "badge" + (b.primary ? " primary" : ""); span.textContent = shortName(b.kind) + (b.primary ? " · 主" : "") + (b.configured ? " · 就绪" : " · 未配置"); el.appendChild(span); }); const chk = await authCheck(); const authBadge = document.createElement("span"); authBadge.className = "badge " + (chk.authenticated ? "ok" : "bad"); authBadge.textContent = chk.authenticated ? ("已登录:" + chk.username + " / " + chk.role) : "未登录"; authBadge.style.cursor="pointer"; authBadge.onclick=()=>location.href="/login"; authBadge.title="点击去登录"; el.appendChild(authBadge); } catch (e) { el.innerHTML = '控制面不可达'; } } function log(el, msg) { const t = new Date().toLocaleTimeString(); el.textContent += "[" + t + "] " + msg + "\\n"; el.scrollTop = el.scrollHeight; } async function publishCF(sessionId, localStream, iceServers) { const pc = new RTCPeerConnection({ iceServers }); localStream.getTracks().forEach((t) => pc.addTrack(t, localStream)); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const resp = await fetch("/api/cf/sessions/" + sessionId + "/tracks/new", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ sessionDescription: { type: "offer", sdp: offer.sdp }, tracks: localStream.getTracks().map((t) => ({ location: "local", trackName: t.kind, kind: t.kind })), autoDiscover: true, }), }); if (!resp.ok) { handleAuthError(resp.status); throw new Error("cf publish failed: " + resp.status); } const data = await resp.json(); await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp }); return pc; } async function watchCF(viewerSessionId, publisherSessionId, iceServers, video) { const pc = new RTCPeerConnection({ iceServers }); pc.ontrack = (e) => { video.srcObject = e.streams[0]; }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const resp = await fetch("/api/cf/sessions/" + viewerSessionId + "/tracks/new", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ sessionDescription: { type: "offer", sdp: offer.sdp }, tracks: [ { location: "remote", sessionId: publisherSessionId, trackName: "audio" }, { location: "remote", sessionId: publisherSessionId, trackName: "video" }, ], autoDiscover: true, }), }); if (!resp.ok) { handleAuthError(resp.status); throw new Error("cf watch failed: " + resp.status); } const data = await resp.json(); await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp }); return pc; } async function publishSRS(room, token, stream, iceServers, localStream) { const pc = new RTCPeerConnection({ iceServers }); localStream.getTracks().forEach((t) => pc.addTrack(t, localStream)); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const url = "/rtc/v1/whip/?app=live&stream=" + encodeURIComponent(stream) + "&token=" + encodeURIComponent(token); const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp", ...authHeaders() }, body: offer.sdp }); if (!resp.ok) { handleAuthError(resp.status); throw new Error("SRS publish failed: " + resp.status + " " + (await resp.text())); } const answer = await resp.text(); await pc.setRemoteDescription({ type: "answer", sdp: answer }); return pc; } async function watchSRS(room, stream, iceServers, video) { const pc = new RTCPeerConnection({ iceServers }); pc.ontrack = (e) => { video.srcObject = e.streams[0]; }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const url = "/rtc/v1/whep/?app=live&stream=" + encodeURIComponent(stream); const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/sdp", ...authHeaders() }, body: offer.sdp }); if (!resp.ok) { handleAuthError(resp.status); throw new Error("SRS watch failed: " + resp.status + " " + (await resp.text())); } const answer = await resp.text(); await pc.setRemoteDescription({ type: "answer", sdp: answer }); return pc; } function findTarget(targets, enumStr) { return (targets || []).find((t) => t.backend === enumStr); } function initPublish() { const room = getRoom(); document.getElementById("roomName").textContent = room; const localVideo = document.getElementById("local"); const logEl = document.getElementById("log"); const statusEl = document.getElementById("status"); const pcs = {}; authCheck().then(chk=>{ if (!chk.authenticated) { log(logEl, "提示:未登录,发布需要 publisher 权限,将自动跳转登录页"); } else if (chk.permissions && !chk.permissions["room:publish"]) { log(logEl, "提示:当前角色 "+chk.role+" 无发布权限,需要 publisher 或 admin"); } }); document.getElementById("start").onclick = async () => { try { const backends = Array.from(document.querySelectorAll('input[name="backend"]:checked')).map((i) => i.value); if (!backends.length) return log(logEl, "请至少选择一个分发后端"); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = stream; for (const b of backends) { try { const resp = await apiPost("/api/publish", { room, backend: ENUM[b] }); if (b === "cloudflare") { pcs[b] = await publishCF(resp.sessionId, stream, resp.iceServers); } else { pcs[b] = await publishSRS(room, resp.publishToken, resp.stream, resp.iceServers, stream); } log(logEl, "已在 " + shortName(resp.target.backend) + " 发布(" + (resp.stream || resp.sessionId) + ")"); } catch (e) { log(logEl, "发布到 " + b + " 失败:" + e.message); } } renderStatus(statusEl, pcs); } catch (e) { log(logEl, "获取摄像头失败:" + e.message); } }; document.getElementById("stop").onclick = async () => { for (const b of Object.keys(pcs)) { try { await apiPost("/api/stop", { room, backend: ENUM[b] }); } catch (e) {} if (pcs[b]) pcs[b].close(); delete pcs[b]; } if (localVideo.srcObject) localVideo.srcObject.getTracks().forEach((t) => t.stop()); log(logEl, "已停止推流"); renderStatus(statusEl, pcs); }; } function renderStatus(el, pcs) { const keys = Object.keys(pcs); el.innerHTML = keys.length ? keys.map((k) => '分发中:' + shortName(ENUM[k]) + "").join("") : '未发布'; } function initWatch() { const room = getRoom(); document.getElementById("roomName").textContent = room; const remoteVideo = document.getElementById("remote"); const logEl = document.getElementById("log"); const statusEl = document.getElementById("status"); let pc = null; let es = null; let watching = false; function backend() { return document.querySelector('input[name="watchBackend"]:checked').value; } authCheck().then(chk=>{ if (!chk.authenticated) { log(logEl, "提示:未登录,观看需要登录,将自动跳转"); } else if (chk.permissions && !chk.permissions["room:subscribe"]) { log(logEl, "提示:当前角色 "+chk.role+" 无订阅权限"); } }); async function subscribe() { if (watching) return; const b = backend(); try { const resp = await apiPost("/api/subscribe", { room, backend: ENUM[b] }); if (b === "cloudflare") { pc = await watchCF(resp.sessionId, resp.publisherSessionId, resp.iceServers, remoteVideo); } else { pc = await watchSRS(room, resp.stream, resp.iceServers, remoteVideo); } watching = true; log(logEl, "已从 " + shortName(ENUM[b]) + " 拉流"); statusEl.innerHTML = '观看中:' + shortName(ENUM[b]) + ""; } catch (e) { log(logEl, "订阅失败:" + e.message); } } document.getElementById("start").onclick = () => { const token = getToken(); const esUrl = "/api/room/" + encodeURIComponent(room) + "/events" + (token ? "?token="+encodeURIComponent(token) : ""); es = new EventSource(esUrl); es.addEventListener("room", (ev) => { const data = JSON.parse(ev.data); const t = findTarget(data.targets, ENUM[backend()]); statusEl.innerHTML = t ? '房间在 ' + shortName(ENUM[backend()]) + " 已直播" : '等待 ' + shortName(ENUM[backend()]) + " 推流…"; if (t && !watching) subscribe(); }); es.onerror = (e)=>{ log(logEl, "房间事件流错误,可能是未登录或权限不足"); }; log(logEl, "已连接房间事件流"); }; document.getElementById("stop").onclick = () => { if (pc) pc.close(); pc = null; if (es) es.close(); es = null; watching = false; remoteVideo.srcObject = null; statusEl.innerHTML = '未观看'; log(logEl, "已停止观看"); }; } return { ENUM, getRoom, loadConfig, log, initPublish, initWatch, apiGet, apiPost, apiGetWithAuth, apiPostWithAuth, login, register, getMe, authCheck, getToken, setToken, clearToken, authHeaders }; })();