261 lines
9.5 KiB
TypeScript
261 lines
9.5 KiB
TypeScript
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} />
|
||
}
|