old backend core lib
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`apps/server-rust/` contains the Axum backend, live-event adapters, authentication, persistence, and WebSocket delivery. Database migrations live in `apps/server-rust/migrations/`; Rust unit and async tests are normally kept beside the modules they exercise. `apps/overlay/src/` contains the React/Vite control and OBS interfaces, with PWA code in `apps/overlay/pwa/` and static assets in `apps/overlay/public/`. Architecture, protocol, security, and component notes belong in `docs/`; deployment support lives in `deploy/`, `Dockerfile`, and `compose.yaml`. `vendor/blivedm/` is an intentionally patched dependency—edit it only for a targeted upstream integration change.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `cargo fmt --manifest-path apps/server-rust/Cargo.toml` formats Rust code.
|
||||
- `cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings` runs strict Rust linting.
|
||||
- `cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets` runs backend tests.
|
||||
- `npm --prefix apps/overlay run dev` starts the Vite development server.
|
||||
- `npm --prefix apps/overlay run format:check` checks Prettier; use `run format` to apply it.
|
||||
- `npm --prefix apps/overlay run build` type-checks and builds the frontend; `run docs:check` validates documentation links.
|
||||
- `docker compose config --quiet` validates deployment configuration; `docker compose up --build -d` builds and starts the stack.
|
||||
|
||||
Run `git diff --check` before submitting changes.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use four spaces in Rust and TOML, and two spaces in TypeScript, CSS, Markdown, JSON, and YAML. Rust uses `snake_case` for modules/functions and `PascalCase` for types. TypeScript uses `camelCase` for functions/values and `PascalCase` for React components and types. Let rustfmt and the pinned Prettier version decide layout. Do not bulk-format vendored code.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Add Rust tests near the changed behavior using `#[test]` or `#[tokio::test]`. There is no fixed coverage threshold, but regressions and tenant-boundary behavior require tests. Frontend changes must pass formatting and production builds. For visual overlay work, manually verify representative OBS sizes and attach before/after screenshots to the pull request.
|
||||
|
||||
## Commits, Pull Requests & Security
|
||||
|
||||
Follow existing history: use a short, lowercase, imperative or descriptive commit subject and keep each commit to one logical change. Pull requests should summarize behavior, list verification commands, note migrations or configuration changes, link relevant issues, and include screenshots for UI work.
|
||||
|
||||
Never commit `config.toml`, `.env`, CookieCloud credentials, TOTP secrets, recovery codes, session secrets, or OBS tokens; update examples with placeholders instead. Preserve tenant isolation, PostgreSQL RLS policies, and authenticated ownership checks in every query and API boundary.
|
||||
@@ -1,10 +1,11 @@
|
||||
# 洛星瓷直播组件服务
|
||||
|
||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay`
|
||||
弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request`
|
||||
点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||
|
||||
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在
|
||||
`vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。
|
||||
`vendor/blivedm`
|
||||
固定了保留原始 JSON、可观测发送失败和安全重连所需的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃,也让宿主在 socket 恢复失败后重建客户端。
|
||||
|
||||
## 文档索引
|
||||
|
||||
@@ -13,6 +14,7 @@
|
||||
- [React 控制台、OBS 与 PWA](apps/overlay/README.md)
|
||||
- [组件开发指南](docs/components/README.md)
|
||||
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
|
||||
- [`song_request` 点歌姬](docs/components/song-request.md)
|
||||
- [WebSocket 实时协议](docs/protocol.md)
|
||||
- [租户、Secret 与部署安全](docs/security.md)
|
||||
- [完整配置注释](config.toml.example)
|
||||
@@ -54,7 +56,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
|
||||
|
||||
`[database].url`
|
||||
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
|
||||
RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。
|
||||
RLS 约束,HTTP
|
||||
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。
|
||||
|
||||
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
|
||||
Secret 与每个用户的 CookieCloud
|
||||
@@ -190,6 +193,9 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通
|
||||
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
|
||||
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
||||
|
||||
每个账户会自动拥有一个不可删除的点歌姬。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5`
|
||||
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
|
||||
|
||||
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
|
||||
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
|
||||
+17
-15
@@ -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);
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -18,3 +18,4 @@ pub mod overlay;
|
||||
pub mod rate_limit;
|
||||
pub mod realtime;
|
||||
pub mod repository;
|
||||
pub mod song_request;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -87,6 +87,9 @@ request_timeout_seconds = 10
|
||||
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
|
||||
# 10000 = 10 元、100000 = 100 元。
|
||||
[overlay]
|
||||
# 主题 ID。当前仅内置 jade-scroll;未来主题会继续使用稳定 ID,避免改名
|
||||
# 影响已经保存到 PostgreSQL 的组件设置。
|
||||
theme_id = "jade-scroll"
|
||||
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
||||
font_scale = 140
|
||||
max_visible = 5
|
||||
|
||||
@@ -47,6 +47,7 @@ flowchart LR
|
||||
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||
| 点歌队列和评分 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
|
||||
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
||||
|
||||
## 启动顺序
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 组件开发指南
|
||||
|
||||
组件是“一个直播源上的独立功能实例”。当前内建
|
||||
`danmaku_overlay`,未来礼物墙、点歌姬或统计组件都应使用同一套契约。
|
||||
组件是“一个直播源上的独立功能实例”。当前内建 `danmaku_overlay` 与
|
||||
`song_request`,未来礼物墙或统计组件也应使用同一套契约。
|
||||
|
||||
## 一个组件由什么组成
|
||||
|
||||
@@ -55,4 +55,5 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
|
||||
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
||||
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||
|
||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md)。
|
||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
||||
[`song-request.md`](song-request.md)。
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
| 字段 | 范围/单位 | 行为 |
|
||||
| ------------------------ | ----------- | -------------------------- |
|
||||
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
|
||||
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||
@@ -28,6 +29,20 @@
|
||||
|
||||
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range input 只改善交互,不能取代服务端校验。
|
||||
|
||||
## 主题
|
||||
|
||||
主题同时封装配色、花纹素材、装饰变体、粒子组合与动画名称。当前内置主题为
|
||||
`jade-scroll`(“青玉花卷”);控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
|
||||
|
||||
增加主题时需要同步完成三处注册:
|
||||
|
||||
1. 在后端 `OverlayThemeId` 中增加稳定 ID,使未知主题无法进入数据库。
|
||||
2. 在 `apps/overlay/src/themes/` 增加主题定义,并注册到 `overlayThemes`。
|
||||
3. 提供主题使用的 CSS 动画和素材;不要在 `Card` 中加入按主题 ID 分支。
|
||||
|
||||
旧组件没有 `themeId` 时会自动使用
|
||||
`jade-scroll`。WebSocket 收到未知 ID 时前端也会安全降级到该主题,以支持前后端滚动部署。
|
||||
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件插入队首,以卷轴动画横向展开。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# `song_request` 点歌姬
|
||||
|
||||
每个账户自动拥有一个不可删除、不可重复创建的 `song_request`
|
||||
实例,并与该账户的固定直播源绑定。组件只订阅规范化的
|
||||
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||
|
||||
## 弹幕命令
|
||||
|
||||
- `点歌 <歌名>`:合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||
- `打分 <1-5>`:对事务执行时的当前歌曲评分。同一 Bilibili UID 只有一票,重复评分会覆盖旧分数。
|
||||
|
||||
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
||||
|
||||
## 设置
|
||||
|
||||
`themeId`、`fontScale`、`scrollSpeedPixelsPerSecond` 和 `edgePauseSeconds`
|
||||
控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与 `requestCooldownSeconds`
|
||||
是可选防刷限制;值 `0` 表示不限制。
|
||||
|
||||
## OBS 与管理
|
||||
|
||||
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
||||
|
||||
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成或取消当前歌曲。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
||||
RLS 再次限制组件归属。
|
||||
+34
-16
@@ -33,14 +33,18 @@ log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
||||
{
|
||||
"version": 1,
|
||||
"type": "authenticated",
|
||||
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465"
|
||||
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
|
||||
"componentKind": "song_request"
|
||||
}
|
||||
```
|
||||
|
||||
之后立即发送
|
||||
`overlay.settings.snapshot`,再开始发送实时事件。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||
之后立即发送通用的 `component.settings.snapshot`,再发送该 kind 的状态快照和实时事件。弹幕姬还会发送
|
||||
`overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||
close 结束连接。
|
||||
|
||||
设置快照中的 `themeId` 是稳定的主题标识,目前支持
|
||||
`jade-scroll`。消费者应把未知主题降级为自身默认主题,不能因主题发布顺序不同而中断实时消息。
|
||||
|
||||
## 版本化事件信封
|
||||
|
||||
```json
|
||||
@@ -65,19 +69,33 @@ close 结束连接。
|
||||
|
||||
## 事件类型
|
||||
|
||||
| `type` | 主要 payload | 说明 |
|
||||
| --------------------------- | --------------------------------------------- | ---------------------- |
|
||||
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
|
||||
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
|
||||
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
|
||||
| `live.enter` | `viewer` | 进房事件 |
|
||||
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
|
||||
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
|
||||
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
|
||||
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
|
||||
| `live.like` | `viewer` | 点赞 |
|
||||
| `live.share` | `viewer` | 分享 |
|
||||
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
|
||||
| `type` | 主要 payload | 说明 |
|
||||
| ----------------------------- | --------------------------------------------- | ---------------------- |
|
||||
| `component.settings.snapshot` | `settings` | 所有组件的设置快照 |
|
||||
| `component.settings.updated` | `settings` | 所有组件的设置更新 |
|
||||
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
|
||||
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
|
||||
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
|
||||
| `live.enter` | `viewer` | 进房事件 |
|
||||
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
|
||||
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
|
||||
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
|
||||
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
|
||||
| `live.like` | `viewer` | 点赞 |
|
||||
| `live.share` | `viewer` | 分享 |
|
||||
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
|
||||
|
||||
## 点歌姬状态
|
||||
|
||||
`song_request` 认证后按顺序接收:
|
||||
|
||||
1. `song.queue.snapshot.begin`:包含 `snapshotId`、`revision`、当前歌曲和待唱总数。
|
||||
2. 零到多个 `song.queue.snapshot.page`:每页最多 100 个待唱项。
|
||||
3. `song.queue.snapshot.end`:提交该快照。
|
||||
|
||||
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
|
||||
`completed`、`cancelled` 或
|
||||
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。
|
||||
|
||||
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||
是供展示使用的人民币数值。
|
||||
|
||||
+20
-6
@@ -59,15 +59,29 @@ impl BiliLiveClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_auth(&mut self) {
|
||||
if let Err(e) = self.send_auth_internal() {
|
||||
log::error!("failed to send auth packet: {}", e);
|
||||
/// Send authentication, including the built-in reconnect fallback.
|
||||
///
|
||||
/// The boolean lets embedding applications rebuild the whole client when
|
||||
/// all socket-level recovery attempts fail. Existing CLI callers may
|
||||
/// safely ignore it.
|
||||
pub fn send_auth(&mut self) -> bool {
|
||||
match self.send_auth_internal() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("failed to send auth packet: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_heart_beat(&mut self) {
|
||||
if let Err(e) = self.send_heart_beat_internal() {
|
||||
log::error!("failed to send heartbeat: {}", e);
|
||||
/// Send a heartbeat and report whether socket-level recovery succeeded.
|
||||
pub fn send_heart_beat(&mut self) -> bool {
|
||||
match self.send_heart_beat_internal() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("failed to send heartbeat: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user