feat(web): rewrite frontend as SolidJS + Tailwind v4 SPA with chat UI

Replace the legacy vanilla HTML/CSS/JS pages with a SolidJS single-page
app (TanStack Router/Query, Ark UI, Tailwind v4) and embed its build
output into the Go binary via //go:embed. SPA routes fall back to
index.html. Includes the room chat/danmaku panel, color picker and
broadcaster overlay on the watch/publish pages. Add web/node_modules
and local data to .gitignore.
This commit is contained in:
noelorin 2026-08-20 15:03:03 +08:00
parent 115b34dcd3
commit 340296f6cc
57 changed files with 5093 additions and 721 deletions

3
.gitignore vendored
View File

@ -3,3 +3,6 @@
*.local.env
data/*
!data/.gitkeep
# 前端工程
web/node_modules

View File

@ -1,343 +0,0 @@
// 直播 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 = '<span class="badge bad">控制面不可达</span>';
}
}
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) => '<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; }
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 = '<span class="target ' + b + '">观看中:' + shortName(ENUM[b]) + "</span>";
} 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
? '<span class="target ' + backend() + '">房间在 ' + shortName(ENUM[backend()]) + " 已直播</span>"
: '<span class="muted">等待 ' + shortName(ENUM[backend()]) + " 推流…</span>";
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 = '<span class="muted">未观看</span>';
log(logEl, "已停止观看");
};
}
return { ENUM, getRoom, loadConfig, log, initPublish, initWatch, apiGet, apiPost, apiGetWithAuth, apiPostWithAuth, login, register, getMe, authCheck, getToken, setToken, clearToken, authHeaders };
})();

View File

@ -0,0 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>e(n,t(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -0,0 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>e(n,t(i,{iconNode:r,name:`cloud`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{E as e,F as t,U as n,V as r,b as i,c as a,f as o,h as s,i as c,j as l,k as u,m as d,p as f,r as p,t as m,u as h,x as g,y as _}from"./ui-W1Pm8dqI.js";import{i as v,n as y}from"./index-CFU6bGFM.js";var b=[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]],x=e=>t(s,n(e,{iconNode:b,name:`log-in`})),S=e(`<form class=space-y-4><div class=space-y-1.5><label class=label for=login-username>用户名</label></div><div class=space-y-1.5><label class=label for=login-password>密码</label></div><div class=space-y-1.5><label class=label>角色`),C=e(`<div class="mx-auto flex w-full max-w-md flex-col gap-4 py-4"><div class="flex flex-col items-center gap-3 text-center"><span class="flex size-10 items-center justify-center rounded-lg border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm"></span><div><h1 class="text-xl font-semibold">登录分流控制台</h1><p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p></div></div><div class="rounded-lg border bg-card p-4"><p class="label mb-2">演示账号</p><div class="grid gap-2 sm:grid-cols-3">`),w=e(`<button type=button class="flex flex-col items-start gap-0.5 rounded-md border bg-background/60 px-3 py-2 text-left transition-colors hover:bg-accent"><span class="text-sm font-medium"></span><span class="text-xs text-muted-foreground"></span><span class="font-mono text-[10px] text-muted-foreground/80">`),T=[{name:`admin`,role:`管理员`,pass:`Admin123!`},{name:`publisher`,role:`发布者`,pass:`Publisher123!`},{name:`viewer`,role:`观看者`,pass:`Viewer123!`}],E=()=>{let e=v(),[n,s]=r(``),[_,b]=r(``),[E,D]=r(`viewer`),[O,k]=r(``),[A,j]=r(!1),M=async t=>{t.preventDefault(),k(``),j(!0);try{await d.login(n(),_()),await f(),await o.refresh(),e({to:`/`})}catch(e){k(String(e?.message||e))}finally{j(!1)}};return(()=>{var e=C(),r=e.firstChild,o=r.firstChild,d=r.nextSibling,f=d.firstChild.nextSibling;return i(o,t(y,{class:`size-5`})),i(e,t(c,{class:`p-6`,get children(){return[(()=>{var e=S(),r=e.firstChild;r.firstChild;var o=r.nextSibling;o.firstChild;var c=o.nextSibling;return c.firstChild,e.addEventListener(`submit`,M),i(r,t(h,{id:`login-username`,get value(){return n()},onInput:s,placeholder:`admin`,autoComplete:`username`,class:`h-9`}),null),i(o,t(h,{id:`login-password`,type:`password`,get value(){return _()},onInput:b,placeholder:`••••••••`,autoComplete:`current-password`,class:`h-9`}),null),i(c,t(a,{get value(){return E()},onChange:e=>D(e),options:[{value:`viewer`,label:`观看者 viewer`},{value:`publisher`,label:`发布者 publisher`}]}),null),i(e,t(p,{type:`submit`,get disabled(){return A()},class:`w-full`,get children(){return[t(x,{class:`size-4`}),g(()=>A()?`登录中…`:`登录`)]}}),null),e})(),t(l,{get when(){return O()},get children(){return t(m,{tone:`error`,class:`mt-4`,get children(){return O()}})}})]}}),d),i(f,t(u,{each:T,children:e=>(()=>{var t=w(),n=t.firstChild,r=n.nextSibling,a=r.nextSibling;return t.$$click=()=>{s(e.name),b(e.pass),D(e.role===`管理员`?`publisher`:`viewer`)},i(n,()=>e.name),i(r,()=>e.role),i(a,()=>e.pass),t})()})),e})()};_([`click`]);export{E as Login};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M16.247 7.761a6 6 0 0 1 0 8.478`,key:`1fwjs5`}],[`path`,{d:`M19.075 4.933a10 10 0 0 1 0 14.134`,key:`ehdyv1`}],[`path`,{d:`M4.925 19.067a10 10 0 0 1 0-14.134`,key:`1q22gi`}],[`path`,{d:`M7.753 16.239a6 6 0 0 1 0-8.478`,key:`r2q7qm`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}]],i=i=>e(n,t(i,{iconNode:r,name:`radio`}));export{i as t};

View File

@ -0,0 +1 @@
import{E as e,F as t,L as n,U as r,V as i,b as a,f as o,h as s,i as c,j as l,k as u,m as d,o as f,r as p,u as m}from"./ui-W1Pm8dqI.js";import{t as h}from"./arrow-right-DNPSxlbJ.js";import{t as g}from"./radio-CvpQ__fB.js";import{i as _}from"./index-CFU6bGFM.js";var v=[[`path`,{d:`M11 20H2`,key:`nlcfvz`}],[`path`,{d:`M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z`,key:`au4z13`}],[`path`,{d:`M11 4H8a2 2 0 0 0-2 2v14`,key:`74r1mk`}],[`path`,{d:`M14 12h.01`,key:`1jfl7z`}],[`path`,{d:`M22 20h-3`,key:`vhrsz`}]],y=e=>t(s,r(e,{iconNode:v,name:`door-open`})),b=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],x=e=>t(s,r(e,{iconNode:b,name:`plus`})),S=e(`<div class="flex items-center gap-2"><h3 class=font-semibold>创建房间`),C=e(`<div class="flex flex-col gap-2 sm:flex-row">`),w=e(`<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">`),T=e(`<div class=space-y-6><div class=space-y-1.5><h1 class="text-2xl font-semibold">房间</h1><p class="text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。</p></div><section class=space-y-3><div class="flex items-center gap-2"><h2 class="text-lg font-semibold">房间列表`),E=e(`<div class="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-10 text-center"><span class="flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground"></span><div><p class=font-medium>暂无房间</p><p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。`),D=e(`<div class="flex items-center justify-between gap-2"><div class="flex min-w-0 items-center gap-2"><span class="truncate font-medium"></span></div><span class="shrink-0 font-mono text-xs text-muted-foreground"> 路分发`),O=()=>{let e=_(),[r,s]=i([]),[v,b]=i(``),[O,k]=i(!1),A=()=>d.listRooms().then(e=>s(e.rooms||[])).catch(()=>{});n(()=>{o.isAuthed()&&A()});let j=async()=>{let t=v().trim();if(t){k(!0);try{await d.createRoom(t),e({to:`/room`,search:{room:t}})}catch(e){alert(String(e?.message||e))}finally{k(!1)}}},M=t=>e({to:`/room`,search:{room:t}});return(()=>{var e=T(),n=e.firstChild.nextSibling,i=n.firstChild,o=i.firstChild;return a(e,t(c,{class:`space-y-3 p-5`,get children(){return[(()=>{var e=S(),n=e.firstChild;return a(e,t(x,{class:`size-4 text-primary`}),n),e})(),(()=>{var e=C();return a(e,t(m,{get value(){return v()},onInput:b,placeholder:`房间名,如 demo`,class:`sm:max-w-xs`,onKeyDown:e=>{e.key===`Enter`&&j()}}),null),a(e,t(p,{onClick:j,get disabled(){return O()},get children(){return[t(h,{class:`size-4`}),`创建并进入`]}}),null),e})()]}}),n),a(i,t(g,{class:`size-4 text-primary`}),o),a(n,t(l,{get when(){return r().length>0},get fallback(){return(()=>{var e=E(),n=e.firstChild;return a(n,t(y,{class:`size-5`})),e})()},get children(){var e=w();return a(e,t(u,{get each(){return r()},children:e=>t(c,{class:`flex flex-col gap-3 p-4`,get children(){return[(()=>{var n=D(),r=n.firstChild,i=r.firstChild,o=r.nextSibling,s=o.firstChild;return a(r,t(f,{get active(){return(e.targets?.length??0)>0}}),i),a(i,()=>e.name),a(o,()=>e.targets?.length??0,s),n})(),t(p,{variant:`outline`,class:`w-full`,onClick:()=>M(e.name),get children(){return[`进入房间`,t(h,{class:`size-4`})]}})]}})})),e}}),null),e})()};export{O as Rooms};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>e(n,t(i,{iconNode:r,name:`server`}));export{i as t};

View File

@ -0,0 +1 @@
import{F as e,U as t,h as n}from"./ui-W1Pm8dqI.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>e(n,t(i,{iconNode:r,name:`shield-check`}));export{i as t};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{E as e,F as t,L as n,U as r,V as i,b as a,c as o,f as s,h as c,i as l,j as u,k as d,m as f,n as p,t as m,v as h,x as g,z as _}from"./ui-W1Pm8dqI.js";import{t as v}from"./shield-check-D8IU7TWB.js";var y=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],b=e=>t(c,r(e,{iconNode:y,name:`shield-alert`})),x=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],S=e=>t(c,r(e,{iconNode:x,name:`user-round`})),C=e(`<div class="hidden grid-cols-[1fr_auto_auto] gap-4 border-b px-5 py-3 text-xs font-medium text-muted-foreground sm:grid"><span>用户名</span><span class=w-28>当前角色</span><span class="w-32 text-right">操作`),w=e(`<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">暂无用户数据`),T=e(`<div class=space-y-6><div class=space-y-1.5><h1 class="text-2xl font-semibold">用户与角色</h1><p class="text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。`),E=e(`<span class="flex items-center gap-2">无权限:需要 user:list`),D=e(`<div><div class="flex min-w-0 items-center gap-3"><span></span><div class=min-w-0><p class="truncate text-sm font-medium"></p><p class="font-mono text-[11px] text-muted-foreground"></p></div></div><div class="flex items-center justify-end gap-2">`),O=[`viewer`,`publisher`,`admin`],k={admin:{label:`管理员`,badge:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`,avatar:`border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta`},publisher:{label:`发布者`,badge:`border-warning/30 bg-warning/10 text-warning`,avatar:`border-warning/30 bg-warning/10 text-warning`},viewer:{label:`观看者`,badge:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`,avatar:`border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan`},guest:{label:`访客`,badge:`border-transparent bg-muted text-muted-foreground`,avatar:`border-transparent bg-muted text-muted-foreground`}},A=()=>{let[e,r]=i([]),[c,y]=i(``);n(()=>{s.can(`user:list`)&&f.listUsers().then(e=>r(e.users||[])).catch(e=>y(String(e?.message||e)))});let x=async(e,t)=>{try{await f.updateUserRole(e,t),r(n=>n.map(n=>n.username===e?{...n,role:t}:n))}catch(e){y(String(e?.message||e))}};return(()=>{var n=T();return n.firstChild,a(n,t(u,{get when(){return s.can(`user:list`)},get fallback(){return t(m,{tone:`warning`,get children(){var e=E(),n=e.firstChild;return a(e,t(b,{class:`size-4`}),n),e}})},get children(){return t(l,{get children(){return[t(u,{get when(){return c()},get children(){return t(m,{tone:`error`,class:`rounded-none border-x-0 border-t-0`,get children(){return c()}})}}),C(),t(d,{get each(){return e()},children:(n,r)=>(()=>{var i=D(),c=i.firstChild,l=c.firstChild,d=l.nextSibling.firstChild,f=d.nextSibling,m=c.nextSibling;return a(l,()=>(n.username[0]??`?`).toUpperCase()),a(d,()=>n.username),a(f,()=>k[n.role].label),a(i,t(p,{get class(){return k[n.role].badge},get children(){return n.role}}),m),a(m,t(u,{get when(){return s.can(`user:manage`)},get fallback(){return t(S,{class:`size-4 text-muted-foreground/60`})},get children(){return t(o,{class:`w-32`,get value(){return n.role},get"aria-label"(){return`调整 ${n.username} 的角色`},onChange:e=>x(n.username,e),get options(){return O.map(e=>({value:e,label:e}))}})}})),_(t=>{var a=`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${r()<e().length-1?`border-b`:``}`,o=`flex size-8 shrink-0 items-center justify-center rounded-md border text-xs font-bold ${k[n.role].avatar}`;return a!==t.e&&h(i,t.e=a),o!==t.t&&h(l,t.t=o),t},{e:void 0,t:void 0}),i})()}),t(u,{get when(){return g(()=>e().length===0)()&&!c()},get children(){var e=w(),n=e.firstChild;return a(e,t(v,{class:`size-4 text-success`}),n),e}})]}})}}),null),n})()};export{A as Users};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,60 +1,29 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>直播 SFU 分流 Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一路推流,多端扇出:Cloudflare Realtime 与 SRS 双 SFU 分发控制台" />
<title>直播 SFU 分流控制台</title>
<script>
(function () {
try {
var t = localStorage.getItem('live-sfu-theme')
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.toggle('dark', t === 'dark')
document.documentElement.style.colorScheme = t
} catch (e) {
document.documentElement.classList.add('light')
}
})()
</script>
<script type="module" crossorigin src="/static/assets/index-CFU6bGFM.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-W1Pm8dqI.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-Djwy0HhI.css">
</head>
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
<nav>
<a href="/publish">开始直播</a>
<a href="/watch">观看直播</a>
<a href="/login">登录</a>
<span id="navAuth" class="muted" style="margin-left:12px;"></span>
</nav>
</div>
<div class="band">
<div class="hero">
<h1>一路推流,多路扇出</h1>
<p>基于 GOSpeak 技术栈的直播 SFU 分流演示。控制面用 protobuf/gRPC 定义,主 SFU 为
<b>Cloudflare Realtime</b>,<b>SRS</b> 作为本地对照后端。同一路发布可同时落到多条分发链路,观众任选后端拉流。</p>
</div>
<div class="grid">
<div class="card">
<h3>进入房间</h3>
<div class="row">
<input type="text" id="room" placeholder="房间名,例如 demo" />
<button onclick="goPublish()">开始直播</button>
<button class="secondary" onclick="goWatch()">观看直播</button>
</div>
<div class="muted" style="margin-top:10px">提示:推流与观看使用同一房间名即可配对。需先登录(默认 admin/Admin123! / publisher/Publisher123! / viewer/Viewer123!)。</div>
</div>
<div class="card">
<h3>后端能力</h3>
<div id="backends" class="targets"><span class="muted">加载中…</span></div>
<div class="muted" style="margin-top:10px">Cloudflare Realtime 需在 <code>.env</code> 配置 <code>CF_APP_ID</code> / <code>CF_APP_SECRET</code>;
SRS 由本地 <code>docker compose up -d srs</code> 提供。</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
LiveSFU.loadConfig(document.getElementById('backends'));
LiveSFU.authCheck().then(chk=>{
const el=document.getElementById('navAuth');
if(chk.authenticated){ el.textContent='已登录:'+chk.username+'/'+chk.role; const a=document.createElement('a'); a.href='#'; a.textContent=' 退出'; a.onclick=()=>{LiveSFU.clearToken(); location.reload();}; el.appendChild(a); } else { el.innerHTML='<a href="/login">未登录,点击登录</a>'; }
});
function goPublish() {
const r = document.getElementById('room').value.trim() || 'demo';
location.href = '/publish?room=' + encodeURIComponent(r);
}
function goWatch() {
const r = document.getElementById('room').value.trim() || 'demo';
location.href = '/watch?room=' + encodeURIComponent(r);
}
</script>
<div id="root"></div>
</body>
</html>

View File

@ -1,152 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录 · 直播 SFU 分流 Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
<style>
.login-card { max-width: 420px; margin: 60px auto; }
.role-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; background:#eef; margin-left:6px; }
.tabs { display:flex; gap:8px; margin-bottom:16px; }
.tabs button { flex:1; }
.tabs button.active { background:#111; color:#fff; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>登录 · Casbin 权限</small></div>
<nav><a href="/">首页</a><a href="/publish">开始直播</a><a href="/watch">观看直播</a></nav>
</div>
<div class="band">
<div class="card login-card">
<h3 id="formTitle">登录</h3>
<div class="tabs">
<button id="tabLogin" class="active" onclick="switchTab('login')">登录</button>
<button id="tabRegister" onclick="switchTab('register')">注册</button>
</div>
<div class="muted" style="margin-bottom:12px">
演示账户:<code>admin / Admin123!</code>(管理员)<br/>
<code>publisher / Publisher123!</code>(可推流/拉流)<br/>
<code>viewer / Viewer123!</code>(仅观看)
<div id="authStatus" style="margin-top:8px;"></div>
</div>
<div>
<label>用户名</label>
<input type="text" id="username" placeholder="username" style="width:100%;margin:6px 0 10px;" />
<label>密码</label>
<input type="password" id="password" placeholder="password" style="width:100%;margin:6px 0 10px;" />
<div id="registerRoleWrap" style="display:none;">
<label>角色(仅管理员注册时可指定)</label>
<select id="role" style="width:100%;margin:6px 0 10px;">
<option value="viewer">viewer - 仅观看</option>
<option value="publisher">publisher - 可推流</option>
<option value="admin">admin - 管理员</option>
</select>
</div>
<div class="row" style="margin-top:10px">
<button id="submitBtn" onclick="submit()">登录</button>
<button class="secondary" onclick="logout()">退出登录</button>
</div>
<div id="msg" class="muted" style="margin-top:10px; white-space:pre-wrap;"></div>
</div>
<div id="me" class="muted" style="margin-top:16px; border-top:1px solid #eee; padding-top:12px;"></div>
<div id="adminPanel" style="display:none; margin-top:16px;">
<h4>用户管理(仅 admin)</h4>
<div id="users" class="targets"></div>
<div class="row" style="margin-top:8px;">
<input type="text" id="targetUser" placeholder="用户名" />
<select id="newRole">
<option value="viewer">viewer</option>
<option value="publisher">publisher</option>
<option value="admin">admin</option>
</select>
<button onclick="updateRole()">更新角色</button>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
let mode = 'login';
function switchTab(m) {
mode = m;
document.getElementById('tabLogin').classList.toggle('active', m==='login');
document.getElementById('tabRegister').classList.toggle('active', m==='register');
document.getElementById('formTitle').textContent = m==='login' ? '登录' : '注册';
document.getElementById('submitBtn').textContent = m==='login' ? '登录' : '注册';
document.getElementById('registerRoleWrap').style.display = m==='register' ? 'block' : 'none';
}
async function refreshMe() {
const meEl = document.getElementById('me');
const statusEl = document.getElementById('authStatus');
const adminPanel = document.getElementById('adminPanel');
try {
const data = await LiveSFU.apiGetWithAuth('/api/auth/me');
meEl.innerHTML = '已登录:<b>' + data.username + '</b> <span class="role-badge">'+data.role+'</span> <br/>过期:' + new Date(data.expires_at*1000).toLocaleString();
statusEl.innerHTML = '<span class="badge primary">已登录</span> ' + data.username + ' / ' + data.role;
if (data.role === 'admin') {
adminPanel.style.display = 'block';
try {
const users = await LiveSFU.apiGetWithAuth('/api/auth/users');
const container = document.getElementById('users');
container.innerHTML = '';
(users.users||[]).forEach(u=>{
const span=document.createElement('span');
span.className='badge';
span.textContent=u.username+' / '+u.role;
container.appendChild(span);
});
} catch(e) {
document.getElementById('users').innerHTML='<span class="muted">'+e.message+'</span>';
}
} else {
adminPanel.style.display='none';
}
} catch(e) {
meEl.textContent='未登录:'+e.message;
statusEl.innerHTML='<span class="badge bad">未登录</span> 请先登录';
adminPanel.style.display='none';
}
}
async function submit() {
const u=document.getElementById('username').value.trim();
const p=document.getElementById('password').value;
const role=document.getElementById('role').value;
const msg=document.getElementById('msg');
msg.textContent='提交中...';
try {
let data;
if (mode==='login') {
data = await LiveSFU.login(u,p);
} else {
data = await LiveSFU.register(u,p,role);
}
msg.textContent='成功:'+JSON.stringify(data,null,2);
await refreshMe();
} catch(e) {
msg.textContent='失败:'+e.message;
}
}
async function logout() {
await fetch('/api/auth/logout',{method:'POST', headers: LiveSFU.authHeaders()});
LiveSFU.clearToken();
document.getElementById('msg').textContent='已退出';
await refreshMe();
}
async function updateRole() {
const username=document.getElementById('targetUser').value.trim();
const role=document.getElementById('newRole').value;
const msg=document.getElementById('msg');
try {
const data=await LiveSFU.apiPostWithAuth('/api/auth/users/role', {username, role});
msg.textContent='更新成功:'+JSON.stringify(data);
await refreshMe();
} catch(e) {
msg.textContent='更新失败:'+e.message;
}
}
refreshMe();
</script>
</body>
</html>

View File

@ -1,46 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>开始直播 · 直播 SFU 分流 Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
<nav><a href="/">首页</a><a href="/watch">观看直播</a><a href="/login">登录</a><span id="navAuth" class="muted" style="margin-left:12px;"></span></nav>
</div>
<div class="band">
<div class="hero"><h1>开始直播</h1><p>房间 <b id="roomName">demo</b> · 选择分发后端,一路推流将扇出到所选 SFU。</p></div>
<div class="two">
<div class="card">
<h3>本地预览</h3>
<video id="local" autoplay playsinline muted></video>
<div class="check">
<label><input type="checkbox" name="backend" value="cloudflare" checked /> Cloudflare Realtime(主 SFU)</label>
</div>
<div class="check">
<label><input type="checkbox" name="backend" value="srs" checked /> SRS(本地对照)</label>
</div>
<div class="row" style="margin-top:10px">
<button id="start">开始推流</button>
<button id="stop" class="danger">停止推流</button>
</div>
<div id="status" style="margin-top:10px"><span class="muted">未发布</span></div>
</div>
<div class="card">
<h3>分发状态</h3>
<div id="status2" class="muted">尚未开始</div>
<h3 style="margin-top:14px">日志</h3>
<div id="log" class="log"></div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
LiveSFU.authCheck().then(chk=>{const el=document.getElementById('navAuth'); if(chk.authenticated){el.textContent='已登录:'+chk.username+'/'+chk.role;} else {el.innerHTML='<a href="/login">未登录</a>';}});
LiveSFU.initPublish();
</script>
</body>
</html>

View File

@ -1,79 +0,0 @@
:root {
--bg: #0b0f17;
--panel: #121826;
--panel-2: #18202f;
--border: #243044;
--text: #e6edf6;
--muted: #93a1b5;
--accent: #38bdf8;
--accent-2: #f59e0b;
--ok: #34d399;
--bad: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 22px; border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #0e1422, #0b0f17);
}
.topbar .brand { font-weight: 700; letter-spacing: .3px; }
.topbar .brand small { color: var(--muted); font-weight: 500; margin-left: 8px; }
.topbar nav a { margin-left: 18px; color: var(--muted); font-size: 14px; }
.topbar nav a:hover { color: var(--text); }
.band { max-width: 1100px; margin: 0 auto; padding: 28px 22px; }
.hero h1 { font-size: 30px; margin: 0 0 8px; }
.hero p { color: var(--muted); max-width: 720px; margin: 0 0 18px; }
.grid { display: grid; gap: 16px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 16px 18px;
}
.card h3 { margin: 0 0 10px; font-size: 15px; }
input[type="text"] {
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
padding: 10px 12px; border-radius: 8px; font-size: 15px; min-width: 240px;
}
button {
background: var(--accent); color: #04121c; border: 0; border-radius: 8px;
padding: 10px 16px; font-size: 14px; font-weight: 600; cursor: pointer;
}
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
button.danger { background: var(--bad); color: #1a0606; }
button:disabled { opacity: .5; cursor: not-allowed; }
button:hover:not(:disabled) { filter: brightness(1.08); }
.check { display: flex; gap: 10px; align-items: center; padding: 8px 0; }
.check label { display: flex; gap: 8px; align-items: center; cursor: pointer; }
.badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
.badge.primary { color: var(--accent-2); border-color: #4a3a16; }
.badge.ok { color: var(--ok); border-color: #14402f; }
.badge.bad { color: var(--bad); border-color: #4a1c1c; }
video {
width: 100%; background: #000; border-radius: 10px; border: 1px solid var(--border);
aspect-ratio: 16 / 9; object-fit: contain;
}
.log {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
background: #0a0e16; border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; height: 180px; overflow: auto; color: var(--muted); white-space: pre-wrap;
}
.kv { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.kv:last-child { border-bottom: 0; }
.targets { display: flex; gap: 10px; flex-wrap: wrap; }
.target {
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; font-size: 13px;
background: var(--panel-2);
}
.target.cf { border-color: #4a3a16; }
.target.srs { border-color: #1b3550; }
.muted { color: var(--muted); font-size: 13px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 820px) { .two { grid-template-columns: 1fr; } }

View File

@ -1,44 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>观看直播 · 直播 SFU 分流 Demo</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="topbar">
<div class="brand">直播 SFU 分流 Demo <small>Cloudflare Realtime · SRS</small></div>
<nav><a href="/">首页</a><a href="/publish">开始直播</a><a href="/login">登录</a><span id="navAuth" class="muted" style="margin-left:12px;"></span></nav>
</div>
<div class="band">
<div class="hero"><h1>观看直播</h1><p>房间 <b id="roomName">demo</b> · 选择从哪个 SFU 拉流,房间一旦直播即自动连接。</p></div>
<div class="two">
<div class="card">
<h3>远端画面</h3>
<video id="remote" autoplay playsinline></video>
<div class="check">
<label><input type="radio" name="watchBackend" value="cloudflare" checked /> Cloudflare Realtime(主 SFU)</label>
</div>
<div class="check">
<label><input type="radio" name="watchBackend" value="srs" /> SRS(本地对照)</label>
</div>
<div class="row" style="margin-top:10px">
<button id="start">开始观看</button>
<button id="stop" class="danger">停止观看</button>
</div>
<div id="status" style="margin-top:10px"><span class="muted">未观看</span></div>
</div>
<div class="card">
<h3>日志</h3>
<div id="log" class="log"></div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
<script>
LiveSFU.authCheck().then(chk=>{const el=document.getElementById('navAuth'); if(chk.authenticated){el.textContent='已登录:'+chk.username+'/'+chk.role;} else {el.innerHTML='<a href="/login">未登录</a>';}});
LiveSFU.initWatch();
</script>
</body>
</html>

27
web/index.html Normal file
View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一路推流,多端扇出:Cloudflare Realtime 与 SRS 双 SFU 分发控制台" />
<title>直播 SFU 分流控制台</title>
<script>
(function () {
try {
var t = localStorage.getItem('live-sfu-theme')
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.classList.toggle('dark', t === 'dark')
document.documentElement.style.colorScheme = t
} catch (e) {
document.documentElement.classList.add('light')
}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

30
web/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "live-sfu-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ark-ui/solid": "5.38.2",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@tanstack/solid-query": "5.101.4",
"@tanstack/solid-router": "1.170.29",
"@tanstack/solid-virtual": "3.13.37",
"hls.js": "^1.5.20",
"lucide-solid": "^1.33.0",
"solid-js": "1.9.15"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"tailwindcss": "4.3.3",
"typescript": "5.9.3",
"vite": "8.2.1",
"vite-plugin-solid": "2.11.14"
}
}

2424
web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

52
web/src/app/router.tsx Normal file
View File

@ -0,0 +1,52 @@
import { RootRoute, Route } from '@tanstack/solid-router'
import { lazy } from 'solid-js'
import { Layout } from './routes/layout'
// 路由级 code-splitting:hls.js / 各页面按需加载,缩小首屏 chunk
const Home = lazy(() => import('./routes/index').then((m) => ({ default: m.Home })))
const Publish = lazy(() => import('./routes/publish').then((m) => ({ default: m.Publish })))
const WatchRoute = lazy(() => import('./routes/watch').then((m) => ({ default: m.WatchRoute })))
const RoomRoute = lazy(() => import('./routes/watch').then((m) => ({ default: m.RoomRoute })))
const Rooms = lazy(() => import('./routes/rooms').then((m) => ({ default: m.Rooms })))
const Users = lazy(() => import('./routes/users').then((m) => ({ default: m.Users })))
const Login = lazy(() => import('./routes/login').then((m) => ({ default: m.Login })))
const rootRoute = new RootRoute({ component: Layout })
const homeRoute = new Route({ getParentRoute: () => rootRoute, path: '/', component: Home as any })
const publishRoute = new Route({
getParentRoute: () => rootRoute,
path: '/publish',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }),
component: Publish as any,
})
const watchRoute = new Route({
getParentRoute: () => rootRoute,
path: '/watch',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }),
component: WatchRoute as any,
})
const roomRoute = new Route({
getParentRoute: () => rootRoute,
path: '/room',
validateSearch: (s: any) => ({ room: typeof s?.room === 'string' ? s.room : 'demo' }),
component: RoomRoute as any,
})
const roomsRoute = new Route({ getParentRoute: () => rootRoute, path: '/rooms', component: Rooms as any })
const usersRoute = new Route({ getParentRoute: () => rootRoute, path: '/users', component: Users as any })
const loginRoute = new Route({ getParentRoute: () => rootRoute, path: '/login', component: Login as any })
export const routeTree = rootRoute.addChildren([
homeRoute,
publishRoute,
watchRoute,
roomRoute,
roomsRoute,
usersRoute,
loginRoute,
])

View File

@ -0,0 +1,209 @@
import { Component, createSignal, onMount, Show, For } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router'
import {
ArrowRight,
Cloud,
MonitorPlay,
RadioTower,
Server,
ShieldCheck,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Badge, Button, Card, LiveDot, Separator, TextInput } from '../../components/ui'
import type { BackendInfo, BackendKind, Room } from '../../lib/types'
function backendIcon(kind: string) {
if (kind === 'BACKEND_KIND_CLOUDFLARE') return Cloud
return Server
}
function roomTargets(r: Room) {
return r.targets?.length ?? 0
}
export const Home: Component = () => {
const navigate = useNavigate()
const [backends, setBackends] = createSignal<BackendInfo[]>([])
const [rooms, setRooms] = createSignal<Room[]>([])
const [room, setRoom] = createSignal('demo')
onMount(async () => {
try {
const c = await api.getConfig()
setBackends(c.backends || [])
} catch {}
if (permissions.isAuthed()) {
try {
const r = await api.listRooms()
setRooms(r.rooms || [])
} catch {}
}
})
const go = (to: string) => navigate({ to, search: { room: room() } })
return (
<div class="space-y-8">
<section class="flex flex-col gap-6 lg:flex-row lg:items-start">
<div class="min-w-0 flex-1 space-y-5">
<div class="space-y-3">
<div class="inline-flex items-center gap-2 font-mono text-xs text-muted-foreground">
<span class="size-1.5 rounded-full bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]" />
live-sfu / fan-out console
</div>
<h1 class="text-3xl leading-tight font-semibold sm:text-4xl">
一路推流,<span class="acid-text">多端扇出</span>
</h1>
<p class="max-w-xl leading-relaxed text-muted-foreground">
Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。
</p>
</div>
<Card class="space-y-4 p-5">
<div class="flex items-center gap-2">
<MonitorPlay class="size-4 text-primary" />
<h2 class="font-semibold">进入房间</h2>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<TextInput value={room()} onInput={setRoom} placeholder="房间名,如 demo" class="sm:max-w-xs" />
<div class="flex flex-wrap gap-2">
<Button onClick={() => go('/room')}>
进入房间
<ArrowRight class="size-4" />
</Button>
<Button variant="outline" onClick={() => go('/publish')}>
<Video class="size-4" />
开始直播
</Button>
</div>
</div>
<Show when={permissions.isAuthed()}>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t pt-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1.5">
当前角色
<Badge class="border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan">{permissions.role()}</Badge>
</span>
<span>
发布:
<b class={permissions.can('room:publish') ? 'text-success' : 'text-muted-foreground'}>
{permissions.can('room:publish') ? '可' : '不可'}
</b>
</span>
<span>
发弹幕:
<b class={permissions.can('room:chat') ? 'text-success' : 'text-muted-foreground'}>
{permissions.can('room:chat') ? '可' : '不可'}
</b>
</span>
</div>
</Show>
</Card>
</div>
<Card class="w-full shrink-0 space-y-4 self-start p-5 lg:w-80 xl:w-[22rem]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<RadioTower class="size-4 text-primary" />
<h2 class="font-semibold">后端状态</h2>
</div>
<Badge variant="secondary">{backends().length} 路</Badge>
</div>
<div class="space-y-2">
<Show
when={backends().length > 0}
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}
>
<For each={backends()}>
{(b) => {
const Icon = backendIcon(b.kind)
return (
<div class="flex items-center justify-between gap-3 rounded-md border p-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{b.name}</p>
<p class="text-xs text-muted-foreground">{b.primary ? '主 SFU' : '对照后端'}</p>
</div>
</div>
<Badge variant={b.configured ? 'success' : 'warning'}>
{b.configured ? '已配置' : '未配置'}
</Badge>
</div>
)
}}
</For>
</Show>
</div>
<Separator />
<div>
<p class="label mb-2">分发协议</p>
<div class="flex flex-wrap gap-1.5">
<For each={['WHIP', 'WHEP', 'HLS', 'FLV']}>
{(p) => (
<Badge variant="outline" class="font-mono">
{p}
</Badge>
)}
</For>
</div>
</div>
<div class="flex gap-2 rounded-md border bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck class="mt-px size-4 shrink-0 text-success" />
<span>凭据由服务端代理注入,浏览器不持有 SFU 密钥。</span>
</div>
</Card>
</section>
<Show when={rooms().length > 0}>
<section class="space-y-3">
<div class="flex items-end justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">在线房间</h2>
<p class="text-sm text-muted-foreground">当前已恢复的分发拓扑</p>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/rooms' })}>
房间列表
<ArrowRight class="size-4" />
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<For each={rooms()}>
{(r) => (
<Card class="space-y-3 p-4">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<LiveDot active={roomTargets(r) > 0} />
<span class="truncate font-medium">{r.name}</span>
</div>
<span class="shrink-0 font-mono text-xs text-muted-foreground">{roomTargets(r)} 路</span>
</div>
<div class="flex gap-2">
<Button size="sm" class="flex-1" onClick={() => navigate({ to: '/room', search: { room: r.name } })}>
进入
</Button>
<Button
variant="outline"
size="sm"
class="flex-1"
onClick={() => navigate({ to: '/publish', search: { room: r.name } })}
>
直播
</Button>
</div>
</Card>
)}
</For>
</div>
</section>
</Show>
</div>
)
}

View File

@ -0,0 +1,119 @@
import { Component, For, onMount, Show } from 'solid-js'
import { Link, Outlet, useLocation, useNavigate } from '@tanstack/solid-router'
import {
CirclePlay,
Home,
MonitorPlay,
RadioTower,
Users,
Video,
} from 'lucide-solid'
import { refreshMe } from '../../lib/auth'
import { permissions } from '../../lib/permissions'
import { Badge, ThemeToggle } from '../../components/ui'
interface NavItem {
to: string
label: string
icon: (p: { class?: string }) => any
activePaths?: string[]
adminOnly?: boolean
}
const NAV: NavItem[] = [
{ to: '/', label: '概览', icon: Home },
{ to: '/rooms', label: '房间', icon: Video },
{ to: '/watch', label: '观看', icon: MonitorPlay, activePaths: ['/watch', '/room'] },
{ to: '/publish', label: '推流', icon: CirclePlay },
{ to: '/users', label: '用户', icon: Users, adminOnly: true },
]
function roleBadge(role: string) {
if (role === 'admin') return 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta'
if (role === 'publisher') return 'border-warning/30 bg-warning/10 text-warning'
return 'border-transparent bg-muted text-muted-foreground'
}
export const Layout: Component = () => {
const navigate = useNavigate()
const location = useLocation()
onMount(async () => {
await refreshMe()
await permissions.refresh()
})
const isActive = (item: NavItem) => {
const paths = item.activePaths ?? [item.to]
return paths.includes(location().pathname)
}
return (
<div class="flex min-h-screen flex-col">
<header class="header-line sticky top-0 z-40 border-b bg-background/85 backdrop-blur-xl">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4">
<Link to="/" class="flex min-w-0 items-center gap-2.5" aria-label="首页">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm">
<RadioTower class="size-4" />
</span>
<span class="truncate text-sm font-semibold sm:text-base">
直播 SFU <span class="acid-text">分流台</span>
</span>
</Link>
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
<Show when={permissions.isAuthed()}>
<span class="hidden items-center gap-2 rounded-md border bg-card py-1 pr-1 pl-1 sm:flex">
<span class="flex size-5 items-center justify-center rounded-sm bg-primary/10 text-[11px] font-bold text-primary">
{(permissions.role()[0] ?? '').toUpperCase()}
</span>
<Badge class={roleBadge(permissions.role())}>{permissions.role()}</Badge>
</span>
</Show>
<ThemeToggle />
<Show when={!permissions.isAuthed()}>
<Link to="/login" class="btn btn-outline btn-sm">
登录
</Link>
</Show>
</div>
</div>
<nav class="mx-auto max-w-6xl overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="flex items-center gap-1">
<For each={NAV}>
{(item) => (
<Show when={!item.adminOnly || permissions.can('user:manage')}>
<Link
to={item.to}
class={`flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors ${
isActive(item)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<item.icon class="size-4" />
{item.label}
</Link>
</Show>
)}
</For>
</div>
</nav>
</header>
<main class="flex-1">
<div class="mx-auto max-w-6xl px-4 py-6 sm:py-8">
<Outlet />
</div>
</main>
<footer class="border-t bg-background/70">
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs text-muted-foreground sm:flex-row">
<span>Cloudflare Realtime · SRS · WHIP / WHEP / HLS</span>
<span class="font-mono">live-sfu fan-out console</span>
</div>
</footer>
</div>
)
}

View File

@ -0,0 +1,127 @@
import { Component, createSignal, For, Show } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router'
import { LogIn, RadioTower } from 'lucide-solid'
import { api } from '../../lib/api'
import { refreshMe } from '../../lib/auth'
import { permissions } from '../../lib/permissions'
import { Alert, Button, Card, Select, TextInput } from '../../components/ui'
const DEMO_USERS = [
{ name: 'admin', role: '管理员', pass: 'Admin123!' },
{ name: 'publisher', role: '发布者', pass: 'Publisher123!' },
{ name: 'viewer', role: '观看者', pass: 'Viewer123!' },
] as const
export const Login: Component = () => {
const navigate = useNavigate()
const [username, setUsername] = createSignal('')
const [password, setPassword] = createSignal('')
const [role, setRole] = createSignal<'viewer' | 'publisher'>('viewer')
const [err, setErr] = createSignal('')
const [busy, setBusy] = createSignal(false)
const submit = async (e: Event) => {
e.preventDefault()
setErr('')
setBusy(true)
try {
await api.login(username(), password())
await refreshMe()
await permissions.refresh()
navigate({ to: '/' })
} catch (e: any) {
setErr(String(e?.message || e))
} finally {
setBusy(false)
}
}
return (
<div class="mx-auto flex w-full max-w-md flex-col gap-4 py-4">
<div class="flex flex-col items-center gap-3 text-center">
<span class="flex size-10 items-center justify-center rounded-lg border border-acid-lime/30 bg-primary text-primary-foreground shadow-sm">
<RadioTower class="size-5" />
</span>
<div>
<h1 class="text-xl font-semibold">登录分流控制台</h1>
<p class="mt-1 text-sm text-muted-foreground">发布、订阅与房间管理需要登录</p>
</div>
</div>
<Card class="p-6">
<form class="space-y-4" onSubmit={submit}>
<div class="space-y-1.5">
<label class="label" for="login-username">
用户名
</label>
<TextInput
id="login-username"
value={username()}
onInput={setUsername}
placeholder="admin"
autoComplete="username"
class="h-9"
/>
</div>
<div class="space-y-1.5">
<label class="label" for="login-password">
密码
</label>
<TextInput
id="login-password"
type="password"
value={password()}
onInput={setPassword}
placeholder="••••••••"
autoComplete="current-password"
class="h-9"
/>
</div>
<div class="space-y-1.5">
<label class="label">角色</label>
<Select
value={role()}
onChange={(v) => setRole(v as 'viewer' | 'publisher')}
options={[
{ value: 'viewer', label: '观看者 viewer' },
{ value: 'publisher', label: '发布者 publisher' },
]}
/>
</div>
<Button type="submit" disabled={busy()} class="w-full">
<LogIn class="size-4" />
{busy() ? '登录中…' : '登录'}
</Button>
</form>
<Show when={err()}>
<Alert tone="error" class="mt-4">
{err()}
</Alert>
</Show>
</Card>
<div class="rounded-lg border bg-card p-4">
<p class="label mb-2">演示账号</p>
<div class="grid gap-2 sm:grid-cols-3">
<For each={DEMO_USERS}>
{(u) => (
<button
type="button"
class="flex flex-col items-start gap-0.5 rounded-md border bg-background/60 px-3 py-2 text-left transition-colors hover:bg-accent"
onClick={() => {
setUsername(u.name)
setPassword(u.pass)
setRole(u.role === '管理员' ? 'publisher' : 'viewer')
}}
>
<span class="text-sm font-medium">{u.name}</span>
<span class="text-xs text-muted-foreground">{u.role}</span>
<span class="font-mono text-[10px] text-muted-foreground/80">{u.pass}</span>
</button>
)}
</For>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,294 @@
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch } from '@tanstack/solid-router'
import {
CircleStop,
Cloud,
FileVideo,
Link2,
MessageSquare,
Server,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Card, CheckboxRow, LiveDot } from '../../components/ui'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import { subscribeRoom } from '../../lib/sse'
import { subscribeChat, sendChat, broadcastChat, type ChatMessage } from '../../lib/chat'
import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc'
import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types'
function backendLabel(kind: string): string {
return kind === 'BACKEND_KIND_CLOUDFLARE'
? 'Cloudflare Realtime'
: kind === 'BACKEND_KIND_SRS'
? 'SRS'
: kind
}
function backendIcon(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server
}
export const Publish: Component = () => {
const search = useSearch({ from: '/publish' })
const room = () => search().room
const [backends, setBackends] = createSignal<BackendInfo[]>([])
const [selected, setSelected] = createSignal<Record<string, boolean>>({
BACKEND_KIND_CLOUDFLARE: true,
BACKEND_KIND_SRS: true,
})
const [stream, setStream] = createSignal<MediaStream | null>(null)
const [running, setRunning] = createSignal(false)
const [targets, setTargets] = createSignal<StreamTarget[]>([])
const [error, setError] = createSignal('')
const [msgs, setMsgs] = createSignal<ChatMessage[]>([])
const [showDanmaku, setShowDanmaku] = createSignal(true)
let videoEl: HTMLVideoElement | undefined
const pcs: Record<string, RTCPeerConnection> = {}
onMount(() => {
api.getConfig().then((c) => setBackends(c.backends || [])).catch(() => {})
const stopRoom = subscribeRoom(room(), (ev: RoomEvent) => setTargets(ev.targets || []))
const stopChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
stopRoom()
stopChat()
})
})
const send = (text: string, color?: string) => {
broadcastChat(room(), text, color).catch(() => {})
}
const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] }))
const start = async () => {
if (!permissions.can('room:publish')) {
setError('当前角色无权发布(需要 room:publish)')
return
}
setError('')
setRunning(true)
try {
if (!stream()) {
const ms = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
setStream(ms)
if (videoEl) videoEl.srcObject = ms
}
const ms = stream()!
const wants = backends().filter((b) => selected()[b.kind])
for (const b of wants) {
const resp = await api.publish(room(), b.kind, '')
if (b.kind === 'BACKEND_KIND_SRS') {
pcs.srs = await whipPublish({
stream: resp.stream!,
token: resp.publish_token!,
local: ms,
iceServers: (resp.ice_servers || []).map(toRTC),
})
} else if (b.kind === 'BACKEND_KIND_CLOUDFLARE') {
pcs.cloudflare = await cfPublish({
sessionId: resp.session_id!,
local: ms,
iceServers: (resp.ice_servers || []).map(toRTC),
})
}
}
} catch (e: any) {
setError(String(e?.message || e))
setRunning(false)
}
}
const stopAll = async () => {
for (const k of Object.keys(pcs)) {
try {
pcs[k].close()
} catch {}
delete pcs[k]
}
for (const t of targets()) {
try {
await api.stop(room(), t.backend)
} catch {}
}
if (stream()) {
stream()!.getTracks().forEach((t) => t.stop())
setStream(null)
}
setTargets([])
setRunning(false)
}
onCleanup(stopAll)
return (
<div class="space-y-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1.5">
<h1 class="text-2xl font-semibold">开始直播</h1>
<p class="text-sm text-muted-foreground">
房间 <span class="font-mono text-foreground">{room()}</span> · 选择分发后端,一路推流将扇出到所选 SFU。
</p>
</div>
<Badge variant={running() ? 'success' : 'outline'}>
<LiveDot active={running()} />
{running() ? '推流中' : '待机'}
</Badge>
</div>
<Show when={!permissions.can('room:publish')}>
<Alert tone="warning">
当前角色({permissions.role()})无权发布,仅 admin / publisher 可推流。可切换到「观看」进入房间发弹幕。
</Alert>
</Show>
<Show when={error()}>
<Alert tone="error">{error()}</Alert>
</Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card class="space-y-3 p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Video class="size-4 text-primary" />
<h3 class="font-semibold">本地预览</h3>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
<span class="font-mono text-xs text-muted-foreground">
{stream() ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频` : 'off'}
</span>
</div>
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline muted class="size-full" />
<Show when={showDanmaku()}>
<DanmakuOverlay messages={msgs} />
</Show>
<Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60">
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5">
<Video class="size-5" />
</span>
<span class="text-xs">摄像头未开启</span>
</div>
</Show>
</div>
<div class="flex flex-wrap gap-2">
<Button onClick={start} disabled={running() || !permissions.can('room:publish')}>
<Video class="size-4" />
开始推流
</Button>
<Button variant="danger" onClick={stopAll} disabled={!running()}>
<CircleStop class="size-4" />
停止推流
</Button>
</div>
</Card>
<Card class="space-y-3 p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Server class="size-4 text-primary" />
<h3 class="font-semibold">分发后端</h3>
</div>
<Badge variant="secondary">{backends().length} 路</Badge>
</div>
<div class="space-y-2">
<Show when={backends().length > 0} fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>}>
<For each={backends()}>
{(b) => {
const Icon = backendIcon(b.kind)
return (
<div class="flex items-center justify-between gap-3 rounded-md border p-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground/70">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<CheckboxRow
label={b.name}
checked={!!selected()[b.kind]}
onChange={() => toggle(b.kind)}
disabled={running()}
/>
<p class="mt-1 text-xs text-muted-foreground">
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
</p>
</div>
</div>
<Badge variant={b.configured ? 'success' : 'warning'}>{b.configured ? '已配置' : '未配置'}</Badge>
</div>
)
}}
</For>
</Show>
</div>
<p class="text-xs leading-relaxed text-muted-foreground">
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
</p>
</Card>
</div>
<Card class="space-y-3 p-5">
<div class="flex items-center gap-2">
<Link2 class="size-4 text-primary" />
<h3 class="font-semibold">分发状态</h3>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">尚未开始推流</div>
}
>
<div class="grid gap-2 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="space-y-2 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="flex min-w-0 items-center gap-2 text-sm font-medium">
{backendLabel(t.backend)}
</span>
<Badge variant="success">直播中</Badge>
</div>
<Show when={t.stream}>
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
<FileVideo class="size-3.5" />
HLS
</a>
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
<FileVideo class="size-3.5" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</Card>
<Card class="flex flex-col gap-3 p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-primary" />
<h3 class="font-semibold">房间弹幕 / 聊天</h3>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="h-80 min-h-0">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { Component, createSignal, createEffect, For, Show } from 'solid-js'
import { useNavigate } from '@tanstack/solid-router'
import { ArrowRight, DoorOpen, Plus, Radio } from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Button, Card, LiveDot, TextInput } from '../../components/ui'
import type { Room } from '../../lib/types'
export const Rooms: Component = () => {
const navigate = useNavigate()
const [rooms, setRooms] = createSignal<Room[]>([])
const [name, setName] = createSignal('')
const [busy, setBusy] = createSignal(false)
const load = () =>
api
.listRooms()
.then((r) => setRooms(r.rooms || []))
.catch(() => {})
createEffect(() => {
if (permissions.isAuthed()) load()
})
const create = async () => {
const n = name().trim()
if (!n) return
setBusy(true)
try {
await api.createRoom(n)
navigate({ to: '/room', search: { room: n } })
} catch (e: any) {
alert(String(e?.message || e))
} finally {
setBusy(false)
}
}
const enter = (room: string) => navigate({ to: '/room', search: { room } })
return (
<div class="space-y-6">
<div class="space-y-1.5">
<h1 class="text-2xl font-semibold">房间</h1>
<p class="text-sm text-muted-foreground">创建或进入房间,房间内可观看、发弹幕与查看分发状态。</p>
</div>
<Card class="space-y-3 p-5">
<div class="flex items-center gap-2">
<Plus class="size-4 text-primary" />
<h3 class="font-semibold">创建房间</h3>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<TextInput
value={name()}
onInput={setName}
placeholder="房间名,如 demo"
class="sm:max-w-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') create()
}}
/>
<Button onClick={create} disabled={busy()}>
<ArrowRight class="size-4" />
创建并进入
</Button>
</div>
</Card>
<section class="space-y-3">
<div class="flex items-center gap-2">
<Radio class="size-4 text-primary" />
<h2 class="text-lg font-semibold">房间列表</h2>
</div>
<Show
when={rooms().length > 0}
fallback={
<div class="flex flex-col items-center gap-3 rounded-lg border bg-card px-6 py-10 text-center">
<span class="flex size-10 items-center justify-center rounded-full bg-secondary text-muted-foreground">
<DoorOpen class="size-5" />
</span>
<div>
<p class="font-medium">暂无房间</p>
<p class="mt-1 text-sm text-muted-foreground">创建一个房间后,分发拓扑会在这里出现。</p>
</div>
</div>
}
>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<For each={rooms()}>
{(r) => (
<Card class="flex flex-col gap-3 p-4">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<LiveDot active={(r.targets?.length ?? 0) > 0} />
<span class="truncate font-medium">{r.name}</span>
</div>
<span class="shrink-0 font-mono text-xs text-muted-foreground">
{r.targets?.length ?? 0} 路分发
</span>
</div>
<Button variant="outline" class="w-full" onClick={() => enter(r.name)}>
进入房间
<ArrowRight class="size-4" />
</Button>
</Card>
)}
</For>
</div>
</Show>
</section>
</div>
)
}

View File

@ -0,0 +1,129 @@
import { Component, createSignal, For, Show, createEffect } from 'solid-js'
import { ShieldAlert, ShieldCheck, UserRound } from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Card, Select } from '../../components/ui'
import type { Role, UserInfo } from '../../lib/types'
const ROLES: Role[] = ['viewer', 'publisher', 'admin']
const ROLE_META: Record<Role, { label: string; badge: string; avatar: string }> = {
admin: {
label: '管理员',
badge: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
avatar: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta',
},
publisher: {
label: '发布者',
badge: 'border-warning/30 bg-warning/10 text-warning',
avatar: 'border-warning/30 bg-warning/10 text-warning',
},
viewer: {
label: '观看者',
badge: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
avatar: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan',
},
guest: {
label: '访客',
badge: 'border-transparent bg-muted text-muted-foreground',
avatar: 'border-transparent bg-muted text-muted-foreground',
},
}
export const Users: Component = () => {
const [users, setUsers] = createSignal<UserInfo[]>([])
const [err, setErr] = createSignal('')
createEffect(() => {
if (!permissions.can('user:list')) return
api
.listUsers()
.then((r) => setUsers(r.users || []))
.catch((e: any) => setErr(String(e?.message || e)))
})
const change = async (username: string, role: Role) => {
try {
await api.updateUserRole(username, role)
setUsers((p) => p.map((u) => (u.username === username ? { ...u, role } : u)))
} catch (e: any) {
setErr(String(e?.message || e))
}
}
return (
<div class="space-y-6">
<div class="space-y-1.5">
<h1 class="text-2xl font-semibold">用户与角色</h1>
<p class="text-sm text-muted-foreground">基于 Casbin RBAC,仅具备 user:manage 的管理员可调整角色。</p>
</div>
<Show
when={permissions.can('user:list')}
fallback={
<Alert tone="warning">
<span class="flex items-center gap-2">
<ShieldAlert class="size-4" />
无权限:需要 user:list
</span>
</Alert>
}
>
<Card>
<Show when={err()}>
<Alert tone="error" class="rounded-none border-x-0 border-t-0">
{err()}
</Alert>
</Show>
<div class="hidden grid-cols-[1fr_auto_auto] gap-4 border-b px-5 py-3 text-xs font-medium text-muted-foreground sm:grid">
<span>用户名</span>
<span class="w-28">当前角色</span>
<span class="w-32 text-right">操作</span>
</div>
<For each={users()}>
{(u, i) => (
<div
class={`grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[1fr_auto_auto] sm:gap-4 ${
i() < users().length - 1 ? 'border-b' : ''
}`}
>
<div class="flex min-w-0 items-center gap-3">
<span
class={`flex size-8 shrink-0 items-center justify-center rounded-md border text-xs font-bold ${ROLE_META[u.role].avatar}`}
>
{(u.username[0] ?? '?').toUpperCase()}
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{u.username}</p>
<p class="font-mono text-[11px] text-muted-foreground">{ROLE_META[u.role].label}</p>
</div>
</div>
<Badge class={ROLE_META[u.role].badge}>{u.role}</Badge>
<div class="flex items-center justify-end gap-2">
<Show
when={permissions.can('user:manage')}
fallback={<UserRound class="size-4 text-muted-foreground/60" />}
>
<Select
class="w-32"
value={u.role}
aria-label={`调整 ${u.username} 的角色`}
onChange={(v) => change(u.username, v as Role)}
options={ROLES.map((r) => ({ value: r, label: r }))}
/>
</Show>
</div>
</div>
)}
</For>
<Show when={users().length === 0 && !err()}>
<div class="flex items-center gap-2 px-5 py-8 text-sm text-muted-foreground">
<ShieldCheck class="size-4 text-success" />
暂无用户数据
</div>
</Show>
</Card>
</Show>
</div>
)
}

View File

@ -0,0 +1,260 @@
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch, useNavigate } from '@tanstack/solid-router'
import {
Cloud,
Eye,
EyeOff,
FileVideo,
Link2,
MessageSquare,
MonitorPlay,
Radio,
Square,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Card, LiveDot, Segmented } from '../../components/ui'
import { subscribeRoom } from '../../lib/sse'
import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
import { playHLS } from '../../lib/hls'
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import type { RoomEvent } from '../../lib/types'
type Mode = 'cloudflare' | 'srs-whep' | 'srs-hls'
const MODE_LABEL: Record<Mode, string> = {
cloudflare: 'Cloudflare',
'srs-whep': 'SRS WHEP',
'srs-hls': 'SRS HLS',
}
function backendText(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? 'Cloudflare Realtime' : 'SRS'
}
function RoomHub(props: { room: string }) {
const navigate = useNavigate()
const room = () => props.room
const [mode, setMode] = createSignal<Mode>('cloudflare')
const [running, setRunning] = createSignal(false)
const [error, setError] = createSignal('')
const [targets, setTargets] = createSignal<RoomEvent['targets']>([])
const [msgs, setMsgs] = createSignal<ChatMessage[]>([])
const [showDanmaku, setShowDanmaku] = createSignal(true)
let videoEl: HTMLVideoElement | undefined
let pc: RTCPeerConnection | undefined
let stopHls: (() => void) | undefined
onMount(() => {
const unsubRoom = subscribeRoom(room(), (ev) => setTargets(ev.targets || []))
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
unsubRoom()
unsubChat()
})
})
function attach(p: RTCPeerConnection) {
p.ontrack = (e) => {
if (videoEl) videoEl.srcObject = e.streams[0]
}
}
const stopAll = () => {
stopHls?.()
stopHls = undefined
pc?.close()
pc = undefined
if (videoEl) {
videoEl.srcObject = null
videoEl.src = ''
}
setRunning(false)
}
onCleanup(stopAll)
const start = async () => {
setError('')
setRunning(true)
try {
if (mode() === 'cloudflare') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_CLOUDFLARE', '')
pc = await cfSubscribe({
viewerSessionId: resp.session_id!,
publisherSessionId: resp.publisher_session_id!,
iceServers: (resp.ice_servers || []).map(toRTC),
})
attach(pc)
} else if (mode() === 'srs-whep') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
pc = await whepSubscribe({ stream: resp.stream!, iceServers: (resp.ice_servers || []).map(toRTC) })
attach(pc)
} else {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
if (!videoEl) throw new Error('video element missing')
stopHls = playHLS(videoEl, `/live/${resp.stream}.m3u8`)
}
} catch (e: any) {
setError(String(e?.message || e))
setRunning(false)
}
}
const send = (text: string) => {
sendChat(room(), text).catch(() => {})
}
return (
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1.5">
<h1 class="text-2xl font-semibold">房间 <span class="font-mono">{room()}</span></h1>
<p class="text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
</div>
<Show when={permissions.can('room:publish')}>
<Button variant="outline" onClick={() => navigate({ to: '/publish', search: { room: room() } })}>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
<Show when={error()}>
<Alert tone="error">{error()}</Alert>
</Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card class="space-y-3 p-4 lg:col-span-2 lg:p-5">
<div class="flex items-center justify-between gap-3">
<Segmented
value={mode()}
onChange={(v) => setMode(v)}
options={[
{ value: 'cloudflare', label: 'Cloudflare', icon: <Cloud class="size-3.5" /> },
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
{ value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> },
]}
/>
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block">fan-out · {MODE_LABEL[mode()]}</span>
</div>
<div class="acid-frame relative aspect-video overflow-hidden rounded-md border bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline class="size-full" />
<Show when={showDanmaku()}>
<DanmakuOverlay messages={msgs} />
</Show>
<div class="pointer-events-none absolute top-3 left-3 flex flex-wrap items-center gap-1.5">
<Badge variant={running() ? 'success' : 'outline'} class="shadow-sm">
<LiveDot active={running()} />
{running() ? '连接中' : '未连接'}
</Badge>
<Badge variant="secondary" class="font-mono shadow-sm">
{MODE_LABEL[mode()]}
</Badge>
</div>
<Show when={!running()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 text-white/60">
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5">
<MonitorPlay class="size-5" />
</span>
<span class="text-xs">等待接入</span>
</div>
</Show>
</div>
<div class="flex flex-wrap gap-2">
<Button onClick={start} disabled={running()}>
<PlayIcon />
开始观看
</Button>
<Button variant="danger" onClick={stopAll} disabled={!running()}>
<Square class="size-4" />
停止
</Button>
<Button variant="ghost" onClick={() => setShowDanmaku((s) => !s)}>
{showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
</div>
</Card>
<Card class="flex h-[28rem] flex-col space-y-3 p-4 lg:p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-primary" />
<h3 class="font-semibold">弹幕 / 聊天</h3>
</div>
<Badge variant="secondary">{msgs().length}</Badge>
</div>
<div class="min-h-0 flex-1">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</Card>
</div>
<Card class="space-y-3 p-5">
<div class="flex items-center gap-2">
<Link2 class="size-4 text-primary" />
<h3 class="font-semibold">分发状态</h3>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">房间暂无直播</div>
}
>
<div class="grid gap-2 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="space-y-2 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{backendText(t.backend)}</span>
<Badge variant="success">直播中</Badge>
</div>
<Show when={t.stream}>
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
<FileVideo class="size-3.5" />
HLS
</a>
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
<FileVideo class="size-3.5" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</Card>
</div>
)
}
function PlayIcon() {
return (
<span class="flex size-4 items-center justify-center">
<svg viewBox="0 0 16 16" class="size-3.5 fill-current" aria-hidden="true">
<path d="M5 3.2v9.6a.5.5 0 0 0 .77.42l7.2-4.8a.5.5 0 0 0 0-.84l-7.2-4.8A.5.5 0 0 0 5 3.2Z" />
</svg>
</span>
)
}
export const WatchRoute: Component = () => {
const search = useSearch({ from: '/watch' })
return <RoomHub room={search().room} />
}
export const RoomRoute: Component = () => {
const search = useSearch({ from: '/room' })
return <RoomHub room={search().room} />
}

View File

@ -0,0 +1,140 @@
import { createSignal, createEffect, For, Show } from 'solid-js'
import { MessageSquare, Send } from 'lucide-solid'
import { Button } from './ui'
import type { ChatMessage } from '../lib/chat'
interface Active {
key: number
text: string
color: string
top: number
dur: number
}
// 弹幕叠加层:新消息从右向左飘过视频画面
export function DanmakuOverlay(props: { messages: () => ChatMessage[] }) {
const [items, setItems] = createSignal<Active[]>([])
let seq = 0
let lane = 0
let lastId = ''
const tops = [6, 18, 30, 42, 54, 66, 78]
createEffect(() => {
const msgs = props.messages()
const last = msgs[msgs.length - 1]
if (!last || last.id === lastId) return
lastId = last.id
const top = tops[lane % tops.length]
lane++
const dur = 7 + Math.random() * 4
const item: Active = { key: ++seq, text: (last.host ? '主播:' : '') + last.message, color: last.color || '#f1f5f9', top, dur }
setItems((p) => {
const next = [...p, item]
return next.length > 50 ? next.slice(next.length - 50) : next
})
})
const remove = (key: number) => setItems((p) => p.filter((i) => i.key !== key))
return (
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<For each={items()}>
{(it) => (
<span
class="danmaku-item"
style={{ top: `${it.top}%`, color: it.color, 'animation-duration': `${it.dur}s` }}
onAnimationEnd={() => remove(it.key)}
>
{it.text}
</span>
)}
</For>
</div>
)
}
function measureTime(ts: number) {
if (!Number.isFinite(ts)) return '--:--'
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function roleColor(role: string) {
if (role === 'admin') return 'text-acid-magenta border-acid-magenta/30 bg-acid-magenta/10'
if (role === 'publisher') return 'text-warning border-warning/30 bg-warning/10'
return 'text-acid-cyan border-acid-cyan/30 bg-acid-cyan/10'
}
// 聊天/弹幕侧栏:消息列表 + 发送框(权限由 canChat 控制)
export function ChatPanel(props: {
messages: () => ChatMessage[]
canChat: boolean
onSend: (text: string) => void
}) {
const [text, setText] = createSignal('')
const send = () => {
const t = text().trim()
if (!t) return
props.onSend(t)
setText('')
}
return (
<div class="flex h-full min-h-0 flex-col">
<div class="flex-1 space-y-3 overflow-y-auto p-3">
<Show when={props.messages().length > 0}>
<For each={props.messages()}>
{(m) => (
<div class="flex items-start gap-2">
<span
class={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-bold ${roleColor(
String(m.role),
)}`}
>
{(m.user[0] ?? '?').toUpperCase()}
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-baseline gap-x-1.5">
<span class="text-xs font-medium">{m.user}</span>
<span class="text-[11px] text-muted-foreground">{m.role}</span>
<Show when={m.host}>
<span class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">主播</span>
</Show>
<span class="font-mono text-[10px] text-muted-foreground/70">{measureTime(m.ts)}</span>
</div>
<p class="mt-0.5 text-sm leading-snug break-words">{m.message}</p>
</div>
</div>
)}
</For>
</Show>
<Show when={props.messages().length === 0}>
<div class="flex h-full min-h-24 flex-col items-center justify-center gap-2 text-center">
<MessageSquare class="size-5 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">还没有弹幕</p>
</div>
</Show>
</div>
<Show
when={props.canChat}
fallback={<p class="border-t p-3 text-xs text-muted-foreground">登录后可在房间内发送弹幕</p>}
>
<div class="flex gap-2 border-t p-2">
<input
class="field h-9 text-sm"
placeholder="发条弹幕"
value={text()}
aria-label="弹幕内容"
onInput={(e) => setText((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') send()
}}
/>
<Button onClick={send} size="icon" aria-label="发送弹幕" class="shrink-0">
<Send class="size-4" />
</Button>
</div>
</Show>
</div>
)
}

322
web/src/components/ui.tsx Normal file
View File

@ -0,0 +1,322 @@
import { For, Show, type JSX } from 'solid-js'
import {
Check,
ChevronDown,
CircleAlert,
CircleCheck,
Info,
Moon,
Sun,
TriangleAlert,
} from 'lucide-solid'
import {
CheckboxRoot,
CheckboxControl,
CheckboxIndicator,
CheckboxLabel,
CheckboxHiddenInput,
} from '@ark-ui/solid/checkbox'
import {
RadioGroupRoot,
RadioGroupItem,
RadioGroupItemControl,
RadioGroupItemText,
RadioGroupItemHiddenInput,
RadioGroupLabel,
} from '@ark-ui/solid/radio-group'
import { theme, toggleTheme } from '../lib/theme'
export function Card(props: { class?: string; children: JSX.Element }) {
return <div class={`card ${props.class ?? ''}`}>{props.children}</div>
}
export function CardHeader(props: { class?: string; children: JSX.Element }) {
return <div class={`flex flex-col gap-1 p-5 pb-0 ${props.class ?? ''}`}>{props.children}</div>
}
export function CardTitle(props: { class?: string; children: JSX.Element }) {
return <h3 class={`font-semibold leading-none ${props.class ?? ''}`}>{props.children}</h3>
}
export function CardDescription(props: { class?: string; children: JSX.Element }) {
return <p class={`text-sm text-muted-foreground ${props.class ?? ''}`}>{props.children}</p>
}
export function CardContent(props: { class?: string; children: JSX.Element }) {
return <div class={`p-5 pt-4 ${props.class ?? ''}`}>{props.children}</div>
}
export function CardFooter(props: { class?: string; children: JSX.Element }) {
return <div class={`flex items-center p-5 pt-0 ${props.class ?? ''}`}>{props.children}</div>
}
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
export type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
const BTN_VARIANT: Record<ButtonVariant, string> = {
primary: 'btn-primary',
secondary: 'btn-secondary',
outline: 'btn-outline',
ghost: 'btn-ghost',
danger: 'btn-destructive',
}
const BTN_SIZE: Record<ButtonSize, string> = {
sm: 'btn-sm',
default: '',
lg: 'btn-lg',
icon: 'btn-icon',
}
export function Button(props: {
onClick?: () => void
variant?: ButtonVariant
size?: ButtonSize
disabled?: boolean
class?: string
type?: 'button' | 'submit'
'aria-label'?: string
title?: string
children: JSX.Element
}) {
return (
<button
type={props.type ?? 'button'}
class={`btn ${BTN_VARIANT[props.variant ?? 'primary']} ${BTN_SIZE[props.size ?? 'default']} ${props.class ?? ''}`}
disabled={props.disabled}
aria-label={props['aria-label']}
title={props.title}
onClick={props.onClick}
>
{props.children}
</button>
)
}
export function TextInput(props: {
id?: string
value?: string
onInput?: (v: string) => void
onKeyDown?: (e: KeyboardEvent) => void
placeholder?: string
type?: string
class?: string
disabled?: boolean
name?: string
autoComplete?: string
'aria-label'?: string
}) {
return (
<input
id={props.id}
class={`field ${props.class ?? ''}`}
type={props.type ?? 'text'}
name={props.name}
autocomplete={props.autoComplete}
placeholder={props.placeholder}
value={props.value ?? ''}
disabled={props.disabled}
aria-label={props['aria-label']}
onInput={(e) => props.onInput?.((e.currentTarget as HTMLInputElement).value)}
onKeyDown={props.onKeyDown}
/>
)
}
export function Select(props: {
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
class?: string
disabled?: boolean
'aria-label'?: string
}) {
return (
<div class={`relative ${props.class ?? ''}`}>
<select
class="field appearance-none pr-8"
value={props.value}
disabled={props.disabled}
aria-label={props['aria-label']}
onChange={(e) => props.onChange((e.currentTarget as HTMLSelectElement).value)}
>
<For each={props.options}>{(o) => <option value={o.value}>{o.label}</option>}</For>
</select>
<ChevronDown class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
)
}
export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger' | 'muted'
const BADGE_TONE: Record<BadgeVariant, string> = {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
success: 'border-success/25 bg-success/10 text-success',
warning: 'border-warning/25 bg-warning/10 text-warning',
danger: 'border-destructive/25 bg-destructive/10 text-destructive',
muted: 'border-transparent bg-muted text-muted-foreground',
}
export function Badge(props: { variant?: BadgeVariant; class?: string; children: JSX.Element }) {
return <span class={`badge ${BADGE_TONE[props.variant ?? 'muted']} ${props.class ?? ''}`}>{props.children}</span>
}
const ALERT_TONE = {
error: {
box: 'border-destructive/30 bg-destructive/8 text-destructive',
icon: CircleAlert,
},
warning: {
box: 'border-warning/30 bg-warning/10 text-warning',
icon: TriangleAlert,
},
success: {
box: 'border-success/30 bg-success/10 text-success',
icon: CircleCheck,
},
info: {
box: 'border-acid-cyan/30 bg-acid-cyan/8 text-accent-foreground',
icon: Info,
},
}
export function Alert(props: {
tone?: 'error' | 'warning' | 'success' | 'info'
class?: string
children: JSX.Element
}) {
const tone = props.tone ?? 'info'
const style = ALERT_TONE[tone]
const Icon = style.icon
return (
<div
role={tone === 'error' ? 'alert' : undefined}
class={`flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm ${style.box} ${props.class ?? ''}`}
>
<Icon class="mt-px size-4 shrink-0" />
<div class="min-w-0 flex-1">{props.children}</div>
</div>
)
}
export function LiveDot(props: { active?: boolean; class?: string }) {
return (
<span
class={`relative inline-flex size-2 shrink-0 rounded-full ${
props.active ? 'bg-success shadow-[0_0_0_3px_hsl(var(--success)/0.14)]' : 'bg-muted-foreground/40'
} ${props.class ?? ''}`}
/>
)
}
export function Separator(props: { class?: string }) {
return <div role="separator" class={`h-px w-full shrink-0 bg-border ${props.class ?? ''}`} />
}
export function ThemeToggle(props: { class?: string }) {
const next = () => (theme() === 'dark' ? '亮色' : '暗色')
return (
<button
type="button"
class={`btn btn-ghost btn-icon ${props.class ?? ''}`}
onClick={toggleTheme}
aria-label={`切换到${next()}模式`}
title={`切换到${next()}模式`}
>
<Show when={theme() === 'dark'} fallback={<Moon class="size-4" />}>
<Sun class="size-4" />
</Show>
</button>
)
}
export function CheckboxRow(props: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<CheckboxRoot
checked={props.checked}
disabled={props.disabled}
onCheckedChange={(e: any) => props.onChange(!!e.checked)}
class="group flex cursor-pointer items-center gap-2.5 select-none"
>
<CheckboxControl
class={`flex size-4 shrink-0 items-center justify-center rounded-sm border shadow-sm transition-colors ${
props.checked
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-card text-transparent group-hover:border-primary/50'
}`}
>
<CheckboxIndicator class="leading-none">
<Check class="size-3.5" strokeWidth={3} />
</CheckboxIndicator>
</CheckboxControl>
<CheckboxLabel class="text-sm leading-none text-foreground">{props.label}</CheckboxLabel>
<CheckboxHiddenInput />
</CheckboxRoot>
)
}
function RadioRow(props: { value: string; label: string }) {
return (
<RadioGroupItem value={props.value} class="group flex cursor-pointer items-center gap-2.5 select-none">
<RadioGroupItemControl class="flex size-4 shrink-0 items-center justify-center rounded-full border border-input bg-card shadow-sm transition-colors data-[state=checked]:border-primary">
<span class="size-2 rounded-full bg-primary opacity-0 transition-opacity group-data-[state=checked]:opacity-100" />
</RadioGroupItemControl>
<RadioGroupItemText class="text-sm leading-none text-foreground">{props.label}</RadioGroupItemText>
<RadioGroupItemHiddenInput />
</RadioGroupItem>
)
}
export function RadioPicker(props: {
label: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
}) {
return (
<RadioGroupRoot
value={props.value}
onValueChange={(e: any) => props.onChange(e.value)}
class="flex flex-col gap-2"
>
<RadioGroupLabel class="label">{props.label}</RadioGroupLabel>
<For each={props.options}>{(o) => <RadioRow value={o.value} label={o.label} />}</For>
</RadioGroupRoot>
)
}
export function Segmented<T extends string>(props: {
label?: string
value: T
onChange: (v: T) => void
options: { value: T; label: string; icon?: JSX.Element }[]
class?: string
}) {
return (
<div class={props.class ?? ''}>
<Show when={props.label}>
<span class="label mb-2 inline-block">{props.label}</span>
</Show>
<div class="inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md border bg-muted p-1">
<For each={props.options}>
{(o) => (
<button
type="button"
class={`inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-sm px-3 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none ${
o.value === props.value
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
}`}
onClick={() => props.onChange(o.value)}
>
{o.icon}
{o.label}
</button>
)}
</For>
</div>
</div>
)
}

297
web/src/index.css Normal file
View File

@ -0,0 +1,297 @@
@import "tailwindcss";
@import "@fontsource-variable/space-grotesk";
@import "@fontsource-variable/jetbrains-mono";
@custom-variant dark (&:is(.dark *));
/* ===== shadcn/ui tokens · Acid(克制版) ===== */
:root {
--radius: 0.5rem;
--background: 56 42% 96%;
--foreground: 248 20% 9%;
--card: 0 0% 100%;
--card-foreground: 248 20% 9%;
--popover: 0 0% 100%;
--popover-foreground: 248 20% 9%;
--primary: 108 60% 30%;
--primary-foreground: 60 40% 98%;
--secondary: 55 30% 91%;
--secondary-foreground: 248 18% 13%;
--muted: 54 26% 91%;
--muted-foreground: 248 9% 42%;
--accent: 188 44% 90%;
--accent-foreground: 201 50% 16%;
--destructive: 2 70% 46%;
--destructive-foreground: 0 0% 100%;
--success: 143 58% 34%;
--warning: 34 88% 38%;
--border: 55 16% 84%;
--input: 55 16% 79%;
--ring: 108 60% 36%;
--acid-lime: 105 72% 42%;
--acid-cyan: 187 90% 38%;
--acid-magenta: 322 82% 44%;
--acid-violet: 258 76% 48%;
color-scheme: light;
}
.dark {
--background: 248 22% 5%;
--foreground: 56 26% 92%;
--card: 247 18% 7%;
--card-foreground: 56 26% 92%;
--popover: 247 18% 7%;
--popover-foreground: 56 26% 92%;
--primary: 91 58% 44%;
--primary-foreground: 240 30% 7%;
--secondary: 247 16% 12%;
--secondary-foreground: 55 20% 88%;
--muted: 247 16% 12%;
--muted-foreground: 246 10% 62%;
--accent: 251 22% 14%;
--accent-foreground: 190 72% 70%;
--destructive: 3 68% 52%;
--destructive-foreground: 0 0% 98%;
--success: 143 62% 55%;
--warning: 38 92% 55%;
--border: 248 14% 18%;
--input: 248 13% 24%;
--ring: 91 58% 50%;
--acid-lime: 90 66% 54%;
--acid-cyan: 188 82% 58%;
--acid-magenta: 325 80% 62%;
--acid-violet: 262 86% 68%;
color-scheme: dark;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-warning: hsl(var(--warning));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-acid-lime: hsl(var(--acid-lime));
--color-acid-cyan: hsl(var(--acid-cyan));
--color-acid-magenta: hsl(var(--acid-magenta));
--color-acid-violet: hsl(var(--acid-violet));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Space Grotesk Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@layer base {
* {
@apply border-border;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
@apply min-h-screen bg-background font-sans text-foreground;
}
#root {
@apply min-h-screen;
}
::selection {
background: hsl(var(--acid-lime) / 0.28);
color: hsl(var(--foreground));
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(115deg, hsl(var(--acid-lime) / 0.1), transparent 34%),
linear-gradient(238deg, hsl(var(--acid-cyan) / 0.09), transparent 44%),
linear-gradient(325deg, hsl(var(--acid-magenta) / 0.07), transparent 42%);
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, hsl(var(--foreground) / 0.035) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / 0.035) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: linear-gradient(to bottom, black, transparent 76%);
}
}
@layer components {
.card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.btn {
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap transition-colors select-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50;
}
.btn-primary {
@apply bg-primary text-primary-foreground shadow-sm hover:bg-primary/90;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80;
}
.btn-outline {
@apply border bg-card shadow-sm hover:bg-accent hover:text-accent-foreground;
}
.btn-ghost {
@apply text-foreground/70 hover:bg-accent hover:text-accent-foreground;
}
.btn-destructive {
@apply bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-lg {
@apply h-10 px-6 text-[0.95rem];
}
.btn-icon {
@apply size-9 p-0;
}
.field {
@apply flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50;
}
.label {
@apply text-sm leading-none font-medium;
}
.badge {
@apply inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors;
}
.acid-text {
background: linear-gradient(
100deg,
hsl(var(--acid-lime)),
hsl(var(--acid-cyan)) 42%,
hsl(var(--acid-violet)) 74%,
hsl(var(--acid-magenta))
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.panel {
@apply rounded-lg border bg-background/70;
}
.focus-ring {
@apply focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none;
}
.acid-frame {
position: relative;
}
.acid-frame::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
hsl(var(--acid-lime) / 0.75),
hsl(var(--acid-cyan) / 0.45) 38%,
hsl(var(--acid-violet) / 0.18) 68%,
hsl(var(--acid-magenta) / 0.5)
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
}
.header-line::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
opacity: 0.55;
pointer-events: none;
background: linear-gradient(
90deg,
hsl(var(--acid-lime)),
hsl(var(--acid-cyan)) 40%,
hsl(var(--acid-violet)) 72%,
hsl(var(--acid-magenta))
);
}
}
/* ===== 弹幕 ===== */
.danmaku-item {
position: absolute;
left: 100%;
white-space: nowrap;
font-weight: 600;
font-size: 1rem;
letter-spacing: 0;
text-shadow: 0 1px 2px rgb(0 0 0 / 0.9), 0 0 14px rgb(0 0 0 / 0.5);
animation-name: danmaku-move;
animation-timing-function: linear;
animation-fill-mode: forwards;
will-change: left;
}
@keyframes danmaku-move {
from { left: 100%; }
to { left: -100%; }
}

23
web/src/index.tsx Normal file
View File

@ -0,0 +1,23 @@
import { render } from 'solid-js/web'
import { RouterProvider, createRouter } from '@tanstack/solid-router'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { initTheme } from './lib/theme'
import './index.css'
import { routeTree } from './app/router'
initTheme()
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
})
const router = createRouter({ routeTree, defaultPreload: 'intent' })
render(
() => (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
),
document.getElementById('root')!,
)

