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
+21 -17
View File
@@ -4,28 +4,31 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
## 路由
| 路由 | 权限 | 作用 |
| ---------------------- | --------------- | ------------------------------------ |
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
| 路由 | 权限 | 作用 |
| --------------------------------------- | --------------- | ------------------------------------ |
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。
## 文件职责
| 文件 | 职责 |
| ------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `src/overlay.tsx` | WebSocket、消息队列、礼物/表情和 OBS 自适应渲染 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
| 文件 | 职责 |
| --------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
## Secret 与状态
@@ -59,3 +62,4 @@ YAML 和项目文档。
- [实时协议](../../docs/protocol.md)
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
- [点歌姬组件](../../docs/components/song-request.md)
+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
}
+17 -15
View File
@@ -5,27 +5,29 @@ crate 导出。
## 模块职责
| 模块 | 职责 |
| ------------- | -------------------------------------------------------- |
| `app` | 依赖组装、迁移、事件队列、源启动与旧配置导入 |
| `auth` | 邀请码、TOTP、恢复码、会话、secret 加密和组件 token |
| `components` | 组件定义、设置版本、订阅、projection 与 handler registry |
| `config` | TOML 解析、部署策略校验和旧单用户兼容字段 |
| `credentials` | CookieCloud URL 边界与 Bilibili Cookie 提取 |
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
| `domain` | provider-independent event 与 WebSocket envelope |
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | source event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| 模块 | 职责 |
| -------------- | -------------------------------------------------------- |
| `app` | 依赖组装、迁移、事件队列、源启动与旧配置导入 |
| `auth` | 邀请码、TOTP、恢复码、会话、secret 加密和组件 token |
| `components` | 组件定义、设置版本、订阅、projection 与 handler registry |
| `config` | TOML 解析、部署策略校验和旧单用户兼容字段 |
| `credentials` | CookieCloud URL 边界与 Bilibili Cookie 提取 |
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
| `domain` | provider-independent event 与 WebSocket envelope |
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | source event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
## 重要不变量
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
- Bilibili listener 必须保持 20 秒心跳;socket 内部重连失败后由 adapter 重建完整客户端。
- projection 无副作用;可靠业务动作必须使用幂等 handler。
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
@@ -0,0 +1,97 @@
-- Persistent, tenant-isolated state for the built-in song request component.
-- Component settings remain JSONB; relational queue and rating data live here
-- because they are durable business facts rather than renderer preferences.
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_song_request
ON component_instances(owner_user_id, kind)
WHERE kind = 'song_request';
CREATE TABLE IF NOT EXISTS song_request_state (
owner_user_id UUID NOT NULL,
component_instance_id UUID PRIMARY KEY,
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (owner_user_id, component_instance_id),
FOREIGN KEY (owner_user_id, component_instance_id)
REFERENCES component_instances(owner_user_id, id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS song_requests (
id UUID PRIMARY KEY,
owner_user_id UUID NOT NULL,
component_instance_id UUID NOT NULL,
requester_uid TEXT NOT NULL CHECK (char_length(requester_uid) BETWEEN 1 AND 64),
requester_name TEXT NOT NULL CHECK (char_length(requester_name) BETWEEN 1 AND 80),
song_title TEXT NOT NULL CHECK (char_length(song_title) BETWEEN 1 AND 80),
normalized_song_title TEXT NOT NULL CHECK (char_length(normalized_song_title) BETWEEN 1 AND 80),
status TEXT NOT NULL CHECK (status IN ('current', 'queued', 'completed', 'cancelled')),
queue_position BIGINT NOT NULL DEFAULT 0 CHECK (queue_position >= 0),
source_event_id UUID NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
UNIQUE (owner_user_id, id),
UNIQUE (owner_user_id, component_instance_id, id),
UNIQUE (component_instance_id, source_event_id),
FOREIGN KEY (owner_user_id, component_instance_id)
REFERENCES song_request_state(owner_user_id, component_instance_id) ON DELETE CASCADE,
CHECK ((status = 'queued' AND queue_position > 0 AND started_at IS NULL AND finished_at IS NULL)
OR (status = 'current' AND queue_position = 0 AND started_at IS NOT NULL AND finished_at IS NULL)
OR (status IN ('completed', 'cancelled') AND queue_position = 0 AND finished_at IS NOT NULL))
);
CREATE UNIQUE INDEX IF NOT EXISTS song_requests_one_current
ON song_requests(component_instance_id)
WHERE status = 'current';
CREATE UNIQUE INDEX IF NOT EXISTS song_requests_unique_active_title
ON song_requests(component_instance_id, normalized_song_title)
WHERE status IN ('current', 'queued');
CREATE INDEX IF NOT EXISTS song_requests_active_order
ON song_requests(owner_user_id, component_instance_id, status, queue_position);
CREATE INDEX IF NOT EXISTS song_requests_history
ON song_requests(owner_user_id, component_instance_id, finished_at DESC)
WHERE status IN ('completed', 'cancelled');
CREATE INDEX IF NOT EXISTS song_requests_requester_active
ON song_requests(owner_user_id, component_instance_id, requester_uid, requested_at DESC)
WHERE status IN ('current', 'queued');
CREATE TABLE IF NOT EXISTS song_ratings (
id UUID PRIMARY KEY,
owner_user_id UUID NOT NULL,
component_instance_id UUID NOT NULL,
song_request_id UUID NOT NULL,
viewer_uid TEXT NOT NULL CHECK (char_length(viewer_uid) BETWEEN 1 AND 64),
viewer_name TEXT NOT NULL CHECK (char_length(viewer_name) BETWEEN 1 AND 80),
score SMALLINT NOT NULL CHECK (score BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (song_request_id, viewer_uid),
FOREIGN KEY (owner_user_id, component_instance_id)
REFERENCES song_request_state(owner_user_id, component_instance_id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id, component_instance_id, song_request_id)
REFERENCES song_requests(owner_user_id, component_instance_id, id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS song_ratings_request
ON song_ratings(owner_user_id, component_instance_id, song_request_id);
ALTER TABLE song_request_state ENABLE ROW LEVEL SECURITY;
ALTER TABLE song_request_state FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS song_request_state_owner ON song_request_state;
CREATE POLICY song_request_state_owner ON song_request_state
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
ALTER TABLE song_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE song_requests FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS song_requests_owner ON song_requests;
CREATE POLICY song_requests_owner ON song_requests
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
ALTER TABLE song_ratings ENABLE ROW LEVEL SECURITY;
ALTER TABLE song_ratings FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS song_ratings_owner ON song_ratings;
CREATE POLICY song_ratings_owner ON song_ratings
USING (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (owner_user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
+23
View File
@@ -27,6 +27,9 @@ use crate::{
rate_limit::AuthRateLimiter,
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
repository::TenantRepository,
song_request::{
SONG_REQUEST_KIND, SongRequestHandler, SongRequestService, SongRequestSnapshotProvider,
},
};
#[derive(Clone)]
@@ -42,6 +45,7 @@ pub struct AppState {
pub enrollment_limiter: AuthRateLimiter,
pub component_socket_slots: Arc<Semaphore>,
pub http: reqwest::Client,
pub song_requests: SongRequestService,
}
impl AppState {
@@ -60,9 +64,26 @@ impl AppState {
let registry = ComponentRegistry::with_builtin_components();
let component_cache = Arc::new(InMemoryComponentStore::default());
let hub = EventHub::new(512);
let song_requests = SongRequestService::new(db.clone(), hub.clone());
registry
.register_handler(
SONG_REQUEST_KIND,
Arc::new(SongRequestHandler::new(song_requests.clone())),
)
.map_err(|error| error.to_string())?;
registry
.register_snapshot_provider(
SONG_REQUEST_KIND,
Arc::new(SongRequestSnapshotProvider::new(song_requests.clone())),
)
.map_err(|error| error.to_string())?;
let router = SourceEventRouter::new(registry.clone(), component_cache.clone(), hub.clone());
let repository =
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
repository
.ensure_song_request_components()
.await
.map_err(|error| error.to_string())?;
repository
.hydrate_all()
.await
@@ -113,6 +134,7 @@ impl AppState {
),
component_socket_slots: Arc::new(Semaphore::new(128)),
http,
song_requests,
};
state.start_all_sources().await?;
Ok(state)
@@ -256,6 +278,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
),
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
(5_i32, include_str!("../migrations/005_song_request.sql")),
] {
let applied = transaction
.query_one(
+25
View File
@@ -27,6 +27,7 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
const TOTP_DIGITS: usize = 6;
@@ -620,6 +621,30 @@ impl AuthService {
],
)
.await?;
let song_component_id = Uuid::new_v4();
let song_settings = serde_json::to_value(SongRequestSettings::default())
.expect("SongRequestSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,$6,1,true)",
&[
&song_component_id,
&user_id,
&default_source_id,
&SONG_REQUEST_KIND,
&SONG_REQUEST_NAME,
&song_settings,
],
)
.await?;
transaction
.execute(
"INSERT INTO song_request_state(owner_user_id,component_instance_id) VALUES($1,$2)",
&[&user_id, &song_component_id],
)
.await?;
transaction
.execute(
"DELETE FROM pending_registrations WHERE id=$1",
+76
View File
@@ -21,6 +21,7 @@ use uuid::Uuid;
use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind},
overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
};
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
@@ -180,6 +181,19 @@ pub trait EventProjection: Send + Sync {
/// `async-trait` dependency.
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
/// Future used by component-specific durable state snapshots during WebSocket
/// authentication. Snapshot messages share the normal component envelope.
pub type SnapshotFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<ComponentMessage>, ComponentError>> + Send + 'a>>;
pub trait ComponentSnapshotProvider: Send + Sync {
fn snapshot<'a>(
&'a self,
component: &'a ComponentInstance,
room_id: &'a str,
) -> SnapshotFuture<'a>;
}
/// An active handler may perform durable side effects (for example recording a
/// song request). It runs independently of WebSocket receiver count and should
/// implement idempotency in its persistence layer.
@@ -280,6 +294,7 @@ pub struct ComponentRuntime {
definition: Arc<dyn ComponentDefinition>,
projection: Arc<dyn EventProjection>,
handlers: Vec<Arc<dyn EventHandler>>,
snapshot_provider: Option<Arc<dyn ComponentSnapshotProvider>>,
}
impl ComponentRuntime {
@@ -320,6 +335,17 @@ impl ComponentRuntime {
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
self.handlers.clone()
}
pub async fn snapshot(
&self,
component: &ComponentInstance,
room_id: &str,
) -> Result<Vec<ComponentMessage>, ComponentError> {
match &self.snapshot_provider {
Some(provider) => provider.snapshot(component, room_id).await,
None => Ok(Vec::new()),
}
}
}
#[derive(Clone)]
@@ -347,6 +373,12 @@ impl ComponentRegistry {
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(SongRequestDefinition),
Arc::new(SongRequestProjection),
)
.expect("built-in component kinds are unique");
registry
}
pub fn register(
@@ -371,6 +403,7 @@ impl ComponentRegistry {
definition,
projection,
handlers: Vec::new(),
snapshot_provider: None,
},
);
Ok(())
@@ -392,6 +425,22 @@ impl ComponentRegistry {
Ok(())
}
pub fn register_snapshot_provider(
&self,
kind: &str,
provider: Arc<dyn ComponentSnapshotProvider>,
) -> Result<(), ComponentError> {
let mut entries = self
.entries
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let runtime = entries
.get_mut(kind)
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?;
runtime.snapshot_provider = Some(provider);
Ok(())
}
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
self.entries
.read()
@@ -469,6 +518,33 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn builtin_song_request_is_registered_with_bounded_unlimited_defaults() {
let registry = ComponentRegistry::default();
let runtime = registry.runtime("song_request").unwrap();
let mut settings = runtime.definition().default_settings();
settings["scrollSpeedPixelsPerSecond"] = Value::from(999);
let validated = registry
.validate_settings("song_request", 1, settings)
.unwrap();
assert_eq!(validated["scrollSpeedPixelsPerSecond"], 200);
assert_eq!(validated["maxQueueSize"], 0);
assert_eq!(validated["maxRequestsPerViewer"], 0);
assert_eq!(validated["requestCooldownSeconds"], 0);
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"song_request",
"点歌姬",
1,
validated,
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Danmaku));
assert!(!subscriptions.contains(LiveEventKind::Gift));
}
#[test]
fn duplicate_component_kinds_are_rejected() {
let registry = ComponentRegistry::default();
+6 -1
View File
@@ -11,7 +11,10 @@ use base64::{Engine, engine::general_purpose::STANDARD};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::{credentials::normalize_cookiecloud_host, overlay::OverlaySettings};
use crate::{
credentials::normalize_cookiecloud_host,
overlay::{OverlaySettings, OverlayThemeId},
};
/// Process-level configuration. Tenant-owned room, CookieCloud and component
/// settings are imported from the legacy sections once and then live in
@@ -123,6 +126,7 @@ struct EmoticonsConfig {
#[derive(Deserialize, Default)]
struct OverlayFileConfig {
theme_id: Option<OverlayThemeId>,
font_scale: Option<u16>,
max_visible: Option<u8>,
collapse_after_seconds: Option<u16>,
@@ -318,6 +322,7 @@ fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] {
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
let default = OverlaySettings::default();
OverlaySettings {
theme_id: file.theme_id.unwrap_or(default.theme_id),
font_scale: file.font_scale.unwrap_or(default.font_scale),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter),
+21
View File
@@ -277,6 +277,27 @@ pub struct ComponentMessage {
}
impl ComponentMessage {
/// Build a component-owned message that does not originate directly from a
/// provider packet, such as a durable queue snapshot or settings update.
pub fn new(
component: &crate::components::ComponentInstance,
room_id: impl Into<String>,
event_type: impl Into<String>,
payload: Value,
) -> Self {
Self {
owner_id: component.owner_id,
component_id: component.id,
source_id: component.source_id,
version: COMPONENT_PROTOCOL_VERSION,
id: Uuid::new_v4(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
room_id: room_id.into(),
event_type: event_type.into(),
payload,
}
}
pub fn from_live_event(
component_id: Uuid,
event: &LiveEvent,
+196 -15
View File
@@ -11,7 +11,7 @@ use std::{sync::Arc, time::Duration};
use axum::{
Json, Router,
extract::{
DefaultBodyLimit, Path, Request, State,
DefaultBodyLimit, Path, Query, Request, State,
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
},
http::{HeaderMap, HeaderValue, StatusCode, header},
@@ -37,6 +37,7 @@ use crate::{
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer,
},
repository::{ComponentView, RepositoryError},
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
};
const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session";
@@ -80,6 +81,22 @@ pub fn router(state: AppState) -> Router {
"/api/v1/components/{public_id}/stream",
get(component_stream),
)
.route(
"/api/v1/components/{id}/song-requests",
get(list_song_requests),
)
.route(
"/api/v1/components/{id}/song-requests/{request_id}/promote",
post(promote_song_request),
)
.route(
"/api/v1/components/{id}/song-requests/{request_id}/complete",
post(complete_song_request),
)
.route(
"/api/v1/components/{id}/song-requests/{request_id}/cancel",
post(cancel_song_request),
)
.route("/api/{*path}", any(api_not_found))
.route("/", get(spa_page))
.route("/login", get(spa_page))
@@ -645,12 +662,127 @@ async fn put_component_settings(
let message = settings_message(
&component,
&session.user.room_id,
"overlay.settings.updated",
"component.settings.updated",
);
state.hub.publish(component.id, Arc::new(message));
if component.kind == "danmaku_overlay" {
state.hub.publish(
component.id,
Arc::new(settings_message(
&component,
&session.user.room_id,
"overlay.settings.updated",
)),
);
}
Ok(Json(json!({"settings":component.settings})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SongRequestsQuery {
scope: Option<String>,
cursor: Option<i64>,
limit: Option<i64>,
}
async fn list_song_requests(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
Query(query): Query<SongRequestsQuery>,
) -> Result<Json<Value>, ApiError> {
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
require_song_component(&component)?;
let scope = match query.scope.as_deref().unwrap_or("active") {
"active" => SongListScope::Active,
"history" => SongListScope::History,
_ => {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_scope",
"scope must be active or history",
));
}
};
let page = state
.song_requests
.list_page(
&component,
scope,
query.cursor.unwrap_or(0),
query.limit.unwrap_or(50),
)
.await?;
Ok(Json(serde_json::to_value(page).map_err(internal)?))
}
async fn promote_song_request(
State(state): State<AppState>,
headers: HeaderMap,
Path((id, request_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
require_song_component(&component)?;
state
.song_requests
.promote(&component, request_id, &session.user.room_id)
.await?;
Ok(Json(json!({"ok":true})))
}
async fn complete_song_request(
State(state): State<AppState>,
headers: HeaderMap,
Path((id, request_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Value>, ApiError> {
change_song_request(state, headers, id, request_id, false).await
}
async fn cancel_song_request(
State(state): State<AppState>,
headers: HeaderMap,
Path((id, request_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Value>, ApiError> {
change_song_request(state, headers, id, request_id, true).await
}
async fn change_song_request(
state: AppState,
headers: HeaderMap,
component_id: Uuid,
request_id: Uuid,
cancelled: bool,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let component = state
.repository
.get_component(session.user.id, component_id)
.await?;
require_song_component(&component)?;
state
.song_requests
.finish(&component, request_id, cancelled, &session.user.room_id)
.await?;
Ok(Json(json!({"ok":true})))
}
fn require_song_component(component: &ComponentInstance) -> Result<(), ApiError> {
if component.kind == SONG_REQUEST_KIND {
Ok(())
} else {
Err(ApiError::new(
StatusCode::NOT_FOUND,
"song_component_not_found",
"Song request component not found",
))
}
}
async fn get_component_token(
State(state): State<AppState>,
headers: HeaderMap,
@@ -877,7 +1009,7 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
let mut receiver = state.hub.subscribe(component_id);
if socket
.send(Message::Text(
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id})
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id,"componentKind":component.kind})
.to_string()
.into(),
))
@@ -893,19 +1025,54 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
return;
}
};
let snapshot = settings_message(&component, &room_id, "overlay.settings.snapshot");
let snapshot = settings_message(&component, &room_id, "component.settings.snapshot");
if send_component_message(&mut socket, &snapshot)
.await
.is_err()
{
return;
}
if component.kind == "danmaku_overlay"
&& send_component_message(
&mut socket,
&settings_message(&component, &room_id, "overlay.settings.snapshot"),
)
.await
.is_err()
{
return;
}
let runtime = match state.registry.runtime(&component.kind) {
Ok(runtime) => runtime,
Err(_) => {
close_ws(&mut socket, "Component is unavailable").await;
return;
}
};
let component_snapshots = match runtime.snapshot(&component, &room_id).await {
Ok(messages) => messages,
Err(error) => {
warn!(component_id = %component.id, %error, "component snapshot failed");
close_ws(&mut socket, "Component snapshot is unavailable").await;
return;
}
};
for message in component_snapshots {
if send_component_message(&mut socket, &message).await.is_err() {
return;
}
}
loop {
tokio::select! {
event = receiver.recv() => match event {
Ok(message) => {
if send_component_message(&mut socket, &message).await.is_err() { break; }
}
// A durable component cannot safely guess dropped deltas. A
// disconnect makes the browser obtain a fresh paged snapshot;
// passive visual components can keep their legacy best-effort
// behavior.
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) if component.kind == SONG_REQUEST_KIND => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
},
@@ -945,17 +1112,12 @@ fn settings_message(
room_id: &str,
event_type: &str,
) -> ComponentMessage {
ComponentMessage {
owner_id: component.owner_id,
component_id: component.id,
source_id: component.source_id,
version: COMPONENT_PROTOCOL_VERSION,
id: Uuid::new_v4(),
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
room_id: room_id.to_owned(),
event_type: event_type.into(),
payload: json!({"settings":component.settings}),
}
ComponentMessage::new(
component,
room_id,
event_type,
json!({"settings":component.settings}),
)
}
async fn require_session(
@@ -1256,6 +1418,25 @@ impl From<RepositoryError> for ApiError {
}
}
impl From<SongRequestError> for ApiError {
fn from(error: SongRequestError) -> Self {
match error {
SongRequestError::NotFound => Self::new(
StatusCode::NOT_FOUND,
"song_request_not_found",
"Song request was not found",
),
SongRequestError::Conflict(message) => {
Self::new(StatusCode::CONFLICT, "song_request_conflict", message)
}
SongRequestError::Invalid(message) => {
Self::new(StatusCode::BAD_REQUEST, "invalid_song_request", message)
}
other => internal(other),
}
}
}
fn internal(error: impl std::fmt::Display) -> ApiError {
error!(%error, "internal operation failed");
ApiError::new(
+1
View File
@@ -18,3 +18,4 @@ pub mod overlay;
pub mod rate_limit;
pub mod realtime;
pub mod repository;
pub mod song_request;
+107 -20
View File
@@ -5,7 +5,11 @@
//! domain payloads. Unknown commands expose only sanitized metadata—never the
//! original unbounded packet or authentication material.
use std::{sync::Arc, time::Duration};
use std::{
sync::Arc,
thread,
time::{Duration, Instant},
};
use async_trait::async_trait;
use blivedm::client::{models::BiliMessage, websocket::BiliLiveClient};
@@ -26,6 +30,9 @@ use crate::{
overlay::{EmoticonCatalog, EmoticonMeta, GiftCatalog, normalize_image_url},
};
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
#[derive(Clone)]
pub struct BilibiliProvider {
cookie: Arc<str>,
@@ -226,37 +233,104 @@ impl LiveProvider for BilibiliProvider {
let listener_cancel = cancel.clone();
let listener_status = status.clone();
let listener = tokio::task::spawn_blocking(move || -> Result<(), String> {
let (upstream_sender, mut upstream_receiver) = futures_mpsc::channel(256);
let mut client = BiliLiveClient::new_auto(Some(&cookie), &room_id, upstream_sender)?;
client.set_read_timeout(Some(Duration::from_secs(1)))?;
client.send_auth();
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: true,
cookie_cloud: true,
detail: "Connected with authenticated blivedm_rs listener".into(),
});
// A full-client retry loop complements blivedm_rs' short socket
// reconnect loop. If all in-place attempts fail, recreating the
// client refreshes the danmaku host and authentication token.
while !listener_cancel.is_cancelled() {
if let Err(error) = client.receive() {
let (upstream_sender, mut upstream_receiver) = futures_mpsc::channel(256);
let mut client =
match BiliLiveClient::new_auto(Some(&cookie), &room_id, upstream_sender) {
Ok(client) => client,
Err(error) => {
warn!(%error, %room_id, "Bilibili listener creation failed; retrying");
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: false,
cookie_cloud: true,
detail: format!("Bilibili connection failed; retrying: {error}"),
});
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
continue;
}
};
if let Err(error) = client.set_read_timeout(Some(Duration::from_secs(1))) {
client.close();
return Err(format!("cannot configure Bilibili socket timeout: {error}"));
}
// Bilibili closes otherwise healthy danmaku sockets after
// roughly one minute without a heartbeat. The upstream CLI
// sends one every 20 seconds; embedded users must do the same.
if !client.send_auth() || !client.send_heart_beat() {
client.close();
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: false,
cookie_cloud: true,
detail: format!("Bilibili connection error: {error}"),
detail: "Bilibili authentication failed; retrying".into(),
});
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
continue;
}
while let Ok(message) = upstream_receiver.try_recv() {
if let Some(message) = normalize(message)
&& raw_sender.blocking_send(message).is_err()
{
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: true,
cookie_cloud: true,
detail: "Bilibili socket connected; waiting for live events".into(),
});
info!(%room_id, "Bilibili live socket connected and heartbeat started");
let mut last_heartbeat = Instant::now();
let mut received_event = false;
let disconnect_error = loop {
if listener_cancel.is_cancelled() {
client.close();
return Ok(());
}
}
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
if !client.send_heart_beat() {
break "heartbeat recovery failed".to_owned();
}
last_heartbeat = Instant::now();
}
if let Err(error) = client.receive() {
break error;
}
while let Ok(message) = upstream_receiver.try_recv() {
let Some(message) = normalize(message) else {
continue;
};
if !received_event {
received_event = true;
info!(%room_id, "Bilibili listener received its first live event");
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: true,
cookie_cloud: true,
detail: "Connected and receiving live events".into(),
});
}
if raw_sender.blocking_send(message).is_err() {
client.close();
return Ok(());
}
}
};
client.close();
warn!(error = %disconnect_error, %room_id, "Bilibili listener disconnected; rebuilding client");
let _ = listener_status.send(SourceStatus {
source_id: context.source_id,
room_id: room_id.clone(),
connected: false,
cookie_cloud: true,
detail: format!("Bilibili disconnected; retrying: {disconnect_error}"),
});
wait_for_retry(&listener_cancel, RECONNECT_BACKOFF);
}
client.close();
Ok(())
});
@@ -285,6 +359,19 @@ impl LiveProvider for BilibiliProvider {
}
}
/// Blocking listener tasks cannot await a cancellation token. Sleeping in
/// short slices keeps shutdown/reconfiguration latency bounded.
fn wait_for_retry(cancel: &CancellationToken, duration: Duration) {
let deadline = Instant::now() + duration;
while !cancel.is_cancelled() {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
thread::sleep(remaining.min(Duration::from_millis(200)));
}
}
#[derive(Clone, Debug)]
struct EmoticonHint {
text: String,
+27
View File
@@ -11,9 +11,25 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::RwLock;
/// Stable identifier for a renderer theme.
///
/// This enum is deliberately shared by defaults, validation and serialization:
/// persisted component settings can therefore never reference a theme that the
/// deployed frontend does not know how to render. Add a variant only together
/// with its frontend theme definition and stylesheet.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OverlayThemeId {
#[default]
JadeScroll,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OverlaySettings {
/// Visual theme selected for this tenant-owned component instance.
#[serde(default)]
pub theme_id: OverlayThemeId,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
pub show_danmaku: bool,
@@ -41,6 +57,7 @@ pub struct OverlaySettings {
impl Default for OverlaySettings {
fn default() -> Self {
Self {
theme_id: OverlayThemeId::default(),
font_scale: default_font_scale(),
show_danmaku: true,
show_enter: true,
@@ -492,10 +509,20 @@ mod tests {
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
object.remove("themeId");
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100);
}
#[test]
fn unknown_theme_ids_are_rejected() {
let mut value = json!(OverlaySettings::default());
value["themeId"] = json!("theme-that-is-not-installed");
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
assert!(error.to_string().contains("unknown variant"));
}
}
+73
View File
@@ -16,6 +16,7 @@ use crate::{
components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError},
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
#[derive(Clone)]
@@ -45,6 +46,61 @@ impl TenantRepository {
Ok(())
}
/// Ensure every active tenant has the built-in singleton song component.
/// The partial unique index makes this safe across concurrent application
/// starts; the state row is repaired independently for existing instances.
pub async fn ensure_song_request_components(&self) -> Result<(), RepositoryError> {
for tenant in self.db.list_active_tenants().await? {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, tenant.user_id).await?;
let existing = transaction
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?;
let component_id: Uuid = if let Some(row) = existing {
row.get(0)
} else {
let id = Uuid::new_v4();
let settings = serde_json::to_value(SongRequestSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,$6,1,true) ON CONFLICT DO NOTHING",
&[
&id,
&tenant.user_id,
&tenant.source_id,
&SONG_REQUEST_KIND,
&SONG_REQUEST_NAME,
&settings,
],
)
.await?;
transaction
.query_one(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?
.get(0)
};
transaction
.execute(
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
VALUES($1,$2) ON CONFLICT(component_instance_id) DO NOTHING",
&[&tenant.user_id, &component_id],
)
.await?;
transaction.commit().await?;
}
Ok(())
}
pub async fn hydrate_tenant(
&self,
owner_id: Uuid,
@@ -72,6 +128,12 @@ impl TenantRepository {
kind: &str,
name: &str,
) -> Result<ComponentInstance, RepositoryError> {
// Every tenant receives this singleton during registration/startup.
// Keeping creation internal prevents a second instance from racing the
// partial unique index and turning a domain conflict into a DB error.
if kind == SONG_REQUEST_KIND {
return Err(RepositoryError::Forbidden);
}
let runtime = self
.registry
.runtime(kind)
@@ -123,6 +185,17 @@ impl TenantRepository {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
let kind: String = transaction
.query_opt(
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
&[&owner_id, &component_id],
)
.await?
.ok_or(RepositoryError::NotFound)?
.get(0);
if kind == SONG_REQUEST_KIND {
return Err(RepositoryError::Forbidden);
}
let changed = transaction
.execute(
"DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2",
File diff suppressed because it is too large Load Diff