385 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|