old backend core lib
This commit is contained in:
+21
-17
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user