/** Transparent OBS renderer for the durable song request queue. */ import { useEffect, useRef, useState } from 'react' import type { CSSProperties, RefObject } from 'react' import { normalizeSongRequestItem, normalizeSongRequestSettings } from './api' import type { ComponentStream } from './stream' import { getOverlayTheme, themeCssVariables } from './themes' import type { OverlayThemeDefinition } from './themes' import { defaultSongRequestSettings } from './types' import type { SongRequestItem, SongRequestSettings } from './types' type QueueState = { initialized: boolean revision: number current?: SongRequestItem queued: SongRequestItem[] } type SnapshotDraft = { id: string revision: number current?: SongRequestItem totalQueued: number items: SongRequestItem[] } const previewCurrent: SongRequestItem = { id: 'preview-current', title: '星河入梦', requester: { uid: '10001', name: '青玉观众' }, status: 'current', queuePosition: 0, requestedAt: new Date().toISOString(), startedAt: new Date().toISOString(), averageScore: 4.8, ratingCount: 26, } const previewQueue: SongRequestItem[] = ['晚风告白', '月下花笺', '云海来信', '落星成诗'].map( (title, index) => ({ id: `preview-${index}`, title, requester: { uid: String(10002 + index), name: ['星光旅人', '花间客', '小瓷片', '月桂'][index], }, status: 'queued', queuePosition: index + 1, requestedAt: new Date().toISOString(), ratingCount: 0, }), ) function payloadRecord(value: unknown): Record { return value != null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } function ordered(items: SongRequestItem[]): SongRequestItem[] { return [...items] .filter(item => item.status === 'queued') .sort((left, right) => left.queuePosition - right.queuePosition) } function useSongQueue(preview: boolean, stream?: ComponentStream) { const [settings, setSettings] = useState(defaultSongRequestSettings) const [queue, setQueue] = useState(() => ({ initialized: preview, revision: 0, current: preview ? previewCurrent : undefined, queued: preview ? previewQueue : [], })) const queueRef = useRef(queue) const draftRef = useRef(undefined) const lastSequenceRef = useRef(0) useEffect(() => { queueRef.current = queue }, [queue]) useEffect(() => { if (preview || !stream) return const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current) for (const message of pending) { lastSequenceRef.current = message.sequence const { type, payload: rawPayload } = message.envelope const payload = payloadRecord(rawPayload) if (type === 'component.settings.snapshot' || type === 'component.settings.updated') { setSettings(normalizeSongRequestSettings(payload.settings)) continue } if (type === 'song.queue.snapshot.begin') { draftRef.current = { id: String(payload.snapshotId ?? ''), revision: Number(payload.revision ?? 0), current: normalizeSongRequestItem(payload.current), totalQueued: Number(payload.totalQueued ?? 0), items: [], } continue } if (type === 'song.queue.snapshot.page') { const draft = draftRef.current if ( !draft || draft.id !== String(payload.snapshotId) || draft.revision !== Number(payload.revision) || draft.items.length !== Number(payload.offset) ) { stream.resync() return } const items = (Array.isArray(payload.items) ? payload.items : []) .map(normalizeSongRequestItem) .filter((item): item is SongRequestItem => Boolean(item)) draft.items.push(...items) continue } if (type === 'song.queue.snapshot.end') { const draft = draftRef.current if ( !draft || draft.id !== String(payload.snapshotId) || draft.revision !== Number(payload.revision) || draft.items.length !== draft.totalQueued ) { stream.resync() return } draftRef.current = undefined const nextQueue = { initialized: true, revision: draft.revision, current: draft.current, queued: ordered(draft.items), } // Keep the reducer reference current before React commits. A delta may // follow snapshot.end in this same buffered batch. queueRef.current = nextQueue setQueue(nextQueue) continue } if (type !== 'song.queue.changed') continue const revision = Number(payload.revision) const previous = queueRef.current if (!previous.initialized || !Number.isSafeInteger(revision)) { stream.resync() return } if (revision <= previous.revision) continue if (revision !== previous.revision + 1) { stream.resync() return } const operation = String(payload.operation ?? '') const itemId = String(payload.itemId ?? '') const item = normalizeSongRequestItem(payload.item) const current = normalizeSongRequestItem(payload.current) let queued = previous.queued.filter(entry => entry.id !== itemId && entry.id !== current?.id) if (item?.status === 'queued') queued.push(item) if (operation === 'promoted' && item) { queued = queued.map(entry => entry.id === item.id ? { ...entry, queuePosition: 1 } : entry, ) } const nextQueue = { initialized: true, revision, current, queued: ordered(queued), } queueRef.current = nextQueue setQueue(nextQueue) } }, [preview, stream, stream?.messages]) return { settings, queue } } function score(item?: SongRequestItem) { if (!item?.ratingCount || item.averageScore == null) return '尚无评分' return `${item.averageScore.toFixed(1)} ★ · ${item.ratingCount} 人` } /** Theme-owned stars and florets used inside the compact current-song card. */ function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) { return ( ) } function useBounceScroll( viewport: RefObject, track: RefObject, speed: number, pauseSeconds: number, dependency: unknown, ) { useEffect(() => { const container = viewport.current const content = track.current if (!container || !content) return let frame = 0 let direction = 1 let last = performance.now() let pausedUntil = last + pauseSeconds * 1000 const resize = new ResizeObserver(() => { container.scrollTop = Math.min( container.scrollTop, Math.max(0, content.scrollHeight - container.clientHeight), ) }) resize.observe(container) resize.observe(content) const tick = (now: number) => { const maximum = Math.max(0, content.scrollHeight - container.clientHeight) const elapsed = Math.min(80, now - last) last = now if (maximum === 0) container.scrollTop = 0 else if (now >= pausedUntil) { container.scrollTop += direction * Math.max(5, speed) * (elapsed / 1000) if (container.scrollTop >= maximum - 0.5) { container.scrollTop = maximum direction = -1 pausedUntil = now + pauseSeconds * 1000 } else if (container.scrollTop <= 0.5) { container.scrollTop = 0 direction = 1 pausedUntil = now + pauseSeconds * 1000 } } frame = requestAnimationFrame(tick) } frame = requestAnimationFrame(tick) return () => { cancelAnimationFrame(frame) resize.disconnect() } }, [dependency, pauseSeconds, speed, track, viewport]) } export function SongRequestOverlay({ preview = false, previewSettings, stream, }: { preview?: boolean previewSettings?: SongRequestSettings stream?: ComponentStream }) { const { settings: remoteSettings, queue } = useSongQueue(preview, stream) const settings = previewSettings ?? remoteSettings const theme = getOverlayTheme(settings.themeId) const root = useRef(null) const viewport = useRef(null) const track = useRef(null) const [bounds, setBounds] = useState({ width: 500, height: 500 }) useEffect(() => { const element = root.current if (!element) return const observer = new ResizeObserver(([entry]) => { setBounds({ width: entry.contentRect.width, height: entry.contentRect.height }) }) observer.observe(element) return () => observer.disconnect() }, []) useBounceScroll( viewport, track, settings.scrollSpeedPixelsPerSecond, settings.edgePauseSeconds, queue.queued.map(item => item.id).join(':'), ) return (
{queue.current ? (
正在演唱 · {queue.current.requester.name} 点歌 {queue.current.title}
) : (
等待弹幕点歌 发送「点歌 歌名」加入队列
)}
{score(queue.current)} 发送「打分 1-5」
待唱歌单 {queue.queued.length}
{queue.queued.map((item, index) => (
{String(index + 1).padStart(2, '0')} {item.title} {item.requester.name}
))} {queue.queued.length === 0 && (
下一首,会由谁来点呢?
)}
) }