327 lines
13 KiB
TypeScript
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} />
|
|
}
|