Files
lxc-streamutils/apps/overlay/src/songOverlay.tsx
T
2026-08-19 12:30:30 -07:00

385 lines
14 KiB
TypeScript

/** 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<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, language: string, stream?: ComponentStream) {
const initialPreview = previewData()
const [settings, setSettings] = useState(defaultSongRequestSettings)
const [queue, setQueue] = useState<QueueState>(() => ({
initialized: preview,
revision: 0,
current: preview ? initialPreview.current : undefined,
queued: preview ? initialPreview.queued : [],
}))
const queueRef = useRef(queue)
const draftRef = useRef<SnapshotDraft | undefined>(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 (
<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
// 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<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),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--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`,
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
['--song-moonlit-current-min' as string]: `${Math.min(118, Math.max(74, bounds.height * 0.135))}px`,
} as CSSProperties
}
>
<ThemeEdges />
<section
className="song-current"
aria-label={translate('song.overlay.current_aria')}
key={queue.current?.id ?? 'empty-current'}
>
<SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true">
<span className="song-current-mark-label">{translate('song.overlay.sing_mark')}</span>
<span className="song-current-mark-note">♫</span>
</div>
{queue.current ? (
<div className="song-current-copy">
<small>
{translate('song.overlay.now_singing', { viewer: queue.current.requester.name })}
</small>
<strong>{queue.current.title}</strong>
</div>
) : (
<div className="song-current-copy empty">
<small>{translate('song.overlay.waiting')}</small>
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
<header>
<span>{translate('song.overlay.queue_title')}</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>
<div className="song-row-copy">
<span className="song-requester">{item.requester.name}</span>
<strong>{item.title}</strong>
</div>
</article>
))}
{queue.queued.length === 0 && (
<div className="song-queue-empty">{translate('song.overlay.queue_empty')}</div>
)}
</div>
</div>
</section>
</main>
)
}