340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|