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([]) const [selected, setSelected] = createSignal>({ BACKEND_KIND_CLOUDFLARE: true, BACKEND_KIND_SRS: true, }) const [stream, setStream] = createSignal(null) const [running, setRunning] = createSignal(false) const [targets, setTargets] = createSignal([]) const [error, setError] = createSignal('') const [msgs, setMsgs] = createSignal([]) const [showDanmaku, setShowDanmaku] = createSignal(true) let videoEl: HTMLVideoElement | undefined const pcs: Record = {} 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 (

publish / {room()}

开始直播

选择分发后端,一路推流将扇出到所选 SFU。

{running() ? '推流中 / live' : '待机 / standby'}
当前角色({permissions.role()})无权发布,仅 admin / publisher 可推流。可切换到「观看」进入房间发弹幕。 {error()}
{stream() ? `${stream()!.getVideoTracks().length} 视频 · ${stream()!.getAudioTracks().length} 音频` : 'tracks · off'}

分发后端

routing
{backends().length} 路
0} fallback={

正在读取后端配置…

} > {(b) => { const Icon = backendIcon(b.kind) return (
toggle(b.kind)} disabled={running()} />

{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}

{b.configured ? '在线' : '未配置'}
) }}

推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。

分发状态

fan-out status
0} fallback={
尚未开始推流
} >
{(t) => (
)}

房间弹幕 / 聊天

danmaku
{msgs().length}
) }