old backend core lib
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
/** 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<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
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<QueueState>(() => ({
|
||||
initialized: preview,
|
||||
revision: 0,
|
||||
current: preview ? previewCurrent : undefined,
|
||||
queued: preview ? previewQueue : [],
|
||||
}))
|
||||
const queueRef = useRef(queue)
|
||||
const draftRef = useRef<SnapshotDraft | undefined>(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 (
|
||||
<div className="song-particle-layer" aria-hidden="true">
|
||||
{theme.ornaments.particles.slice(0, count).map((kind, index) => (
|
||||
<i className={`song-particle ${kind}`} key={`${kind}-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useBounceScroll(
|
||||
viewport: RefObject<HTMLDivElement | null>,
|
||||
track: RefObject<HTMLDivElement | null>,
|
||||
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<HTMLElement>(null)
|
||||
const viewport = useRef<HTMLDivElement>(null)
|
||||
const track = useRef<HTMLDivElement>(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 (
|
||||
<main
|
||||
ref={root}
|
||||
className={`song-overlay ${theme.className} ${bounds.width < 520 ? 'song-narrow' : ''} ${bounds.height < 250 ? 'song-short' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
style={
|
||||
{
|
||||
...themeCssVariables(theme),
|
||||
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
|
||||
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="song-current"
|
||||
aria-label="当前歌曲"
|
||||
key={queue.current?.id ?? 'empty-current'}
|
||||
>
|
||||
<SongParticles theme={theme} />
|
||||
<div className="song-current-mark" aria-hidden="true">
|
||||
唱
|
||||
</div>
|
||||
{queue.current ? (
|
||||
<div className="song-current-copy">
|
||||
<small>正在演唱 · {queue.current.requester.name} 点歌</small>
|
||||
<strong>{queue.current.title}</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="song-current-copy empty">
|
||||
<small>等待弹幕点歌</small>
|
||||
<strong>发送「点歌 歌名」加入队列</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="song-score">
|
||||
<b>{score(queue.current)}</b>
|
||||
<small>发送「打分 1-5」</small>
|
||||
</div>
|
||||
</section>
|
||||
<section className="song-queue" aria-label="待唱队列">
|
||||
<header>
|
||||
<span>待唱歌单</span>
|
||||
<b>{queue.queued.length}</b>
|
||||
</header>
|
||||
<div className="song-queue-viewport" ref={viewport}>
|
||||
<div className="song-queue-track" ref={track}>
|
||||
{queue.queued.map((item, index) => (
|
||||
<article
|
||||
className="song-row"
|
||||
key={item.id}
|
||||
style={{ ['--song-row-delay' as string]: `${Math.min(index, 6) * 35}ms` }}
|
||||
>
|
||||
<span className="song-index">{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<span className="song-requester">{item.requester.name}</span>
|
||||
</article>
|
||||
))}
|
||||
{queue.queued.length === 0 && (
|
||||
<div className="song-queue-empty">下一首,会由谁来点呢?</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user