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

340 lines
13 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, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch } from '@tanstack/solid-router'
import {
CircleStop,
Cloud,
FileVideo,
MessageSquare,
Server,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, CheckboxRow, Corners, LiveDot, Meter, Reveal } from '../../components/ui'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import { subscribeRoom } from '../../lib/sse'
import { subscribeChat, sendChat, 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) => {
sendChat(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="mx-auto max-w-[1400px] space-y-5 px-4 py-8 sm:px-6 sm:py-10">
<Reveal>
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="mono-label text-muted-foreground">publish / {room()}</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground">
选择分发后端,一路推流将扇出到所选 SFU。
</p>
</div>
<div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5">
<LiveDot active={running()} />
<span class="mono-label">{running() ? '推流中 / live' : '待机 / standby'}</span>
<Meter active={running()} />
</div>
</div>
</Reveal>
<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 gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Reveal delay={1} class="min-w-0">
<div class="relative h-full border border-line bg-card/50 p-4 sm:p-5">
<Corners />
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Video class="size-4 text-acid-lime" />
<h3 class="font-semibold">本地预览</h3>
<span class="micro-label text-muted-foreground">preview</span>
</div>
<div class="flex items-center gap-3">
<span class="font-mono text-[11px] text-muted-foreground">
{stream()
? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频`
: 'tracks · off'}
</span>
<Button variant="ghost" size="sm" onClick={() => setShowDanmaku((v) => !v)}>
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
</div>
</div>
<div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline muted 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="bg-black/50 shadow-sm backdrop-blur-sm">
<LiveDot active={running()} />
{running() ? 'LIVE' : 'STANDBY'}
</Badge>
</div>
<Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60">
<span class="flex size-12 items-center justify-center border border-white/20 bg-white/5">
<Video class="size-5" />
</span>
<span class="micro-label text-white/50">camera off</span>
</div>
</Show>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button class="h-10" onClick={start} disabled={running() || !permissions.can('room:publish')}>
<Video class="size-4" />
开始推流
</Button>
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
<CircleStop class="size-4" />
停止推流
</Button>
</div>
</div>
</Reveal>
<Reveal delay={2} class="min-w-0 self-start">
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Server class="size-4 text-acid-lime" />
<h3 class="font-semibold">分发后端</h3>
<span class="micro-label text-muted-foreground">routing</span>
</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">
<CheckboxRow
label={b.name}
checked={!!selected()[b.kind]}
onChange={() => toggle(b.kind)}
disabled={running()}
/>
<p class="micro-label mt-1.5 text-muted-foreground">
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
</p>
</div>
</div>
<Badge variant={b.configured ? 'success' : 'muted'}>
{b.configured ? '在线' : '未配置'}
</Badge>
</div>
)
}}
</For>
</Show>
</div>
<p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground">
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。
</p>
</div>
</Reveal>
</div>
<Reveal delay={1}>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center gap-2">
<FileVideo class="size-4 text-acid-lime" />
<h3 class="font-semibold">分发状态</h3>
<span class="micro-label text-muted-foreground">fan-out status</span>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
尚未开始推流
</div>
}
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="relative border border-line/80 bg-background/40 p-4">
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{backendLabel(t.backend)}</span>
<Badge variant="success">
<LiveDot active />
直播中
</Badge>
</div>
<Show when={t.stream}>
<div class="mt-3 flex flex-wrap gap-2">
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
HLS
</a>
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
<FileVideo class="size-3" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
session · {t.session_id}
</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</Reveal>
<Reveal delay={2}>
<div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-acid-lime" />
<h3 class="font-semibold">房间弹幕 / 聊天</h3>
<span class="micro-label text-muted-foreground">danmaku</span>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="mt-4 h-80 min-h-0">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</div>
</Reveal>
</div>
)
}