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:
parent
115b34dcd3
commit
340296f6cc
|
|
@ -3,3 +3,6 @@
|
|||
*.local.env
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# 前端工程
|
||||
web/node_modules
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
@ -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};
|
||||
|
|
@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; } }
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
])
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
@ -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')!,
|
||||
)
|
||||
|
|
@ -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 }),
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
@ -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 () => {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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 },
|
||||
})
|
||||
Loading…
Reference in New Issue