56
web/src/lib/api.ts Normal file
View File

@ -0,0 +1,56 @@
import type {
GetConfigResponse,
ListRoomsResponse,
PublishResponse,
SubscribeResponse,
UserInfo,
Role,
} from './types'
const BASE = ''
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method,
credentials: 'same-origin',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || res.statusText || String(res.status))
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
getConfig: () => req<GetConfigResponse>('GET', '/api/config'),
listRooms: () => req<ListRoomsResponse>('GET', '/api/rooms'),
publish: (room: string, backend: string, identity = '') =>
req<PublishResponse>('POST', '/api/publish', { room, backend, identity }),
subscribe: (room: string, backend: string, identity = '') =>
req<SubscribeResponse>('POST', '/api/subscribe', { room, backend, identity }),
stop: (room: string, backend: string) =>
req<{ ok: boolean }>('POST', '/api/stop', { room, backend }),
login: (username: string, password: string) =>
req<{ token: string; user: UserInfo; expires_at: number }>('POST', '/api/auth/login', {
username,
password,
}),
register: (username: string, password: string, role: Role) =>
req<{ token: string; user: UserInfo; expires_at: number }>('POST', '/api/auth/register', {
username,
password,
role,
}),
me: () =>
req<{ username: string; role: Role; expires_at: number; issued_at: number }>('GET', '/api/auth/me'),
srsStreams: () => req<{ code: number; streams: string[] }>('GET', '/api/srs/streams'),
authCheck: () => req<any>('GET', '/api/auth/check'),
listUsers: () => req<{ users: UserInfo[] }>('GET', '/api/auth/users'),
updateUserRole: (username: string, role: Role) =>
req<{ status: string; username: string; role: string }>('POST', '/api/auth/users/role', { username, role }),
createRoom: (name: string) =>
req<{ ok: boolean; room: string }>('POST', '/api/rooms', { name }),
}

