live-sfu-demo/internal/server/static/app.js

231 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 直播 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 = '<span class="badge bad">控制面不可达</span>';
}
}
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) => '<span class="target ' + k + '">分发中:' + shortName(ENUM[k]) + "</span>").join("")
: '<span class="muted">未发布</span>';
}
// ---------- 观看页 ----------
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 = '<span class="target ' + b + '">观看中:' + shortName(ENUM[b]) + "</span>";
} 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
? '<span class="target ' + backend() + '">房间在 ' + shortName(ENUM[backend()]) + " 已直播</span>"
: '<span class="muted">等待 ' + shortName(ENUM[backend()]) + " 推流…</span>";
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 = '<span class="muted">未观看</span>';
log(logEl, "已停止观看");
};
}
return { ENUM, getRoom, loadConfig, log, initPublish, initWatch, apiGet, apiPost };
})();