378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|