20
web/src/lib/auth.ts Normal file
View File

@ -0,0 +1,20 @@
import { createSignal } from 'solid-js'
import { api } from './api'
import type { UserInfo } from './types'
const [user, setUser] = createSignal<UserInfo | null>(null)
export const auth = {
user,
setUser,
isAuthed: () => user() != null,
}
export async function refreshMe() {
try {
const m = await api.me()
setUser(m)
} catch {
setUser(null)
}
}

45
web/src/lib/chat.ts Normal file
View File

@ -0,0 +1,45 @@
import type { Role } from './types'
export interface ChatMessage {
id: string
room: string
user: string
role: Role | string
message: string
color?: string
host?: boolean
ts: number
}
// 订阅房间弹幕(SSE,事件名 chat)
export function subscribeChat(room: string, onMsg: (m: ChatMessage) => void): () => void {
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/chat`, { withCredentials: true })
es.addEventListener('chat', (e) => {
try {
onMsg(JSON.parse((e as MessageEvent).data) as ChatMessage)
} catch {
/* ignore */
}
})
return () => es.close()
}
// 主播独立外部推送:以主播身份推送弹幕(需 room:publish 权限,可带 Bearer token 供外部工具调用)
export async function broadcastChat(room: string, message: string, color?: string): Promise<void> {
await fetch(`/api/room/${encodeURIComponent(room)}/broadcast`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, color }),
})
}
// 发送一条弹幕(需登录)
export async function sendChat(room: string, message: string, color?: string): Promise<void> {
await fetch(`/api/room/${encodeURIComponent(room)}/chat`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, color }),
})
}

19
web/src/lib/hls.ts Normal file
View File

@ -0,0 +1,19 @@
import Hls from 'hls.js'
export function playHLS(video: HTMLVideoElement, url: string): () => void {
if (Hls.isSupported()) {
const hls = new Hls()
hls.loadSource(url)
hls.attachMedia(video)
video.play().catch(() => {})
return () => hls.destroy()
}
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
video.play().catch(() => {})
return () => {
video.src = ''
}
}
return () => {}
}

View File

@ -0,0 +1,31 @@
import { createSignal } from 'solid-js'
import { api } from './api'
import { auth } from './auth'
export interface AuthCheck {
enabled: boolean
authenticated: boolean
username?: string
role?: string
permissions?: Record<string, boolean>
}
const [check, setCheck] = createSignal<AuthCheck | null>(null)
export const permissions = {
check,
setCheck,
refresh: async () => {
try {
const c = await api.authCheck()
setCheck(c)
} catch {
setCheck(null)
}
},
// 能力判断(与 Casbin 策略一一对应):room:publish / room:subscribe / room:chat / user:manage ...
can: (action: string) => !!check()?.permissions?.[action],
// 当前角色:优先鉴权接口返回,回退到已登录用户信息
role: () => check()?.role ?? auth.user()?.role ?? 'guest',
isAuthed: () => !!auth.user() || !!check()?.authenticated,
}

16
web/src/lib/sse.ts Normal file
View File

@ -0,0 +1,16 @@
import type { RoomEvent } from './types'
export function subscribeRoom(room: string, onEvent: (ev: RoomEvent) => void): () => void {
const es = new EventSource(`/api/room/${encodeURIComponent(room)}/events`, {
withCredentials: true,
})
es.addEventListener('room', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data)
onEvent(data as RoomEvent)
} catch {
/* ignore malformed */
}
})
return () => es.close()
}

39
web/src/lib/theme.ts Normal file
View File

@ -0,0 +1,39 @@
import { createSignal } from 'solid-js'
export type ThemeMode = 'light' | 'dark'
const THEME_KEY = 'live-sfu-theme'
const [theme, setTheme] = createSignal<ThemeMode>('light')
function preferSystem(): ThemeMode {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function apply(mode: ThemeMode) {
const root = document.documentElement
root.classList.toggle('dark', mode === 'dark')
root.style.colorScheme = mode
try {
localStorage.setItem(THEME_KEY, mode)
} catch {
/* ignore private-mode storage errors */
}
setTheme(mode)
}
export function initTheme() {
let mode: ThemeMode = 'light'
try {
const saved = localStorage.getItem(THEME_KEY)
mode = saved === 'light' || saved === 'dark' ? saved : preferSystem()
} catch {
mode = preferSystem()
}
apply(mode)
}
export function toggleTheme() {
apply(theme() === 'dark' ? 'light' : 'dark')
}
export { theme }

69
web/src/lib/types.ts Normal file
View File

@ -0,0 +1,69 @@
export type BackendKind =
| 'BACKEND_KIND_UNSPECIFIED'
| 'BACKEND_KIND_CLOUDFLARE'
| 'BACKEND_KIND_SRS'
export interface IceServer {
urls: string[]
username?: string
credential?: string
}
export interface BackendInfo {
kind: BackendKind
name: string
configured: boolean
primary: boolean
}
export interface GetConfigResponse {
backends: BackendInfo[]
candidate: string
token_required: boolean
}
export interface StreamTarget {
backend: BackendKind
session_id?: string
stream?: string
publish_token?: string
url?: string
published_at?: number
}
export interface Room {
name: string
targets: StreamTarget[]
}
export interface RoomEvent {
room: string
targets: StreamTarget[]
}
export interface PublishResponse {
session_id?: string
stream?: string
publish_token?: string
ice_servers?: IceServer[]
target?: StreamTarget
}
export interface SubscribeResponse {
session_id?: string
stream?: string
publisher_session_id?: string
ice_servers?: IceServer[]
}
export interface ListRoomsResponse {
rooms: Room[]
}
export type Role = 'admin' | 'publisher' | 'viewer' | 'guest'
export interface UserInfo {
username: string
role: Role
created_at?: number
}

110
web/src/lib/webrtc.ts Normal file
View File

@ -0,0 +1,110 @@
import type { IceServer } from './types'
export function toRTC(s: IceServer): RTCIceServer {
return {
urls: s.urls,
username: s.username,
credential: s.credential,
}
}
interface PublishOpts {
stream: string
token: string
local: MediaStream
iceServers?: RTCIceServer[]
}
// SRS 推流:WHIP 信令经 Go 网关反代到 SRS :1985。
export async function whipPublish(opts: PublishOpts): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] })
opts.local.getTracks().forEach((t) => pc.addTrack(t, opts.local))
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const res = await fetch(
`/rtc/v1/whip/?app=live&stream=${encodeURIComponent(opts.stream)}&token=${encodeURIComponent(
opts.token,
)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
credentials: 'same-origin',
body: offer.sdp ?? '',
},
)
const answer = await res.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
return pc
}
// SRS 拉流:WHEP 信令经网关反代到 SRS :1985。
export async function whepSubscribe(opts: {
stream: string
iceServers?: RTCIceServer[]
}): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const res = await fetch(`/rtc/v1/whep/?app=live&stream=${encodeURIComponent(opts.stream)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
credentials: 'same-origin',
body: offer.sdp ?? '',
})
const answer = await res.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
return pc
}
interface CfOpts {
sessionId: string
local: MediaStream
iceServers?: RTCIceServer[]
}
// Cloudflare 推流:服务端已建 session,浏览器仅交换 SDP(轨道 location=local)。
export async function cfPublish(opts: CfOpts): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] })
opts.local.getTracks().forEach((t) => pc.addTrack(t, opts.local))
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const res = await fetch(`/api/cf/sessions/${opts.sessionId}/tracks/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sessionDescription: { type: 'offer', sdp: offer.sdp },
tracks: [{ location: 'local' }],
}),
})
const data = await res.json()
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp })
return pc
}
// Cloudflare 拉流:viewer session 订阅发布者 session 的轨道(location=remote)。
export async function cfSubscribe(opts: {
viewerSessionId: string
publisherSessionId: string
iceServers?: RTCIceServer[]
}): Promise<RTCPeerConnection> {
const pc = new RTCPeerConnection({ iceServers: opts.iceServers || [] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const res = await fetch(`/api/cf/sessions/${opts.viewerSessionId}/tracks/new`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sessionDescription: { type: 'offer', sdp: offer.sdp },
tracks: [{ location: 'remote', sessionId: opts.publisherSessionId }],
}),
})
const data = await res.json()
await pc.setRemoteDescription({ type: data.sessionDescription.type, sdp: data.sessionDescription.sdp })
return pc
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
web/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}

16
web/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
// 构建产物直接落到 Go 的 //go:embed 目录,保持单体仓库发布形态。
// base 设为 /static/ 让 Vite 资源路径命中 Go 的 /static/ 文件服务。
export default defineConfig({
plugins: [solid(), tailwindcss()],
base: '/static/',
build: {
outDir: fileURLToPath(new URL('../internal/server/static', import.meta.url)),
emptyOutDir: true,
},
server: { port: 5173 },
})