live-sfu-demo/web/src/app/routes/index.tsx

378 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { Component, createSignal, createEffect, For, onCleanup, onMount, Show } from 'solid-js'
import { Link, useNavigate } from '@tanstack/solid-router'
import {
ArrowRight,
Cloud,
Hash,
MonitorPlay,
RadioTower,
Server,
ShieldCheck,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import {
Badge,
Button,
Corners,
LiveDot,
Marquee,
Meter,
Reveal,
} from '../../components/ui'
import type { BackendInfo, Room } from '../../lib/types'
const PROTOCOLS = ['WHIP', 'WHEP', 'HLS', 'FLV']
function backendIcon(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server
}
function targetsOf(r: Room) {
return r.targets?.length ?? 0
}
function EmptyRooms(props: { authed: boolean }) {
return (
<div class="mt-6 flex flex-col items-center gap-3 border border-dashed border-line px-6 py-14 text-center">
<MonitorPlay class="size-6 text-muted-foreground/60" />
<div>
<p class="font-medium">{props.authed ? '暂无在线房间' : '房间列表需登录'}</p>
<p class="mt-1 text-sm text-muted-foreground">
{props.authed
? '创建一个房间后,分发拓扑会出现在这里。'
: '登录后可查看与管理房间分发拓扑。'}
</p>
</div>
<Show when={!props.authed}>
<Link to="/login" class="btn btn-outline btn-sm mt-1">
去登录
</Link>
</Show>
</div>
)
}
export const Home: Component = () => {
const navigate = useNavigate()
const [backends, setBackends] = createSignal<BackendInfo[]>([])
const [rooms, setRooms] = createSignal<Room[]>([])
const [room, setRoom] = createSignal('demo')
let heroEl: HTMLElement | undefined
onMount(() => {
api
.getConfig()
.then((c) => setBackends(c.backends || []))
.catch(() => {})
const el = heroEl
if (el && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// 鼠标视差:各层按 data-parallax 深度位移,rAF + lerp 平滑;聚光灯同步跟随
const layers = Array.from(el.querySelectorAll<HTMLElement>('[data-parallax]'))
let tx = 0
let ty = 0
let cx = 0
let cy = 0
let raf = 0
const onMove = (e: MouseEvent) => {
const r = el.getBoundingClientRect()
tx = (e.clientX - r.left) / r.width - 0.5
ty = (e.clientY - r.top) / r.height - 0.5
el.style.setProperty('--mx', `${e.clientX - r.left}px`)
el.style.setProperty('--my', `${e.clientY - r.top}px`)
}
const onLeave = () => {
tx = 0
ty = 0
}
const tick = () => {
cx += (tx - cx) * 0.08
cy += (ty - cy) * 0.08
for (const layer of layers) {
const d = Number(layer.dataset.parallax || 0)
layer.style.transform = `translate3d(${(cx * d).toFixed(2)}px, ${(cy * d).toFixed(2)}px, 0)`
}
raf = requestAnimationFrame(tick)
}
el.addEventListener('mousemove', onMove)
el.addEventListener('mouseleave', onLeave)
raf = requestAnimationFrame(tick)
onCleanup(() => {
el.removeEventListener('mousemove', onMove)
el.removeEventListener('mouseleave', onLeave)
cancelAnimationFrame(raf)
})
}
})
// 登录态就绪后再拉房间(整页刷新时鉴权接口晚于组件挂载,需响应式等待)
createEffect(() => {
if (!permissions.check()) return
if (!permissions.isAuthed()) return
api
.listRooms()
.then((r) => setRooms(r.rooms || []))
.catch(() => {})
})
const go = (to: string) => navigate({ to, search: { room: room() } })
return (
<div class="relative">
{/* ===== HERO ===== */}
<section ref={(n) => (heroEl = n)} class="relative overflow-hidden">
<div class="spotlight" aria-hidden="true" />
<div
class="ghost-text display pointer-events-none absolute -top-8 right-0 hidden text-[24vw] leading-none will-change-transform xl:block"
data-parallax="-40"
aria-hidden="true"
>
SFU
</div>
<div class="relative mx-auto max-w-[1400px] px-4 pt-14 pb-12 sm:px-6 sm:pt-20">
<Reveal>
<div class="flex items-center gap-2.5">
<span class="size-2 bg-success pulse" />
<span class="mono-label text-muted-foreground">live-sfu / fan-out console</span>
</div>
</Reveal>
<Reveal delay={1}>
<h1 class="display mt-6 text-[clamp(3.4rem,9vw,7.75rem)] will-change-transform" data-parallax="10">
<span class="block">一路推流,</span>
<span class="block">
<span class="text-acid-lime">多端扇出</span>
<span class="mono-label ml-3 align-top text-acid-lime sm:ml-5">/ fan-out</span>
</span>
</h1>
</Reveal>
<Reveal delay={2}>
<p class="mt-6 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
Cloudflare Realtime 与 SRS 双 SFU 分发,浏览器经服务端反代直连,凭证不出服务端。
</p>
</Reveal>
<Reveal delay={3} class="mt-10">
<div class="relative max-w-3xl border border-line bg-card/60 p-3 backdrop-blur-sm will-change-transform sm:p-4" data-parallax="6">
<Corners />
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex h-11 flex-1 items-center gap-2.5 border border-line bg-background/70 px-3 transition-colors focus-within:border-ring">
<Hash class="size-4 shrink-0 text-muted-foreground" />
<input
class="field-mono h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
value={room()}
onInput={(e) => setRoom((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go('/room')
}}
placeholder="房间名,如 demo"
aria-label="房间名"
/>
</div>
<Button class="h-11 sm:w-auto" onClick={() => go('/room')}>
进入房间
<ArrowRight class="size-4" />
</Button>
<Button variant="outline" class="h-11" onClick={() => go('/publish')}>
<Video class="size-4" />
开始直播
</Button>
</div>
<Show when={permissions.isAuthed()}>
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/70 pt-3">
<span class="mono-label flex items-center gap-1.5 text-muted-foreground">
role <span class="text-acid-cyan">{permissions.role()}</span>
</span>
<span class="mono-label text-muted-foreground">
publish ·{' '}
<span class={permissions.can('room:publish') ? 'text-success' : 'opacity-50'}>
{permissions.can('room:publish') ? 'ON' : 'OFF'}
</span>
</span>
<span class="mono-label text-muted-foreground">
chat ·{' '}
<span class={permissions.can('room:chat') ? 'text-success' : 'opacity-50'}>
{permissions.can('room:chat') ? 'ON' : 'OFF'}
</span>
</span>
</div>
</Show>
</div>
</Reveal>
<Reveal delay={4} class="mt-12">
<div class="grid max-w-3xl grid-cols-3 divide-x divide-line/80 border border-line bg-card/40 will-change-transform" data-parallax="4">
<div class="px-4 py-3 sm:px-5">
<p class="display text-3xl sm:text-4xl dark:text-primary">{backends().length || '–'}</p>
<p class="mono-label mt-1.5 text-muted-foreground">sfu backends</p>
</div>
<div class="px-4 py-3 sm:px-5">
<p class="display text-3xl sm:text-4xl dark:text-primary">
{permissions.isAuthed() ? rooms().length : '–'}
</p>
<p class="mono-label mt-1.5 text-muted-foreground">online rooms</p>
</div>
<div class="px-4 py-3 sm:px-5">
<p class="display text-3xl sm:text-4xl dark:text-primary">4</p>
<p class="mono-label mt-1.5 text-muted-foreground">protocols</p>
</div>
</div>
</Reveal>
</div>
</section>
<Marquee
items={[
'WHIP → SFU',
'SRS FAN-OUT',
'WHEP / HLS / FLV',
'CLOUDFLARE REALTIME',
'WEBRTC LOW-LATENCY',
'一路推流 · 多端扇出',
'FAN-OUT CONSOLE',
]}
/>
{/* ===== 在线房间 + 信号栈 ===== */}
<section class="mx-auto grid max-w-[1400px] gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div class="min-w-0">
<Reveal>
<div class="flex items-end justify-between gap-4 border-b border-line pb-4">
<div>
<p class="mono-label text-muted-foreground">online rooms</p>
<h2 class="display mt-1.5 text-3xl sm:text-4xl">在线房间</h2>
</div>
<Button
variant="ghost"
size="sm"
class="hidden sm:inline-flex"
onClick={() => navigate({ to: '/rooms' })}
>
全部
<ArrowRight class="size-3.5" />
</Button>
</div>
</Reveal>
<Show when={rooms().length > 0} fallback={<EmptyRooms authed={permissions.isAuthed()} />}>
<div class="mt-2">
<For each={rooms()}>
{(r, i) => (
<Reveal delay={Math.min(i() + 1, 5)}>
<div class="group -mx-3 grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-line/80 py-4 transition-colors hover:bg-accent/40 sm:mx-0 sm:-mx-4 sm:grid-cols-[3rem_auto_minmax(0,1fr)_auto] sm:gap-4 sm:px-4">
<span class="hidden font-mono text-xs text-muted-foreground/70 sm:block">
{String(i() + 1).padStart(2, '0')}
</span>
<LiveDot active={targetsOf(r) > 0} />
<div class="min-w-0">
<p class="display truncate text-xl transition-colors sm:text-2xl dark:group-hover:text-primary">
{r.name}
</p>
<p class="mono-label mt-1 text-muted-foreground">
{targetsOf(r)} targets · {targetsOf(r) > 0 ? 'live' : 'idle'}
</p>
</div>
<div class="col-span-2 flex gap-2 sm:col-span-1">
<Button
size="sm"
variant="outline"
class="flex-1 sm:flex-none"
onClick={() => navigate({ to: '/room', search: { room: r.name } })}
>
进入
</Button>
<Button
size="sm"
class="flex-1 sm:flex-none"
onClick={() => navigate({ to: '/publish', search: { room: r.name } })}
>
直播
</Button>
</div>
</div>
</Reveal>
)}
</For>
</div>
</Show>
</div>
<aside class="min-w-0">
<Reveal>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<Corners />
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<RadioTower class="size-4 text-acid-lime" />
<h2 class="font-semibold">Signal Stack</h2>
</div>
<Badge variant="muted">{backends().length} 路</Badge>
</div>
<div class="mt-4 space-y-2.5">
<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 border p-3 transition-colors ${
b.configured
? 'border-line bg-background/50'
: 'border-line/60 bg-background/20 opacity-70'
}`}
>
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center border border-line bg-background text-foreground/80">
<Icon class="size-4" />
</span>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{b.name}</p>
<p class="micro-label mt-0.5 text-muted-foreground">
{b.primary ? 'primary sfu' : 'standby'}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5">
<Meter active={b.configured} />
<Badge variant={b.configured ? 'success' : 'muted'}>
{b.configured ? '在线' : '未配置'}
</Badge>
</div>
</div>
)
}}
</For>
</Show>
</div>
<div class="mt-4 border-t border-line/70 pt-4">
<p class="micro-label text-muted-foreground">distribution protocols</p>
<div class="mt-2.5 flex flex-wrap gap-1.5">
<For each={PROTOCOLS}>
{(p) => <Badge variant="outline" class="font-mono">{p}</Badge>}
</For>
</div>
</div>
<div class="mt-4 flex items-start gap-2.5 border border-line/70 bg-background/40 p-3">
<ShieldCheck class="mt-px size-4 shrink-0 text-success" />
<p class="text-xs leading-relaxed text-muted-foreground">
凭据由服务端代理注入,浏览器不持有 SFU 密钥。
</p>
</div>
</div>
</Reveal>
</aside>
</section>
</div>
)
}