// 直播 SFU 分流 Demo 前端逻辑。控制面走 JSON 网关(protojson),媒体面走反向代理直连 SFU。 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; } async function apiGet(path) { const r = await fetch(path); if (!r.ok) throw new Error(path + " -> " + r.status); return r.json(); } async function apiPost(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await r.text(); if (!r.ok) throw new Error((text || r.status)); return text ? JSON.parse(text) : {}; } 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); }); } catch (e) { el.innerHTML = '控制面不可达'; } } function log(el, msg) { const t = new Date().toLocaleTimeString(); el.textContent += "[" + t + "] " + msg + "\n"; el.scrollTop = el.scrollHeight; } // ---------- WebRTC:Cloudflare Realtime ---------- 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" }, body: JSON.stringify({ sessionDescription: { type: "offer", sdp: offer.sdp }, tracks: localStream.getTracks().map((t) => ({ location: "local", trackName: t.kind, kind: t.kind })), autoDiscover: true, }), }); 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" }, body: JSON.stringify({ sessionDescription: { type: "offer", sdp: offer.sdp }, tracks: [ { location: "remote", sessionId: publisherSessionId, trackName: "audio" }, { location: "remote", sessionId: publisherSessionId, trackName: "video" }, ], autoDiscover: true, }), }); const data = await resp.json(); await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp }); return pc; } // ---------- WebRTC:SRS (WHIP/WHEP) ---------- 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" }, body: offer.sdp }); 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" }, body: offer.sdp }); 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 = {}; 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; } 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 = () => { es = new EventSource("/api/room/" + encodeURIComponent(room) + "/events"); 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(); }); 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 }; })();