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

327 lines
13 KiB
TypeScript

import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
import { useSearch, useNavigate } from '@tanstack/solid-router'
import {
Cloud,
Eye,
EyeOff,
FileVideo,
Globe,
MessageSquare,
MonitorPlay,
Radio,
Square,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Corners, LiveDot, Reveal, Segmented } from '../../components/ui'
import { subscribeRoom } from '../../lib/sse'
import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
import { playHLS } from '../../lib/hls'
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import type { DistributionTarget, RoomEvent } from '../../lib/types'
type Mode = 'srs-hls' | 'srs-whep' | 'cf-sfu' | 'cdn-hls'
const MODE_LABEL: Record<Mode, string> = {
'srs-hls': 'SRS HLS',
'srs-whep': 'SRS WHEP',
'cf-sfu': 'CF SFU',
'cdn-hls': 'CDN HLS',
}
function distText(kind: string) {
if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return 'SRS HLS'
if (kind === 'DISTRIBUTION_KIND_CF_SFU') return 'CF SFU'
if (kind === 'DISTRIBUTION_KIND_CDN') return 'CDN'
return kind
}
function RoomHub(props: { room: string }) {
const navigate = useNavigate()
const room = () => props.room
const [mode, setMode] = createSignal<Mode>('srs-hls')
const [running, setRunning] = createSignal(false)
const [error, setError] = createSignal('')
const [targets, setTargets] = createSignal<RoomEvent['targets']>([])
const [dists, setDists] = createSignal<DistributionTarget[]>([])
const [msgs, setMsgs] = createSignal<ChatMessage[]>([])
const [showDanmaku, setShowDanmaku] = createSignal(true)
let videoEl: HTMLVideoElement | undefined
let pc: RTCPeerConnection | undefined
let stopHls: (() => void) | undefined
onMount(() => {
const unsubRoom = subscribeRoom(room(), (ev) => {
setTargets(ev.targets || [])
setDists(ev.distributions || [])
})
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => {
unsubRoom()
unsubChat()
})
})
function attach(p: RTCPeerConnection) {
p.ontrack = (e) => {
if (videoEl) videoEl.srcObject = e.streams[0]
}
}
const stopAll = () => {
stopHls?.()
stopHls = undefined
pc?.close()
pc = undefined
if (videoEl) {
videoEl.srcObject = null
videoEl.src = ''
}
setRunning(false)
}
onCleanup(stopAll)
const start = async () => {
setError('')
setRunning(true)
try {
if (mode() === 'cf-sfu') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_CLOUDFLARE', '')
pc = await cfSubscribe({
viewerSessionId: resp.session_id!,
publisherSessionId: resp.publisher_session_id!,
iceServers: (resp.ice_servers || []).map(toRTC),
})
attach(pc)
} else if (mode() === 'srs-whep') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
pc = await whepSubscribe({ stream: resp.stream!, iceServers: (resp.ice_servers || []).map(toRTC) })
attach(pc)
} else if (mode() === 'cdn-hls') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
if (!videoEl) throw new Error('video element missing')
const cdnDist = dists().find((d) => d.kind === 'DISTRIBUTION_KIND_CDN')
// Prefer CDN URL if available, fallback to /live/cdn path
const url = cdnDist?.url || `/live/cdn/${resp.stream}.m3u8`
stopHls = playHLS(videoEl, url)
} else {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
if (!videoEl) throw new Error('video element missing')
stopHls = playHLS(videoEl, `/live/${resp.stream}.m3u8`)
}
} catch (e: any) {
setError(String(e?.message || e))
setRunning(false)
}
}
const send = (text: string) => {
sendChat(room(), text).catch(() => {})
}
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 class="min-w-0">
<p class="mono-label text-muted-foreground">watch / room</p>
<h1 class="display mt-2 truncate text-4xl sm:text-5xl">{room()}</h1>
<p class="mt-3 text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2.5 border border-line bg-card/60 px-3.5 py-2.5">
<LiveDot active={targets().length > 0} />
<span class="mono-label">
{targets().length} trunk · {dists().length} 分发 · {targets().length > 0 ? 'live' : 'idle'}
</span>
</div>
<Show when={permissions.can('room:publish')}>
<Button
variant="outline"
class="h-10"
onClick={() => navigate({ to: '/publish', search: { room: room() } })}
>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
</div>
</Reveal>
<Show when={error()}>
<Alert tone="error">{error()}</Alert>
</Show>
<div class="grid gap-4 lg:grid-cols-3">
<Reveal delay={1} class="min-w-0 lg:col-span-2">
<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-3">
<Segmented
value={mode()}
onChange={(v) => setMode(v)}
options={[
{ value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> },
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
{ value: 'cf-sfu', label: 'CF SFU', icon: <Cloud class="size-3.5" /> },
{ value: 'cdn-hls', label: 'CDN HLS', icon: <Globe class="size-3.5" /> },
]}
/>
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block">
fan-out · {MODE_LABEL[mode()]}
</span>
</div>
<div class="relative mt-4 aspect-video overflow-hidden border border-line/70 bg-black">
<video ref={(el) => (videoEl = el)} autoplay playsinline 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() ? 'CONNECTING' : 'OFFLINE'}
</Badge>
<Badge variant="secondary" class="border-line bg-black/50 text-white/90 shadow-sm backdrop-blur-sm">
{MODE_LABEL[mode()]}
</Badge>
</div>
<Show when={!running()}>
<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">
<MonitorPlay class="size-5" />
</span>
<span class="micro-label text-white/50">standby · 等待接入</span>
</div>
</Show>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button class="h-10" onClick={start} disabled={running()}>
<PlayIcon />
开始观看
</Button>
<Button variant="danger" class="h-10" onClick={stopAll} disabled={!running()}>
<Square class="size-4" />
停止
</Button>
<Button variant="ghost" class="h-10" onClick={() => setShowDanmaku((s) => !s)}>
{showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
</div>
</div>
</Reveal>
<Reveal delay={2} class="min-w-0">
<div class="relative flex h-full min-h-[24rem] flex-col 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 hidden text-muted-foreground sm:inline">danmaku</span>
</div>
<Badge variant="muted">{msgs().length}</Badge>
</div>
<div class="mt-4 min-h-0 flex-1">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</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 || dists().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 space-y-3">
<Show when={targets().length > 0}>
<div class="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">SRS 主干</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>
</div>
)}
</For>
</div>
</Show>
<Show when={dists().length > 0}>
<div class="grid gap-3 md:grid-cols-3">
<For each={dists()}>
{(d) => (
<div class="relative border border-line/60 bg-background/30 p-3">
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-medium">{distText(d.kind)}</span>
<Badge variant={d.status === 'ready' ? 'success' : d.status === 'forwarding' ? 'warning' : 'muted'}>
{d.status}
</Badge>
</div>
<p class="mt-1.5 truncate font-mono text-[10px] text-muted-foreground">{d.url}</p>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Reveal>
</div>
)
}
function PlayIcon() {
return (
<span class="flex size-4 items-center justify-center">
<svg viewBox="0 0 16 16" class="size-3.5 fill-current" aria-hidden="true">
<path d="M5 3.2v9.6a.5.5 0 0 0 .77.42l7.2-4.8a.5.5 0 0 0 0-.84l-7.2-4.8A.5.5 0 0 0 5 3.2Z" />
</svg>
</span>
)
}
export const WatchRoute: Component = () => {
const search = useSearch({ from: '/watch' })
return <RoomHub room={search().room} />
}
export const RoomRoute: Component = () => {
const search = useSearch({ from: '/room' })
return <RoomHub room={search().room} />
}