diff --git a/Cargo.lock b/Cargo.lock index fdb7cf3..1eb2226 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -484,6 +484,7 @@ dependencies = [ "dialoguer", "dirs", "error-stack", + "flv", "flv-fix", "futures", "glob", diff --git a/app/(app)/history/page.module.scss b/app/(app)/history/page.module.scss new file mode 100644 index 0000000..2f0ef07 --- /dev/null +++ b/app/(app)/history/page.module.scss @@ -0,0 +1,9 @@ +/* 历史记录页:Tab 面板不裁剪内容(Semi Tabs 面板默认 overflow hidden 会吃掉小窗阴影与浮层) */ +.tabs :global(.semi-tabs-content), +.tabs :global(.semi-tabs-pane) { + overflow: visible; +} + +.tabs :global(.semi-tabs-bar) { + margin-bottom: 16px; +} diff --git a/app/(app)/history/page.tsx b/app/(app)/history/page.tsx index c047d3e..3de85c9 100644 --- a/app/(app)/history/page.tsx +++ b/app/(app)/history/page.tsx @@ -1,5 +1,5 @@ 'use client' -import { Modal, Table, Typography } from '@douyinfe/semi-ui' +import { Modal, Table, Tabs, TabPane, Typography } from '@douyinfe/semi-ui' import { IconVideoListStroked } from '@douyinfe/semi-icons' import { SortOrder } from '@douyinfe/semi-ui/lib/es/table' import useSWR from 'swr' @@ -9,14 +9,23 @@ import dynamic from 'next/dynamic' import { humDate } from '@/app/lib/utils' import { formatSize } from '@/app/lib/use-dashboard' import PageHeader from '../components/PageHeader' +import LiveMonitor from '@/app/ui/LiveMonitor' import dc from '@/app/ui/data-card.module.scss' +import styles from './page.module.scss' const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false, }) +type HistoryTab = 'files' | 'monitor' + +/** + * 历史记录:「录制文件」(已录完的文件回放)与「实时监视」(正在录制的直播间多路同屏)两个 Tab。 + * 监视器是独立组件 ,要挪到别的页面只需换个挂载点。 + */ export default function History() { const { Text } = Typography + const [tab, setTab] = useState('files') const { data: data, error, isLoading } = useSWR('/v1/videos', fetcher) const [fileName, setFileName] = useState() const [visible, setVisible] = useState(false) @@ -60,18 +69,33 @@ export default function History() { } title="历史记录" - description="已录制的视频文件,可在线回放" + description="已录制的视频文件可在线回放;「实时监视」同屏查看正在录制的直播间" />
-
- - + setTab(key as HistoryTab)} + className={styles.tabs} + keepDOM={false} + > + +
+
+ + + + {/* keepDOM=false:切走即卸载播放器、断开全部预览连接 */} + + + setVisible(false)} diff --git a/app/lib/api-streamer.ts b/app/lib/api-streamer.ts index a9ebd83..7a7d7c7 100644 --- a/app/lib/api-streamer.ts +++ b/app/lib/api-streamer.ts @@ -93,6 +93,42 @@ export interface StudioEntity { extra_fields?: string; } +/** 正在录制的直播间能否在页面内预览(复用正在录制的那一路流,见 GET /v1/streamers/{id}/live) */ +export interface LivePreviewInfo { + /** 当前下载器能否提供预览;为 true 时 format 仍可能是 null(刚开始拉流、容器未定) */ + available: boolean; + /** 视频流的容器,与 /live 响应的 Content-Type 一致:flv / mpegts 走 mpegts.js,fmp4 直接 MediaSource */ + format: 'flv' | 'mpegts' | 'fmp4' | null; + /** fmp4 时从 init segment 解出的 RFC 6381 编码串(如 avc1.64001f,mp4a.40.2),其它容器为 null */ + codecs: string | null; + /** 不可预览的原因(ffmpeg / streamlink 子进程落盘、HEVC FLV 等) */ + reason: string | null; + /** 这一路有没有实时弹幕(平台实现了弹幕客户端),有则 /v1/streamers/{id}/danmaku(SSE)可用 */ + danmaku: boolean; + /** 浏览器能否直连 CDN 拉这一路(全局配置 preview_transport = direct 时用;不能则回落中转并显示原因) */ + direct: DirectCapability; +} + +export interface DirectCapability { + capable: boolean; + /** 不能直连的原因;capable 为 true 时为 null */ + reason: string | null; +} + +/** GET /v1/streamers/{id}/live-url:正在录制的那条流的 CDN 直链 */ +export interface LiveUrlInfo { + url: string; + /** 浏览器该用哪个播放器:flv → mpegts.js,hls → hls.js(TS / fMP4 分片都行) */ + format: 'flv' | 'hls' | null; + platform: string; + /** 过期时间估计(Unix 秒),直链里没有可识别的过期参数时为 null */ + expires_at: number | null; + direct: DirectCapability; +} + +/** 直播预览的取流方式(全局配置 preview_transport),空值视同 relay */ +export type PreviewTransport = 'relay' | 'direct'; + export interface LiveStreamerEntity { id: number; url: string; @@ -111,6 +147,8 @@ export interface LiveStreamerEntity { live_cover_url?: string | null; /** 录制中的主播头像地址;图片请走 /v1/streamers/{id}/avatar 代理 */ live_avatar_url?: string | null; + /** 录制中的预览能力;未录制为 null */ + preview?: LivePreviewInfo | null; statusTag?: React.ReactNode; format?: string; time_range?: string | Date[]; diff --git a/app/lib/danmaku-feed.ts b/app/lib/danmaku-feed.ts new file mode 100644 index 0000000..1759243 --- /dev/null +++ b/app/lib/danmaku-feed.ts @@ -0,0 +1,99 @@ +'use client' +import { useEffect, useMemo } from 'react' +import { API_BASE } from './api-streamer' + +/** SSE 里一条弹幕的形状,与后端 `DanmakuFrame` 一致 */ +export interface DanmakuFrame { + /** 直播间 id:一条 SSE 可能复用给多个直播间 */ + id: number + kind: 'danmaku' | 'gift' | 'super_chat' | 'guard_buy' + text: string + name: string | null + /** RGB 整数(白 16777215) */ + color: number + /** Unix 毫秒 */ + ts: number +} + +const KINDS: DanmakuFrame['kind'][] = ['danmaku', 'gift', 'super_chat', 'guard_buy'] + +/** + * 若干直播间共用的一条实时弹幕连接。 + * + * 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个,监视器同屏 N 路视频已经占了 N 个, + * 弹幕不能再每路一条——所以由页面持有一个 feed(`/v1/danmaku?ids=…`,一条 SSE), + * 各播放器按 id 订阅自己那一路。单路弹层同样走这里(ids 只有一个)。 + */ +export class DanmakuFeed { + private source: EventSource | null = null + private active = false + private listeners = new Map void>>() + + /** 构造不产生副作用;`open()` 之后、有订阅者时才真正连接(StrictMode 下重复构造无害) */ + constructor(readonly ids: number[]) {} + + /** 允许连接;已有订阅者就立刻连。 */ + open() { + this.active = true + this.connectIfNeeded() + } + + /** 断开并禁止再连;订阅者保留,`open()` 后可恢复。 */ + close() { + this.active = false + this.source?.close() + this.source = null + } + + /** 订阅某个直播间的弹幕;返回退订函数。 */ + subscribe(id: number, listener: (frame: DanmakuFrame) => void): () => void { + let set = this.listeners.get(id) + if (!set) { + set = new Set() + this.listeners.set(id, set) + } + set.add(listener) + this.connectIfNeeded() + return () => { + set?.delete(listener) + } + } + + private connectIfNeeded() { + if (!this.active || this.source || this.ids.length === 0 || typeof window === 'undefined') return + if (![...this.listeners.values()].some((set) => set.size > 0)) return + const source = new EventSource(`${API_BASE}/v1/danmaku?ids=${this.ids.join(',')}`) + const handle = (e: MessageEvent) => { + let frame: DanmakuFrame + try { + frame = JSON.parse(e.data) + } catch { + return + } + if (!frame.text) return + this.listeners.get(frame.id)?.forEach((l) => l(frame)) + } + for (const kind of KINDS) source.addEventListener(kind, handle as EventListener) + this.source = source + } +} + +/** 同一组 id 的稳定键,供 effect 依赖:顺序无关。 */ +export function feedKey(ids: number[]): string { + return [...ids].sort((a, b) => a - b).join(',') +} + +/** + * 按需持有一个 feed:`enabled` 且 ids 非空时连接,ids 变化(监视器换路)时换一条, + * 关闭 / 卸载时断开。返回值给各播放器的 `danmaku.feed`。 + */ +export function useDanmakuFeed(ids: number[], enabled: boolean): DanmakuFeed | null { + const key = enabled ? feedKey(ids) : '' + // 构造无副作用,连接在 effect 里 open() 之后才发生 + const feed = useMemo(() => (key ? new DanmakuFeed(key.split(',').map(Number)) : null), [key]) + useEffect(() => { + feed?.open() + return () => feed?.close() + }, [feed]) + return feed +} diff --git a/app/lib/use-dashboard.ts b/app/lib/use-dashboard.ts index 6fb4496..5759c15 100644 --- a/app/lib/use-dashboard.ts +++ b/app/lib/use-dashboard.ts @@ -1,7 +1,7 @@ 'use client' import { useSyncExternalStore } from 'react' import useSWR from 'swr' -import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer' +import { API_BASE, fetcher, LiveStreamerEntity, StreamerInfo, FileList, LiveUrlInfo, PreviewTransport } from './api-streamer' import { platformName } from './status' /** @@ -112,6 +112,71 @@ export function liveImageUrl( return `${API_BASE}/v1/streamers/${id}/${kind}?v=${(h >>> 0).toString(36)}` } +/** 正在录制的那一路流的同源地址(chunked FLV / MPEG-TS / fMP4),供页面内播放器直接拉取。 */ +export function livePreviewUrl(id: number): string { + return `${API_BASE}/v1/streamers/${id}/live` +} + +/** 浏览器直连模式:向后端要当前录制中那条流的 CDN 直链。 */ +export function fetchLiveUrl(id: number): Promise { + return fetcher(`/v1/streamers/${id}/live-url`, { cache: 'no-store' }) +} + +/** + * CDN 直链在页面里能不能直接用:https 页面拉 http 直链会被当作混合内容拦掉(抖音的直链就是 http), + * 实测这些 CDN 都支持 https,直接换协议。 + */ +export function directPlayableUrl(url: string): string { + if (typeof window !== 'undefined' && window.location.protocol === 'https:' && url.startsWith('http://')) { + return 'https://' + url.slice('http://'.length) + } + return url +} + +/** + * 全局配置里的预览取流方式。与空间配置页共用同一个 SWR key,改完保存这里立刻拿到; + * 配置还没加载到时按 relay 处理(默认值)。 + */ +export function usePreviewTransport(): PreviewTransport { + const { data } = useSWR<{ preview_transport?: PreviewTransport | null }>('/v1/configuration', fetcher, { + refreshInterval: SLOW_REFRESH_MS, + }) + return data?.preview_transport === 'direct' ? 'direct' : 'relay' +} + +/** + * 卡片 / 监视器能否起播:正在录制、下载器能旁路、容器已确定。 + * 容器未定(刚开始拉流的前几秒)时按钮先禁用,下一次轮询拿到 format 再放开。 + */ +export function canPreview(streamer: LiveStreamerEntity): streamer is LiveStreamerEntity & { + preview: { available: true; format: 'flv' | 'mpegts' | 'fmp4' } +} { + const format = streamer.preview?.format + return ( + streamer.status === LIVE_STATUS && + !!streamer.preview?.available && + (format === 'flv' || format === 'mpegts' || format === 'fmp4') + ) +} + +/** 容器短名 → 界面标签 */ +export function previewFormatLabel(format: 'flv' | 'mpegts' | 'fmp4' | null | undefined): string | null { + if (format === 'flv') return 'FLV' + if (format === 'mpegts') return 'TS' + if (format === 'fmp4') return 'fMP4' + return null +} + +/** 预览按钮禁用时的提示文案;能预览时返回 null。 */ +export function previewDisabledReason(streamer: LiveStreamerEntity): string | null { + if (streamer.status !== LIVE_STATUS) return '未在录制' + const preview = streamer.preview + if (!preview) return '预览信息尚未就绪' + if (!preview.available) return preview.reason || '当前下载器不支持预览' + if (!preview.format) return '正在建立预览,请稍候' + return null +} + const DAY = 86400 const EVENT_WINDOW = DAY // 事件流只展示最近 24h diff --git a/app/lib/use-local-pref.ts b/app/lib/use-local-pref.ts new file mode 100644 index 0000000..537aa2e --- /dev/null +++ b/app/lib/use-local-pref.ts @@ -0,0 +1,66 @@ +'use client' +import { useSyncExternalStore } from 'react' + +/** + * 记在 localStorage 里的界面偏好(同屏路数、弹幕开关等)。 + * 用 useSyncExternalStore 而不是「useState + effect 里 setState」:服务端与首屏统一给默认值, + * 挂载后同步读到真实值,没有水合不一致,也不触发 react-hooks/set-state-in-effect。 + */ +const listeners = new Set<() => void>() + +function notify() { + listeners.forEach((l) => l()) +} + +function subscribe(listener: () => void) { + listeners.add(listener) + window.addEventListener('storage', listener) + return () => { + listeners.delete(listener) + window.removeEventListener('storage', listener) + } +} + +export function readPref(key: string): string | null { + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(key) + } catch { + return null + } +} + +export function writePref(key: string, value: string) { + try { + window.localStorage.setItem(key, value) + } catch { + /* 隐私模式等写不进去时就只在本次会话里生效 */ + } + notify() +} + +/** 布尔偏好。 */ +export function useBoolPref(key: string, fallback: boolean): [boolean, (v: boolean) => void] { + const value = useSyncExternalStore( + subscribe, + () => { + const raw = readPref(key) + return raw === null ? fallback : raw === '1' + }, + () => fallback + ) + return [value, (v: boolean) => writePref(key, v ? '1' : '0')] +} + +/** 只接受给定候选值的数字偏好。 */ +export function useChoicePref(key: string, options: number[], fallback: number): [number, (v: number) => void] { + const value = useSyncExternalStore( + subscribe, + () => { + const n = Number(readPref(key)) + return options.includes(n) ? n : fallback + }, + () => fallback + ) + return [value, (v: number) => writePref(key, String(v))] +} diff --git a/app/ui/LiveMonitor.tsx b/app/ui/LiveMonitor.tsx new file mode 100644 index 0000000..4d645b3 --- /dev/null +++ b/app/ui/LiveMonitor.tsx @@ -0,0 +1,270 @@ +'use client' +import React, { useState } from 'react' +import useSWR from 'swr' +import { Button, Empty, Select, Switch, Tag, Tooltip, Typography } from '@douyinfe/semi-ui' +import { IconPlay, IconStop } from '@douyinfe/semi-icons' +import { fetcher, LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer' +import { LIVE_STATUS, platformName } from '@/app/lib/status' +import { + canPreview, + formatRate, + liveImageUrl, + previewDisabledReason, + previewFormatLabel, + SLOW_REFRESH_MS, + STREAMERS_REFRESH_MS, +} from '@/app/lib/use-dashboard' +import { useBoolPref, useChoicePref } from '@/app/lib/use-local-pref' +import { useDanmakuFeed } from '@/app/lib/danmaku-feed' +import { LivePreviewPlayer } from './LivePreview' +import styles from './live-monitor.module.scss' + +/** + * 监视器默认同屏路数。每路小窗占用对应直播间的 1 个预览连接 + * (服务端每间上限见 `PREVIEW_MAX_SUBSCRIBERS_PER_ROOM`,进程上限 16),改这里即可换默认值。 + */ +export const DEFAULT_MONITOR_TILES = 4 +/** + * 浏览器对同一主机的 HTTP/1.1 并发连接只有 6 个:每路小窗占 1 个长连接,弹幕复用 1 个, + * 列表轮询还要留 1 个,所以 HTTP/1.1 下最多 4 路;页面走 HTTP/2、HTTP/3(TLS 反代)时不受此限, + * 放开 6 / 9 路。 + */ +export const MONITOR_TILE_OPTIONS_H1 = [1, 2, 4] +export const MONITOR_TILE_OPTIONS_H2 = [1, 2, 4, 6, 9] + +function tileOptions(): number[] { + if (typeof window === 'undefined') return MONITOR_TILE_OPTIONS_H1 + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + const proto = nav?.nextHopProtocol ?? '' + return proto === 'h2' || proto === 'h3' ? MONITOR_TILE_OPTIONS_H2 : MONITOR_TILE_OPTIONS_H1 +} +const TILES_KEY = 'biliup.monitor.maxTiles' +/** 多路同屏默认不开弹幕(每路一条 SSE、多路叠加太吵),开关记在本地 */ +const DANMAKU_KEY = 'biliup.monitor.danmaku' + +/** 用户的选择:手动激活的顺序(先进先出)与手动停掉的房间;其余由数据推导。 */ +interface Selection { + order: number[] + stopped: number[] +} + +/** + * 由「用户选择 + 当前可预览房间 + 路数上限」推导正在播放的房间列表(纯函数,无副作用): + * 先保留用户激活且仍可预览的,再按列表顺序自动补满,手动停掉的不自动补。 + */ +export function resolveActive( + selection: Selection, + previewableIds: number[], + maxTiles: number +): number[] { + const previewable = new Set(previewableIds) + const stopped = new Set(selection.stopped) + const active: number[] = [] + for (const id of selection.order) { + if (previewable.has(id) && !active.includes(id)) active.push(id) + } + for (const id of previewableIds) { + if (active.length >= maxTiles) break + if (!active.includes(id) && !stopped.has(id)) active.push(id) + } + return active.slice(Math.max(0, active.length - maxTiles)) +} + +/** + * 实时监视:所有正在录制的直播间的小窗网格。 + * 可预览且在路数上限内的直接播放(默认静音);超出的显示封面,点击替换最早开始的一路; + * 下载器不能旁路的显示封面与原因。独立组件,可整体挪到别的页面。 + */ +export default function LiveMonitor() { + const { Text } = Typography + const { data: streamers, error, isLoading } = useSWR( + '/v1/streamers', + fetcher, + { refreshInterval: STREAMERS_REFRESH_MS } + ) + const { data: infos } = useSWR('/v1/streamer-info', fetcher, { + refreshInterval: SLOW_REFRESH_MS, + }) + const options = tileOptions() + const [maxTiles, setMaxTiles] = useChoicePref(TILES_KEY, options, DEFAULT_MONITOR_TILES) + const [danmaku, setDanmaku] = useBoolPref(DANMAKU_KEY, false) + const [selection, setSelection] = useState({ order: [], stopped: [] }) + + const live = (streamers ?? []).filter((s) => s.status === LIVE_STATUS) + const previewable = live.filter(canPreview) + const previewableIds = previewable.map((s) => s.id) + const active = resolveActive(selection, previewableIds, maxTiles) + const activeSet = new Set(active) + // 正在播且平台有弹幕客户端的那几路共用一条弹幕连接 + const danmakuIds = previewable.filter((s) => activeSet.has(s.id) && s.preview?.danmaku).map((s) => s.id) + const danmakuFeed = useDanmakuFeed(danmakuIds, danmaku) + + const infoByUrl = new Map() + for (const i of infos ?? []) { + const cur = infoByUrl.get(i.url) + if (!cur || i.date > cur.date) infoByUrl.set(i.url, i) + } + + // 激活:满了就替换最早开始的一路(先进先出) + const activate = (id: number) => { + setSelection((prev) => { + const current = resolveActive(prev, previewableIds, maxTiles) + const kept = current.length >= maxTiles ? current.slice(1) : current + return { + order: [...kept.filter((x) => x !== id), id], + stopped: prev.stopped.filter((x) => x !== id), + } + }) + } + const stop = (id: number) => { + setSelection((prev) => ({ + order: prev.order.filter((x) => x !== id), + stopped: prev.stopped.includes(id) ? prev.stopped : [...prev.stopped, id], + })) + } + + if (error) { + return ( +
+ +
+ ) + } + if (!isLoading && live.length === 0) { + return ( +
+ +
+ ) + } + + const full = active.length >= maxTiles + return ( +
+
+
+ + 正在监视 {active.length} / {maxTiles} 路 + + + 录制中 {live.length} 间 · 可预览 {previewable.length} 间 + +
+
+ + + + 弹幕 + + setDanmaku(!!v)} aria-label="弹幕" /> + + + + 同屏路数 + +