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

261 lines
9.5 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, useNavigate } from '@tanstack/solid-router'
import {
Cloud,
Eye,
EyeOff,
FileVideo,
Link2,
MessageSquare,
MonitorPlay,
Radio,
Square,
Video,
} from 'lucide-solid'
import { api } from '../../lib/api'
import { permissions } from '../../lib/permissions'
import { Alert, Badge, Button, Card, LiveDot, 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 { RoomEvent } from '../../lib/types'
type Mode = 'cloudflare' | 'srs-whep' | 'srs-hls'
const MODE_LABEL: Record<Mode, string> = {
cloudflare: 'Cloudflare',
'srs-whep': 'SRS WHEP',
'srs-hls': 'SRS HLS',
}
function backendText(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? 'Cloudflare Realtime' : 'SRS'
}
function RoomHub(props: { room: string }) {
const navigate = useNavigate()
const room = () => props.room
const [mode, setMode] = createSignal<Mode>('cloudflare')
const [running, setRunning] = createSignal(false)
const [error, setError] = createSignal('')
const [targets, setTargets] = createSignal<RoomEvent['targets']>([])
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 || []))
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() === 'cloudflare') {
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 {
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="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-1.5">
<h1 class="text-2xl font-semibold">房间 <span class="font-mono">{room()}</span></h1>
<p class="text-sm text-muted-foreground">观看、发弹幕与查看分发状态。</p>
</div>
<Show when={permissions.can('room:publish')}>
<Button variant="outline" onClick={() => navigate({ to: '/publish', search: { room: room() } })}>
<Video class="size-4" />
开始直播到该房间
</Button>
</Show>
</div>
<Show when={error()}>
<Alert tone="error">{error()}</Alert>
</Show>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card class="space-y-3 p-4 lg:col-span-2 lg:p-5">
<div class="flex items-center justify-between gap-3">
<Segmented
value={mode()}
onChange={(v) => setMode(v)}
options={[
{ value: 'cloudflare', label: 'Cloudflare', icon: <Cloud class="size-3.5" /> },
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
{ value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay 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="acid-frame relative aspect-video overflow-hidden rounded-md border 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="shadow-sm">
<LiveDot active={running()} />
{running() ? '连接中' : '未连接'}
</Badge>
<Badge variant="secondary" class="font-mono shadow-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 text-white/60">
<span class="flex size-12 items-center justify-center rounded-full border border-white/20 bg-white/5">
<MonitorPlay class="size-5" />
</span>
<span class="text-xs">等待接入</span>
</div>
</Show>
</div>
<div class="flex flex-wrap gap-2">
<Button onClick={start} disabled={running()}>
<PlayIcon />
开始观看
</Button>
<Button variant="danger" onClick={stopAll} disabled={!running()}>
<Square class="size-4" />
停止
</Button>
<Button variant="ghost" onClick={() => setShowDanmaku((s) => !s)}>
{showDanmaku() ? <EyeOff class="size-4" /> : <Eye class="size-4" />}
{showDanmaku() ? '隐藏弹幕' : '显示弹幕'}
</Button>
</div>
</Card>
<Card class="flex h-[28rem] flex-col space-y-3 p-4 lg:p-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MessageSquare class="size-4 text-primary" />
<h3 class="font-semibold">弹幕 / 聊天</h3>
</div>
<Badge variant="secondary">{msgs().length}</Badge>
</div>
<div class="min-h-0 flex-1">
<ChatPanel messages={msgs} canChat={permissions.can('room:chat')} onSend={send} />
</div>
</Card>
</div>
<Card class="space-y-3 p-5">
<div class="flex items-center gap-2">
<Link2 class="size-4 text-primary" />
<h3 class="font-semibold">分发状态</h3>
</div>
<Show
when={targets().length > 0}
fallback={
<div class="rounded-md border border-dashed p-5 text-center text-sm text-muted-foreground">房间暂无直播</div>
}
>
<div class="grid gap-2 md:grid-cols-2">
<For each={targets()}>
{(t) => (
<div class="space-y-2 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{backendText(t.backend)}</span>
<Badge variant="success">直播中</Badge>
</div>
<Show when={t.stream}>
<div class="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs">
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.m3u8`} target="_blank">
<FileVideo class="size-3.5" />
HLS
</a>
<a class="inline-flex items-center gap-1 text-primary hover:underline" href={`/live/${t.stream}.flv`} target="_blank">
<FileVideo class="size-3.5" />
FLV
</a>
</div>
</Show>
<Show when={t.session_id}>
<p class="truncate font-mono text-[11px] text-muted-foreground">session:{t.session_id}</p>
</Show>
</div>
)}
</For>
</div>
</Show>
</Card>
</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} />
}