/** 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 { translate, useI18n } from './i18n' import type { ComponentStream } from './stream' import { getOverlayTheme, themeCssVariables } from './themes' import type { OverlayThemeDefinition } from './themes' import { ThemeEdges } from './ThemeEdges' import { defaultSongRequestSettings } from './types' import type { SongRequestItem, SongRequestSettings } from './types' import { typographyVariables } from './typography' type QueueState = { initialized: boolean revision: number current?: SongRequestItem queued: SongRequestItem[] } type SnapshotDraft = { id: string revision: number current?: SongRequestItem totalQueued: number items: SongRequestItem[] } function previewData(): { current: SongRequestItem; queued: SongRequestItem[] } { const now = new Date().toISOString() const titles = translate('song.overlay.preview_titles').split('|') const viewers = translate('song.overlay.preview_viewers').split('|') return { current: { id: 'preview-current', title: translate('song.overlay.preview_title'), requester: { uid: '10001', name: translate('song.overlay.preview_viewer') }, status: 'current', queuePosition: 0, requestedAt: now, startedAt: now, }, queued: titles.map((title, index) => ({ id: `preview-${index}`, title, requester: { uid: String(10002 + index), name: viewers[index] ?? viewers[0] }, status: 'queued', queuePosition: index + 1, requestedAt: now, })), } } 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, language: string, stream?: ComponentStream) { const initialPreview = previewData() const [settings, setSettings] = useState(defaultSongRequestSettings) const [queue, setQueue] = useState(() => ({ initialized: preview, revision: 0, current: preview ? initialPreview.current : undefined, queued: preview ? initialPreview.queued : [], })) const queueRef = useRef(queue) const draftRef = useRef(undefined) const lastSequenceRef = useRef(0) useEffect(() => { queueRef.current = queue }, [queue]) useEffect(() => { if (!preview) return const translated = previewData() const next = { initialized: true, revision: 0, current: translated.current, queued: translated.queued, } queueRef.current = next setQueue(next) }, [language, preview]) 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 = operation === 'cleared' ? [] : 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 } } /** 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 // OBS can ship an older Chromium that rounds scrollTop to whole pixels. // Keep fractional movement here so low configured speeds still advance. let pendingPixels = 0 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) { pendingPixels += Math.max(5, speed) * (elapsed / 1000) const wholePixels = Math.floor(pendingPixels) if (wholePixels > 0) { container.scrollTop += direction * wholePixels pendingPixels -= wholePixels } if (container.scrollTop >= maximum - 0.5) { container.scrollTop = maximum direction = -1 pendingPixels = 0 pausedUntil = now + pauseSeconds * 1000 } else if (container.scrollTop <= 0.5) { container.scrollTop = 0 direction = 1 pendingPixels = 0 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 { language } = useI18n() const { settings: remoteSettings, queue } = useSongQueue(preview, language, 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 ? (
{translate('song.overlay.now_singing', { viewer: queue.current.requester.name })} {queue.current.title}
) : (
{translate('song.overlay.waiting')} {translate('song.overlay.request_help')}
)}
{translate('song.overlay.queue_title')} {queue.queued.length}
{queue.queued.map((item, index) => (
{String(index + 1).padStart(2, '0')}
{item.requester.name} {item.title}
))} {queue.queued.length === 0 && (
{translate('song.overlay.queue_empty')}
)}
) }