old backend core lib

This commit is contained in:
2026-07-18 11:44:58 -07:00
parent 994854d104
commit f599672a30
36 changed files with 3776 additions and 276 deletions
+67 -1
View File
@@ -14,8 +14,13 @@ import type {
Invitation,
OverlaySettings,
Session,
SongRequestItem,
SongRequestPage,
SongRequestSettings,
TotpEnrollment,
} from './types'
import { normalizeThemeId } from './themes'
import { defaultOverlaySettings, defaultSongRequestSettings } from './types'
export class ApiError extends Error {
readonly status: number
@@ -172,7 +177,68 @@ export function normalizeComponents(value: unknown): ComponentSummary[] {
export function normalizeSettings(value: unknown): OverlaySettings {
const root = object(value)
return (root.settings ?? value) as OverlaySettings
const settings = object(root.settings ?? value)
return {
...defaultOverlaySettings,
...(settings as Partial<OverlaySettings>),
themeId: normalizeThemeId(settings.themeId),
}
}
export function normalizeSongRequestSettings(value: unknown): SongRequestSettings {
const root = object(value)
const settings = object(root.settings ?? value)
return {
...defaultSongRequestSettings,
...(settings as Partial<SongRequestSettings>),
themeId: normalizeThemeId(settings.themeId),
}
}
export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined {
const item = object(value)
const requester = object(item.requester)
if (typeof item.id !== 'string' || typeof item.title !== 'string') return undefined
const status = String(item.status)
if (!['current', 'queued', 'completed', 'cancelled'].includes(status)) return undefined
return {
id: item.id,
title: item.title,
requester: { uid: String(requester.uid ?? ''), name: String(requester.name ?? '直播间观众') },
status: status as SongRequestItem['status'],
queuePosition: Number(item.queuePosition ?? 0),
requestedAt: String(item.requestedAt ?? ''),
startedAt:
typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined,
finishedAt:
typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined,
averageScore:
typeof item.averageScore === 'number' || item.averageScore === null
? item.averageScore
: undefined,
ratingCount: Number(item.ratingCount ?? 0),
}
}
export function normalizeSongRequestPage(value: unknown): SongRequestPage {
const root = object(value)
const current = normalizeSongRequestItem(root.current)
const summary = object(root.summary)
return {
revision: Number(root.revision ?? 0),
current: current ?? null,
items: (Array.isArray(root.items) ? root.items : [])
.map(normalizeSongRequestItem)
.filter((item): item is SongRequestItem => Boolean(item)),
nextCursor: typeof root.nextCursor === 'number' ? root.nextCursor : null,
summary: {
activeCount: Number(summary.activeCount ?? 0),
queuedCount: Number(summary.queuedCount ?? 0),
completedCount: Number(summary.completedCount ?? 0),
cancelledCount: Number(summary.cancelledCount ?? 0),
ratingCount: Number(summary.ratingCount ?? 0),
},
}
}
export function normalizeSource(value: unknown): CookieCloudSource {
+153 -15
View File
@@ -3,6 +3,137 @@
background: transparent;
}
.theme-jade-scroll {
color: var(--theme-text);
}
.limit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 22px 0;
padding: 18px;
border: 1px solid rgba(111, 228, 211, 0.18);
border-radius: 16px;
}
.limit-grid legend {
padding: 0 8px;
color: var(--muted, #9ccbc5);
}
.limit-grid label {
display: grid;
gap: 7px;
}
.song-stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.stat-card {
display: grid;
gap: 8px;
padding: 18px 20px;
}
.stat-card small,
.current-song-admin small,
.song-admin-list small {
color: #98c9c3;
}
.stat-card b {
color: #dffff7;
font-size: 2rem;
}
.current-song-admin {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.current-song-admin h2 {
margin: 6px 0;
color: #eafff9;
font-size: clamp(1.5rem, 4vw, 2.4rem);
}
.song-admin-list {
display: grid;
gap: 10px;
}
.song-admin-list article {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid rgba(119, 232, 214, 0.14);
border-radius: 13px;
background: rgba(5, 39, 49, 0.45);
}
.song-admin-list article > div:not(.form-actions) {
display: grid;
gap: 5px;
min-width: 0;
}
.queue-number {
color: #7df4df;
font-variant-numeric: tabular-nums;
text-align: center;
}
.obs-status {
box-sizing: border-box;
display: grid;
width: 100vw;
height: 100vh;
place-items: center;
padding: 20px;
color: #e7fff9;
background: transparent;
text-align: center;
}
.obs-status.pending {
opacity: 0;
}
@media (max-width: 760px) {
.limit-grid,
.song-stat-grid {
grid-template-columns: 1fr 1fr;
}
.current-song-admin {
align-items: stretch;
flex-direction: column;
}
.song-admin-list article {
grid-template-columns: 36px minmax(0, 1fr);
}
.song-admin-list article .form-actions {
grid-column: 2;
}
}
@media (max-width: 480px) {
.limit-grid,
.song-stat-grid {
grid-template-columns: 1fr;
}
}
html,
body,
#root,
@@ -21,7 +152,7 @@ body,
}
.card-decor {
--decor-primary-image: url('/assets/floral-divider.svg');
--decor-primary-image: var(--theme-pattern-divider);
--decor-primary-position: center 44%;
--decor-primary-size: 92% auto;
--decor-primary-transform: none;
@@ -34,7 +165,7 @@ body,
#6adcc6 68%,
#f4bfd4 96%
);
--decor-secondary-image: url('/assets/floral-cluster.svg');
--decor-secondary-image: var(--theme-pattern-cluster);
--decor-secondary-position: right -10px bottom -22px;
--decor-secondary-size: 34% auto;
--decor-secondary-transform: none;
@@ -123,12 +254,12 @@ body,
}
.decor-v2 {
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-image: var(--theme-pattern-vine);
--decor-primary-position: left -12px bottom -13px;
--decor-primary-size: 73% auto;
--decor-primary-opacity: 0.105;
--decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-image: var(--theme-pattern-divider);
--decor-secondary-position: right -22px top -18px;
--decor-secondary-size: 62% auto;
--decor-secondary-opacity: 0.055;
@@ -139,7 +270,7 @@ body,
}
.decor-v3 {
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-image: var(--theme-pattern-vine);
--decor-primary-position: left -15px bottom -15px;
--decor-primary-size: 77% auto;
--decor-primary-transform: scaleX(-1);
@@ -155,7 +286,7 @@ body,
}
.decor-v4 {
--decor-primary-image: url('/assets/floral-cluster.svg');
--decor-primary-image: var(--theme-pattern-cluster);
--decor-primary-position: left -18px bottom -28px;
--decor-primary-size: 45% auto;
--decor-primary-transform: rotate(-5deg);
@@ -172,13 +303,13 @@ body,
}
.decor-v5 {
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-image: var(--theme-pattern-vine);
--decor-primary-position: center top -17px;
--decor-primary-size: 90% auto;
--decor-primary-transform: scaleY(-1);
--decor-primary-opacity: 0.095;
--decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-image: var(--theme-pattern-divider);
--decor-secondary-position: left 18% top -15px;
--decor-secondary-size: 55% auto;
--decor-secondary-opacity: 0.06;
@@ -233,7 +364,8 @@ body,
height: var(--particle-size);
opacity: 0;
will-change: transform, opacity;
animation: card-sparkle var(--particle-duration, 4000ms) ease-in-out infinite;
animation: var(--theme-motion-sparkle, card-sparkle) var(--particle-duration, 4000ms) ease-in-out
infinite;
}
.card-particle.star {
@@ -362,7 +494,7 @@ body,
}
.copy span {
color: #e7fff9;
color: var(--theme-text, #e7fff9);
}
.danmaku-content {
@@ -434,7 +566,8 @@ body,
inset 12px 0 18px -15px rgba(142, 255, 230, 0.95),
inset -12px 0 18px -15px rgba(142, 255, 230, 0.95),
0 10px 28px rgba(0, 15, 25, 0.32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
animation: var(--theme-motion-unfurl, scroll-unfurl) var(--unfold-duration, 1000ms)
cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
}
.card.danmaku.expanded::after {
@@ -460,8 +593,8 @@ body,
inset -6px 0 7px -7px rgba(216, 255, 246, 0.72),
-2px 0 0 rgba(7, 46, 53, 0.72),
2px 0 0 rgba(7, 46, 53, 0.72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95)
both;
animation: var(--theme-motion-rails-open, scroll-rails-open) var(--unfold-duration, 1000ms)
cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
}
.card.danmaku.expanded .copy {
@@ -475,7 +608,7 @@ body,
}
.card.danmaku.expanded .copy b {
color: #f0fffb;
color: var(--theme-user, #f0fffb);
font-size: 0.78em;
letter-spacing: 0.06em;
}
@@ -507,7 +640,7 @@ body,
.card.danmaku.compact .copy b {
max-width: none;
flex: 0 0 auto;
color: #aeece1;
color: var(--theme-compact-user, #aeece1);
}
.card.danmaku.compact .copy span {
@@ -1354,6 +1487,11 @@ select {
gap: 19px 30px;
}
.theme-selector {
max-width: 520px;
margin-bottom: 24px;
}
.slider-grid label {
display: grid;
gap: 9px;
+497 -11
View File
@@ -17,17 +17,25 @@ import {
normalizeComponents,
normalizeInvitations,
normalizeSettings,
normalizeSongRequestPage,
normalizeSongRequestSettings,
normalizeSource,
} from './api'
import { Overlay } from './overlay'
import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { defaultOverlaySettings } from './types'
import { SongRequestOverlay } from './songOverlay'
import { getOverlayTheme, overlayThemes } from './themes'
import { defaultOverlaySettings, defaultSongRequestSettings } from './types'
import type {
AuthUser,
ComponentSettings,
ComponentSummary,
CookieCloudSource,
Invitation,
OverlaySettings,
SongRequestItem,
SongRequestPage,
SongRequestSettings,
} from './types'
const previewPresets = [
@@ -41,6 +49,10 @@ function isDanmakuKind(kind: string): boolean {
return kind === 'danmaku_overlay' || kind === 'danmaku'
}
function isSongRequestKind(kind: string): boolean {
return kind === 'song_request'
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
function Panel({
@@ -150,9 +162,26 @@ function SettingsEditor({
['showLike', '点赞'],
['showShare', '分享'],
]
const selectedTheme = getOverlayTheme(settings.themeId)
return (
<div className="settings-editor">
<div className="field-grid theme-selector">
<label>
主题
<select
value={settings.themeId}
onChange={event => edit('themeId', event.target.value as OverlaySettings['themeId'])}
>
{overlayThemes.map(theme => (
<option value={theme.id} key={theme.id}>
{theme.name}
</option>
))}
</select>
<small>{selectedTheme.description}</small>
</label>
</div>
<div className="slider-grid">
<label>
<span>
@@ -326,6 +355,153 @@ function OverlayPreview({ settings }: { settings: OverlaySettings }) {
)
}
function SongRequestSettingsEditor({
settings,
onChange,
onSave,
saving,
}: {
settings: SongRequestSettings
onChange: (settings: SongRequestSettings) => void
onSave: () => Promise<void>
saving: boolean
}) {
const edit = <K extends keyof SongRequestSettings>(key: K, value: SongRequestSettings[K]) =>
onChange({ ...settings, [key]: value })
return (
<div className="settings-editor">
<div className="field-grid theme-selector">
<label>
主题
<select
value={settings.themeId}
onChange={event =>
edit('themeId', event.target.value as SongRequestSettings['themeId'])
}
>
{overlayThemes.map(theme => (
<option value={theme.id} key={theme.id}>
{theme.name}
</option>
))}
</select>
<small>{getOverlayTheme(settings.themeId).description}</small>
</label>
</div>
<div className="slider-grid">
<label>
<span>
字号 <output>{settings.fontScale}%</output>
</span>
<input
type="range"
min="50"
max="250"
step="5"
value={settings.fontScale}
onChange={event => edit('fontScale', +event.target.value)}
/>
</label>
<label>
<span>
滚动速度 <output>{settings.scrollSpeedPixelsPerSecond}px/s</output>
</span>
<input
type="range"
min="5"
max="200"
step="5"
value={settings.scrollSpeedPixelsPerSecond}
onChange={event => edit('scrollSpeedPixelsPerSecond', +event.target.value)}
/>
</label>
<label>
<span>
两端暂停 <output>{settings.edgePauseSeconds}s</output>
</span>
<input
type="range"
min="0"
max="15"
value={settings.edgePauseSeconds}
onChange={event => edit('edgePauseSeconds', +event.target.value)}
/>
</label>
</div>
<fieldset className="limit-grid">
<legend>可选防刷限制(0 表示不限制)</legend>
<label>
队列上限
<input
type="number"
min="0"
max="10000"
value={settings.maxQueueSize}
onChange={event => edit('maxQueueSize', +event.target.value)}
/>
</label>
<label>
每位观众上限
<input
type="number"
min="0"
max="1000"
value={settings.maxRequestsPerViewer}
onChange={event => edit('maxRequestsPerViewer', +event.target.value)}
/>
</label>
<label>
点歌冷却(秒)
<input
type="number"
min="0"
max="86400"
value={settings.requestCooldownSeconds}
onChange={event => edit('requestCooldownSeconds', +event.target.value)}
/>
</label>
</fieldset>
<div className="form-actions align-end">
<button type="button" disabled={saving} onClick={() => void onSave()}>
{saving ? '正在保存…' : '保存并实时同步'}
</button>
</div>
</div>
)
}
function SongRequestPreview({ settings }: { settings: SongRequestSettings }) {
const [preset, setPreset] = useState(previewPresets[3])
return (
<Panel
title="点歌姬预览"
description="当前歌曲固定在顶部;待唱列表溢出后会往返滚动。"
className="preview-panel"
>
<div className="preset-buttons">
{previewPresets.map(size => (
<button
type="button"
className={size.label === preset.label ? 'active' : 'secondary'}
onClick={() => setPreset(size)}
key={size.label}
>
{size.label}
<small>
{size.width}×{size.height}
</small>
</button>
))}
</div>
<div className="preview-viewport">
<div className="preview-frame" style={{ width: preset.width, height: preset.height }}>
<SongRequestOverlay preview previewSettings={settings} />
</div>
</div>
</Panel>
)
}
function SourceEditor({
source,
onSaved,
@@ -727,11 +903,21 @@ function ComponentList({
key={component.id}
>
<span className="component-icon" aria-hidden="true">
{isDanmakuKind(component.kind) ? '弹' : '件'}
{isDanmakuKind(component.kind)
? '弹'
: isSongRequestKind(component.kind)
? '歌'
: '件'}
</span>
<span>
<b>{component.name}</b>
<small>{isDanmakuKind(component.kind) ? '直播弹幕姬' : component.kind}</small>
<small>
{isDanmakuKind(component.kind)
? '直播弹幕姬'
: isSongRequestKind(component.kind)
? '直播点歌姬'
: component.kind}
</small>
</span>
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
</button>
@@ -740,7 +926,7 @@ function ComponentList({
)}
<div className="future-components">
<span>即将支持</span>
<small>礼物展示 · 点歌姬</small>
<small>礼物展示 · 点唱互动</small>
</div>
</aside>
)
@@ -755,7 +941,7 @@ export function ComponentsPage({
}) {
const [components, setComponents] = useState<ComponentSummary[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [settings, setSettings] = useState<OverlaySettings>()
const [settings, setSettings] = useState<ComponentSettings>()
const [source, setSource] = useState<CookieCloudSource>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -782,7 +968,9 @@ export function ComponentsPage({
`/api/v1/components/${encodeURIComponent(component.id)}/settings`,
)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
const next = isSongRequestKind(component.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
} catch (reason) {
@@ -849,7 +1037,9 @@ export function ComponentsPage({
json('PUT', settings),
)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
const next = isSongRequestKind(selected.kind)
? { ...defaultSongRequestSettings, ...normalizeSongRequestSettings(payload) }
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' })
@@ -886,13 +1076,47 @@ export function ComponentsPage({
<>
<Panel title="弹幕姬设置" description="每一项都独立保存在当前用户的组件下。">
<SettingsEditor
settings={settings}
onChange={setSettings}
settings={settings as OverlaySettings}
onChange={next => setSettings(next)}
onSave={saveSettings}
saving={saving}
/>
</Panel>
<OverlayPreview settings={settings} />
<OverlayPreview settings={settings as OverlaySettings} />
</>
) : isSongRequestKind(selected.kind) && settings ? (
<>
<Panel
title="点歌姬设置"
description="观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
aside={
<button
type="button"
className="secondary"
onClick={() => {
const target = `/control/components/${encodeURIComponent(selected.id)}/song-requests`
if (
!window.open(
target,
`song-requests-${selected.id}`,
'popup,width=1080,height=760',
)
)
location.assign(target)
}}
>
打开点歌统计
</button>
}
>
<SongRequestSettingsEditor
settings={settings as SongRequestSettings}
onChange={next => setSettings(next)}
onSave={saveSettings}
saving={saving}
/>
</Panel>
<SongRequestPreview settings={settings as SongRequestSettings} />
</>
) : (
<Panel title="组件设置">
@@ -900,7 +1124,7 @@ export function ComponentsPage({
</Panel>
)}
<ObsAccessPanel component={selected} key={selected.id} />
<TestEvents componentId={selected.id} />
{isDanmakuKind(selected.kind) && <TestEvents componentId={selected.id} />}
</>
)}
{!loading && !selected && (
@@ -915,6 +1139,268 @@ export function ComponentsPage({
)
}
async function loadCompleteSongQueue(componentId: string): Promise<SongRequestPage> {
// A queue may change between paginated HTTP requests. Restart instead of
// presenting pages from different revisions as one ordering.
for (let attempt = 0; attempt < 3; attempt += 1) {
let cursor = 0
let result: SongRequestPage | undefined
let revision: number | undefined
let consistent = true
const items: SongRequestItem[] = []
do {
const payload = await api<unknown>(
`/api/v1/components/${encodeURIComponent(componentId)}/song-requests?scope=active&limit=100&cursor=${cursor}`,
)
const page = normalizeSongRequestPage(payload)
revision ??= page.revision
if (page.revision !== revision) {
consistent = false
break
}
result = page
items.push(...page.items)
cursor = page.nextCursor ?? -1
} while (cursor >= 0)
if (consistent && result) return { ...result, items }
}
throw new Error('点歌队列正在频繁变化,请稍后重试')
}
/** Session-protected queue operations and long-term song request statistics. */
export function SongRequestsPage({
user,
componentId,
onLogout,
}: {
user: AuthUser
componentId: string
onLogout: () => Promise<void>
}) {
const [active, setActive] = useState<SongRequestPage>()
const [historyPage, setHistoryPage] = useState<SongRequestPage>()
const [flash, setFlash] = useState<Flash>()
const [busyId, setBusyId] = useState('')
const loadingRef = useRef(false)
const load = useCallback(async () => {
if (loadingRef.current) return
loadingRef.current = true
try {
const [activePage, historyPayload] = await Promise.all([
loadCompleteSongQueue(componentId),
api<unknown>(
`/api/v1/components/${encodeURIComponent(componentId)}/song-requests?scope=history&limit=50`,
),
])
setActive(activePage)
setHistoryPage(normalizeSongRequestPage(historyPayload))
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '点歌队列读取失败') })
} finally {
loadingRef.current = false
}
}, [componentId])
useEffect(() => {
let timer = 0
const synchronize = () => {
window.clearInterval(timer)
if (document.visibilityState === 'visible') {
void load()
timer = window.setInterval(() => void load(), 2000)
}
}
synchronize()
document.addEventListener('visibilitychange', synchronize)
return () => {
window.clearInterval(timer)
document.removeEventListener('visibilitychange', synchronize)
}
}, [load])
const mutate = async (item: SongRequestItem, action: 'promote' | 'complete' | 'cancel') => {
if (action === 'cancel' && !window.confirm(`确定取消「${item.title}」吗?`)) return
setBusyId(item.id)
setFlash(undefined)
try {
await api(
`/api/v1/components/${encodeURIComponent(componentId)}/song-requests/${encodeURIComponent(item.id)}/${action}`,
json('POST'),
)
setFlash({
kind: 'success',
text:
action === 'promote'
? '已移到下一首。'
: action === 'complete'
? '已完成并自动切换下一首。'
: '已取消点歌。',
})
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '队列操作失败') })
} finally {
setBusyId('')
}
}
const summary = active?.summary ?? historyPage?.summary
return (
<ControlLayout user={user} active="components" onLogout={onLogout}>
<div className="admin-content song-admin">
<div className="page-heading">
<div>
<p className="eyebrow">SONG REQUEST MANAGER</p>
<h1>点歌统计与队列</h1>
</div>
<a
className="button-link secondary"
href={`/control/?component=${encodeURIComponent(componentId)}`}
>
返回组件设置
</a>
</div>
<FlashMessage flash={flash} />
<div className="song-stat-grid">
{[
['活动点歌', summary?.activeCount ?? 0],
['已经完成', summary?.completedCount ?? 0],
['已经取消', summary?.cancelledCount ?? 0],
['累计评分', summary?.ratingCount ?? 0],
].map(([label, value]) => (
<div className="stat-card jade-panel" key={label}>
<small>{label}</small>
<b>{value}</b>
</div>
))}
</div>
<Panel title="当前歌曲" description="完成或取消当前歌曲后,队首会自动接替。">
{active?.current ? (
<div className="current-song-admin">
<div>
<small>
{active.current.requester.name} · UID {active.current.requester.uid}
</small>
<h2>{active.current.title}</h2>
<p>
{active.current.ratingCount
? `平均 ${active.current.averageScore?.toFixed(2)} 分 · ${active.current.ratingCount} 人评分`
: '尚无评分'}
</p>
</div>
<div className="form-actions">
<button
type="button"
disabled={busyId === active.current.id}
onClick={() => void mutate(active.current!, 'complete')}
>
完成当前歌曲
</button>
<button
type="button"
className="danger"
disabled={busyId === active.current.id}
onClick={() => void mutate(active.current!, 'cancel')}
>
取消
</button>
</div>
</div>
) : (
<div className="empty-state">当前没有正在演唱的歌曲。</div>
)}
</Panel>
<Panel
title={`待唱队列(${active?.items.length ?? 0})`}
description="置顶会把歌曲移动为下一首,不会打断当前歌曲。"
>
<div className="song-admin-list">
{active?.items.map((item, index) => (
<article key={item.id}>
<span className="queue-number">{index + 1}</span>
<div>
<b>{item.title}</b>
<small>
{item.requester.name} · {formatDate(item.requestedAt)}
</small>
</div>
<div className="form-actions">
<button
type="button"
className="secondary small"
disabled={busyId === item.id || index === 0}
onClick={() => void mutate(item, 'promote')}
>
移到下一首
</button>
<button
type="button"
className="danger small"
disabled={busyId === item.id}
onClick={() => void mutate(item, 'cancel')}
>
取消
</button>
</div>
</article>
))}
{active && active.items.length === 0 && (
<div className="empty-state">待唱队列为空。</div>
)}
{!active && <div className="empty-state">正在读取完整队列…</div>}
</div>
</Panel>
<Panel title="近期历史" description="长期历史保存在数据库中;此处显示最近 50 条。">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>歌曲</th>
<th>点歌用户</th>
<th>结果</th>
<th>评分</th>
<th>结束时间</th>
</tr>
</thead>
<tbody>
{historyPage?.items.map(item => (
<tr key={item.id}>
<td>
<b>{item.title}</b>
</td>
<td>{item.requester.name}</td>
<td>
<span
className={`status-chip ${item.status === 'completed' ? 'online' : 'offline'}`}
>
{item.status === 'completed' ? '已完成' : '已取消'}
</span>
</td>
<td>
{item.ratingCount
? `${item.averageScore?.toFixed(1)} / 5(${item.ratingCount})`
: '—'}
</td>
<td>{formatDate(item.finishedAt ?? undefined)}</td>
</tr>
))}
{historyPage && historyPage.items.length === 0 && (
<tr>
<td colSpan={5}>
<div className="empty-state">尚无历史记录。</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</Panel>
</div>
</ControlLayout>
)
}
function formatDate(value?: string): string {
if (!value) return '永久'
const date = new Date(value)
+28 -2
View File
@@ -10,12 +10,27 @@ import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
import { EnrollmentPage, LoginPage } from './auth'
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
import { ComponentsPage, ForbiddenPage, InvitationsPage, SongRequestsPage } from './control'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { useComponentStream } from './stream'
import type { Session } from './types'
import './style.css'
import './control.css'
import './song.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
const stream = useComponentStream(false, publicId, accessToken)
if (!publicId || !accessToken)
return <main className="obs-status">OBS 地址缺少组件标识或访问令牌</main>
if (stream.connection === 'denied')
return <main className="obs-status">OBS 访问令牌无效或已经轮换</main>
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return <Overlay stream={stream} />
return <main className="obs-status pending">正在连接组件…</main>
}
function Redirect({ to }: { to: string }) {
useEffect(() => {
@@ -135,6 +150,17 @@ function App() {
if (session.user) return <Redirect to="/control/" />
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
}
const songRequestsMatch = path.match(/^\/control\/components\/([^/]+)\/song-requests$/)
if (songRequestsMatch) {
if (!session.user) return <Redirect to="/control/login" />
return (
<SongRequestsPage
user={session.user}
componentId={decodeURIComponent(songRequestsMatch[1])}
onLogout={logout}
/>
)
}
if (path === '/control') {
if (location.pathname === '/control') return <Redirect to="/control/" />
if (!session.user) return <Redirect to="/control/login" />
@@ -161,7 +187,7 @@ if (obsMatch) {
} catch {
publicId = ''
}
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
root.render(<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />)
} else {
initializePwa()
root.render(<App />)
+87 -139
View File
@@ -8,7 +8,10 @@
* long-running browser source.
*/
import { useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { CSSProperties } from 'react'
import type { ComponentStream } from './stream'
import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types'
import type { OverlaySettings } from './types'
@@ -60,26 +63,9 @@ type DanmakuSegment =
type OverlayProps = {
preview?: boolean
previewSettings?: OverlaySettings
publicId?: string
accessToken?: string
stream?: ComponentStream
}
const cardParticles = [
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
] as const
const decorVariantCount = 6
function stableHash(value: string) {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
@@ -89,21 +75,33 @@ function stableHash(value: string) {
return hash >>> 0
}
function chooseDecorVariant(seed: string, previous?: number) {
function chooseDecorVariant(seed: string, variantCount: number, previous?: number) {
const hash = stableHash(seed)
const base = hash % decorVariantCount
const safeCount = Math.max(1, variantCount)
const base = hash % safeCount
if (previous === undefined || base !== previous) return base
return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount
if (safeCount === 1) return base
return (base + 1 + ((hash >>> 8) % (safeCount - 1))) % safeCount
}
function CardDecor({ count, variant }: { count: number; variant: number }) {
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0)))
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount
function CardDecor({
count,
variant,
theme,
}: {
count: number
variant: number
theme: OverlayThemeDefinition
}) {
const particles = theme.ornaments.particles
const visible = Math.min(particles.length, Math.max(0, Math.round(count || 0)))
const variantCount = Math.max(1, theme.ornaments.variantCount)
const normalized = ((variant % variantCount) + variantCount) % variantCount
return (
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
<i className="card-decor-surface" />
<div className="card-particle-layer">
{cardParticles.slice(0, visible).map((kind, index) => (
{particles.slice(0, visible).map((kind, index) => (
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
))}
</div>
@@ -111,11 +109,6 @@ function CardDecor({ count, variant }: { count: number; variant: number }) {
)
}
function streamUrl(publicId: string) {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
}
function enabled(type: string, settings: OverlaySettings) {
return (
(type === 'live.danmaku' && settings.showDanmaku) ||
@@ -130,112 +123,66 @@ function enabled(type: string, settings: OverlaySettings) {
function parseSettings(value: unknown): OverlaySettings {
if (!value || typeof value !== 'object') return defaultOverlaySettings
return { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
const candidate = { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
return { ...candidate, themeId: normalizeThemeId(candidate.themeId) }
}
function useEvents(
disabled: boolean,
publicId?: string,
accessToken?: string,
): {
settings: OverlaySettings
items: Item[]
setItems: Dispatch<SetStateAction<Item[]>>
connection: 'idle' | 'connecting' | 'connected' | 'denied'
} {
function useEvents(preview: boolean, stream?: ComponentStream) {
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
const [items, setItems] = useState<Item[]>([])
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>(
'idle',
)
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
if (disabled || !publicId || !accessToken) {
setConnection('idle')
return
}
let dead = false
let socket: WebSocket | undefined
let timer = 0
let retries = 0
const open = () => {
setConnection('connecting')
socket = new WebSocket(streamUrl(publicId))
socket.onopen = () => {
retries = 0
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
}
socket.onclose = event => {
if (dead) return
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
setConnection('denied')
return
}
setConnection('connecting')
const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
retries += 1
timer = window.setTimeout(open, delay)
}
socket.onmessage = event => {
try {
const envelope = JSON.parse(event.data) as Envelope
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
setConnection('connected')
return
}
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
setConnection('denied')
socket?.close(1008, 'Unauthorized')
return
}
setConnection('connected')
if (
envelope.type === 'overlay.settings.snapshot' ||
envelope.type === 'overlay.settings.updated'
) {
setSettings(parseSettings(envelope.payload?.settings))
return
}
setItems(old => {
const current = settingsRef.current
if (!enabled(envelope.type, current)) return old
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
const key = combo ? `combo:${combo}` : envelope.id
const existing = old.find(item => item.key === key)
const decorVariant =
existing?.decorVariant ??
chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
})
} catch {
// A malformed upstream event must not break a long-running OBS source.
}
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
for (const message of pending) {
lastSequenceRef.current = message.sequence
const envelope = message.envelope as Envelope
if (
envelope.type === 'overlay.settings.snapshot' ||
envelope.type === 'overlay.settings.updated' ||
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const nextSettings = parseSettings(envelope.payload?.settings)
// Update the ref immediately so a live event in the same React batch
// observes the settings frame that preceded it.
settingsRef.current = nextSettings
setSettings(nextSettings)
continue
}
setItems(old => {
const current = settingsRef.current
if (!enabled(envelope.type, current)) return old
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
const key = combo ? `combo:${combo}` : envelope.id
const existing = old.find(item => item.key === key)
const theme = getOverlayTheme(current.themeId)
const decorVariant =
existing?.decorVariant ??
chooseDecorVariant(
`${envelope.type}:${key}`,
theme.ornaments.variantCount,
old[0]?.decorVariant,
)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
})
}
open()
return () => {
dead = true
window.clearTimeout(timer)
socket?.close()
}
}, [accessToken, disabled, publicId])
}, [preview, stream, stream?.messages])
useEffect(() => {
setItems(current => current.slice(0, settings.maxVisible))
}, [settings.maxVisible])
return { settings, items, setItems, connection }
return { settings, items, setItems }
}
function giftTier(item: Item, settings: OverlaySettings) {
@@ -298,10 +245,12 @@ function Card({
item,
settings,
expanded,
theme,
}: {
item: Item
settings: OverlaySettings
expanded: boolean
theme: OverlayThemeDefinition
}) {
const payload = item.payload || {}
const viewer = payload.viewer || {}
@@ -320,7 +269,7 @@ function Card({
<article
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
>
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
<CardDecor count={settings.particleCount} variant={item.decorVariant} theme={theme} />
{gift && (
<div className="gift-art">
{gift.animationUrl || gift.imageUrl ? (
@@ -350,11 +299,12 @@ function Card({
)
}
export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) {
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
const root = useRef<HTMLDivElement>(null)
const events = useEvents(preview, publicId, accessToken)
const events = useEvents(preview, stream)
const { items, setItems } = events
const settings = previewSettings || events.settings
const theme = getOverlayTheme(settings.themeId)
const [shape, setShape] = useState('standard')
const [expandedKey, setExpandedKey] = useState<string>()
const fontFactor = settings.fontScale / 100
@@ -417,34 +367,32 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
return () => window.clearTimeout(timer)
}, [items, settings.collapseAfterSeconds, shape])
const missingAccess = !preview && (!publicId || !accessToken)
return (
<main
ref={root}
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
data-connection={events.connection}
style={{
['--motion' as string]: `${settings.motionIntensity / 100}`,
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
['--font-body' as string]: `${18 * fontFactor}px`,
['--font-expanded' as string]: `${26 * fontFactor}px`,
['--font-compact' as string]: `${15 * fontFactor}px`,
}}
className={`overlay ${theme.className} ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={
{
...themeCssVariables(theme),
['--motion' as string]: `${settings.motionIntensity / 100}`,
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
['--font-body' as string]: `${18 * fontFactor}px`,
['--font-expanded' as string]: `${26 * fontFactor}px`,
['--font-compact' as string]: `${15 * fontFactor}px`,
} as CSSProperties
}
>
<section className="wall">
{missingAccess && (
<div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>
)}
{!missingAccess && events.connection === 'denied' && (
<div className="obs-configuration-error">OBS 访问令牌已失效。</div>
)}
<div className="cards">
{items.map(item => (
<Card
item={item}
settings={settings}
expanded={item.key === expandedKey}
theme={theme}
key={item.key}
/>
))}
+369
View File
@@ -0,0 +1,369 @@
html,
body,
#root {
background: transparent;
}
.song-overlay {
box-sizing: border-box;
display: grid;
/* Percentages work both as an OBS root and inside the control preview. Using
vw/vh here made the nested preview measure the dashboard viewport instead. */
width: 100%;
height: 100%;
min-width: 0;
grid-template-rows: auto minmax(0, 1fr);
gap: clamp(4px, 0.8vmin, 8px);
padding: clamp(4px, 0.8vmin, 9px);
color: var(--theme-text);
font-family: 'Noto Serif SC', 'Microsoft YaHei', sans-serif;
font-size: var(--song-font-size, clamp(12px, 2.4vmin, 24px));
overflow: hidden;
}
.song-current,
.song-queue {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--theme-card-border);
border-radius: clamp(8px, 1.5vmin, 15px);
background: var(--theme-card-background);
box-shadow:
inset 0 1px rgba(231, 255, 249, 0.12),
0 10px 32px rgba(0, 12, 24, 0.24);
backdrop-filter: blur(10px);
}
.song-current::after,
.song-queue::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.1;
background: var(--theme-pattern-vine) left bottom / min(70%, 480px) auto no-repeat;
}
.song-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: clamp(6px, 1.2vmin, 12px);
min-height: var(--song-current-min, 56px);
padding: clamp(6px, 1.2vmin, 12px);
animation: song-current-arrive 0.58s cubic-bezier(0.18, 0.86, 0.22, 1);
}
.song-current-mark {
display: grid;
place-items: center;
width: clamp(30px, 3em, 46px);
aspect-ratio: 1;
border: 1px solid rgba(133, 255, 232, 0.5);
border-radius: 50%;
color: var(--theme-accent);
background: rgba(6, 48, 57, 0.72);
box-shadow: 0 0 24px rgba(95, 255, 226, 0.22);
font-size: 1.25em;
}
.song-current-copy,
.song-score {
position: relative;
z-index: 1;
display: grid;
min-width: 0;
gap: 0.12em;
}
.song-current-copy small,
.song-score small {
color: var(--theme-compact-user);
font-size: 0.68em;
}
.song-current-copy strong {
overflow-wrap: anywhere;
font-size: 1.35em;
line-height: 1.08;
text-shadow: 0 0 20px rgba(133, 255, 232, 0.2);
}
.song-score {
justify-items: end;
white-space: nowrap;
}
.song-score b {
font-size: 0.92em;
color: var(--theme-price);
}
.song-queue {
display: grid;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.song-queue > header {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.38em 0.72em;
border-bottom: 1px solid rgba(133, 255, 232, 0.16);
color: var(--theme-compact-user);
letter-spacing: 0.12em;
}
.song-queue > header b {
min-width: 2em;
padding: 0.06em 0.45em;
border-radius: 999px;
text-align: center;
color: var(--theme-text);
background: rgba(109, 235, 211, 0.15);
}
.song-queue-viewport {
min-height: 0;
overflow: hidden;
}
.song-queue-track {
position: relative;
z-index: 1;
display: grid;
gap: 0.28em;
padding: 0.35em;
}
.song-row {
display: grid;
min-width: 0;
grid-template-columns: 2.5em minmax(0, 1fr) minmax(4em, auto);
align-items: center;
gap: 0.55em;
padding: 0.4em 0.62em;
border: 1px solid rgba(128, 238, 218, 0.13);
border-radius: 0.62em;
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
overflow: hidden;
isolation: isolate;
animation: song-row-insert 0.62s cubic-bezier(0.16, 0.86, 0.24, 1.08) both;
animation-delay: var(--song-row-delay, 0ms);
}
.song-row > * {
position: relative;
z-index: 2;
}
/* Every queue bar gets a small theme-matched star and floret. Keeping these as
pseudo-elements avoids multiplying DOM nodes for a very long queue. */
.song-row::before,
.song-row::after {
content: '';
position: absolute;
z-index: 1;
width: 0.72em;
height: 0.72em;
pointer-events: none;
animation: var(--theme-motion-sparkle, song-sparkle) 3.8s ease-in-out infinite;
}
.song-row::before {
top: 12%;
right: 1.5%;
background: linear-gradient(135deg, #fff5bc, #aaffed 60%, #efd4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(179, 255, 237, 0.72));
}
.song-row::after {
right: 8%;
bottom: 3%;
border-radius: 50%;
background:
radial-gradient(circle at 50% 17%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
animation-delay: -1.7s;
}
.song-row strong {
min-width: 0;
overflow-wrap: anywhere;
}
.song-index {
color: var(--theme-accent);
font-variant-numeric: tabular-nums;
}
.song-requester {
min-width: 0;
overflow: hidden;
color: var(--theme-compact-user);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-queue-empty {
padding: 1.2em 0.8em;
color: var(--theme-compact-user);
text-align: center;
}
.song-particle-layer {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
.song-particle {
--particle-size: clamp(5px, 0.7em, 10px);
position: absolute;
width: var(--particle-size);
height: var(--particle-size);
opacity: 0;
animation: var(--theme-motion-sparkle, song-sparkle) 3.6s ease-in-out infinite;
}
.song-particle.star {
background: linear-gradient(135deg, #fff5bc, #aaffed 58%, #efd4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(179, 255, 237, 0.8));
}
.song-particle.floret {
border-radius: 50%;
background:
radial-gradient(circle at 50% 17%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd6e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
}
.song-particle:nth-child(1) {
left: 2%;
top: 14%;
animation-delay: -0.4s;
}
.song-particle:nth-child(2) {
right: 2%;
top: 12%;
animation-delay: -1.4s;
}
.song-particle:nth-child(3) {
left: 18%;
bottom: 7%;
animation-delay: -2.4s;
}
.song-particle:nth-child(4) {
right: 18%;
bottom: 8%;
animation-delay: -0.9s;
}
.song-particle:nth-child(5) {
left: 43%;
top: 4%;
animation-delay: -3.1s;
}
.song-particle:nth-child(6) {
right: 38%;
bottom: 3%;
animation-delay: -1.9s;
}
.song-particle:nth-child(7) {
left: 66%;
top: 8%;
animation-delay: -2.8s;
}
.song-particle:nth-child(8) {
right: 8%;
bottom: 13%;
animation-delay: -0.2s;
}
@keyframes song-current-arrive {
from {
opacity: 0;
transform: translateY(-7px) scaleX(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes song-row-insert {
from {
opacity: 0;
clip-path: inset(0 100% 0 0 round 0.62em);
transform: translateX(24px) scaleX(0.92);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0 round 0.62em);
transform: none;
}
}
@keyframes song-sparkle {
0%,
100% {
opacity: 0.06;
transform: translateY(3px) rotate(0) scale(0.5);
}
42% {
opacity: 0.82;
transform: translateY(-2px) rotate(40deg) scale(1.05);
}
72% {
opacity: 0.2;
transform: translateY(-6px) rotate(75deg) scale(0.72);
}
}
.song-overlay.song-narrow .song-current {
grid-template-columns: auto minmax(0, 1fr);
}
.song-overlay.song-narrow .song-score {
grid-column: 2;
grid-template-columns: auto auto;
justify-items: start;
gap: 0.5em;
}
.song-overlay.song-short .song-current {
min-height: 42px;
padding-block: 4px;
}
.song-overlay.song-short .song-current-mark {
width: clamp(28px, 2.5em, 42px);
}
.song-overlay.song-short .song-queue > header {
padding-block: 0.24em;
}
@media (prefers-reduced-motion: reduce) {
.song-current,
.song-row,
.song-particle,
.song-row::before,
.song-row::after {
animation: none;
}
}
+346
View File
@@ -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>
)
}
+137
View File
@@ -0,0 +1,137 @@
/** Shared authenticated WebSocket transport for every OBS component renderer. */
import { useCallback, useEffect, useRef, useState } from 'react'
export type ComponentEnvelope = {
id: string
type: string
payload?: Record<string, unknown>
}
export type StreamMessage = {
sequence: number
envelope: ComponentEnvelope
}
export type ComponentStream = {
componentKind?: string
connection: 'idle' | 'connecting' | 'connected' | 'denied'
/**
* Ordered frames retained long enough for React consumers to drain them.
*
* A single `message` state value is not sufficient here: React may batch
* several WebSocket callbacks into one render and would then expose only the
* final frame. Durable components such as the song queue need every frame in
* a paged snapshot, so producers append and consumers track `sequence`.
*/
messages: readonly StreamMessage[]
resync: () => void
}
const MAX_BUFFERED_MESSAGES = 1024
function appendMessage(
messages: readonly StreamMessage[],
message: StreamMessage,
): readonly StreamMessage[] {
const next = [...messages, message]
return next.length > MAX_BUFFERED_MESSAGES
? next.slice(next.length - MAX_BUFFERED_MESSAGES)
: next
}
function streamUrl(publicId: string) {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
}
export function useComponentStream(
disabled: boolean,
publicId?: string,
accessToken?: string,
): ComponentStream {
const [componentKind, setComponentKind] = useState<string>()
const [connection, setConnection] = useState<ComponentStream['connection']>('idle')
const [messages, setMessages] = useState<readonly StreamMessage[]>([])
const socketRef = useRef<WebSocket | undefined>(undefined)
const sequenceRef = useRef(0)
const resync = useCallback(() => {
socketRef.current?.close(4000, 'State resync requested')
}, [])
useEffect(() => {
if (disabled || !publicId || !accessToken) {
setComponentKind(undefined)
setConnection('idle')
setMessages([])
return
}
let dead = false
let socket: WebSocket | undefined
let timer = 0
let retries = 0
const open = () => {
setConnection('connecting')
// A reconnect starts a new snapshot epoch. Sequence numbers remain
// monotonic so mounted consumers can distinguish new frames from any
// state they already applied, while the obsolete buffer is discarded.
setMessages([])
const candidate = new WebSocket(streamUrl(publicId))
socket = candidate
socketRef.current = candidate
candidate.onopen = () => {
retries = 0
candidate.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
}
candidate.onclose = event => {
if (socketRef.current === candidate) socketRef.current = undefined
if (dead) return
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
setConnection('denied')
return
}
setConnection('connecting')
const delay = event.code === 4000 ? 100 : Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
retries += 1
timer = window.setTimeout(open, delay)
}
candidate.onmessage = event => {
try {
const envelope = JSON.parse(event.data) as ComponentEnvelope & {
componentKind?: string
}
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
setComponentKind(envelope.componentKind)
setConnection('connected')
return
}
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
setConnection('denied')
candidate.close(1008, 'Unauthorized')
return
}
setConnection('connected')
sequenceRef.current += 1
const message = { sequence: sequenceRef.current, envelope }
// Functional updates compose even when React batches many native
// WebSocket callbacks into a single render.
setMessages(current => appendMessage(current, message))
} catch {
// Ignore malformed frames without killing a long-running OBS source.
}
}
}
open()
return () => {
dead = true
window.clearTimeout(timer)
socket?.close()
if (socketRef.current === socket) socketRef.current = undefined
}
}, [accessToken, disabled, publicId])
return { componentKind, connection, messages, resync }
}
+16 -10
View File
@@ -11,7 +11,7 @@ body,
}
body {
background: transparent;
color: #dcfffa;
color: var(--theme-text, #dcfffa);
}
.overlay {
width: 100%;
@@ -39,15 +39,19 @@ body {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: 1px solid rgba(101, 226, 211, 0.28);
border: 1px solid var(--theme-card-border, rgba(101, 226, 211, 0.28));
border-radius: 14px;
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61));
background: var(
--theme-card-background,
linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61))
);
backdrop-filter: blur(15px);
animation: arrive calc(0.35s + 0.35s * var(--motion)) cubic-bezier(0.19, 0.9, 0.3, 1) both;
animation: var(--theme-motion-arrive, arrive) calc(0.35s + 0.35s * var(--motion))
cubic-bezier(0.19, 0.9, 0.3, 1) both;
}
.card:before {
content: '';
@@ -55,7 +59,8 @@ body {
inset: 0;
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
transform: translateX(-120%);
animation: sheen calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s both;
animation: var(--theme-motion-sheen, sheen) calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s
both;
}
.copy {
position: relative;
@@ -66,20 +71,20 @@ body {
font-size: clamp(12px, 2.7vw, 17px);
}
.copy b {
color: #e6fff9;
color: var(--theme-user, #e6fff9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy span {
color: #a9dbd4;
color: var(--theme-text, #a9dbd4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy em {
font-style: normal;
color: #ffdc89;
color: var(--theme-price, #ffdc89);
font-size: 0.84em;
}
.gift-art {
@@ -108,7 +113,8 @@ body {
background:
radial-gradient(circle at 20% 50%, rgba(69, 236, 190, 0.4), transparent 35%),
linear-gradient(118deg, rgba(7, 63, 84, 0.97), rgba(12, 105, 89, 0.85));
animation: featured calc(2.8s - 1.5s * var(--motion)) ease-in-out infinite;
animation: var(--theme-motion-featured, featured) calc(2.8s - 1.5s * var(--motion)) ease-in-out
infinite;
}
.gift.featured .gift-art {
width: 92px;
@@ -120,7 +126,7 @@ body {
top: 6px;
color: #ceffe4;
letter-spacing: 9px;
animation: float 2.2s ease-in-out infinite;
animation: var(--theme-motion-float, float) 2.2s ease-in-out infinite;
}
.narrow .overlay {
padding: 6px;
+46
View File
@@ -0,0 +1,46 @@
import { jadeScrollTheme } from './jadeScroll'
import type { OverlayThemeDefinition, OverlayThemeId } from './types'
export type { OverlayThemeDefinition, OverlayThemeId, ThemeParticle } from './types'
/** Ordered registry used by both the control select and OBS renderer. */
export const overlayThemes: readonly OverlayThemeDefinition[] = [jadeScrollTheme]
const themesById = new Map(overlayThemes.map(theme => [theme.id, theme]))
/**
* Resolve untrusted snapshots defensively. The Rust API rejects unknown IDs,
* while this fallback keeps older or cached OBS pages renderable during deploys.
*/
export function getOverlayTheme(id: unknown): OverlayThemeDefinition {
return themesById.get(id as OverlayThemeId) ?? jadeScrollTheme
}
export function normalizeThemeId(id: unknown): OverlayThemeId {
return getOverlayTheme(id).id
}
/** Translate semantic theme fields into the renderer's CSS contract. */
export function themeCssVariables(
theme: OverlayThemeDefinition,
): Readonly<Record<`--theme-${string}`, string>> {
return {
'--theme-text': theme.palette.text,
'--theme-user': theme.palette.user,
'--theme-compact-user': theme.palette.compactUser,
'--theme-accent': theme.palette.accent,
'--theme-price': theme.palette.price,
'--theme-card-border': theme.surfaces.cardBorder,
'--theme-card-background': theme.surfaces.cardBackground,
'--theme-pattern-divider': `url("${theme.ornaments.patterns.divider}")`,
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
'--theme-motion-arrive': theme.motion.arrive,
'--theme-motion-sheen': theme.motion.sheen,
'--theme-motion-featured': theme.motion.featured,
'--theme-motion-float': theme.motion.float,
'--theme-motion-sparkle': theme.motion.sparkle,
'--theme-motion-unfurl': theme.motion.unfurl,
'--theme-motion-rails-open': theme.motion.railsOpen,
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
/** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = {
id: 'jade-scroll',
name: '青玉花卷',
description: '暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。',
className: 'theme-jade-scroll',
palette: {
text: '#e7fff9',
user: '#f0fffb',
compactUser: '#aeece1',
accent: '#85ffe8',
price: '#ffdc89',
},
surfaces: {
cardBorder: 'rgba(101, 226, 211, 0.28)',
cardBackground: 'linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61))',
},
ornaments: {
variantCount: 6,
particles: [
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
],
patterns: { divider, cluster, vine },
},
motion: {
arrive: 'arrive',
sheen: 'sheen',
featured: 'featured',
float: 'float',
sparkle: 'card-sparkle',
unfurl: 'scroll-unfurl',
railsOpen: 'scroll-rails-open',
},
}
+48
View File
@@ -0,0 +1,48 @@
/** Stable IDs persisted in component settings and emitted over WebSocket. */
export const overlayThemeIds = ['jade-scroll'] as const
export type OverlayThemeId = (typeof overlayThemeIds)[number]
export type ThemeParticle = 'star' | 'floret'
/**
* Everything visual that varies between overlay themes.
*
* Layout and accessibility behavior stay in the renderer; palette, decorative
* assets, particle composition and named motion primitives live here. This
* keeps a future theme additive instead of requiring conditionals in Card.
*/
export type OverlayThemeDefinition = {
id: OverlayThemeId
name: string
description: string
className: string
palette: {
text: string
user: string
compactUser: string
accent: string
price: string
}
surfaces: {
cardBorder: string
cardBackground: string
}
ornaments: {
variantCount: number
particles: readonly ThemeParticle[]
patterns: {
divider: string
cluster: string
vine: string
}
}
motion: {
arrive: string
sheen: string
featured: string
float: string
sparkle: string
unfurl: string
railsOpen: string
}
}
+60 -1
View File
@@ -5,7 +5,10 @@
* database rows or Bilibili packets. Secret fields are optional because the
* server normally returns only `*Configured` flags after initial submission.
*/
import type { OverlayThemeId } from './themes'
export type OverlaySettings = {
themeId: OverlayThemeId
fontScale: number
showDanmaku: boolean
showEnter: boolean
@@ -26,6 +29,7 @@ export type OverlaySettings = {
}
export const defaultOverlaySettings: OverlaySettings = {
themeId: 'jade-scroll',
fontScale: 140,
showDanmaku: true,
showEnter: true,
@@ -45,6 +49,61 @@ export const defaultOverlaySettings: OverlaySettings = {
featuredValueThreshold: 100_000,
}
/** Renderer and anti-spam settings for the built-in song request component. */
export type SongRequestSettings = {
themeId: OverlayThemeId
fontScale: number
scrollSpeedPixelsPerSecond: number
edgePauseSeconds: number
/** Zero keeps the corresponding business limit disabled. */
maxQueueSize: number
maxRequestsPerViewer: number
requestCooldownSeconds: number
}
export const defaultSongRequestSettings: SongRequestSettings = {
themeId: 'jade-scroll',
fontScale: 100,
scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2,
maxQueueSize: 0,
maxRequestsPerViewer: 0,
requestCooldownSeconds: 0,
}
export type ComponentSettings = OverlaySettings | SongRequestSettings
export type SongRequester = { uid: string; name: string }
export type SongRequestItem = {
id: string
title: string
requester: SongRequester
status: 'current' | 'queued' | 'completed' | 'cancelled'
queuePosition: number
requestedAt: string
startedAt?: string | null
finishedAt?: string | null
averageScore?: number | null
ratingCount: number
}
export type SongQueueSummary = {
activeCount: number
queuedCount: number
completedCount: number
cancelledCount: number
ratingCount: number
}
export type SongRequestPage = {
revision: number
current?: SongRequestItem | null
items: SongRequestItem[]
nextCursor?: number | null
summary: SongQueueSummary
}
export type UserRole = 'system_admin' | 'user' | string
export type AuthUser = {
@@ -67,7 +126,7 @@ export type ComponentSummary = {
kind: string
name: string
enabled?: boolean
settings?: OverlaySettings
settings?: ComponentSettings
updatedAt?: string
}