Compare commits

...
16 Commits
Author SHA1 Message Date
felis 1020c75e37 fix guard effects 2026-08-28 21:28:53 -07:00
felis e5186f62bd 调整弹幕姬进入动态 2026-08-19 12:50:19 -07:00
felis 845b0f5900 更新点歌和弹幕微调 2026-08-19 12:30:30 -07:00
felis a36d511d39 update UI details for new stream 2026-08-14 12:20:46 -07:00
felis acc4c117f7 Font related improvements 2026-08-13 12:05:47 -07:00
felis 4eda93cf24 update UI 2026-08-11 09:19:13 -07:00
felis 97a3f1be48 装饰 2026-08-06 10:38:07 -07:00
felis ac80cd1db4 新主题 2026-08-06 00:41:10 -07:00
felis 7eb9d00a50 remove streamer-specific branding 2026-07-21 19:47:21 -07:00
felis 716c6f3f2f add configurable gift menu overlays
Add tenant-scoped gift menu settings, catalog-backed triggers, infinite OBS rendering, and guard assets. Normalize legacy and protobuf gift values for blind-box, battery-tier, and transaction-aware matching.
2026-07-21 19:41:49 -07:00
felis c4eca7b8bf add full-screen gift effects 2026-07-19 00:39:56 -07:00
felis f79852d8e6 add account localization and switchable rooms
Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
2026-07-18 23:28:05 -07:00
felis 53ae90ec13 move live source ownership to accounts 2026-07-18 22:24:00 -07:00
felis 1598d3c403 change core library to libilibili 2026-07-18 12:57:36 -07:00
felis f599672a30 old backend core lib 2026-07-18 11:44:58 -07:00
felis 994854d104 formatting and comments 2026-07-16 00:12:26 -07:00
131 changed files with 18470 additions and 13208 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{ts,tsx,js,json,css,html,md,yaml,yml}]
indent_style = space
indent_size = 2
[*.{rs,toml}]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
-9
View File
@@ -27,12 +27,3 @@ data/
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# Only the vendored blivedm crate source and its manifests are required to
# compile the local patch. Exclude its upstream docs, examples and tooling.
vendor/blivedm/*
!vendor/blivedm/Cargo.toml
!vendor/blivedm/Cargo.lock
!vendor/blivedm/LICENSE
!vendor/blivedm/src/
!vendor/blivedm/src/**
+8
View File
@@ -0,0 +1,8 @@
.git/
**/dist/
**/node_modules/
**/target/
vendor/
config.toml
*.png
*.svg
+11
View File
@@ -0,0 +1,11 @@
{
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100,
"proseWrap": "always",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}
+31
View File
@@ -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`. The backend uses the sibling `../libilibili` checkout as a path dependency; coordinate API changes in that crate instead of copying it here.
## 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.
+7 -6
View File
@@ -2,27 +2,28 @@ FROM node:22-bookworm-slim AS web-build
WORKDIR /app/apps/overlay WORKDIR /app/apps/overlay
COPY apps/overlay/package.json apps/overlay/package-lock.json ./ COPY apps/overlay/package.json apps/overlay/package-lock.json ./
RUN npm ci RUN npm ci
COPY resources /app/resources
COPY apps/overlay ./ COPY apps/overlay ./
RUN npm run build RUN npm run build
FROM rust:1.97-bookworm AS rust-build FROM rust:1.97-bookworm AS rust-build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/ COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
COPY vendor/blivedm ./vendor/blivedm # `libilibili` is a sibling checkout in local development. Compose exposes it
# as a named build context so the image always compiles the same local source
# without copying a second, drifting crate into this repository.
COPY --from=libilibili . /libilibili
COPY apps/server-rust/src ./apps/server-rust/src COPY apps/server-rust/src ./apps/server-rust/src
COPY apps/server-rust/migrations ./apps/server-rust/migrations COPY apps/server-rust/migrations ./apps/server-rust/migrations
COPY resources ./resources
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target-cache \ --mount=type=cache,target=/app/target-cache \
CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \ CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# The upstream client logs full upstream HTTP responses at INFO, which can include
# account metadata and short-lived connection tokens. Keep application lifecycle
# logs while suppressing dependency INFO output by default.
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
COPY --from=web-build /app/apps/overlay/dist /app/web COPY --from=web-build /app/apps/overlay/dist /app/web
EXPOSE 9719 EXPOSE 9719
+134 -25
View File
@@ -1,20 +1,57 @@
# 洛星瓷直播组件服务 # 多用户直播组件服务
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
`gift_effect` 全屏礼物特效、`guard_effect` 大航海特效与 `gift_menu`
礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 `vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。 直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
crate。它负责 Cookie/WBI
API、直播 WebSocket 认证、心跳以及 JSON、zlib、brotli 包解析;本项目的 provider
adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。crate 尚未建模的少量兼容命令只在 provider 边界解析,原始包不会进入组件协议。
控制台和 OBS 展示的所有文案统一来自
`resources/i18n.toml`。匿名页面可以先使用浏览器偏好,登录后语言保存为账户级偏好,并实时同步到该账户的所有 OBS 组件。目前支持简体中文和英语。
## 文档索引
- [总体架构与事件流](docs/architecture.md)
- [Rust 后端模块](apps/server-rust/README.md)
- [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)
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
- [`guard_effect` 大航海特效](docs/components/guard-effect.md)
- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
- [WebSocket 实时协议](docs/protocol.md)
- [租户、Secret 与部署安全](docs/security.md)
- [完整配置注释](config.toml.example)
## 后端结构 ## 后端结构
`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出: `src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出:
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。 - `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs`
- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。 - `components.rs`
- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
- `realtime.rs` 按 component
UUID 建立独立广播通道;账户直播事件先按 owner 提供给全部启用组件,再由组件订阅筛选,副作用 handler 不依赖 OBS
WebSocket 是否在线。
- `auth.rs`、`repository.rs` 和 PostgreSQL
RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs`
从服务端会话推导 owner,不接受客户端自报 owner ID。
## 部署 ## 部署
源码目录需要保持为相邻 checkout,Compose 会把 `libilibili` 作为独立 BuildKit 上下文传入镜像:
```text
source/
├── libilibili/
└── lxc-streamutils/
```
先复制配置并生成独立密钥: 先复制配置并生成独立密钥:
```sh ```sh
@@ -25,13 +62,45 @@ openssl rand -hex 32 # 填入兼容字段 admin.session_secret
docker compose up --build -d docker compose up --build -d
``` ```
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。 Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config`
读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719`
直接暴露到公网。
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。 容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将
`config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置
`APP_UID` 与 `APP_GID`。
`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。 `[database].url`
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
RLS 约束,HTTP
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
`account_preferences` 表中。
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。 `security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
Secret 与每个用户的 CookieCloud
Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
## 开发与代码质量
Rust 使用仓库内 `rustfmt.toml`,前端和文档使用固定版本 Prettier。推荐提交前执行:
```sh
cargo fmt --manifest-path apps/server-rust/Cargo.toml
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
npm --prefix apps/overlay run format
npm --prefix apps/overlay run format:check
npm --prefix apps/overlay run docs:check
npm --prefix apps/overlay run build
docker compose config --quiet
git diff --check
```
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
ignore 会排除依赖、构建产物、PNG/SVG 和包含真实 Secret 的 `config.toml`。`libilibili`
是独立 crate;协议解析能力应在该项目中维护,本仓库只维护领域事件适配。
## 首次初始化与登录 ## 首次初始化与登录
@@ -41,7 +110,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
https://danmaku.luoxingci.com/control/setup https://danmaku.luoxingci.com/control/setup
``` ```
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。 填写系统管理员用户名和 `config.toml` 中的
`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof: `admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
@@ -50,9 +120,16 @@ https://danmaku.luoxingci.com/control/setup
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。 - 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。 - 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。
系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 系统管理员可在 `/control/invitations`
创建和撤销邀请码。每个邀请码只能使用一次,并指定一个尚未占用的初始 Bilibili
`room_id`。受邀用户完成注册后,可以在 `/control/account`
自行切换到其他尚未被账户占用的直播间;切换不会改变组件、队列或 OBS 地址。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。 每个用户在 `/control/account`
配置账户级直播监听和自己的 CookieCloud 同步 UUID/Key 与密码。该配置只创建一条上游连接,并供账户下全部现有和未来组件使用。地址必须位于部署管理员配置的
`security.cookiecloud_allowed_hosts`
白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili
`SESSDATA`。
### 旧单用户配置迁移 ### 旧单用户配置迁移
@@ -67,7 +144,8 @@ https://danmaku.luoxingci.com/control/setup
## 必须使用 HTTPS ## 必须使用 HTTPS
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写: 公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持
`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
```nginx ```nginx
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
@@ -103,36 +181,67 @@ server {
} }
``` ```
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。 只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置
`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
## 控制台 PWA ## 控制台 PWA
通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control` 会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 + TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和 `/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。 通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control`
会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 +
TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和
`/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。
PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*` 浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有 `/api/*` 响应始终在线直连且由服务端返回 `Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。 PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*`
浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有
`/api/*` 响应始终在线直连且由服务端返回
`Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。 部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。
## 组件与 OBS 地址 ## 组件与 OBS 地址
登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是: 登录 `/control/`
后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
```text ```text
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token> https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
``` ```
`publicId` 是组件 UUID。`#token=...` 位于 URL fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 `/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 `publicId` 是组件 UUID。`#token=...` 位于 URL
fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 每个账户会自动拥有不可删除的点歌姬、全屏礼物特效、大航海特效和礼物菜单组件。观众发送 `点歌歌名` 或
`点歌 歌名`
加入队列;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态与历史持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。 礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度。该组件只订阅一次性
`live.gift`,不会接收大航海事件,也不会把连击更新重复播放为新礼物。密集礼物在每个 OBS 礼物组件中按有界 FIFO 逐个播放,当前动画结束前不会叠加下一笔特效。
大航海特效是独立的 `guard_effect` 组件,只订阅
`live.guard.buy`。舰长、提督和总督分别播放独立视频,或按主题展示月下星光庆祝;它拥有自己的实例、设置、OBS
token 和 WebSocket 通道。连续上舰事件同样按有界 FIFO 逐个完整播放,不会覆盖正在播放的视频。
礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从
`360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
## 弹幕姬展示 ## 弹幕姬展示
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。 每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。
花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 花边 SVG 已本地打包,来源及公版/CC0 许可记录在
`apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。 后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过
`[gifts]` 调整。
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。 普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的
`SESSDATA`
刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过
`[emoticons]` 调整。
+90
View File
@@ -0,0 +1,90 @@
# React 控制台与 OBS 前端
Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域托管。生产容器不包含 Node。
## 路由
| 路由 | 权限 | 作用 |
| --------------------------------------- | --------------- | -------------------------------- |
| `/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。控制台允许同一组件类型创建多个命名实例;列表必须显示实例名称而不是只显示类型,以便不同 OBS 场景的样式和地址可被区分。
## 文件职责
| 文件 | 职责 |
| ----------------------- | ---------------------------------------------------- |
| `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/giftEffect.tsx` | 礼物流星与视口自适应渲染 |
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
| `src/guardEffect.tsx` | 独立大航海视频、感谢卷轴与月夜庆祝渲染 |
| `src/guardThemes.ts` | 可扩展大航海特效主题注册表与 CSS 变量 |
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
## Secret 与状态
- TOTP Secret、恢复码、邀请码和新 OBS 地址只保存在当前 React state。
- API normalizer 不把未知对象直接传播到组件。
- API mutation 离线时立即失败,不进入 Background Sync。
- PWA 更新在 dirty form 或一次性 secret 可见时被阻止。
- OBS token 从 URL fragment 读取,只在 WebSocket 第一帧发送。
- OBS 收到坏 JSON、坏图片或未知事件时局部降级,不让浏览器源崩溃。
## PWA
manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用 network-first
HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite
build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。
## 语言资源
`../../resources/i18n.toml` 是控制台、OBS 组件、API 错误和 PWA
metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则嵌入同一文件来验证允许保存的 locale。增加文案键时必须为每个 locale 提供值;增加语言时同时增加完整
`[locales."<code>".messages]` 段。业务命令如“点歌”是 Bilibili 输入协议,不属于 UI 翻译。
## 字体资源
Noto Serif SC、ZCOOL XiaoWei 与 LXGW
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书与鸿雷行书简体则以固定 WOFF2 文件保存在
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。前三套 Fontsource 字体与漓雨手书的 OFL-1.1 许可证输出到
`/fonts/licenses/`;鸿雷行书的随附说明不构成开放授权,公开或商业部署前必须确认 Web 嵌入与再分发权利,具体摘要见
`public/fonts/NOTICE.md`。
## 格式化与构建
```bash
npm run format
npm run format:check
npm run docs:check
npm run build
```
`npm run format`
使用仓库根目录的 Prettier 配置,同时格式化 TypeScript、TSX、CSS、HTML、JSON、Markdown、Compose
YAML 和项目文档。
组件协议与弹幕姬行为分别见:
- [实时协议](../../docs/protocol.md)
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
- [点歌姬组件](../../docs/components/song-request.md)
- [全屏礼物特效](../../docs/components/gift-effect.md)
- [大航海特效](../../docs/components/guard-effect.md)
- [礼物菜单组件](../../docs/components/gift-menu.md)
+5 -5
View File
@@ -1,18 +1,18 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="__DEFAULT_LANGUAGE__">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0a302f" /> <meta name="theme-color" content="#0a302f" />
<meta name="description" content="洛星瓷直播组件与 OBS 浏览器源控制台" /> <meta name="description" content="__APP_DESCRIPTION__" />
<meta name="application-name" content="洛星瓷直播云台" /> <meta name="application-name" content="__APP_NAME__" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="直播云台" /> <meta name="apple-mobile-web-app-title" content="__APP_SHORT_NAME__" />
<link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
<title>洛星瓷直播云台</title> <title>__APP_NAME__</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+61
View File
@@ -8,6 +8,9 @@
"name": "lxc-stream-overlay", "name": "lxc-stream-overlay",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/lxgw-wenkai": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@fontsource/zcool-xiaowei": "5.3.0",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1" "react-dom": "19.1.1"
}, },
@@ -15,6 +18,8 @@
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2", "@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"smol-toml": "1.4.2",
"typescript": "5.9.2", "typescript": "5.9.2",
"vite": "7.3.6" "vite": "7.3.6"
} }
@@ -743,6 +748,33 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fontsource/lxgw-wenkai": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/lxgw-wenkai/-/lxgw-wenkai-5.3.0.tgz",
"integrity": "sha512-+U5AAOXeaB7o+3G0Qm7I7Djz2Jd1hqNY+kClOZH8bTunyyGa2WxY12LZftt2AqUsxlfxwdQpd75Js1HQIt4H4g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/noto-serif-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/zcool-xiaowei": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/zcool-xiaowei/-/zcool-xiaowei-5.3.0.tgz",
"integrity": "sha512-iRuUPl9ONFbYzi6JcEshpE4VedINyzQ4hv0Z5XEt0cB0pMPiQeqc2Qvv1NwJCNzkPwG02VCdcO+cF2K8ylYn2A==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1573,6 +1605,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.1.1", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -1665,6 +1713,19 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/smol-toml": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+23 -3
View File
@@ -2,7 +2,27 @@
"name": "lxc-stream-overlay", "name": "lxc-stream-overlay",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"scripts": { "build": "tsc -b && vite build", "dev": "vite" }, "scripts": {
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" }, "build": "tsc -b && vite build && node scripts/check-font-assets.mjs",
"devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" } "dev": "vite",
"docs:check": "node scripts/check-doc-links.mjs ../..",
"format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml",
"format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml"
},
"dependencies": {
"@fontsource/lxgw-wenkai": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@fontsource/zcool-xiaowei": "5.3.0",
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"smol-toml": "1.4.2",
"typescript": "5.9.2",
"vite": "7.3.6"
}
} }
+14 -6
View File
@@ -7,8 +7,7 @@
- License: CC0 1.0 / public domain - License: CC0 1.0 / public domain
- Retrieved: 2026-07-15 - Retrieved: 2026-07-15
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by `floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg - Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899 - Original artist: Paul Bürck, 1899
@@ -16,8 +15,7 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0 - License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15 - Retrieved: 2026-07-15
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by `floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg - Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899 - Original artist: Paul Bürck, 1899
@@ -25,5 +23,15 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0 - License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15 - Retrieved: 2026-07-15
The overlay uses these SVGs as recolorable CSS masks inside message cards and The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
bundles them locally, so OBS never needs to load the source websites. so OBS never needs to load the source websites.
`moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by
the Moonlit Water danmaku, song-request, and gift-menu components.
# Membership voyage videos
`captain.webm`, `admiral.webm`, and `general.webm` are project-owner-supplied 1280×720 VP9/Opus
videos used by the independent Jade Starfall Guard, Admiral, and Governor component respectively.
Each video runs for approximately 7.988 seconds. Their redistribution rights remain the
responsibility of the deployment owner.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" aria-hidden="true">
<!-- Repository-original transparent lotus silhouette used as a CSS mask. -->
<g fill="#000">
<path d="M48 39C36 28 35 15 48 3c13 12 12 25 0 36Z" />
<path d="M43 43C27 38 19 26 24 10c16 5 23 17 19 33Z" opacity=".9" />
<path d="M53 43c16-5 24-17 19-33-16 5-23 17-19 33Z" opacity=".9" />
<path d="M39 48C21 49 9 40 7 24c17-1 28 8 32 24Z" opacity=".75" />
<path d="M57 48c18 1 30-8 32-24-17-1-28 8-32 24Z" opacity=".75" />
<path d="M48 62C29 62 16 56 10 45c13 4 25 4 38-1 13 5 25 5 38 1-6 11-19 17-38 17Z" />
<path d="M17 67c20-5 42-5 62 0-21 3-41 3-62 0Z" opacity=".62" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 697 B

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 40" role="img" aria-label="">
<!--
Repository-original ornament for the Moonlit Water theme. The monochrome
alpha shape is used as a CSS mask, allowing each component to tint it with
the same restrained jade-and-gold palette.
-->
<g fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 20h370c34 0 49-5 69-15-8 10-8 20 0 30-20-10-35-15-69-15" opacity=".62" />
<path d="M1190 20H820c-34 0-49-5-69-15 8 10 8 20 0 30 20-10 35-15 69-15" opacity=".62" />
<path d="M48 20c15-10 31-10 46 0-15 10-31 10-46 0Zm1104 0c-15-10-31-10-46 0 15 10 31 10 46 0Z" opacity=".72" />
<path d="M533 20c21-4 36-12 47-18-2 9 5 14 20 18-15 4-22 9-20 18-11-6-26-14-47-18Z" />
<path d="M667 20c-21-4-36-12-47-18 2 9-5 14-20 18 15 4 22 9 20 18 11-6 26-14 47-18Z" />
<path d="M563 20c13-8 25-8 37 0-12 8-24 8-37 0Zm74 0c-13-8-25-8-37 0 12 8 24 8 37 0Z" opacity=".74" />
</g>
<g fill="#000">
<circle cx="112" cy="20" r="2" opacity=".78" />
<circle cx="1088" cy="20" r="2" opacity=".78" />
<circle cx="488" cy="20" r="2.2" />
<circle cx="712" cy="20" r="2.2" />
<path d="m600 10 3 7 7 3-7 3-3 7-3-7-7-3 7-3Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,35 +0,0 @@
{
"id": "/control",
"name": "洛星瓷直播云台",
"short_name": "直播云台",
"description": "洛星瓷直播组件与 OBS 浏览器源控制台",
"lang": "zh-CN",
"dir": "auto",
"start_url": "/control/?source=pwa",
"scope": "/control/",
"display": "standalone",
"orientation": "any",
"background_color": "#03121a",
"theme_color": "#0a302f",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+33
View File
@@ -0,0 +1,33 @@
# Bundled font notice
## Liyu Shoushu
`liyu-shoushu-v0.107.woff2` is a WOFF2 conversion of `LiyuShoushu.ttf` from the official
[Liyu Shoushu v0.107 release](https://github.com/InkPantherType/LiyuShoushu/releases/tag/v0.107).
- Release tag commit: `219a0f42cb7b3f0d00be6e5bf9f8b5298a1f3060`
- Original TTF SHA-256: `16554439b7e95da1fc354ed7b2c1551e83e942c3c5afb2a2e883761c6037dc22`
- Bundled WOFF2 SHA-256: `b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3`
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
- Coverage retained: 18,828 glyphs and 18,221 Unicode mappings
- License: SIL Open Font License 1.1; see
[`licenses/liyu-shoushu-OFL.txt`](licenses/liyu-shoushu-OFL.txt)
The original font credits Yuji Kataoka and the Yuji Project Authors for the base brush glyphs, LXGW
and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's
[zi2zi-JiT](https://github.com/kaonashi-tyc/zi2zi-JiT) for generated Chinese glyphs. This notice
preserves the zi2zi-JiT attribution required by the upstream font documentation.
## HongLei XingShu Jian
`honglei-xingshu-jian.woff2` is a full-font WOFF2 conversion of the project owner's
`鸿雷行书简体.otf`; it is used only by the independent Jade Starfall membership component.
- Embedded family: `hongleixingshu` / `鸿雷行书简体`
- Original OTF SHA-256: `5081c025bc350339885297550b33d6742cd6467c7db5b340c537d0eb1e357234`
- Bundled WOFF2 SHA-256: `6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d`
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
- Coverage retained: 7,012 glyphs
- License: the supplied download notice does not grant an open-source or redistribution license.
Confirm that the deployment owner holds Web embedding and redistribution rights before public or
commercial deployment.
Binary file not shown.
@@ -0,0 +1,94 @@
Copyright 2026 Alexander Tseng.
Portions Copyright 2021 The Yuji Project Authors (https://github.com/Kinutafontfactory/Yuji).
Portions Copyright LXGW and LXGWZhenKai Project Authors (https://github.com/lxgw/LxgwZhenKai).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+38 -19
View File
@@ -1,4 +1,11 @@
/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */ /**
* Network policy for the `/control/` application shell.
*
* `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a
* network-first strategy so fresh HTML can reference the newest hashed bundle;
* known static assets use cache-first. The worker never handles API, WebSocket
* or OBS routes because they are outside both this fetch policy and its scope.
*/
const buildId = '__PWA_BUILD_ID__' const buildId = '__PWA_BUILD_ID__'
const cacheName = `lxc-control-v2-shell-${buildId}` const cacheName = `lxc-control-v2-shell-${buildId}`
const shellPath = '/control/' const shellPath = '/control/'
@@ -24,8 +31,9 @@ async function precacheShell() {
const html = await response.clone().text() const html = await response.clone().text()
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone()))) await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)] const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
.map(match => match[1]) match => match[1],
)
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])]) await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
} }
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
}) })
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil((async () => { event.waitUntil(
const names = await caches.keys() (async () => {
await Promise.all(names const names = await caches.keys()
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName) await Promise.all(
.map(name => caches.delete(name))) names
await self.clients.claim() .filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
})()) .map(name => caches.delete(name)),
)
await self.clients.claim()
})(),
)
}) })
self.addEventListener('message', event => { self.addEventListener('message', event => {
@@ -52,9 +64,12 @@ function isControlShell(pathname) {
} }
function isStaticAsset(pathname) { function isStaticAsset(pathname) {
return pathname.startsWith('/assets/') return (
|| pathname.startsWith('/pwa/') pathname.startsWith('/assets/') ||
|| pathname === '/control/manifest.webmanifest' pathname.startsWith('/fonts/') ||
pathname.startsWith('/pwa/') ||
pathname === '/control/manifest.webmanifest'
)
} }
async function navigationResponse(request) { async function navigationResponse(request) {
@@ -62,16 +77,20 @@ async function navigationResponse(request) {
const pathname = new URL(request.url).pathname const pathname = new URL(request.url).pathname
try { try {
const response = await fetch(request) const response = await fetch(request)
if (shellRoutes.includes(pathname) if (
&& response.ok shellRoutes.includes(pathname) &&
&& response.headers.get('content-type')?.includes('text/html')) { response.ok &&
response.headers.get('content-type')?.includes('text/html')
) {
await cache.put(pathname, response.clone()) await cache.put(pathname, response.clone())
} }
return response return response
} catch { } catch {
return (await cache.match(request, { ignoreSearch: true })) return (
|| (await cache.match(shellPath)) (await cache.match(request, { ignoreSearch: true })) ||
|| Response.error() (await cache.match(shellPath)) ||
Response.error()
)
} }
} }
+56
View File
@@ -0,0 +1,56 @@
/**
* Verify repository-local Markdown links without making network requests.
*
* External URLs are intentionally skipped: CI should not fail because an
* upstream website is temporarily unavailable. Local documentation links are
* deterministic and catch renamed component guides or incorrect relative paths.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, extname, resolve } from 'node:path'
const root = resolve(process.argv[2] ?? new URL('../../..', import.meta.url).pathname)
const ignoredDirectories = new Set([
'.agents',
'.codex',
'.git',
'dist',
'node_modules',
'target',
'vendor',
])
const markdownFiles = []
function collect(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue
const path = resolve(directory, entry.name)
if (entry.isDirectory()) collect(path)
else if (extname(entry.name).toLowerCase() === '.md') markdownFiles.push(path)
}
}
collect(root)
const failures = []
const markdownLink = /\[[^\]]*\]\(([^)]+)\)/g
for (const file of markdownFiles) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(markdownLink)) {
const rawTarget = match[1].trim().replace(/^<|>$/g, '')
if (!rawTarget || rawTarget.startsWith('#') || /^[a-z][a-z+.-]*:/i.test(rawTarget)) continue
// The project does not currently use titled local links. Splitting here
// still handles the conventional `(path "title")` form if one is added.
const target = decodeURI(rawTarget.split(/\s+["']/)[0].split('#')[0])
if (!target) continue
const destination = resolve(dirname(file), target)
if (!existsSync(destination)) failures.push(`${file}: ${rawTarget}`)
}
}
if (failures.length > 0) {
console.error(`Broken local documentation links:\n${failures.join('\n')}`)
process.exitCode = 1
} else {
console.log(`Checked ${markdownFiles.length} Markdown files; local links are valid.`)
}
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
const dist = new URL('../dist/', import.meta.url)
async function filesBelow(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true })
const files = []
for (const entry of entries) {
const relative = `${prefix}${entry.name}`
if (entry.isDirectory()) {
files.push(...(await filesBelow(new URL(`${entry.name}/`, directory), `${relative}/`)))
} else {
files.push(relative)
}
}
return files
}
const files = await filesBelow(dist)
const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai']
const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2'
const hongleiFont = 'fonts/honglei-xingshu-jian.woff2'
for (const font of expectedFonts) {
if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) {
throw new Error(`Missing bundled WOFF2 assets for ${font}`)
}
if (!files.includes(`fonts/licenses/${font}-OFL.txt`)) {
throw new Error(`Missing bundled license for ${font}`)
}
}
if (!files.includes(liyuFont)) throw new Error('Missing bundled WOFF2 asset for liyu-shoushu')
if (!files.includes('fonts/licenses/liyu-shoushu-OFL.txt')) {
throw new Error('Missing bundled license for liyu-shoushu')
}
const liyuDigest = createHash('sha256')
.update(await readFile(new URL(liyuFont, dist)))
.digest('hex')
if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') {
throw new Error(`Unexpected liyu-shoushu WOFF2 digest: ${liyuDigest}`)
}
if (!files.includes(hongleiFont)) throw new Error('Missing bundled WOFF2 asset for HongLei XingShu')
const hongleiDigest = createHash('sha256')
.update(await readFile(new URL(hongleiFont, dist)))
.digest('hex')
if (hongleiDigest !== '6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d') {
throw new Error(`Unexpected HongLei XingShu WOFF2 digest: ${hongleiDigest}`)
}
const guardVideos = new Map([
['assets/captain.webm', 'e9d2b416a582c4606cec1d613027e941bfc422f3ad12de24e88980908496d759'],
['assets/admiral.webm', '7c504469f638f321768edecb3b5ba25d6826737349f3479c89fb1776d35a8672'],
['assets/general.webm', '869b54a5104e4dfbcc97a6338e8805444e0f0034c6d8075530b2242a2c3436e1'],
])
for (const [video, expectedDigest] of guardVideos) {
if (!files.includes(video)) throw new Error(`Missing bundled membership video: ${video}`)
const digest = createHash('sha256')
.update(await readFile(new URL(video, dist)))
.digest('hex')
if (digest !== expectedDigest) throw new Error(`Unexpected membership video digest: ${video}`)
}
const legacyWoff = files.find(file => file.endsWith('.woff'))
if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`)
const css = (
await Promise.all(
files.filter(file => file.endsWith('.css')).map(file => readFile(new URL(file, dist), 'utf8')),
)
).join('\n')
for (const family of [
'Noto Serif SC',
'ZCOOL XiaoWei',
'LXGW WenKai',
'Liyu Shoushu',
'HongLei XingShu',
]) {
const declarations = [
`font-family:${family}`,
`font-family:${JSON.stringify(family)}`,
`font-family:'${family}'`,
]
if (!declarations.some(declaration => css.includes(declaration))) {
throw new Error(`Missing @font-face declaration for ${family}`)
}
}
if (!css.includes('font-display:block') || css.includes('font-display:swap')) {
throw new Error('Bundled fonts must block local fallback while loading')
}
console.log('Verified same-origin component fonts, licenses, and membership videos.')
+58
View File
@@ -0,0 +1,58 @@
/**
* Shared Moonlit Water edge ornament.
*
* Keeping the artwork inline lets the persisted decoration setting control
* real SVG stroke width. A raster image or CSS-scaled mask would only distort
* the motif and could not make its calligraphic lines genuinely heavier.
*/
function EdgeArtwork({ position }: { position: 'top' | 'bottom' }) {
return (
<svg
className={`theme-edge-art theme-edge-${position}`}
viewBox="0 0 1200 56"
preserveAspectRatio="none"
aria-hidden="true"
>
<g className="theme-edge-rule">
<path d="M12 30h338c52 0 86-8 116-24-13 13-13 29 0 42-30-16-64-24-116-24H12" />
<path d="M1188 30H850c-52 0-86-8-116-24 13 13 13 29 0 42 30-16 64-24 116-24h338" />
</g>
<g className="theme-edge-water">
<path d="M38 38c35-14 70-14 105 0s70 14 105 0" />
<path d="M1162 38c-35-14-70-14-105 0s-70 14-105 0" />
</g>
<g className="theme-edge-cloud">
<path d="M192 27c10-14 31-14 40 0 8-10 25-8 29 4-22 8-53 8-76 0 1-2 4-3 7-4Z" />
<path d="M1008 27c-10-14-31-14-40 0-8-10-25-8-29 4 22 8 53 8 76 0-1-2-4-3-7-4Z" />
</g>
<g className="theme-edge-leaves">
<path d="M485 28c13-14 27-17 42-9-10 13-24 16-42 9Z" />
<path d="M715 28c-13-14-27-17-42-9 10 13 24 16 42 9Z" />
</g>
<g className="theme-edge-lotus">
<path d="M600 34c-9-10-9-22 0-32 9 10 9 22 0 32Z" />
<path d="M595 36c-15-5-22-16-19-29 15 5 22 15 19 29Z" />
<path d="M605 36c15-5 22-16 19-29-15 5-22 15-19 29Z" />
<path d="M590 40c-17 0-28-7-32-20 16 0 27 7 32 20Z" />
<path d="M610 40c17 0 28-7 32-20-16 0-27 7-32 20Z" />
<path d="M600 49c-18 0-31-5-39-15 14 4 27 3 39-3 12 6 25 7 39 3-8 10-21 15-39 15Z" />
</g>
<g className="theme-edge-dots">
<circle cx="92" cy="27" r="3.2" />
<circle cx="1108" cy="27" r="3.2" />
<path d="m536 27 4-7 4 7-4 7Z" />
<path d="m664 27-4-7-4 7 4 7Z" />
</g>
</svg>
)
}
/** Decorative chrome is ignored by assistive technology and never captures input. */
export function ThemeEdges() {
return (
<div className="theme-edge-set" aria-hidden="true">
<EdgeArtwork position="top" />
<EdgeArtwork position="bottom" />
</div>
)
}
+356 -77
View File
@@ -1,23 +1,61 @@
/**
* Same-origin API client and defensive wire-format normalizers.
*
* The backend is authoritative and may evolve response wrappers independently
* from a deployed frontend. Normalizers accept those compatible wrappers while
* producing strict UI models. Mutations are refused while offline and are never
* queued for background replay, which avoids applying an old user's action
* after logout or tenant switching.
*/
import type { import type {
AuthUser, AuthUser,
ComponentSummary, ComponentSummary,
CookieCloudSource, CookieCloudSource,
GuardEffectSettings,
GiftEffectSettings,
GiftCatalogItem,
GiftMenuItem,
GiftMenuSettings,
Invitation, Invitation,
OverlaySettings, OverlaySettings,
Session, Session,
SongRequestItem,
SongRequestPage,
SongRequestSettings,
TotpEnrollment, TotpEnrollment,
} from './types' } from './types'
import { normalizeThemeId } from './themes'
import { normalizeFontFamilyId } from './typography'
import { normalizeGiftEffectThemeId } from './giftThemes'
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
import { normalizeGuardEffectThemeId } from './guardThemes'
import {
defaultGiftEffectSettings,
defaultGuardEffectSettings,
defaultGiftMenuSettings,
defaultOverlaySettings,
defaultSongRequestSettings,
} from './types'
import { currentLanguage, hasTranslation, translate } from './i18n'
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number readonly status: number
readonly code?: string readonly code?: string
readonly messageKey?: string
readonly fieldErrors?: Record<string, string> readonly fieldErrors?: Record<string, string>
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) { constructor(
status: number,
message: string,
code?: string,
messageKey?: string,
fieldErrors?: Record<string, string>,
) {
super(message) super(message)
this.name = 'ApiError' this.name = 'ApiError'
this.status = status this.status = status
this.code = code this.code = code
this.messageKey = messageKey
this.fieldErrors = fieldErrors this.fieldErrors = fieldErrors
} }
} }
@@ -33,11 +71,13 @@ async function parseResponse(response: Response): Promise<unknown> {
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> { export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase() const method = (init.method ?? 'GET').toUpperCase()
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) { if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline') throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.offline')
} }
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json') if (init.body != null && !headers.has('content-type'))
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json') headers.set('accept', 'application/json')
headers.set('accept-language', currentLanguage())
const response = await fetch(path, { const response = await fetch(path, {
...init, ...init,
@@ -49,13 +89,27 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) { if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
window.dispatchEvent(new Event('lxc:session-expired')) window.dispatchEvent(new Event('lxc:session-expired'))
} }
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {} const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const code = typeof error.code === 'string' ? error.code : undefined
const messageKey =
typeof error.messageKey === 'string'
? error.messageKey
: typeof error.message_key === 'string'
? error.message_key
: code
? `api.error.${code}`
: undefined
const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined
throw new ApiError( throw new ApiError(
response.status, response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`), localized ??
typeof error.code === 'string' ? error.code : undefined, String(
error.message ?? error.error ?? translate('api.http_error', { status: response.status }),
),
code,
messageKey,
error.fieldErrors && typeof error.fieldErrors === 'object' error.fieldErrors && typeof error.fieldErrors === 'object'
? error.fieldErrors as Record<string, string> ? (error.fieldErrors as Record<string, string>)
: undefined, : undefined,
) )
} }
@@ -71,24 +125,29 @@ export function json(method: string, body?: unknown): RequestInit {
function object(value: unknown): Record<string, unknown> { function object(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value) return value != null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> ? (value as Record<string, unknown>)
: {} : {}
} }
export function normalizeSession(value: unknown): Session { export function normalizeSession(value: unknown): Session {
const root = object(value) const root = object(value)
const candidate = root.user const candidate =
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null) root.user ??
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
? root
: null)
const raw = object(candidate) const raw = object(candidate)
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string' const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
const user: AuthUser | null = hasUser ? { const user: AuthUser | null = hasUser
id: String(raw.id ?? ''), ? {
username: String(raw.username ?? ''), id: String(raw.id ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined, username: String(raw.username ?? ''),
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined, roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user', displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined, role: typeof raw.role === 'string' ? raw.role : 'user',
} : null totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
}
: null
return { user, setupRequired: root.setupRequired === true } return { user, setupRequired: root.setupRequired === true }
} }
@@ -98,19 +157,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
return { return {
enrollmentToken: String(enrollmentToken ?? ''), enrollmentToken: String(enrollmentToken ?? ''),
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined, qrSvg:
qrDataUrl: typeof totp.qrDataUrl === 'string' typeof totp.qrSvg === 'string'
? totp.qrDataUrl ? totp.qrSvg
: typeof totp.qrCodeDataUrl === 'string' : typeof root.qrSvg === 'string'
? totp.qrCodeDataUrl ? root.qrSvg
: typeof root.qrDataUrl === 'string' : undefined,
? root.qrDataUrl qrDataUrl:
typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
: undefined,
otpauthUri:
typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined, : undefined,
otpauthUri: typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''), manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined, expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
} }
@@ -119,81 +185,294 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
export function normalizeRecoveryCodes(value: unknown): string[] { export function normalizeRecoveryCodes(value: unknown): string[] {
const root = object(value) const root = object(value)
const codes = root.recoveryCodes ?? object(root.recovery).codes const codes = root.recoveryCodes ?? object(root.recovery).codes
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : [] return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === 'string')
: []
} }
export function normalizeComponents(value: unknown): ComponentSummary[] { export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value) const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : [] const list = Array.isArray(value)
return list.map((entry) => { ? value
const item = object(entry) : Array.isArray(root.components)
return { ? root.components
id: String(item.id ?? ''), : root.component && typeof root.component === 'object'
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''), ? [root.component]
kind: String(item.kind ?? item.type ?? 'danmaku'), : []
name: String(item.name ?? '弹幕姬'), return list
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, .map(entry => {
settings: item.settings as OverlaySettings | undefined, const item = object(entry)
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined, return {
} id: String(item.id ?? ''),
}).filter(item => item.id) publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? translate('components.danmaku_type')),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
})
.filter(item => item.id)
} }
export function normalizeSettings(value: unknown): OverlaySettings { export function normalizeSettings(value: unknown): OverlaySettings {
const root = object(value) 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),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
viewerColor: normalizeOptionalHexColor(settings.viewerColor),
danmakuColor: normalizeOptionalHexColor(settings.danmakuColor),
}
}
function normalizeOptionalHexColor(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim().toUpperCase()
return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : null
}
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),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
requesterColor: normalizeOptionalHexColor(settings.requesterColor),
songTitleColor: normalizeOptionalHexColor(settings.songTitleColor),
}
}
export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings {
const root = object(value)
const settings = object(root.settings ?? value)
const normal = object(settings.normal)
const high = object(settings.high)
const featured = object(settings.featured)
return {
...defaultGiftEffectSettings,
...(settings as Partial<GiftEffectSettings>),
themeId: normalizeGiftEffectThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
normal: { ...defaultGiftEffectSettings.normal, ...normal },
high: { ...defaultGiftEffectSettings.high, ...high },
featured: { ...defaultGiftEffectSettings.featured, ...featured },
}
}
export function normalizeGuardEffectSettings(value: unknown): GuardEffectSettings {
const root = object(value)
const settings = object(root.settings ?? value)
return {
...defaultGuardEffectSettings,
...(settings as Partial<GuardEffectSettings>),
themeId: normalizeGuardEffectThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
}
}
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
const item = object(value)
const trigger = object(item.trigger)
if (typeof item.id !== 'string' || typeof item.description !== 'string') return undefined
if (trigger.kind === 'gift') {
const giftId = Number(trigger.giftId)
if (!Number.isSafeInteger(giftId) || giftId <= 0 || typeof trigger.giftName !== 'string')
return undefined
return {
id: item.id,
description: item.description,
trigger: {
kind: 'gift',
giftId,
giftName: trigger.giftName,
unitPrice: Number(trigger.unitPrice ?? 0),
imageUrl: typeof trigger.imageUrl === 'string' ? trigger.imageUrl : undefined,
},
}
}
if (
trigger.kind === 'guard' &&
['captain', 'admiral', 'governor'].includes(String(trigger.level))
) {
return {
id: item.id,
description: item.description,
trigger: {
kind: 'guard',
level: trigger.level as 'captain' | 'admiral' | 'governor',
},
}
}
const amount = Number(trigger.amount)
if (trigger.kind === 'battery' && Number.isSafeInteger(amount) && amount > 0) {
return {
id: item.id,
description: item.description,
trigger: { kind: 'battery', amount },
}
}
return undefined
}
export function normalizeGiftMenuSettings(value: unknown): GiftMenuSettings {
const root = object(value)
const settings = object(root.settings ?? value)
const items = Array.isArray(settings.items)
? settings.items.map(normalizeGiftMenuItem).filter(item => item !== undefined)
: []
return {
...defaultGiftMenuSettings,
...(settings as Partial<GiftMenuSettings>),
themeId: normalizeGiftMenuThemeId(settings.themeId),
fontFamily: normalizeFontFamilyId(settings.fontFamily),
items,
}
}
export function normalizeGiftCatalog(value: unknown): GiftCatalogItem[] {
const root = object(value)
if (!Array.isArray(root.gifts)) return []
return root.gifts.flatMap(candidate => {
const gift = object(candidate)
const id = Number(gift.id)
if (!Number.isSafeInteger(id) || id <= 0 || typeof gift.name !== 'string') return []
return [
{
id,
name: gift.name,
coinType: String(gift.coinType ?? 'gold'),
batteryValue: Number(gift.batteryValue ?? Number(gift.unitPrice ?? 0) / 100),
unitPrice: Number(gift.unitPrice ?? 0),
imageUrl:
typeof gift.imageUrl === 'string'
? gift.imageUrl
: typeof gift.animationUrl === 'string'
? gift.animationUrl
: undefined,
animationUrl: typeof gift.animationUrl === 'string' ? gift.animationUrl : undefined,
},
]
})
}
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 ?? translate('common.viewer')),
},
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,
}
}
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),
},
}
} }
export function normalizeSource(value: unknown): CookieCloudSource { export function normalizeSource(value: unknown): CookieCloudSource {
const root = object(value) const root = object(value)
const source = object(root.source ?? root) const source = object(root.source ?? root)
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud) const cookieCloud = object(
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
)
const status = object(source.status) const status = object(source.status)
return { return {
roomId: String(source.roomId ?? root.roomId ?? ''), roomId: String(source.roomId ?? root.roomId ?? ''),
cookieCloud: { cookieCloud: {
host: String(cookieCloud.host ?? ''), host: String(cookieCloud.host ?? ''),
key: '', key: '',
keyConfigured: cookieCloud.keyConfigured === true keyConfigured:
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0), cookieCloud.keyConfigured === true ||
passwordConfigured: cookieCloud.passwordConfigured === true (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|| cookieCloud.configured === true passwordConfigured:
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0), cookieCloud.passwordConfigured === true ||
cookieCloud.configured === true ||
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
}, },
connected: typeof source.connected === 'boolean' connected:
? source.connected typeof source.connected === 'boolean'
: typeof status.connected === 'boolean' ? source.connected
? status.connected : typeof status.connected === 'boolean'
: undefined, ? status.connected
detail: typeof source.detail === 'string' : undefined,
? source.detail detail:
: typeof status.detail === 'string' typeof source.detail === 'string'
? status.detail ? source.detail
: undefined, : typeof status.detail === 'string'
? status.detail
: undefined,
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined, updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
} }
} }
export function normalizeInvitations(value: unknown): Invitation[] { export function normalizeInvitations(value: unknown): Invitation[] {
const root = object(value) const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : [] const list = Array.isArray(value)
return list.map((entry) => { ? value
const item = object(entry) : Array.isArray(root.invitations)
return { ? root.invitations
id: String(item.id ?? ''), : []
code: typeof item.code === 'string' ? item.code : undefined, return list
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined, .map(entry => {
roomId: String(item.roomId ?? ''), const item = object(entry)
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined, return {
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined, id: String(item.id ?? ''),
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined, code: typeof item.code === 'string' ? item.code : undefined,
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined, codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined, roomId: String(item.roomId ?? ''),
} createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
}).filter(item => item.id) createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt:
typeof item.consumedAt === 'string' || item.consumedAt === null
? item.consumedAt
: undefined,
revokedAt:
typeof item.revokedAt === 'string' || item.revokedAt === null
? item.revokedAt
: undefined,
}
})
.filter(item => item.id)
} }
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string { export function errorMessage(error: unknown, fallback = translate('common.failed')): string {
if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey))
return translate(error.messageKey)
return error instanceof Error && error.message ? error.message : fallback return error instanceof Error && error.message ? error.message : fallback
} }
+161 -81
View File
@@ -1,3 +1,11 @@
/**
* Passwordless login and one-time TOTP enrollment screens.
*
* QR material, manual keys and recovery codes exist only in React memory and
* are dropped as soon as their enrollment phase completes. PWA activation is
* blocked while those values or partially completed forms are visible so an
* update cannot erase information that the server will not reveal again.
*/
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { FormEvent, ReactNode } from 'react' import type { FormEvent, ReactNode } from 'react'
import { import {
@@ -9,19 +17,31 @@ import {
normalizeRecoveryCodes, normalizeRecoveryCodes,
} from './api' } from './api'
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa' import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import { LanguageSelect, useI18n } from './i18n'
import type { TotpEnrollment } from './types' import type { TotpEnrollment } from './types'
function AuthShell({ eyebrow, title, children, footer }: { function AuthShell({
eyebrow,
title,
children,
footer,
}: {
eyebrow: string eyebrow: string
title: string title: string
children: ReactNode children: ReactNode
footer?: ReactNode footer?: ReactNode
}) { }) {
const { t } = useI18n()
return ( return (
<main className="auth-page"> <main className="auth-page">
<section className="auth-card jade-panel"> <section className="auth-card jade-panel">
<PwaControls /> <div className="auth-toolbar">
<div className="auth-mark" aria-hidden="true">星</div> <LanguageSelect />
<PwaControls />
</div>
<div className="auth-mark" aria-hidden="true">
{t('common.star')}
</div>
<p className="eyebrow">{eyebrow}</p> <p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1> <h1>{title}</h1>
{children} {children}
@@ -31,34 +51,42 @@ function AuthShell({ eyebrow, title, children, footer }: {
) )
} }
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) { export function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const { t } = useI18n()
const source = useMemo(() => { const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}` if (enrollment.qrSvg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
return undefined return undefined
}, [enrollment.qrDataUrl, enrollment.qrSvg]) }, [enrollment.qrDataUrl, enrollment.qrSvg])
return ( return (
<div className="totp-enrollment"> <div className="totp-enrollment">
<div className="totp-qr"> <div className="totp-qr">
{source {source ? (
? <img src={source} alt="TOTP 验证器绑定二维码" /> <img src={source} alt={t('auth.totp_qr_alt')} />
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>} ) : (
<span>{t('auth.qr_unavailable')}</span>
)}
</div> </div>
<div className="totp-copy"> <div className="totp-copy">
<h2>绑定动态验证器</h2> <h2>{t('auth.bind_authenticator')}</h2>
<ol> <ol>
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li> <li>{t('auth.totp_step_scan')}</li>
<li>若无法扫码,选择“输入设置密钥”。</li> <li>{t('auth.totp_step_manual')}</li>
<li>输入应用中出现的 6 位动态码完成绑定。</li> <li>{t('auth.totp_step_confirm')}</li>
</ol> </ol>
<label> <label>
手工设置密钥 {t('auth.manual_key')}
<div className="secret-row"> <div className="secret-row">
<code>{enrollment.manualKey || '未提供'}</code> <code>{enrollment.manualKey || t('common.not_provided')}</code>
{enrollment.manualKey && ( {enrollment.manualKey && (
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}> <button
复制 type="button"
className="text-button"
onClick={() => void copyToClipboard(enrollment.manualKey)}
>
{t('common.copy')}
</button> </button>
)} )}
</div> </div>
@@ -69,58 +97,71 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
} }
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) { function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
const { t } = useI18n()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const text = codes.join('\n') const text = codes.join('\n')
const download = () => { const download = () => {
const blob = new Blob([ const blob = new Blob(
'洛星瓷直播组件恢复码\n', [`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
'每个恢复码只能使用一次,请离线妥善保存。\n\n', { type: 'text/plain;charset=utf-8' },
text, )
'\n',
], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const anchor = document.createElement('a') const anchor = document.createElement('a')
anchor.href = url anchor.href = url
anchor.download = 'luoxingci-recovery-codes.txt' anchor.download = 'live-component-recovery-codes.txt'
anchor.click() anchor.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
return ( return (
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码"> <AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p> <p className="auth-lead">{t('auth.recovery_description')}</p>
{codes.length > 0 {codes.length > 0 ? (
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div> <div className="recovery-grid">
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>} {codes.map(code => (
<code key={code}>{code}</code>
))}
</div>
) : (
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
)}
<div className="form-actions"> <div className="form-actions">
{codes.length > 0 && ( {codes.length > 0 && (
<> <>
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}> <button
{copied ? '已复制' : '复制全部'} type="button"
className="secondary"
onClick={() => void copyToClipboard(text).then(setCopied)}
>
{copied ? t('common.copied') : t('auth.copy_all')}
</button>
<button type="button" className="secondary" onClick={download}>
{t('auth.download_text')}
</button> </button>
<button type="button" className="secondary" onClick={download}>下载文本</button>
</> </>
)} )}
<button type="button" onClick={onContinue}>我已妥善保存</button> <button type="button" onClick={onContinue}>
{t('auth.recovery_saved')}
</button>
</div> </div>
</AuthShell> </AuthShell>
) )
} }
export function LoginPage({ onAuthenticated, setupRequired }: { export function LoginPage({
onAuthenticated,
setupRequired,
}: {
onAuthenticated: () => Promise<void> onAuthenticated: () => Promise<void>
setupRequired: boolean setupRequired: boolean
}) { }) {
const { t } = useI18n()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [totpCode, setTotpCode] = useState('') const [totpCode, setTotpCode] = useState('')
const [useRecoveryCode, setUseRecoveryCode] = useState(false) const [useRecoveryCode, setUseRecoveryCode] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
usePwaUpdateBlocker( usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
'login-form',
'完成或清空正在填写的登录表单',
busy || Boolean(username || totpCode),
)
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -132,7 +173,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
await onAuthenticated() await onAuthenticated()
location.assign('/control/') location.assign('/control/')
} catch (reason) { } catch (reason) {
setError(errorMessage(reason, '用户名或动态验证码不正确')) setError(errorMessage(reason, t('auth.login_failed')))
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -140,20 +181,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
return ( return (
<AuthShell <AuthShell
eyebrow="洛星瓷直播组件" eyebrow={t('auth.product_eyebrow')}
title="回到你的云台" title={t('auth.login_title')}
footer={( footer={
<p> <p>
{setupRequired {setupRequired ? (
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></> <>
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>} {t('auth.first_deployment')} <a href={authRoute('setup')}>{t('auth.create_admin')}</a>
</>
) : (
<>
{t('auth.have_invitation')}{' '}
<a href={authRoute('register')}>{t('auth.register_account')}</a>
</>
)}
</p> </p>
)} }
> >
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p> <p className="auth-lead">{t('auth.passwordless_description')}</p>
<form className="stack-form" onSubmit={submit}> <form className="stack-form" onSubmit={submit}>
<label> <label>
用户名 {t('auth.username')}
<input <input
autoFocus autoFocus
required required
@@ -163,7 +211,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
/> />
</label> </label>
<label> <label>
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'} {useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
<input <input
required required
className={useRecoveryCode ? 'recovery-input' : 'otp-input'} className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
@@ -171,11 +219,15 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'} autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
pattern={useRecoveryCode ? undefined : '[0-9]{6}'} pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
maxLength={useRecoveryCode ? 64 : 6} maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'} placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
value={totpCode} value={totpCode}
onChange={event => setTotpCode(useRecoveryCode onChange={event =>
? event.target.value.trimStart().slice(0, 64) setTotpCode(
: event.target.value.replace(/\D/g, '').slice(0, 6))} useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/> />
</label> </label>
<button <button
@@ -186,19 +238,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
setTotpCode('') setTotpCode('')
}} }}
> >
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'} {useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
</button> </button>
{error && <div className="notice error" role="alert">{error}</div>} {error && (
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button> <div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
</form> </form>
</AuthShell> </AuthShell>
) )
} }
export function EnrollmentPage({ mode, onAuthenticated }: { export function EnrollmentPage({
mode,
onAuthenticated,
}: {
mode: 'setup' | 'register' mode: 'setup' | 'register'
onAuthenticated: () => Promise<void> onAuthenticated: () => Promise<void>
}) { }) {
const { language, t } = useI18n()
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? '' const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
const [inviteCode, setInviteCode] = useState(inviteFromFragment) const [inviteCode, setInviteCode] = useState(inviteFromFragment)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
@@ -211,10 +271,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
const isSetup = mode === 'setup' const isSetup = mode === 'setup'
usePwaUpdateBlocker( usePwaUpdateBlocker(
'totp-enrollment', 'totp-enrollment',
enrollment || recoveryCodes enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
? '完成 TOTP 绑定并保存一次性恢复码' busy ||
: '完成或清空正在填写的注册表单', Boolean(
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes), inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
),
) )
const start = async (event: FormEvent) => { const start = async (event: FormEvent) => {
@@ -231,7 +292,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
}), }),
) )
const next = normalizeEnrollment(payload) const next = normalizeEnrollment(payload)
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识') if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
setEnrollment(next) setEnrollment(next)
setBootstrapPassword('') setBootstrapPassword('')
if (!isSetup) { if (!isSetup) {
@@ -239,7 +300,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
history.replaceState(null, '', authRoute('register')) history.replaceState(null, '', authRoute('register'))
} }
} catch (reason) { } catch (reason) {
setError(errorMessage(reason, '无法开始安全注册流程')) setError(errorMessage(reason, t('auth.registration_start_failed')))
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -253,7 +314,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
try { try {
const payload = await api<unknown>( const payload = await api<unknown>(
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`, `/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }), json('POST', {
enrollmentToken: enrollment.enrollmentToken,
code: totpCode,
language,
}),
) )
setTotpCode('') setTotpCode('')
// Drop QR/manual-key material from React state as soon as enrollment is // Drop QR/manual-key material from React state as soon as enrollment is
@@ -261,7 +326,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
setEnrollment(undefined) setEnrollment(undefined)
setRecoveryCodes(normalizeRecoveryCodes(payload)) setRecoveryCodes(normalizeRecoveryCodes(payload))
} catch (reason) { } catch (reason) {
setError(errorMessage(reason, '动态验证码无效或注册流程已过期')) setError(errorMessage(reason, t('auth.registration_confirm_failed')))
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -276,11 +341,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
if (enrollment) { if (enrollment) {
return ( return (
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP"> <AuthShell
eyebrow={isSetup ? t('auth.setup_step_two') : t('auth.register_step_two')}
title={t('auth.require_totp')}
>
<TotpQr enrollment={enrollment} /> <TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}> <form className="stack-form compact-form" onSubmit={confirm}>
<label> <label>
验证器中的 6 位动态码 {t('auth.authenticator_code')}
<input <input
required required
autoFocus autoFocus
@@ -294,8 +362,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))} onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/> />
</label> </label>
{error && <div className="notice error" role="alert">{error}</div>} {error && (
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button> <div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy || totpCode.length !== 6}>
{busy ? t('auth.confirming') : t('auth.confirm_create')}
</button>
</form> </form>
</AuthShell> </AuthShell>
) )
@@ -303,19 +377,21 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
return ( return (
<AuthShell <AuthShell
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'} eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
title={isSetup ? '创建系统管理员' : '创建你的账户'} title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>} footer={
<p>
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
</p>
}
> >
<p className="auth-lead"> <p className="auth-lead">
{isSetup {isSetup ? t('auth.setup_description') : t('auth.register_description')}
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
</p> </p>
<form className="stack-form" onSubmit={start}> <form className="stack-form" onSubmit={start}>
{!isSetup && ( {!isSetup && (
<label> <label>
邀请码 {t('auth.invitation')}
<input <input
required required
autoFocus={!inviteFromFragment} autoFocus={!inviteFromFragment}
@@ -326,7 +402,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
</label> </label>
)} )}
<label> <label>
用户名 {t('auth.username')}
<input <input
required required
autoFocus={isSetup || Boolean(inviteFromFragment)} autoFocus={isSetup || Boolean(inviteFromFragment)}
@@ -339,7 +415,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
</label> </label>
{isSetup && ( {isSetup && (
<label> <label>
一次性初始化口令 {t('auth.bootstrap_password')}
<input <input
required required
type="password" type="password"
@@ -347,11 +423,15 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
value={bootstrapPassword} value={bootstrapPassword}
onChange={event => setBootstrapPassword(event.target.value)} onChange={event => setBootstrapPassword(event.target.value)}
/> />
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small> <small>{t('auth.bootstrap_help')}</small>
</label> </label>
)} )}
{error && <div className="notice error" role="alert">{error}</div>} {error && (
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button> <div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
</form> </form>
</AuthShell> </AuthShell>
) )
+1131 -198
View File
File diff suppressed because it is too large Load Diff
+2852 -246
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useState } from 'react'
type QueueItem = { id: string }
type EffectQueueState<T extends QueueItem> = {
active?: T
pending: T[]
}
export function enqueueEffect<T extends QueueItem>(
state: EffectQueueState<T>,
effect: T,
capacity: number,
): EffectQueueState<T> {
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
return state
}
if (!state.active) return { active: effect, pending: state.pending }
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
if (state.pending.length >= boundedCapacity) return state
return { ...state, pending: [...state.pending, effect] }
}
export function completeEffect<T extends QueueItem>(
state: EffectQueueState<T>,
activeId: string,
): EffectQueueState<T> {
if (state.active?.id !== activeId) return state
const [active, ...pending] = state.pending
return { active, pending }
}
export function useEffectQueue<T extends QueueItem>() {
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
const enqueue = useCallback((effect: T, capacity: number) => {
setState(current => enqueueEffect(current, effect, capacity))
}, [])
const complete = useCallback((activeId: string) => {
setState(current => completeEffect(current, activeId))
}, [])
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
}
+15
View File
@@ -0,0 +1,15 @@
@font-face {
font-family: 'Liyu Shoushu';
font-style: normal;
font-display: block;
font-weight: 400;
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
}
@font-face {
font-family: 'HongLei XingShu';
font-style: normal;
font-display: block;
font-weight: 400;
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
}
+570
View File
@@ -0,0 +1,570 @@
.gift-effect-overlay {
position: relative;
isolation: isolate;
/* Percentages fill both a real OBS viewport and the nested control preview. */
width: 100%;
height: 100%;
overflow: hidden;
color: var(--gift-cyan, #a9fff2);
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
.meteor-sky,
.meteor-burst {
position: absolute;
inset: 0;
overflow: hidden;
}
.meteor-burst {
animation: gift-burst-lifetime var(--burst-duration) linear both;
}
.gift-meteor {
position: absolute;
top: var(--meteor-y);
left: 0;
width: var(--meteor-size);
height: var(--meteor-size);
opacity: 0;
will-change: transform, opacity;
animation: var(--gift-motion-meteor, gift-meteor-flight) var(--meteor-duration) linear
var(--meteor-delay) both;
}
.meteor-core {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
overflow: hidden;
place-items: center;
border: max(2px, calc(var(--meteor-size) * 0.035)) solid
color-mix(in srgb, var(--gift-jade) 72%, white);
border-radius: 50%;
background:
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.42), transparent 25%),
radial-gradient(circle, rgba(31, 125, 122, 0.94), rgba(2, 24, 39, 0.96));
box-shadow:
0 0 calc(var(--meteor-size) * 0.18) var(--gift-cyan),
0 0 calc(var(--meteor-size) * 0.55) color-mix(in srgb, var(--gift-jade) 56%, transparent),
inset 0 0 calc(var(--meteor-size) * 0.18) rgba(223, 255, 247, 0.45);
}
.meteor-core::after {
content: '';
position: absolute;
inset: 5%;
border: 1px solid rgba(255, 236, 173, 0.6);
border-radius: inherit;
box-shadow: inset 0 0 12px rgba(255, 238, 185, 0.25);
}
.meteor-core img {
width: 82%;
height: 82%;
object-fit: contain;
opacity: 0.96;
filter: blur(0.55px) saturate(0.92) brightness(1.08)
drop-shadow(0 0 7px rgba(255, 238, 178, 0.72)) drop-shadow(0 0 13px rgba(225, 255, 248, 0.48));
}
.meteor-fallback {
color: var(--gift-gold);
font-size: calc(var(--meteor-size) * 0.55);
text-shadow: 0 0 12px var(--gift-cyan);
}
.meteor-tail {
position: absolute;
top: 38%;
right: 48%;
z-index: 1;
width: calc(var(--meteor-size) * 5.4);
height: 24%;
border-radius: 100% 0 0 100%;
opacity: var(--trail-opacity);
background:
linear-gradient(
90deg,
transparent 0%,
rgba(255, 190, 54, 0.025) 18%,
rgba(255, 199, 67, 0.14) 42%,
rgba(255, 211, 91, 0.5) 72%,
rgba(255, 244, 185, 0.96) 100%
),
linear-gradient(90deg, transparent 8%, rgba(255, 174, 26, 0.08) 48%, #ffd96d 100%);
filter: blur(calc(var(--meteor-size) * 0.022))
drop-shadow(0 0 calc(var(--meteor-size) * 0.08) rgba(255, 196, 54, 0.72));
clip-path: polygon(0 50%, 100% 8%, 100% 92%);
}
.meteor-tail::after {
content: '';
position: absolute;
inset: 39% 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 205, 75, 0.12) 48%,
rgba(255, 249, 207, 0.92)
);
box-shadow: 0 0 calc(var(--meteor-size) * 0.06) rgba(255, 218, 100, 0.72);
}
.tier-high .meteor-tail {
background: linear-gradient(
90deg,
transparent,
rgba(255, 184, 33, 0.12) 35%,
rgba(255, 211, 77, 0.62) 74%,
#fff2ac
);
}
.tier-featured .meteor-tail {
background: linear-gradient(
90deg,
transparent,
rgba(255, 171, 19, 0.16) 28%,
rgba(255, 207, 62, 0.72) 70%,
#fff9cf
);
filter: blur(calc(var(--meteor-size) * 0.02))
drop-shadow(0 0 calc(var(--meteor-size) * 0.12) rgba(255, 207, 55, 0.88));
}
.meteor-spark {
position: absolute;
z-index: 2;
width: 12%;
height: 12%;
background: var(--gift-gold);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 5px var(--gift-cyan));
animation: gift-meteor-sparkle 720ms ease-in-out infinite alternate;
}
.meteor-spark-a {
top: -8%;
right: -15%;
}
.meteor-spark-b {
right: 14%;
bottom: -14%;
width: 8%;
height: 8%;
animation-delay: -360ms;
}
.gift-low-motion .meteor-spark {
animation: none;
}
@keyframes gift-meteor-flight {
0% {
opacity: 0;
transform: translate3d(calc(var(--meteor-size) * -5.5), 0, 0) scale(0.76) rotate(-5deg);
}
8% {
opacity: 1;
}
88% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(calc(100vw + var(--meteor-size) * 2), var(--meteor-drift), 0) scale(1.06)
rotate(8deg);
}
}
@keyframes gift-burst-lifetime {
from,
to {
visibility: visible;
}
}
@keyframes gift-meteor-sparkle {
from {
opacity: 0.25;
transform: rotate(0) scale(0.55);
}
to {
opacity: 1;
transform: rotate(45deg) scale(1.2);
}
}
@media (prefers-reduced-motion: reduce) {
.gift-meteor {
animation-duration: max(var(--meteor-duration), 6s);
}
.meteor-spark {
animation: none;
}
}
/* -------------------------------------------------------------------------- */
/* Moonlit Water */
/* -------------------------------------------------------------------------- */
/* This theme intentionally has no scene-sized colour wash. Every visible
pixel below belongs to an effect, allowing OBS to retain a true alpha
background over games, cameras and artwork. */
.gift-theme-moonlit-water {
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.moonlit-whisper-copy,
.moonlit-gift-whisper > small,
.moonlit-ceremony-copy {
filter: brightness(var(--component-font-brightness, 1.3));
}
.gift-theme-moonlit-water .moonlit-gift-whisper {
position: absolute;
z-index: 5;
display: grid;
min-width: min(82vw, 310px);
grid-template-columns: 2.5em minmax(0, 1fr) auto;
align-items: center;
gap: 0.65em;
color: #eadfc3;
font-size: clamp(14px, 1.15vw, 23px);
font-weight: 500;
letter-spacing: 0.045em;
opacity: 0;
pointer-events: none;
text-shadow: 0 1px 7px rgba(11, 40, 42, 0.82);
transform: translate3d(18px, 0, 0);
animation: moonlit-whisper-appear 7.2s ease-in-out var(--moonlit-whisper-delay) both;
}
.moonlit-whisper-ripple {
position: absolute;
right: 0;
bottom: -0.35em;
left: 2.7em;
height: 1px;
opacity: 0.65;
background: linear-gradient(90deg, transparent, #e7c982 18% 72%, transparent);
box-shadow: 0 0 7px rgba(231, 201, 130, 0.38);
transform-origin: left;
animation: moonlit-ripple-line 2.8s ease-out var(--moonlit-whisper-delay) both;
}
.moonlit-whisper-image,
.moonlit-ceremony-image {
display: grid;
place-items: center;
}
.moonlit-whisper-image {
width: 2.25em;
height: 2.25em;
}
.moonlit-whisper-image img,
.moonlit-ceremony-image img {
width: 100%;
height: 100%;
object-fit: contain;
filter: saturate(0.78) sepia(0.12) brightness(1.12) drop-shadow(0 0 8px rgba(231, 201, 130, 0.48));
}
.moonlit-whisper-image .meteor-fallback,
.moonlit-ceremony-image .meteor-fallback {
color: #e7c982;
font-size: 1.55em;
text-shadow: 0 0 10px rgba(231, 201, 130, 0.7);
}
.moonlit-whisper-copy {
display: grid;
min-width: 0;
gap: 0.08em;
}
.moonlit-whisper-copy b {
overflow: hidden;
color: #b9caaa;
font-size: 0.76em;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.moonlit-whisper-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moonlit-gift-whisper small {
align-self: end;
color: #e7c982;
font-size: 0.75em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.gift-theme-moonlit-water .moonlit-gift-ceremony {
position: absolute;
inset: 0;
z-index: 12;
display: grid;
place-items: center;
overflow: hidden;
color: #eadfc3;
isolation: isolate;
opacity: 0;
pointer-events: none;
animation: moonlit-offering-sweep 8.4s ease-in-out both;
}
.moonlit-ceremony-moon {
position: absolute;
top: 50%;
left: 50%;
z-index: -3;
width: min(44vmin, 560px);
aspect-ratio: 1;
border-radius: 50%;
opacity: 0.76;
background:
radial-gradient(circle at 35% 30%, rgba(255, 250, 226, 0.8), transparent 6%),
radial-gradient(circle at 68% 43%, rgba(180, 194, 168, 0.24), transparent 12%),
radial-gradient(circle at 42% 72%, rgba(172, 186, 159, 0.2), transparent 9%),
radial-gradient(
circle,
#f4e8c8 0 43%,
#d9d5b7 64%,
rgba(212, 218, 188, 0.12) 71%,
transparent 72%
);
box-shadow: 0 0 72px rgba(239, 224, 180, 0.23);
transform: translate(-50%, -50%) scale(0.25);
animation: moonlit-moon-rise 8.4s cubic-bezier(0.2, 0.78, 0.25, 1) both;
}
.moonlit-ceremony-water {
position: absolute;
right: 0;
bottom: 7%;
left: 0;
z-index: -2;
height: 30%;
opacity: 0.9;
}
.moonlit-ceremony-water i {
position: absolute;
left: 50%;
width: min(60vw, 1040px);
height: clamp(14px, 2.1vmin, 28px);
border-top: 1px solid rgba(234, 223, 195, 0.6);
border-radius: 50%;
box-shadow: 0 -2px 16px rgba(154, 187, 167, 0.26);
transform: translateX(-50%) scaleX(0.12);
animation: moonlit-water-spread 5.5s ease-out both;
}
.moonlit-ceremony-water i:nth-child(1) {
bottom: 5%;
}
.moonlit-ceremony-water i:nth-child(2) {
bottom: 25%;
width: min(47vw, 780px);
animation-delay: 0.42s;
}
.moonlit-ceremony-water i:nth-child(3) {
bottom: 45%;
width: min(33vw, 560px);
animation-delay: 0.72s;
}
.moonlit-ceremony-stars,
.moonlit-ceremony-stars i {
position: absolute;
inset: 0;
}
.moonlit-ceremony-stars i {
inset: auto;
width: var(--moonlit-size);
height: var(--moonlit-size);
opacity: 0;
background: #eadfc3;
clip-path: polygon(50% 0, 59% 41%, 100% 50%, 59% 59%, 50% 100%, 41% 59%, 0 50%, 41% 41%);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.78));
animation: moonlit-ceremony-sparkle 2.4s ease-in-out var(--moonlit-delay) infinite;
}
.moonlit-ceremony-copy {
position: relative;
z-index: 2;
display: grid;
max-width: min(80vw, 940px);
justify-items: center;
gap: clamp(5px, 0.9vmin, 12px);
padding-bottom: min(17vmin, 180px);
text-align: center;
text-shadow: 0 2px 11px rgba(10, 38, 40, 0.88);
}
.moonlit-ceremony-copy span {
color: #b9caaa;
font-size: clamp(12px, 1.35vw, 25px);
letter-spacing: 0.5em;
}
.moonlit-ceremony-copy b {
color: #e7c982;
font-size: clamp(18px, 2.15vw, 41px);
font-weight: 500;
letter-spacing: 0.16em;
}
.moonlit-ceremony-copy strong {
color: #eadfc3;
font-size: clamp(27px, 4.5vw, 84px);
font-weight: 500;
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.moonlit-ceremony-copy small {
color: #e7c982;
font-size: clamp(13px, 1.55vw, 29px);
font-variant-numeric: tabular-nums;
}
.moonlit-ceremony-image {
position: absolute;
bottom: max(7%, 4vmin);
left: 50%;
z-index: 3;
width: min(14vmin, 176px);
aspect-ratio: 1;
transform: translateX(-50%);
}
.moonlit-ceremony-image::before {
content: '';
position: absolute;
inset: -22%;
z-index: -1;
border: 1px solid rgba(231, 201, 130, 0.7);
border-radius: 50%;
box-shadow:
0 0 26px rgba(231, 201, 130, 0.32),
inset 0 0 18px rgba(196, 219, 196, 0.16);
animation: moonlit-image-halo 2.8s ease-in-out infinite;
}
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-moon,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-image::before {
animation: none;
}
@keyframes moonlit-whisper-appear {
0%,
100% {
opacity: 0;
transform: translate3d(18px, 0, 0);
}
12%,
78% {
opacity: 1;
transform: none;
}
}
@keyframes moonlit-ripple-line {
from {
opacity: 0;
transform: scaleX(0.15);
}
28%,
78% {
opacity: 0.7;
transform: scaleX(1);
}
to {
opacity: 0;
transform: scaleX(1.18);
}
}
@keyframes moonlit-offering-sweep {
0%,
100% {
opacity: 0;
}
9%,
85% {
opacity: 1;
}
}
@keyframes moonlit-moon-rise {
0% {
opacity: 0;
transform: translate(-50%, -40%) scale(0.2);
}
22%,
78% {
opacity: 0.76;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -55%) scale(1.08);
}
}
@keyframes moonlit-water-spread {
0% {
opacity: 0;
transform: translateX(-50%) scaleX(0.12);
}
20%,
76% {
opacity: 0.8;
transform: translateX(-50%) scaleX(1);
}
100% {
opacity: 0;
transform: translateX(-50%) scaleX(1.2);
}
}
@keyframes moonlit-ceremony-sparkle {
0%,
100% {
opacity: 0.06;
transform: scale(0.45) rotate(0);
}
46% {
opacity: 0.95;
transform: scale(1.12) rotate(42deg);
}
}
@keyframes moonlit-image-halo {
50% {
opacity: 0.46;
transform: scale(1.15);
}
}
+441
View File
@@ -0,0 +1,441 @@
/** Full-viewport gift renderer. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftEffectSettings } from './api'
import { useEffectQueue } from './effectQueue'
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftEffectSettings } from './types'
import type { GiftEffectSettings, MeteorTierSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type Gift = {
name?: string
totalPrice?: number
priceCny?: number
imageUrl?: string
animationUrl?: string
}
type EffectPayload = {
viewer?: Viewer
gift?: Gift
quantity?: number
settings?: Partial<GiftEffectSettings>
}
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
type VisualEffect = {
id: string
payload: EffectPayload
/** Preview cards force a tier so custom thresholds do not change the selected demo. */
previewTier?: GiftTier
}
type GiftTier = 'normal' | 'high' | 'featured'
export type GiftEffectPreviewMode = GiftTier
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
const MOONLIT_CEREMONY_THRESHOLD = 50_000
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
if (effect.previewTier) return effect.previewTier
const value = effect.payload.gift?.totalPrice ?? 0
if (value >= settings.featuredValueThreshold) return 'featured'
if (value >= settings.highValueThreshold) return 'high'
return 'normal'
}
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
const viewer = { uid: '10001', name: translate('gift.preview.viewer') }
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
return {
id: `preview-${mode}-${nonce}`,
type: 'live.gift',
payload: {
viewer,
quantity: 1,
gift: {
name: translate(`gift.preview.${mode}`),
totalPrice,
priceCny: totalPrice / 1000,
imageUrl: '/pwa/icon-192.png',
},
},
}
}
function toVisualEffect(
envelope: EffectEnvelope,
previewTier?: GiftTier,
): VisualEffect | undefined {
if (envelope.type !== 'live.gift') return undefined
return {
id: envelope.id,
payload: envelope.payload ?? {},
previewTier,
}
}
function useGiftEffects(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GiftEffectSettings | undefined,
previewMode: GiftEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGiftEffectSettings)
const queue = useEffectQueue<VisualEffect>()
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
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 envelope = message.envelope as EffectEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGiftEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const effect = toVisualEffect(envelope)
if (!effect) continue
queue.enqueue(effect, settingsRef.current.queueCapacity)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode)
if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity)
}, [language, preview, previewMode, previewNonce])
return {
settings,
effect: queue.active,
pendingCount: queue.pendingCount,
completeEffect: queue.complete,
}
}
function GiftImage({ gift }: { gift: Gift }) {
const [source, setSource] = useState(gift.animationUrl || gift.imageUrl || '')
useEffect(
() => setSource(gift.animationUrl || gift.imageUrl || ''),
[gift.animationUrl, gift.imageUrl],
)
if (!source) return <span className="meteor-fallback">✦</span>
return (
<img
src={source}
alt={gift.name || translate('common.gift')}
decoding="async"
referrerPolicy="no-referrer"
onError={() => {
if (gift.imageUrl && source !== gift.imageUrl) setSource(gift.imageUrl)
else setSource('')
}}
/>
)
}
function MeteorBurst({
effect,
tier,
tierSettings,
scale,
viewportWidth,
trailIntensity,
lowPerformance,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
tierSettings: MeteorTierSettings
scale: number
viewportWidth: number
trailIntensity: number
lowPerformance: boolean
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
const size = Math.max(20, tierSettings.size * scale)
const speed = Math.max(80, tierSettings.speed * scale)
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const meteors = Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
return {
index,
startY: 7 + (seed % 78),
drift: ((seed >>> 8) % 37) - 18,
delay: index * 95 + ((seed >>> 16) % 260),
duration,
lifetime:
(reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260),
}
})
const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600)
return (
<div
className={`meteor-burst tier-${tier}`}
aria-label={gift.name || translate('common.gift')}
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
onComplete()
}
}}
>
{meteors.map(({ index, startY, drift, delay, duration }) => {
return (
<div
className="gift-meteor"
key={`${effect.id}:${index}`}
style={
{
['--meteor-size' as string]: `${size}px`,
['--meteor-y' as string]: `${startY}%`,
['--meteor-drift' as string]: `${drift}vh`,
['--meteor-delay' as string]: `${delay}ms`,
['--meteor-duration' as string]: `${duration}ms`,
['--trail-opacity' as string]: `${trailIntensity / 100}`,
} as CSSProperties
}
>
<i className="meteor-tail" />
<i className="meteor-spark meteor-spark-a" />
<i className="meteor-spark meteor-spark-b" />
<span className="meteor-core">
<GiftImage gift={gift} />
</span>
</div>
)
})}
</div>
)
}
/**
* The small-gift treatment for the sparse moonlit theme. It is intentionally
* compact and stays out of the centre of a broadcaster's scene: a gift image,
* name and a single water-line appear briefly without adding a panel behind it.
*/
function MoonlitGiftWhisper({
effect,
onComplete,
}: {
effect: VisualEffect
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
const seed = hash(effect.id)
return (
<article
className="moonlit-gift-whisper"
aria-label={gift.name || translate('common.gift')}
style={
{
top: `${8 + (seed % 72)}%`,
right: `${3 + ((seed >>> 8) % 10)}%`,
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
} as CSSProperties
}
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-whisper-appear'
)
onComplete()
}}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
<GiftImage gift={gift} />
</span>
<div className="moonlit-whisper-copy">
<b>{viewer}</b>
<span>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</span>
</div>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</article>
)
}
/**
* A full-viewport but transparent offering ceremony for CNY 50+ gifts. The
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({
effect,
tier,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
return (
<section
className={`moonlit-gift-ceremony tier-${tier}`}
aria-live="polite"
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-offering-sweep'
)
onComplete()
}}
>
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="moonlit-ceremony-stars" aria-hidden="true">
{Array.from({ length: 14 }, (_, index) => {
const seed = hash(`${effect.id}:ceremony:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--moonlit-delay' as string]: `${-((seed >>> 16) % 2_400)}ms`,
['--moonlit-size' as string]: `${4 + ((seed >>> 24) % 10)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="moonlit-ceremony-copy">
<span>{translate('gift.moonlit.ceremony_label')}</span>
<b>{viewer}</b>
<strong>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</strong>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</div>
<div className="moonlit-ceremony-image">
<GiftImage gift={gift} />
</div>
</section>
)
}
export function GiftEffectOverlay({
preview = false,
previewSettings,
previewMode = 'high',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GiftEffectSettings
previewMode?: GiftEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGiftEffects(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGiftEffectTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
const [bounds, setBounds] = useState({ width: 1920, height: 1080 })
useEffect(() => {
if (!root.current) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(root.current)
return () => observer.disconnect()
}, [])
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const moonlit = theme.id === 'moonlit-water'
const effect = remote.effect
let activeVisual = null
if (effect) {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
const onComplete = () => remote.completeEffect(effect.id)
activeVisual = moonlit ? (
totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} onComplete={onComplete} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
)
) : (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
onComplete={onComplete}
key={effect.id}
/>
)
}
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{activeVisual}
</div>
</main>
)
}
+607
View File
@@ -0,0 +1,607 @@
html,
body,
#root {
background: transparent;
}
.gift-menu-overlay {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
align-items: flex-start;
overflow: hidden;
padding: clamp(4px, 1.2vmin, 14px);
color: #eafffa;
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
.gift-menu-viewport {
position: relative;
width: 100%;
height: min(100%, var(--menu-panel-height));
min-height: min(100%, var(--menu-row-height));
overflow: hidden;
border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 38%, transparent);
border-radius: clamp(10px, 1.5vmin, 22px);
outline: 1px solid rgba(255, 227, 142, 0.07);
outline-offset: -4px;
background: linear-gradient(145deg, rgba(3, 31, 44, 0.64), rgba(4, 54, 57, 0.4));
box-shadow:
inset 0 0 22px rgba(103, 240, 216, 0.07),
0 5px 18px rgba(0, 8, 18, 0.2);
scrollbar-width: none;
}
.gift-menu-preview {
pointer-events: auto;
}
.gift-menu-viewport::-webkit-scrollbar {
display: none;
}
.gift-menu-track {
position: relative;
z-index: 1;
width: 100%;
}
.gift-menu-page-stack {
height: 100%;
}
.gift-menu-page {
position: absolute;
inset: 0 0 auto;
width: 100%;
}
.gift-menu-row {
position: relative;
isolation: isolate;
display: grid;
width: 100%;
height: var(--menu-row-height);
min-width: 0;
grid-template-columns: calc(var(--menu-row-height) * 0.72) minmax(0, 1fr) auto;
align-items: center;
gap: calc(var(--menu-row-height) * 0.13);
overflow: hidden;
padding: calc(var(--menu-row-height) * 0.1) calc(var(--menu-row-height) * 0.22);
border: var(--component-decoration-line, 1px) solid
color-mix(in srgb, var(--menu-jade) 36%, transparent);
border-radius: calc(var(--menu-row-height) * 0.17);
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
var(--menu-vine) right center / auto 170% no-repeat;
box-shadow:
inset 0 0 calc(var(--menu-row-height) * 0.22) rgba(105, 239, 216, 0.08),
0 calc(var(--menu-row-height) * 0.05) calc(var(--menu-row-height) * 0.14) rgba(0, 8, 18, 0.34);
}
.gift-menu-row + .gift-menu-row {
margin-top: 0;
}
.gift-menu-row:nth-child(3n + 2) {
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(13, 58, 69, 0.74), rgba(3, 27, 41, 0.9)),
var(--menu-divider) 76% 50% / auto 150% no-repeat;
}
.gift-menu-row:nth-child(3n) {
background:
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(11, 68, 64, 0.72), rgba(3, 27, 41, 0.9)),
var(--menu-cluster) right 10% center / auto 160% no-repeat;
}
.gift-menu-row::after {
content: '';
position: absolute;
inset: 4px;
z-index: -1;
border: 1px solid rgba(255, 226, 138, 0.1);
border-radius: inherit;
pointer-events: none;
}
.gift-menu-icon {
position: relative;
display: grid;
width: calc(var(--menu-row-height) * 0.58);
height: calc(var(--menu-row-height) * 0.58);
place-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--menu-gold) 44%, var(--menu-jade));
border-radius: 50%;
color: var(--menu-gold);
background: radial-gradient(
circle at 35% 28%,
rgba(255, 255, 255, 0.28),
rgba(11, 81, 78, 0.78) 45%,
rgba(3, 23, 38, 0.94)
);
box-shadow:
0 0 calc(var(--menu-row-height) * 0.12) rgba(103, 244, 218, 0.34),
inset 0 0 calc(var(--menu-row-height) * 0.08) rgba(255, 225, 140, 0.22);
font-size: calc(var(--menu-row-height) * 0.22);
font-style: normal;
flex: none;
}
.gift-menu-gift-icon i {
position: absolute;
}
.gift-menu-gift-icon img {
position: relative;
z-index: 1;
width: 82%;
height: 82%;
object-fit: contain;
filter: drop-shadow(0 0 6px rgba(202, 255, 244, 0.58));
}
.gift-menu-guard-icon {
border-radius: 34% 66% 36% 64%;
font-size: calc(var(--menu-row-height) * 0.23);
text-shadow: 0 0 7px currentColor;
}
.gift-menu-guard-icon img {
width: 90%;
height: 90%;
object-fit: contain;
filter: drop-shadow(0 0 7px currentColor);
}
.gift-menu-guard-icon.level-admiral {
color: var(--menu-rose);
}
.gift-menu-guard-icon.level-governor {
color: #fff0a8;
box-shadow: 0 0 calc(var(--menu-row-height) * 0.18) rgba(255, 215, 105, 0.5);
}
.gift-menu-battery-icon i {
position: relative;
width: 34%;
height: 58%;
border: 2px solid var(--menu-gold);
border-radius: 18%;
background: linear-gradient(to top, #ffd75c 0 68%, transparent 68%);
box-shadow: 0 0 8px rgba(255, 217, 93, 0.55);
}
.gift-menu-battery-icon i::before {
content: '';
position: absolute;
top: -13%;
left: 30%;
width: 40%;
height: 10%;
border-radius: 2px 2px 0 0;
background: var(--menu-gold);
}
.gift-menu-copy {
display: grid;
min-width: 0;
gap: calc(var(--menu-row-height) * 0.035);
line-height: 1.2;
}
.gift-menu-copy strong {
overflow: hidden;
color: var(--menu-gold);
font-size: var(--menu-title-size);
font-weight: 650;
letter-spacing: 0.05em;
text-overflow: ellipsis;
text-shadow: 0 0 8px rgba(255, 221, 123, 0.28);
white-space: nowrap;
}
.gift-menu-copy span {
display: -webkit-box;
overflow: hidden;
color: #edfffb;
font-size: var(--menu-copy-size);
line-height: 1.25;
text-shadow: 0 1px 4px rgba(0, 7, 15, 0.9);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.gift-menu-row em {
position: absolute;
inset: calc(var(--menu-row-height) * 0.06);
z-index: 7;
display: grid;
min-width: 0;
place-items: center;
overflow: visible;
padding: 0.28em 1em;
border: 1px solid rgba(255, 229, 146, 0.42);
border-radius: calc(var(--menu-row-height) * 0.13);
color: #fff5bf;
background:
linear-gradient(90deg, rgba(3, 28, 42, 0.96), rgba(12, 77, 72, 0.94), rgba(3, 28, 42, 0.96)),
var(--menu-divider) center / auto 180% no-repeat;
box-shadow:
inset 0 0 calc(var(--menu-row-height) * 0.22) rgba(109, 246, 219, 0.18),
0 0 calc(var(--menu-row-height) * 0.16) rgba(255, 221, 125, 0.16);
font-size: max(var(--menu-badge-size), calc(var(--menu-row-height) * 0.18));
font-weight: 700;
font-style: normal;
line-height: 1.15;
overflow-wrap: anywhere;
text-align: center;
text-shadow:
0 1px 5px rgba(0, 5, 12, 0.95),
0 0 8px rgba(255, 224, 139, 0.25);
white-space: normal;
}
.gift-menu-row.triggered {
z-index: 2;
border-color: rgba(255, 231, 143, 0.92);
animation: gift-menu-row-awaken var(--menu-highlight-duration) ease-out both;
}
.gift-menu-row.triggered::before {
content: '';
position: absolute;
inset: -40% -25%;
z-index: -1;
background: linear-gradient(
105deg,
transparent 27%,
rgba(255, 218, 107, 0.08) 39%,
rgba(255, 250, 211, 0.78) 50%,
rgba(105, 245, 219, 0.32) 58%,
transparent 72%
);
transform: translateX(-65%);
animation: gift-menu-highlight-sweep var(--menu-highlight-duration) ease-out both;
}
.gift-menu-row-particles {
position: absolute;
inset: 0;
z-index: 4;
overflow: hidden;
pointer-events: none;
}
.gift-menu-row-particles i {
position: absolute;
top: 55%;
width: calc(var(--menu-row-height) * 0.08);
height: calc(var(--menu-row-height) * 0.08);
opacity: 0;
background: linear-gradient(135deg, var(--menu-gold), var(--menu-cyan));
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
}
.gift-menu-row-particles i:nth-child(1) {
left: 9%;
animation-delay: 0ms;
}
.gift-menu-row-particles i:nth-child(2) {
left: 20%;
animation-delay: 55ms;
}
.gift-menu-row-particles i:nth-child(3) {
left: 31%;
animation-delay: 110ms;
}
.gift-menu-row-particles i:nth-child(4) {
left: 42%;
animation-delay: 165ms;
}
.gift-menu-row-particles i:nth-child(5) {
left: 53%;
animation-delay: 220ms;
}
.gift-menu-row-particles i:nth-child(6) {
left: 64%;
animation-delay: 275ms;
}
.gift-menu-row-particles i:nth-child(7) {
left: 75%;
animation-delay: 330ms;
}
.gift-menu-row-particles i:nth-child(8) {
left: 86%;
animation-delay: 385ms;
}
.gift-menu-row.triggered .gift-menu-row-particles i {
animation-name: gift-menu-particle-bloom;
animation-duration: var(--menu-particle-duration);
animation-timing-function: ease-out;
animation-fill-mode: both;
}
.gift-menu-empty {
display: grid;
min-height: var(--menu-row-height);
place-items: center;
border: 1px dashed rgba(111, 240, 216, 0.34);
border-radius: 16px;
color: rgba(220, 255, 248, 0.78);
background: rgba(3, 31, 44, 0.58);
font-size: var(--menu-empty-size);
}
.gift-menu-low-motion .gift-menu-row-particles,
.gift-menu-low-motion .gift-menu-row.triggered::before {
display: none;
}
@keyframes gift-menu-row-awaken {
0% {
filter: brightness(1);
transform: scale(1);
}
8% {
filter: brightness(1.65) saturate(1.2);
transform: scale(0.995);
}
28% {
filter: brightness(1.24);
transform: scale(1);
}
100% {
filter: brightness(1);
}
}
@keyframes gift-menu-highlight-sweep {
0% {
opacity: 0;
transform: translateX(-65%);
}
12% {
opacity: var(--menu-motion);
}
55% {
opacity: 0.72;
}
100% {
opacity: 0;
transform: translateX(65%);
}
}
@keyframes gift-menu-particle-bloom {
0% {
opacity: 0;
transform: translateY(10px) rotate(0) scale(0.4);
}
18% {
opacity: var(--menu-motion);
}
100% {
opacity: 0;
transform: translateY(calc(var(--menu-row-height) * -0.62)) rotate(100deg) scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.gift-menu-row-particles,
.gift-menu-row.triggered::before {
display: none;
}
}
/* Moonlit Water is a transparent programme list rather than a framed menu.
The thin rules are intentional typographic separators, not card borders. */
.gift-menu-theme-moonlit-water.gift-menu-overlay {
padding: clamp(8px, 1.3vmin, 16px);
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.gift-menu-copy,
.gift-menu-row > em,
.gift-menu-empty {
filter: brightness(var(--component-font-brightness, 1.3));
}
.gift-menu-theme-moonlit-water .gift-menu-viewport {
height: min(100%, var(--menu-panel-height));
border: 0;
border-radius: 0;
outline: 0;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-page-leaving {
z-index: 1;
animation: moonlit-menu-page-leave var(--menu-page-transition-duration) ease-in-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-page-entering {
z-index: 2;
animation: moonlit-menu-page-enter var(--menu-page-transition-duration) ease-in-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-row,
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n + 2),
.gift-menu-theme-moonlit-water .gift-menu-row:nth-child(3n) {
gap: calc(var(--menu-row-height) * 0.12);
padding-inline: calc(var(--menu-row-height) * 0.12);
border: 0;
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.17);
border-radius: 0;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-row::after {
display: none;
}
.gift-menu-theme-moonlit-water .gift-menu-icon {
width: calc(var(--menu-row-height) * 0.54);
height: calc(var(--menu-row-height) * 0.54);
border: 0;
border-radius: 0;
color: #e7c982;
background: transparent;
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-gift-icon img {
filter: saturate(0.75) sepia(0.1) brightness(1.08) drop-shadow(0 0 5px rgba(231, 201, 130, 0.4));
}
.gift-menu-theme-moonlit-water .gift-menu-guard-icon img {
filter: saturate(0.72) sepia(0.08) drop-shadow(0 0 5px rgba(231, 201, 130, 0.38));
}
.gift-menu-theme-moonlit-water .gift-menu-battery-icon i {
border-color: #e7c982;
border-radius: 10%;
background: linear-gradient(to top, rgba(231, 201, 130, 0.88) 0 68%, transparent 68%);
box-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-battery-icon i::before {
background: #e7c982;
}
.gift-menu-theme-moonlit-water .gift-menu-copy {
gap: calc(var(--menu-row-height) * 0.025);
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.gift-menu-theme-moonlit-water .gift-menu-copy strong {
color: #e7c982;
font-weight: 600;
letter-spacing: 0.09em;
text-shadow: none;
}
.gift-menu-theme-moonlit-water .gift-menu-copy span {
color: #eadfc3;
text-shadow: 0 1px 5px rgba(10, 34, 38, 0.86);
}
.gift-menu-theme-moonlit-water .gift-menu-row em {
inset: 0;
padding: 0.28em 1em;
border: 0;
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.82);
border-radius: 0;
color: #eadfc3;
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
box-shadow: none;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.9);
}
.gift-menu-theme-moonlit-water .gift-menu-row.triggered {
border-bottom-color: rgba(231, 201, 130, 0.9);
animation: moonlit-menu-awaken var(--menu-highlight-duration) ease-out both;
}
.gift-menu-theme-moonlit-water .gift-menu-row.triggered::before {
inset: 0;
background: linear-gradient(
90deg,
transparent 14%,
rgba(231, 201, 130, 0.15) 42%,
rgba(244, 235, 205, 0.6) 50%,
rgba(174, 199, 175, 0.16) 59%,
transparent 85%
);
}
.gift-menu-theme-moonlit-water .gift-menu-row-particles i {
background: #e7c982;
}
.gift-menu-theme-moonlit-water .gift-menu-empty {
border: 0;
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.25);
border-radius: 0;
color: #afc2ad;
background: transparent;
}
@keyframes moonlit-menu-awaken {
0% {
filter: brightness(1);
}
14% {
filter: brightness(1.32);
}
100% {
filter: brightness(1);
}
}
/* The old page dissolves from its left edge while the next page is revealed
along the same path. This reads as a soft horizontal water-light wipe, not
as a moving list, and remains independent of the OBS viewport dimensions. */
@keyframes moonlit-menu-page-leave {
0% {
opacity: 1;
clip-path: inset(0);
filter: blur(0);
transform: translateX(0);
}
55% {
opacity: 0.48;
}
100% {
opacity: 0;
clip-path: inset(0 0 0 100%);
filter: blur(3px);
transform: translateX(8px);
}
}
@keyframes moonlit-menu-page-enter {
0% {
opacity: 0;
clip-path: inset(0 100% 0 0);
filter: blur(3px);
transform: translateX(-8px);
}
48% {
opacity: 0.56;
}
100% {
opacity: 1;
clip-path: inset(0);
filter: blur(0);
transform: translateX(0);
}
}
.gift-menu-low-motion .gift-menu-page-leaving {
display: none;
}
.gift-menu-low-motion .gift-menu-page-entering {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.gift-menu-page-leaving {
display: none;
}
.gift-menu-page-entering {
animation: none;
}
}
+428
View File
@@ -0,0 +1,428 @@
/** Transparent gift-to-content menu with theme-selectable motion for OBS. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftMenuSettings } from './api'
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemeVariables } from './giftMenuThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftMenuSettings } from './types'
import type { GiftMenuItem, GiftMenuSettings } from './types'
import { ThemeEdges } from './ThemeEdges'
import { typographyVariables } from './typography'
type MenuEnvelope = {
id: string
type: string
payload?: {
settings?: Partial<GiftMenuSettings>
itemIds?: string[]
viewer?: { name?: string }
}
}
type ActiveTrigger = {
nonce: number
itemIds: string[]
viewer: string
}
type PageTransition = {
currentStart: number
previousStart?: number
nonce: number
}
function triggerLabel(item: GiftMenuItem): string {
if (item.trigger.kind === 'gift') return item.trigger.giftName
if (item.trigger.kind === 'guard') return translate(`gift_menu.guard.${item.trigger.level}`)
return translate('gift_menu.overlay.battery', {
amount: item.trigger.amount,
})
}
function MenuIcon({ item }: { item: GiftMenuItem }) {
if (item.trigger.kind === 'gift') {
return (
<span className="gift-menu-icon gift-menu-gift-icon">
<i aria-hidden="true">✦</i>
{item.trigger.imageUrl && (
<img
src={item.trigger.imageUrl}
alt=""
decoding="async"
referrerPolicy="no-referrer"
onError={event => {
event.currentTarget.hidden = true
}}
/>
)}
</span>
)
}
if (item.trigger.kind === 'guard') {
return (
<span className={`gift-menu-icon gift-menu-guard-icon level-${item.trigger.level}`}>
<img src={giftMenuGuardIconUrl(item.trigger.level)} alt="" decoding="async" />
</span>
)
}
return (
<span className="gift-menu-icon gift-menu-battery-icon" aria-hidden="true">
<i />
</span>
)
}
function GiftMenuRow({
item,
triggered,
viewer,
onPreviewTrigger,
}: {
item: GiftMenuItem
triggered: boolean
viewer: string
onPreviewTrigger?: (id: string) => void
}) {
return (
<article
className={`gift-menu-row ${triggered ? 'triggered' : ''}`}
data-item-id={item.id}
onClick={onPreviewTrigger ? () => onPreviewTrigger(item.id) : undefined}
role={onPreviewTrigger ? 'button' : undefined}
tabIndex={onPreviewTrigger ? 0 : undefined}
onKeyDown={event => {
if (onPreviewTrigger && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
onPreviewTrigger(item.id)
}
}}
>
<MenuIcon item={item} />
<div className="gift-menu-copy">
<strong>{triggerLabel(item)}</strong>
<span>{item.description}</span>
</div>
{triggered && viewer && <em>{translate('gift_menu.overlay.triggered_by', { viewer })}</em>}
<div className="gift-menu-row-particles" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</article>
)
}
function useGiftMenuStream(preview: boolean, stream?: ComponentStream) {
const [settings, setSettings] = useState(defaultGiftMenuSettings)
const [active, setActive] = useState<ActiveTrigger>()
const lastSequence = useRef(0)
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequence.current)
for (const message of pending) {
lastSequence.current = message.sequence
const envelope = message.envelope as MenuEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
setSettings(normalizeGiftMenuSettings(envelope.payload?.settings))
} else if (envelope.type === 'gift-menu.triggered') {
const itemIds = Array.isArray(envelope.payload?.itemIds)
? envelope.payload.itemIds.filter(id => typeof id === 'string')
: []
if (itemIds.length)
setActive({
nonce: Date.now(),
itemIds,
viewer: String(envelope.payload?.viewer?.name ?? ''),
})
}
}
}, [preview, stream, stream?.messages])
return { settings, active, setActive }
}
export function GiftMenuOverlay({
preview = false,
previewSettings,
previewTriggeredItemId,
previewNonce = 0,
onPreviewTrigger,
stream,
}: {
preview?: boolean
previewSettings?: GiftMenuSettings
previewTriggeredItemId?: string
previewNonce?: number
onPreviewTrigger?: (id: string) => void
stream?: ComponentStream
}) {
useI18n()
const remote = useGiftMenuStream(preview, stream)
const settings = previewSettings ?? remote.settings
const [active, setActive] = useState<ActiveTrigger>()
const effectiveActive = preview ? active : remote.active
const viewport = useRef<HTMLDivElement>(null)
const pauseUntil = useRef(0)
// Older OBS Chromium builds quantize scrollTop to whole pixels. Retaining
// the sub-pixel remainder makes every configured speed linear instead of
// losing movements smaller than one pixel per animation frame.
const scrollRemainder = useRef(0)
const [loop, setLoop] = useState(false)
const theme = getGiftMenuTheme(settings.themeId)
const paged = theme.id === 'moonlit-water'
const pageSize = Math.max(1, Math.floor(settings.visibleRows))
const itemIds = settings.items.map(item => item.id).join('\u0000')
const [page, setPage] = useState<PageTransition>({ currentStart: 0, nonce: 0 })
const cycleHeight = settings.items.length * settings.rowHeight
const pageTransitionDurationMs = settings.lowPerformanceMode
? 0
: Math.round(650 + (settings.motionIntensity / 100) * 550)
const showPage = useCallback(
(requestedStart: number) => {
const count = settings.items.length
const nextStart = count === 0 || requestedStart >= count ? 0 : Math.max(0, requestedStart)
setPage(current =>
current.currentStart === nextStart
? current
: {
currentStart: nextStart,
previousStart: current.currentStart,
nonce: current.nonce + 1,
},
)
},
[settings.items.length],
)
useEffect(() => {
if (!preview || !previewTriggeredItemId) return
setActive({
nonce: previewNonce,
itemIds: [previewTriggeredItemId],
viewer: '',
})
}, [preview, previewNonce, previewTriggeredItemId])
useEffect(() => {
const node = viewport.current
if (!node) return
if (paged) {
node.scrollTop = 0
setLoop(false)
return
}
const update = () => setLoop(cycleHeight > node.clientHeight + 1)
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [cycleHeight, paged])
useEffect(() => {
const node = viewport.current
if (paged || !node || !loop || cycleHeight <= 0) return
if (node.scrollTop < cycleHeight || node.scrollTop >= cycleHeight * 2)
node.scrollTop = cycleHeight + (node.scrollTop % cycleHeight)
scrollRemainder.current = 0
let frame = 0
let previous = performance.now()
const animate = (now: number) => {
const elapsed = Math.min(50, now - previous)
previous = now
if (now >= pauseUntil.current && settings.scrollSpeedPixelsPerSecond > 0) {
scrollRemainder.current += (settings.scrollSpeedPixelsPerSecond * elapsed) / 1_000
const wholePixels = Math.floor(scrollRemainder.current)
if (wholePixels > 0) {
node.scrollTop += wholePixels
scrollRemainder.current -= wholePixels
}
if (node.scrollTop >= cycleHeight * 2) node.scrollTop -= cycleHeight
if (node.scrollTop < cycleHeight) node.scrollTop += cycleHeight
}
frame = requestAnimationFrame(animate)
}
frame = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(frame)
scrollRemainder.current = 0
}
}, [cycleHeight, loop, paged, settings.scrollSpeedPixelsPerSecond])
useEffect(() => {
if (!paged) return
setPage(current => ({
currentStart: current.currentStart < settings.items.length ? current.currentStart : 0,
nonce: current.nonce + 1,
}))
}, [itemIds, pageSize, paged, settings.items.length])
useEffect(() => {
if (!paged || page.previousStart === undefined) return
const timer = window.setTimeout(
() =>
setPage(current =>
current.nonce === page.nonce
? { currentStart: current.currentStart, nonce: current.nonce }
: current,
),
pageTransitionDurationMs,
)
return () => window.clearTimeout(timer)
}, [page.nonce, page.previousStart, pageTransitionDurationMs, paged])
useEffect(() => {
if (
!paged ||
effectiveActive ||
settings.items.length <= pageSize ||
settings.pageIntervalMs <= 0
)
return
const timer = window.setTimeout(
() => showPage(page.currentStart + pageSize),
settings.pageIntervalMs,
)
return () => window.clearTimeout(timer)
}, [
effectiveActive,
page.currentStart,
pageSize,
paged,
settings.items.length,
settings.pageIntervalMs,
showPage,
])
useEffect(() => {
const node = viewport.current
const id = effectiveActive?.itemIds[0]
if (!node || !id) return
const index = settings.items.findIndex(item => item.id === id)
if (index < 0) return
if (paged) {
showPage(index)
return
}
scrollRemainder.current = 0
if (!loop || cycleHeight <= 0) {
node.scrollTo({
top: index * settings.rowHeight,
behavior: 'smooth',
})
} else {
const alignedToTop = (copy: number) => copy * cycleHeight + index * settings.rowHeight
const candidates = [0, 1, 2].map(alignedToTop)
const target = candidates.reduce((best, value) =>
Math.abs(value - node.scrollTop) < Math.abs(best - node.scrollTop) ? value : best,
)
node.scrollTo({ top: Math.max(0, target), behavior: 'smooth' })
}
pauseUntil.current = performance.now() + settings.highlightDurationMs
}, [
cycleHeight,
effectiveActive?.nonce,
loop,
paged,
settings.highlightDurationMs,
settings.items,
settings.rowHeight,
showPage,
])
useEffect(() => {
if (!effectiveActive) return
const timer = window.setTimeout(() => {
if (preview) setActive(undefined)
else remote.setActive(undefined)
}, settings.highlightDurationMs)
return () => window.clearTimeout(timer)
}, [effectiveActive?.nonce, preview, settings.highlightDurationMs])
const copies = useMemo(() => (loop ? [0, 1, 2] : [0]), [loop])
const pageRows = (start: number) =>
settings.items
.slice(start, start + pageSize)
.map(item => (
<GiftMenuRow
item={item}
triggered={effectiveActive?.itemIds.includes(item.id) === true}
viewer={effectiveActive?.viewer ?? ''}
onPreviewTrigger={onPreviewTrigger}
key={item.id}
/>
))
return (
<main
className={`gift-menu-overlay ${theme.className} ${preview ? 'gift-menu-preview' : ''} ${settings.lowPerformanceMode ? 'gift-menu-low-motion' : ''}`}
style={
{
...giftMenuThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--menu-row-height' as string]: `${settings.rowHeight}px`,
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
['--menu-copy-size' as string]: `${settings.rowHeight * 0.17 * (settings.fontScale / 100)}px`,
['--menu-badge-size' as string]: `${settings.rowHeight * 0.13 * (settings.fontScale / 100)}px`,
['--menu-empty-size' as string]: `${18 * (settings.fontScale / 100)}px`,
['--menu-highlight-duration' as string]: `${settings.highlightDurationMs}ms`,
['--menu-particle-duration' as string]: `${settings.highlightDurationMs * 0.72}ms`,
['--menu-page-transition-duration' as string]: `${pageTransitionDurationMs}ms`,
['--menu-motion' as string]: settings.motionIntensity / 100,
} as CSSProperties
}
data-connection={stream?.connection ?? 'idle'}
>
<ThemeEdges />
<section
className="gift-menu-viewport"
ref={viewport}
aria-label={translate('gift_menu.aria')}
>
{settings.items.length === 0 ? (
<div className="gift-menu-empty">{translate('gift_menu.overlay.empty')}</div>
) : paged ? (
<div className="gift-menu-track gift-menu-page-stack">
{page.previousStart !== undefined && (
<div
className="gift-menu-page gift-menu-page-leaving"
key={`leaving:${page.nonce}:${page.previousStart}`}
>
{pageRows(page.previousStart)}
</div>
)}
<div
className={`gift-menu-page ${page.previousStart !== undefined ? 'gift-menu-page-entering' : ''}`}
key={`current:${page.nonce}:${page.currentStart}`}
>
{pageRows(page.currentStart)}
</div>
</div>
) : (
<div className="gift-menu-track">
{copies.flatMap(copy =>
settings.items.map(item => (
<GiftMenuRow
item={item}
triggered={effectiveActive?.itemIds.includes(item.id) === true}
viewer={effectiveActive?.viewer ?? ''}
onPreviewTrigger={onPreviewTrigger}
key={`${copy}:${item.id}`}
/>
)),
)}
</div>
)}
</section>
</main>
)
}
+85
View File
@@ -0,0 +1,85 @@
import type { CSSProperties } from 'react'
import type { GiftMenuThemeId } from './types'
export type GiftMenuTheme = {
id: GiftMenuThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
jade: string
cyan: string
gold: string
rose: string
ink: string
}
ornaments: { divider: string; cluster: string; vine: string; edge: string }
}
export const giftMenuThemes: readonly GiftMenuTheme[] = [
{
id: 'jade-banquet',
nameKey: 'gift_menu.theme.jade_banquet.name',
descriptionKey: 'gift_menu.theme.jade_banquet.description',
className: 'gift-menu-theme-jade-banquet',
palette: {
jade: '#71f0d5',
cyan: '#b8fff4',
gold: '#ffe28a',
rose: '#ffcce1',
ink: '#031a26',
},
ornaments: {
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
edge: '/assets/moonlit-edge.svg',
},
},
{
id: 'moonlit-water',
nameKey: 'gift_menu.theme.moonlit_water.name',
descriptionKey: 'gift_menu.theme.moonlit_water.description',
className: 'gift-menu-theme-moonlit-water',
palette: {
jade: '#9bb8aa',
cyan: '#d9e3c9',
gold: '#e7c982',
rose: '#dbc6a0',
ink: 'transparent',
},
ornaments: {
divider: '/assets/floral-divider.svg',
cluster: '/assets/floral-cluster.svg',
vine: '/assets/floral-vine.svg',
edge: '/assets/moonlit-edge.svg',
},
},
]
export function getGiftMenuTheme(id: unknown): GiftMenuTheme {
return giftMenuThemes.find(theme => theme.id === id) ?? giftMenuThemes[0]
}
export function normalizeGiftMenuThemeId(id: unknown): GiftMenuThemeId {
return getGiftMenuTheme(id).id
}
/** Local transparent membership icons supplied with the overlay bundle. */
export function giftMenuGuardIconUrl(level: 'captain' | 'admiral' | 'governor'): string {
return `/assets/${level}.png`
}
export function giftMenuThemeVariables(theme: GiftMenuTheme): CSSProperties {
return {
['--menu-jade' as string]: theme.palette.jade,
['--menu-cyan' as string]: theme.palette.cyan,
['--menu-gold' as string]: theme.palette.gold,
['--menu-rose' as string]: theme.palette.rose,
['--menu-ink' as string]: theme.palette.ink,
['--menu-divider' as string]: `url(${theme.ornaments.divider})`,
['--menu-cluster' as string]: `url(${theme.ornaments.cluster})`,
['--menu-vine' as string]: `url(${theme.ornaments.vine})`,
['--theme-pattern-edge' as string]: `url(${theme.ornaments.edge})`,
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { CSSProperties } from 'react'
import type { GiftEffectThemeId } from './types'
export type GiftEffectTheme = {
id: GiftEffectThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
jade: string
cyan: string
gold: string
}
motion: {
meteor: string
}
}
export const giftEffectThemes: readonly GiftEffectTheme[] = [
{
id: 'jade-starfall',
nameKey: 'gift.theme.jade_starfall.name',
descriptionKey: 'gift.theme.jade_starfall.description',
className: 'gift-theme-jade-starfall',
palette: {
jade: '#72f3d8',
cyan: '#a9fff2',
gold: '#ffe7a4',
},
motion: {
meteor: 'gift-meteor-flight',
},
},
{
id: 'moonlit-water',
nameKey: 'gift.theme.moonlit_water.name',
descriptionKey: 'gift.theme.moonlit_water.description',
className: 'gift-theme-moonlit-water',
palette: {
jade: '#8daea2',
cyan: '#c6d8c6',
gold: '#e7c982',
},
motion: {
meteor: 'moonlit-offering-sweep',
},
},
]
export function getGiftEffectTheme(id: unknown): GiftEffectTheme {
return giftEffectThemes.find(theme => theme.id === id) ?? giftEffectThemes[0]
}
export function normalizeGiftEffectThemeId(id: unknown): GiftEffectThemeId {
return getGiftEffectTheme(id).id
}
export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties {
return {
['--gift-jade' as string]: theme.palette.jade,
['--gift-cyan' as string]: theme.palette.cyan,
['--gift-gold' as string]: theme.palette.gold,
['--gift-motion-meteor' as string]: theme.motion.meteor,
}
}
+422
View File
@@ -0,0 +1,422 @@
.guard-effect-overlay {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--guard-cyan, #a9fff2);
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
.jade-guard-voyage {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
background: #000;
}
.jade-guard-voyage > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
animation: jade-guard-video-fade var(--guard-duration) linear both;
}
.jade-guard-scroll {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
display: grid;
width: min(86vw, 1180px);
min-height: min(34vh, 360px);
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
place-content: center;
justify-items: center;
gap: clamp(5px, 0.9vh, 12px);
border-block: 1px solid rgba(255, 229, 153, 0.7);
color: #fff4cf;
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
text-align: center;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.96),
0 0 14px rgba(0, 0, 0, 0.92),
0 0 22px rgba(255, 220, 130, 0.48);
background: linear-gradient(
90deg,
transparent,
rgba(3, 15, 22, 0.34) 12%,
rgba(3, 15, 22, 0.62) 50%,
rgba(3, 15, 22, 0.34) 88%,
transparent
);
box-shadow:
0 -8px 24px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.2);
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
will-change: clip-path, opacity, transform;
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
}
.jade-guard-scroll::before,
.jade-guard-scroll::after {
content: '';
position: absolute;
top: -7%;
bottom: -7%;
width: clamp(5px, 0.55vw, 10px);
border: 1px solid rgba(255, 239, 190, 0.82);
border-radius: 999px;
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
}
.jade-guard-scroll::before {
left: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll::after {
right: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll strong,
.jade-guard-scroll b,
.jade-guard-scroll span {
position: relative;
z-index: 1;
overflow-wrap: anywhere;
}
.jade-guard-scroll strong {
color: #fff5d2;
font-size: clamp(42px, 7vw, 126px);
font-weight: 400;
letter-spacing: 0.12em;
line-height: 1;
}
.jade-guard-scroll b {
color: #d4fff3;
font-size: clamp(24px, 3.7vw, 66px);
font-weight: 400;
letter-spacing: 0.16em;
line-height: 1.08;
}
.jade-guard-scroll span {
color: #ffe5a6;
font-size: clamp(34px, 5.4vw, 94px);
letter-spacing: 0.18em;
line-height: 1;
}
.guard-celebration {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
overflow: hidden;
place-items: center;
opacity: 0;
color: #edfffa;
background:
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
}
.guard-nebula {
position: absolute;
inset: -25%;
background: conic-gradient(
from 90deg,
transparent,
rgba(93, 237, 209, 0.18),
transparent 32%,
rgba(255, 207, 230, 0.12),
transparent 68%,
rgba(255, 230, 156, 0.13),
transparent
);
filter: blur(28px);
animation: guard-nebula-turn 9s linear infinite;
}
.guard-stars {
position: absolute;
inset: 0;
}
.guard-stars i {
position: absolute;
width: var(--star-size);
height: var(--star-size);
opacity: 0.1;
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
filter: drop-shadow(0 0 6px var(--guard-cyan));
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
}
.guard-halo {
position: absolute;
width: min(62vmin, 720px);
aspect-ratio: 1;
border: 1px solid rgba(155, 255, 235, 0.4);
border-radius: 50%;
box-shadow:
0 0 60px rgba(87, 241, 212, 0.25),
inset 0 0 70px rgba(255, 224, 166, 0.12);
animation: guard-halo-breathe 2.6s ease-in-out infinite;
}
.guard-halo i {
position: absolute;
inset: 7%;
border: 1px solid rgba(255, 227, 170, 0.38);
border-radius: 45% 55% 48% 52%;
transform: rotate(30deg);
}
.guard-halo i:nth-child(2) {
inset: 15%;
border-color: rgba(255, 195, 224, 0.32);
transform: rotate(76deg);
}
.guard-halo i:nth-child(3) {
inset: 23%;
border-color: rgba(123, 246, 224, 0.45);
transform: rotate(122deg);
}
.guard-copy {
position: relative;
z-index: 3;
display: grid;
max-width: min(82vw, 1000px);
justify-items: center;
gap: clamp(8px, 1.5vh, 20px);
text-align: center;
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
filter: brightness(var(--component-font-brightness, 1.3));
}
.guard-copy span {
color: var(--guard-gold);
font-size: clamp(13px, 1.6vw, 30px);
letter-spacing: 0.45em;
}
.guard-copy strong {
color: #f3fffc;
font-size: clamp(38px, 7vw, 132px);
font-weight: 500;
letter-spacing: 0.1em;
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
}
.guard-copy b {
color: var(--guard-rose);
font-size: clamp(16px, 2.3vw, 44px);
font-weight: 500;
letter-spacing: 0.12em;
}
.guard-low-motion .guard-nebula,
.guard-low-motion .guard-halo {
animation: none;
}
.guard-theme-moonlit-water {
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.guard-theme-moonlit-water .guard-celebration {
color: #eadfc3;
background: transparent;
font-family: inherit;
}
.guard-theme-moonlit-water .guard-celebration::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: min(62vmin, 780px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(246, 237, 207, 0.82) 0 42%,
rgba(226, 224, 193, 0.2) 63%,
transparent 71%
);
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
transform: translate(-50%, -50%);
}
.guard-theme-moonlit-water .guard-nebula {
background:
repeating-radial-gradient(
ellipse at 50% 80%,
transparent 0 7%,
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
transparent 7.7% 12%
),
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
filter: blur(1px);
animation: moonlit-guard-water 7s ease-in-out infinite;
}
.guard-theme-moonlit-water .guard-stars i {
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
}
.guard-theme-moonlit-water .guard-halo {
width: min(77vmin, 930px);
border-color: rgba(231, 201, 130, 0.42);
box-shadow:
0 0 44px rgba(231, 201, 130, 0.18),
inset 0 0 65px rgba(182, 207, 180, 0.1);
}
.guard-theme-moonlit-water .guard-halo i {
border-color: rgba(203, 219, 184, 0.36);
}
.guard-theme-moonlit-water .guard-copy {
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
}
.guard-theme-moonlit-water .guard-copy span,
.guard-theme-moonlit-water .guard-copy b {
color: #e7c982;
}
.guard-theme-moonlit-water .guard-copy strong {
color: #eadfc3;
filter: none;
}
@keyframes guard-effect-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@keyframes jade-guard-video-fade {
0%,
100% {
opacity: 0;
}
6.25%,
93.75% {
opacity: 1;
}
}
@keyframes jade-guard-scroll-reveal {
0% {
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
}
12.5%,
87.5% {
opacity: 1;
clip-path: inset(0 round 10px);
transform: translate(-50%, -50%);
}
100% {
opacity: 0;
clip-path: inset(0 round 10px);
transform: translate(-46%, -50%);
}
}
@keyframes jade-guard-copy-fade {
0%,
100% {
opacity: 0;
}
12.5%,
87.5% {
opacity: 1;
}
}
@keyframes guard-star-pulse {
0%,
100% {
opacity: 0.08;
transform: rotate(0) scale(0.45);
}
48% {
opacity: 1;
transform: rotate(50deg) scale(1.32);
}
}
@keyframes guard-nebula-turn {
to {
transform: rotate(360deg);
}
}
@keyframes guard-halo-breathe {
50% {
transform: scale(1.08) rotate(3deg);
box-shadow:
0 0 110px rgba(87, 241, 212, 0.38),
inset 0 0 90px rgba(255, 224, 166, 0.2);
}
}
@keyframes moonlit-guard-water {
50% {
opacity: 0.68;
transform: scale(1.04) translateY(-1.4%);
}
}
@keyframes moonlit-guard-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.guard-nebula,
.guard-stars i,
.guard-halo {
animation: none;
}
.jade-guard-scroll {
clip-path: none;
transform: translate(-50%, -50%);
animation-name: jade-guard-copy-fade;
}
}
+286
View File
@@ -0,0 +1,286 @@
/** Full-viewport renderer for the independently subscribed membership component. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGuardEffectSettings } from './api'
import { useEffectQueue } from './effectQueue'
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGuardEffectSettings } from './types'
import type { GuardEffectSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type GuardPayload = {
viewer?: Viewer
guardName?: string
quantity?: number
price?: number
settings?: Partial<GuardEffectSettings>
}
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
type GuardEffect = {
id: string
payload: GuardPayload
}
type GuardLevel = 'captain' | 'admiral' | 'governor'
export type GuardEffectPreviewMode = GuardLevel
const JADE_VIDEO_DURATION_MS = 8_000
function guardLevel(guardName: string | undefined): GuardLevel {
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
return 'captain'
}
function guardVideoUrl(level: GuardLevel): string {
return `/assets/${level === 'governor' ? 'general' : level}.webm`
}
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
return {
id: `preview-${level}-${nonce}`,
type: 'live.guard.buy',
payload: {
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
guardName: translate(`gift_menu.guard.${level}`),
quantity: 1,
price: 198_000,
},
}
}
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
if (envelope.type !== 'live.guard.buy') return undefined
return {
id: envelope.id,
payload: envelope.payload ?? {},
}
}
function useGuardEffect(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GuardEffectSettings | undefined,
previewMode: GuardEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGuardEffectSettings)
const queue = useEffectQueue<GuardEffect>()
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
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 envelope = message.envelope as GuardEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const next = toGuardEffect(envelope)
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const currentSettings = previewSettings ?? settingsRef.current
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
}, [
language,
preview,
previewMode,
previewNonce,
previewSettings?.effectDurationMs,
previewSettings?.themeId,
])
return {
settings,
effect: queue.active,
pendingCount: queue.pendingCount,
completeEffect: queue.complete,
}
}
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function JadeGuardVoyage({
effect,
settings,
muted,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
muted: boolean
onComplete: () => void
}) {
const level = guardLevel(effect.payload.guardName)
const guard = translate(`gift_menu.guard.${level}`)
const viewer =
effect.payload.viewer?.name || effect.payload.viewer?.uid || translate('common.viewer')
const title = settings.titleTemplate.replaceAll('{guard}', guard)
return (
<section
className={`jade-guard-voyage guard-level-${level}`}
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
>
<video
autoPlay
playsInline
preload="auto"
muted={muted}
src={guardVideoUrl(level)}
key={`${effect.id}:${level}`}
aria-hidden="true"
onAnimationEnd={event => {
if (event.animationName === 'jade-guard-video-fade') onComplete()
}}
/>
<div className="jade-guard-scroll" aria-live="polite">
<strong>{title}</strong>
<b>{viewer}</b>
<span>{settings.closingText}</span>
</div>
</section>
)
}
function MoonlitGuardCelebration({
effect,
settings,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
onComplete: () => void
}) {
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
onComplete()
}}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('guard.salute')}</span>
<strong>{translate('guard.title', { guard })}</strong>
<b>{translate('guard.viewer', { viewer })}</b>
</div>
</section>
)
}
export function GuardEffectOverlay({
preview = false,
previewSettings,
previewMode = 'captain',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GuardEffectSettings
previewMode?: GuardEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGuardEffect(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGuardEffectTheme(settings.themeId)
const effect = remote.effect
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
return (
<main
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...guardThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
{effect &&
onComplete &&
(theme.id === 'moonlit-water' ? (
<MoonlitGuardCelebration
key={effect.id}
effect={effect}
settings={settings}
onComplete={onComplete}
/>
) : (
<JadeGuardVoyage
key={effect.id}
effect={effect}
settings={settings}
muted={preview}
onComplete={onComplete}
/>
))}
</main>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { CSSProperties } from 'react'
import type { GuardEffectThemeId } from './types'
export type GuardEffectTheme = {
id: GuardEffectThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
cyan: string
gold: string
rose: string
night: string
}
motion: {
reveal: string
starPulse: string
}
}
export const guardEffectThemes: readonly GuardEffectTheme[] = [
{
id: 'jade-starfall',
nameKey: 'guard.theme.jade_starfall.name',
descriptionKey: 'guard.theme.jade_starfall.description',
className: 'guard-theme-jade-starfall',
palette: {
cyan: '#a9fff2',
gold: '#ffe7a4',
rose: '#ffd0e5',
night: '#020c18',
},
motion: {
reveal: 'guard-effect-reveal',
starPulse: 'guard-star-pulse',
},
},
{
id: 'moonlit-water',
nameKey: 'guard.theme.moonlit_water.name',
descriptionKey: 'guard.theme.moonlit_water.description',
className: 'guard-theme-moonlit-water',
palette: {
cyan: '#c6d8c6',
gold: '#e7c982',
rose: '#dbc6a0',
night: 'transparent',
},
motion: {
reveal: 'moonlit-guard-reveal',
starPulse: 'guard-star-pulse',
},
},
]
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
}
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
return getGuardEffectTheme(id).id
}
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
return {
['--guard-cyan' as string]: theme.palette.cyan,
['--guard-gold' as string]: theme.palette.gold,
['--guard-rose' as string]: theme.palette.rose,
['--guard-night' as string]: theme.palette.night,
['--guard-motion-reveal' as string]: theme.motion.reveal,
['--guard-motion-star' as string]: theme.motion.starPulse,
}
}
+145
View File
@@ -0,0 +1,145 @@
/** Runtime localization backed exclusively by `resources/i18n.toml`. */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
type Messages = Record<string, string>
type LocaleDefinition = { name: string; messages: Messages }
type Parameters = Record<string, string | number>
const resource = __I18N_RESOURCE__
const storageKey = 'lxc-stream-language'
const localeEntries = Object.entries(resource.locales)
function supported(value: string | null | undefined): boolean {
return Boolean(value && resource.locales[value])
}
function browserLanguage(): string {
let saved: string | null = null
try {
saved = localStorage.getItem(storageKey)
} catch {
// Some embedded browsers disable storage. Locale selection still works
// for the current document and authenticated accounts remain authoritative.
}
if (saved && supported(saved)) return saved
for (const candidate of navigator.languages) {
if (supported(candidate)) return candidate
const prefix = candidate.split('-')[0].toLowerCase()
const match = localeEntries.find(([code]) => code.split('-')[0].toLowerCase() === prefix)
if (match) return match[0]
}
return resource.default_language
}
let activeLanguage = browserLanguage()
function format(message: string, parameters?: Parameters): string {
if (!parameters) return message
return message.replace(/\{([a-zA-Z0-9_]+)\}/g, (placeholder, key: string) =>
Object.hasOwn(parameters, key) ? String(parameters[key]) : placeholder,
)
}
export function translate(key: string, parameters?: Parameters, language = activeLanguage): string {
const locale = resource.locales[language] ?? resource.locales[resource.default_language]
const fallback = resource.locales[resource.default_language]
return format(locale?.messages[key] ?? fallback?.messages[key] ?? key, parameters)
}
export function currentLanguage(): string {
return activeLanguage
}
export function hasTranslation(key: string): boolean {
return localeEntries.some(([, locale]) => Object.hasOwn(locale.messages, key))
}
type I18nContextValue = {
language: string
locales: Array<{ code: string; name: string }>
setLanguage: (language: string) => void
t: (key: string, parameters?: Parameters) => string
}
const I18nContext = createContext<I18nContextValue | undefined>(undefined)
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState(activeLanguage)
const setLanguage = useCallback((next: string) => {
if (!supported(next)) return
activeLanguage = next
try {
localStorage.setItem(storageKey, next)
} catch {
// Storage is a pre-login convenience, not a requirement for switching.
}
document.documentElement.lang = next
setLanguageState(next)
}, [])
useEffect(() => {
document.documentElement.lang = language
document.title = translate('pwa.manifest.name', undefined, language)
const localizedMetadata: Array<[string, string]> = [
['meta[name="description"]', 'pwa.manifest.description'],
['meta[name="application-name"]', 'pwa.manifest.name'],
['meta[name="apple-mobile-web-app-title"]', 'pwa.manifest.short_name'],
]
for (const [selector, key] of localizedMetadata) {
document
.querySelector<HTMLMetaElement>(selector)
?.setAttribute('content', translate(key, undefined, language))
}
}, [language])
const value = useMemo<I18nContextValue>(
() => ({
language,
locales: localeEntries.map(([code, locale]) => ({
code,
name: (locale as LocaleDefinition).name,
})),
setLanguage,
t: (key, parameters) => translate(key, parameters, language),
}),
[language, setLanguage],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext)
if (!context) throw new Error('I18nProvider is missing')
return context
}
export function LanguageSelect({
onChange,
className = '',
}: {
onChange?: (language: string) => void | Promise<void>
className?: string
}) {
const { language, locales, setLanguage, t } = useI18n()
return (
<label className={`language-select ${className}`.trim()}>
<span>{t('language.label')}</span>
<select
value={language}
onChange={event => {
const next = event.target.value
setLanguage(next)
void onChange?.(next)
}}
>
{locales.map(locale => (
<option key={locale.code} value={locale.code}>
{locale.name}
</option>
))}
</select>
</label>
)
}
+110 -18
View File
@@ -1,36 +1,91 @@
/**
* Browser entry point and intentionally small client-side router.
*
* `/obs/:publicId` is selected before the control application is initialized.
* That early split is a security and reliability boundary: OBS sources never
* register the control-console PWA or execute authenticated dashboard requests.
* All other routes share the session bootstrap and passwordless auth flow.
*/
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api' import { ApiError, api, errorMessage, json, normalizeSession } from './api'
import { EnrollmentPage, LoginPage } from './auth' import { EnrollmentPage, LoginPage } from './auth'
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control' import {
AccountLiveSourcePage,
ComponentsPage,
ForbiddenPage,
InvitationsPage,
SongRequestsPage,
} from './control'
import { GiftEffectOverlay } from './giftEffect'
import { GuardEffectOverlay } from './guardEffect'
import { GiftMenuOverlay } from './giftMenu'
import { Overlay, tokenFromFragment } from './overlay' import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa' import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { useComponentStream } from './stream'
import { I18nProvider, translate, useI18n } from './i18n'
import type { Session } from './types' import type { Session } from './types'
import '@fontsource/noto-serif-sc/400.css'
import '@fontsource/zcool-xiaowei/400.css'
import '@fontsource/lxgw-wenkai/500.css'
import './fonts.css'
import './style.css' import './style.css'
import './control.css' import './control.css'
import './giftEffect.css'
import './guardEffect.css'
import './giftMenu.css'
import './song.css'
import './themeEdges.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
const stream = useComponentStream(false, publicId, accessToken)
const { setLanguage, t } = useI18n()
useEffect(() => {
if (stream.language) setLanguage(stream.language)
}, [setLanguage, stream.language])
if (!publicId || !accessToken)
return <main className="obs-status">{t('main.obs_missing_credentials')}</main>
if (stream.connection === 'denied')
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
if (stream.componentKind === 'gift_effect') return <GiftEffectOverlay stream={stream} />
if (stream.componentKind === 'guard_effect') return <GuardEffectOverlay stream={stream} />
if (stream.componentKind === 'gift_menu') return <GiftMenuOverlay stream={stream} />
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return <Overlay stream={stream} />
return <main className="obs-status pending">{t('main.obs_connecting')}</main>
}
function Redirect({ to }: { to: string }) { function Redirect({ to }: { to: string }) {
const { t } = useI18n()
useEffect(() => { useEffect(() => {
location.replace(to) location.replace(to)
}, [to]) }, [to])
return <main className="route-loading">正在前往云台…</main> return <main className="route-loading">{t('main.redirecting')}</main>
} }
function NotFoundPage() { function NotFoundPage() {
const { t } = useI18n()
return ( return (
<main className="auth-page"> <main className="auth-page">
<section className="auth-card jade-panel not-found"> <section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">云</div> <div className="auth-mark" aria-hidden="true">
<p className="eyebrow">404 · LOST IN THE CLOUDS</p> {t('common.cloud')}
<h1>这里没有组件</h1> </div>
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p> <p className="eyebrow">{t('main.not_found_eyebrow')}</p>
<a className="button-link" href="/control/">返回控制台</a> <h1>{t('main.not_found_title')}</h1>
<p className="auth-lead">{t('main.not_found_description')}</p>
<a className="button-link" href="/control/">
{t('main.back_to_console')}
</a>
</section> </section>
</main> </main>
) )
} }
function App() { function App() {
const { setLanguage, t } = useI18n()
const [session, setSession] = useState<Session>() const [session, setSession] = useState<Session>()
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
@@ -40,15 +95,24 @@ function App() {
setLoadError('') setLoadError('')
try { try {
const payload = await api<unknown>('/api/v1/auth/me') const payload = await api<unknown>('/api/v1/auth/me')
setSession(normalizeSession(payload)) const next = normalizeSession(payload)
setSession(next)
if (next.user) {
try {
const preferences = await api<{ language?: string }>('/api/v1/account/preferences')
if (preferences.language) setLanguage(preferences.language)
} catch {
// The local pre-login choice remains usable if preferences cannot load.
}
}
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
setSession({ user: null, setupRequired: false }) setSession({ user: null, setupRequired: false })
return return
} }
setLoadError(errorMessage(error, '无法连接认证服务')) setLoadError(errorMessage(error, translate('main.auth_unavailable')))
} }
}, []) }, [setLanguage])
useEffect(() => { useEffect(() => {
void refreshSession() void refreshSession()
@@ -83,16 +147,20 @@ function App() {
<main className="auth-page"> <main className="auth-page">
<section className="auth-card jade-panel"> <section className="auth-card jade-panel">
<PwaControls /> <PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p> <p className="eyebrow">
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1> {t(offline ? 'main.offline_eyebrow' : 'main.connection_error_eyebrow')}
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>} </p>
<h1>{offline ? t('main.offline_title') : t('main.unavailable_title')}</h1>
{offline && <p className="auth-lead">{t('main.offline_description')}</p>}
<div className="notice error">{loadError}</div> <div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button> <button type="button" disabled={offline} onClick={() => void refreshSession()}>
{t('main.retry')}
</button>
</section> </section>
</main> </main>
) )
} }
if (!session) return <main className="route-loading">正在验证安全会话…</main> if (!session) return <main className="route-loading">{t('main.verifying_session')}</main>
const logout = async () => { const logout = async () => {
try { try {
@@ -117,14 +185,30 @@ function App() {
if (session.user) return <Redirect to="/control/" /> if (session.user) return <Redirect to="/control/" />
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} /> 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 (path === '/control') {
if (location.pathname === '/control') return <Redirect to="/control/" /> if (location.pathname === '/control') return <Redirect to="/control/" />
if (!session.user) return <Redirect to="/control/login" /> if (!session.user) return <Redirect to="/control/login" />
return <ComponentsPage user={session.user} onLogout={logout} /> return <ComponentsPage user={session.user} onLogout={logout} />
} }
if (path === '/control/account') {
if (!session.user) return <Redirect to="/control/login" />
return <AccountLiveSourcePage user={session.user} onLogout={logout} />
}
if (path === '/control/invitations') { if (path === '/control/invitations') {
if (!session.user) return <Redirect to="/control/login" /> if (!session.user) return <Redirect to="/control/login" />
if (session.user.role !== 'system_admin') return <ForbiddenPage user={session.user} onLogout={logout} /> if (session.user.role !== 'system_admin')
return <ForbiddenPage user={session.user} onLogout={logout} />
return <InvitationsPage user={session.user} onLogout={logout} /> return <InvitationsPage user={session.user} onLogout={logout} />
} }
return <NotFoundPage /> return <NotFoundPage />
@@ -142,8 +226,16 @@ if (obsMatch) {
} catch { } catch {
publicId = '' publicId = ''
} }
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />) root.render(
<I18nProvider>
<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />
</I18nProvider>,
)
} else { } else {
initializePwa() initializePwa()
root.render(<App />) root.render(
<I18nProvider>
<App />
</I18nProvider>,
)
} }
+321 -167
View File
@@ -1,7 +1,22 @@
import { useEffect, useRef, useState } from 'react' /**
import type { Dispatch, SetStateAction } from 'react' * Transparent OBS renderer and component WebSocket client.
*
* The bearer token is read from the URL fragment, then sent as the first
* WebSocket frame; fragments never reach Nginx access logs or the initial HTTP
* request. Incoming events are treated as untrusted display data, bounded by
* component settings, and malformed frames are ignored without terminating a
* long-running browser source.
*/
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import type { ComponentStream } from './stream'
import { translate, useI18n } from './i18n'
import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types' import { defaultOverlaySettings } from './types'
import type { OverlaySettings } from './types' import type { OverlaySettings } from './types'
import { typographyVariables } from './typography'
import { ThemeEdges } from './ThemeEdges'
type Envelope = { type Envelope = {
id: string id: string
@@ -13,7 +28,7 @@ type Envelope = {
} }
type LivePayload = { type LivePayload = {
viewer?: { uid?: string; name?: string } viewer?: { uid?: string; name?: string; avatarUrl?: string }
text?: string text?: string
message?: string message?: string
guardName?: string guardName?: string
@@ -51,13 +66,9 @@ type DanmakuSegment =
type OverlayProps = { type OverlayProps = {
preview?: boolean preview?: boolean
previewSettings?: OverlaySettings previewSettings?: OverlaySettings
publicId?: string stream?: ComponentStream
accessToken?: string
} }
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
const decorVariantCount = 6
function stableHash(value: string) { function stableHash(value: string) {
let hash = 2166136261 let hash = 2166136261
for (let index = 0; index < value.length; index += 1) { for (let index = 0; index < value.length; index += 1) {
@@ -67,21 +78,33 @@ function stableHash(value: string) {
return hash >>> 0 return hash >>> 0
} }
function chooseDecorVariant(seed: string, previous?: number) { function chooseDecorVariant(seed: string, variantCount: number, previous?: number) {
const hash = stableHash(seed) 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 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 }) { function CardDecor({
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0))) count,
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount 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 ( return (
<div className={`card-decor decor-v${normalized}`} aria-hidden="true"> <div className={`card-decor decor-v${normalized}`} aria-hidden="true">
<i className="card-decor-surface" /> <i className="card-decor-surface" />
<div className="card-particle-layer"> <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}`} /> <i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
))} ))}
</div> </div>
@@ -89,117 +112,81 @@ 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) { function enabled(type: string, settings: OverlaySettings) {
return (type === 'live.danmaku' && settings.showDanmaku) return (
|| (type === 'live.enter' && settings.showEnter) (type === 'live.danmaku' && settings.showDanmaku) ||
|| (type.startsWith('live.gift') && settings.showGift) (type === 'live.enter' && settings.showEnter) ||
|| (type === 'live.superchat' && settings.showSuperchat) (type.startsWith('live.gift') && settings.showGift) ||
|| (type === 'live.guard.buy' && settings.showGuard) (type === 'live.superchat' && settings.showSuperchat) ||
|| (type === 'live.like' && settings.showLike) (type === 'live.guard.buy' && settings.showGuard) ||
|| (type === 'live.share' && settings.showShare) (type === 'live.like' && settings.showLike) ||
(type === 'live.share' && settings.showShare)
)
} }
function parseSettings(value: unknown): OverlaySettings { function parseSettings(value: unknown): OverlaySettings {
if (!value || typeof value !== 'object') return defaultOverlaySettings 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): { function useEvents(preview: boolean, stream?: ComponentStream) {
settings: OverlaySettings
items: Item[]
setItems: Dispatch<SetStateAction<Item[]>>
connection: 'idle' | 'connecting' | 'connected' | 'denied'
} {
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings) const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
const settingsRef = useRef(settings) const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => { useEffect(() => {
settingsRef.current = settings settingsRef.current = settings
}, [settings]) }, [settings])
useEffect(() => { useEffect(() => {
if (disabled || !publicId || !accessToken) { if (preview || !stream) return
setConnection('idle') const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
return for (const message of pending) {
} lastSequenceRef.current = message.sequence
const envelope = message.envelope as Envelope
let dead = false if (
let socket: WebSocket | undefined envelope.type === 'overlay.settings.snapshot' ||
let timer = 0 envelope.type === 'overlay.settings.updated' ||
let retries = 0 envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
const open = () => { ) {
setConnection('connecting') const nextSettings = parseSettings(envelope.payload?.settings)
socket = new WebSocket(streamUrl(publicId)) // Update the ref immediately so a live event in the same React batch
socket.onopen = () => { // observes the settings frame that preceded it.
retries = 0 settingsRef.current = nextSettings
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken })) setSettings(nextSettings)
} continue
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.
}
} }
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[old.length - 1]?.decorVariant,
)
const next = [
...old.filter(item => item.key !== key),
{ ...envelope, key, received: Date.now(), decorVariant },
]
return next.slice(Math.max(0, next.length - current.maxVisible))
})
} }
}, [preview, stream, stream?.messages])
open()
return () => {
dead = true
window.clearTimeout(timer)
socket?.close()
}
}, [accessToken, disabled, publicId])
useEffect(() => { useEffect(() => {
setItems(current => current.slice(0, settings.maxVisible)) setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
}, [settings.maxVisible]) }, [settings.maxVisible])
return { settings, items, setItems, connection } return { settings, items, setItems }
} }
function giftTier(item: Item, settings: OverlaySettings) { function giftTier(item: Item, settings: OverlaySettings) {
@@ -212,11 +199,12 @@ function giftTier(item: Item, settings: OverlaySettings) {
} }
const eventRenderers: Record<string, (payload: LivePayload) => string> = { const eventRenderers: Record<string, (payload: LivePayload) => string> = {
'live.enter': () => '踏入了云台', 'live.enter': () => translate('overlay.enter'),
'live.superchat': payload => payload.message || '', 'live.superchat': payload => payload.message || '',
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`, 'live.guard.buy': payload =>
'live.like': () => '点亮了一颗星', translate('overlay.guard', { guard: payload.guardName || translate('common.guard') }),
'live.share': () => '分享了直播间', 'live.like': () => translate('overlay.like'),
'live.share': () => translate('overlay.share'),
} }
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) { function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
@@ -239,60 +227,121 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
} }
function DanmakuBody({ payload }: { payload: LivePayload }) { function DanmakuBody({ payload }: { payload: LivePayload }) {
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined const segments = Array.isArray(payload.segments)
? (payload.segments as DanmakuSegment[])
: undefined
if (!segments?.length) return <>{payload.text || ''}</> if (!segments?.length) return <>{payload.text || ''}</>
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url return (
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} /> <>
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</> {segments.map((segment, index) =>
segment.type === 'emoticon' && segment.url ? (
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
) : (
<span className="danmaku-text" key={`text:${index}`}>
{segment.text}
</span>
),
)}
</>
)
} }
function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) { function ViewerAvatar({ viewer }: { viewer: NonNullable<LivePayload['viewer']> }) {
const [failed, setFailed] = useState(false)
const name = viewer.name?.trim() || translate('common.viewer')
const initial = Array.from(name)[0] || '观'
return (
<div className="viewer-avatar" aria-hidden="true">
{viewer.avatarUrl && !failed ? (
<img
src={viewer.avatarUrl}
alt=""
decoding="async"
draggable={false}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
) : (
<span>{initial}</span>
)}
</div>
)
}
function Card({
item,
settings,
expanded,
theme,
}: {
item: Item
settings: OverlaySettings
expanded: boolean
theme: OverlayThemeDefinition
}) {
const payload = item.payload || {} const payload = item.payload || {}
const viewer = payload.viewer || {} const viewer = payload.viewer || {}
const gift = payload.gift const gift = payload.gift
const isDanmaku = item.type === 'live.danmaku' const isDanmaku = item.type === 'live.danmaku'
const tier = gift ? giftTier(item, settings) : '' const tier = gift ? giftTier(item, settings) : ''
const body = gift const body = gift ? (
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}` translate('overlay.gift', {
: isDanmaku gift: gift.name || translate('common.gift'),
? <DanmakuBody payload={payload} /> quantity: payload.quantity || 1,
: eventRenderers[item.type]?.(payload) || '送来了一份互动' })
) : isDanmaku ? (
<DanmakuBody payload={payload} />
) : (
eventRenderers[item.type]?.(payload) || translate('overlay.interaction')
)
return ( return (
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}> <article
<CardDecor count={settings.particleCount} variant={item.decorVariant} /> className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
>
<CardDecor count={settings.particleCount} variant={item.decorVariant} theme={theme} />
{gift && ( {gift && (
<div className="gift-art"> <div className="gift-art">
{gift.animationUrl || gift.imageUrl {gift.animationUrl || gift.imageUrl ? (
? ( <img
<img src={gift.animationUrl || gift.imageUrl}
src={gift.animationUrl || gift.imageUrl} alt={gift.name || translate('common.gift')}
alt={gift.name || '礼物'} onError={event => {
onError={event => { const image = event.currentTarget
const image = event.currentTarget if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl else image.style.display = 'none'
else image.style.display = 'none' }}
}} />
/> ) : (
) <span>✦</span>
: <span>✦</span>} )}
</div> </div>
)} )}
<div className="copy"> <div className="copy">
<b>{viewer.name || '直播间观众'}</b> {isDanmaku && <ViewerAvatar viewer={viewer} />}
<b>{viewer.name || translate('common.viewer')}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span> <span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>} {typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
<em>¥ {gift.priceCny.toFixed(2)}</em>
)}
</div> </div>
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>} {tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article> </article>
) )
} }
export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) { export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
const { language } = useI18n()
const root = useRef<HTMLDivElement>(null) const root = useRef<HTMLDivElement>(null)
const events = useEvents(preview, publicId, accessToken) const cardsRef = useRef<HTMLDivElement>(null)
const previousCardTopsRef = useRef(new Map<string, number>())
const stackAnimationRef = useRef<Animation | null>(null)
const events = useEvents(preview, stream)
const { items, setItems } = events const { items, setItems } = events
const settings = previewSettings || events.settings const settings = previewSettings || events.settings
const theme = getOverlayTheme(settings.themeId)
const [shape, setShape] = useState('standard') const [shape, setShape] = useState('standard')
const [expandedKey, setExpandedKey] = useState<string>() const [expandedKey, setExpandedKey] = useState<string>()
const fontFactor = settings.fontScale / 100 const fontFactor = settings.fontScale / 100
@@ -308,16 +357,8 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
}, []) }, [])
useEffect(() => { useEffect(() => {
if (preview && !items.length) { if (preview) {
setItems([ setItems([
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
},
{ {
id: 'gift-preview', id: 'gift-preview',
key: 'gift-preview', key: 'gift-preview',
@@ -325,17 +366,38 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
decorVariant: 3, decorVariant: 3,
type: 'live.gift', type: 'live.gift',
payload: { payload: {
viewer: { name: '星光旅人' }, viewer: { name: translate('overlay.preview_gift_viewer') },
quantity: 1, quantity: 1,
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' }, gift: {
name: translate('overlay.preview_gift'),
totalPrice: 12_000,
priceCny: 12,
imageUrl: '',
animationUrl: '',
},
},
},
{
id: 'text-preview',
key: 'text-preview',
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
}, },
}, },
]) ])
} }
}, [items.length, preview, setItems]) }, [items.length, language, preview, setItems])
useEffect(() => { useEffect(() => {
const newest = items[0] if (!settings.expandNewDanmaku) {
setExpandedKey(undefined)
return
}
const newest = items[items.length - 1]
if (!newest) { if (!newest) {
setExpandedKey(undefined) setExpandedKey(undefined)
return return
@@ -343,33 +405,125 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
setExpandedKey(newest.key) setExpandedKey(newest.key)
const densityFactor = shape === 'short' ? 0.6 : 1 const densityFactor = shape === 'short' ? 0.6 : 1
const timer = window.setTimeout( const timer = window.setTimeout(
() => setExpandedKey(key => key === newest.key ? undefined : key), () => setExpandedKey(key => (key === newest.key ? undefined : key)),
settings.collapseAfterSeconds * 1000 * densityFactor, settings.collapseAfterSeconds * 1000 * densityFactor,
) )
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [items, settings.collapseAfterSeconds, shape]) }, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape])
useLayoutEffect(() => {
const cards = cardsRef.current
if (!cards) return
const elements = Array.from(cards.children).filter(
(element): element is HTMLElement => element instanceof HTMLElement,
)
const previousTops = previousCardTopsRef.current
const hasNewDanmaku = items.some(
item => item.type === 'live.danmaku' && !previousTops.has(item.key),
)
// If another push is still running, carry its current offset into the
// next FLIP animation so bursts of chat do not snap between positions.
let runningOffset = 0
const transform = getComputedStyle(cards).transform
if (transform !== 'none') {
try {
runningOffset = new DOMMatrixReadOnly(transform).m42
} catch {
runningOffset = 0
}
}
stackAnimationRef.current?.cancel()
const currentTops = new Map<string, number>()
items.forEach((item, index) => {
const element = elements[index]
if (element) currentTops.set(item.key, element.getBoundingClientRect().top)
})
const reducedMotion =
settings.lowPerformanceMode ||
(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
if (!settings.expandNewDanmaku && hasNewDanmaku && !reducedMotion && elements.length > 0) {
const anchorIndex = items.findIndex(item => previousTops.has(item.key))
const anchor = anchorIndex >= 0 ? elements[anchorIndex] : undefined
const anchorKey = anchorIndex >= 0 ? items[anchorIndex]?.key : undefined
const previousTop = anchorKey ? previousTops.get(anchorKey) : undefined
const currentTop = anchor?.getBoundingClientRect().top
const gap = Number.parseFloat(getComputedStyle(cards).rowGap) || 0
const fallbackOffset = cards.getBoundingClientRect().height + gap
const pushOffset = Math.max(
0,
previousTop !== undefined && currentTop !== undefined
? previousTop + runningOffset - currentTop
: fallbackOffset,
)
if (pushOffset > 0.5) {
const duration = 350 + settings.motionIntensity * 3.5
const animation = cards.animate(
[
{ transform: `translate3d(0, ${pushOffset}px, 0)` },
{ transform: 'translate3d(0, 0, 0)' },
],
{
duration,
easing: 'cubic-bezier(0.2, 0.82, 0.24, 1)',
},
)
stackAnimationRef.current = animation
animation.addEventListener('finish', () => {
if (stackAnimationRef.current === animation) stackAnimationRef.current = null
})
}
}
previousCardTopsRef.current = currentTops
}, [items, settings.expandNewDanmaku, settings.lowPerformanceMode, settings.motionIntensity])
useEffect(
() => () => {
stackAnimationRef.current?.cancel()
},
[],
)
const missingAccess = !preview && (!publicId || !accessToken)
return ( return (
<main <main
ref={root} ref={root}
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`} className={`overlay ${theme.className} ${shape} ${settings.expandNewDanmaku ? '' : 'stack-arrival'} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
data-connection={events.connection} data-theme={theme.id}
style={{ data-connection={stream?.connection || 'idle'}
['--motion' as string]: `${settings.motionIntensity / 100}`, style={
['--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`, ...themeCssVariables(theme),
['--font-body' as string]: `${18 * fontFactor}px`, ...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--font-expanded' as string]: `${26 * fontFactor}px`, ['--component-viewer-color' as string]: settings.viewerColor || undefined,
['--font-compact' as string]: `${15 * fontFactor}px`, ['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
}} ['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--component-decoration-rail' as string]: `${settings.decorationLineWeight / 50}px`,
['--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
}
> >
<ThemeEdges />
<section className="wall"> <section className="wall">
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>} <div className="cards" ref={cardsRef}>
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
<div className="cards">
{items.map(item => ( {items.map(item => (
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} /> <Card
item={item}
settings={settings}
expanded={settings.expandNewDanmaku && item.key === expandedKey}
theme={theme}
key={item.key}
/>
))} ))}
</div> </div>
</section> </section>
+68 -31
View File
@@ -1,4 +1,14 @@
/**
* Install, offline and explicit-update lifecycle for the control-console PWA.
*
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
* the scope. The worker caches only the public application shell and static
* assets. API responses, WebSockets and secrets remain network-only. A waiting
* worker activates only after user confirmation and after every registered
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react' import { useEffect, useSyncExternalStore } from 'react'
import { translate, useI18n } from './i18n'
interface InstallChoice { interface InstallChoice {
outcome: 'accepted' | 'dismissed' outcome: 'accepted' | 'dismissed'
@@ -58,9 +68,10 @@ function addManifest() {
function observeRegistration(registration: ServiceWorkerRegistration) { function observeRegistration(registration: ServiceWorkerRegistration) {
emit({ emit({
registration, registration,
waitingWorker: registration.waiting && navigator.serviceWorker.controller waitingWorker:
? registration.waiting registration.waiting && navigator.serviceWorker.controller
: snapshot.waitingWorker, ? registration.waiting
: snapshot.waitingWorker,
}) })
registration.addEventListener('updatefound', () => { registration.addEventListener('updatefound', () => {
@@ -76,24 +87,29 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
async function removeLegacyRootWorker() { async function removeLegacyRootWorker() {
const registrations = await navigator.serviceWorker.getRegistrations() const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(async registration => { await Promise.all(
const scopePath = new URL(registration.scope).pathname registrations.map(async registration => {
const workers = [registration.installing, registration.waiting, registration.active] const scopePath = new URL(registration.scope).pathname
const isLegacy = scopePath === '/' const workers = [registration.installing, registration.waiting, registration.active]
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js') const isLegacy =
if (isLegacy) await registration.unregister() scopePath === '/' &&
})) workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}),
)
const cacheNames = await caches.keys() const cacheNames = await caches.keys()
await Promise.all(cacheNames await Promise.all(
.filter(name => name.startsWith('lxc-control-shell-')) cacheNames
.map(name => caches.delete(name))) .filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)),
)
} }
export function cleanupLegacyPwa() { export function cleanupLegacyPwa() {
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
void removeLegacyRootWorker().catch(error => { void removeLegacyRootWorker().catch(error => {
console.warn('旧版 PWA 清理失败', error) console.warn('Legacy PWA cleanup failed', error)
}) })
} }
@@ -107,7 +123,7 @@ async function registerWorker() {
observeRegistration(registration) observeRegistration(registration)
} catch (error) { } catch (error) {
// A failed PWA registration must never stop the online control console. // A failed PWA registration must never stop the online control console.
console.warn('控制台 PWA 注册失败', error) console.warn('Console PWA registration failed', error)
} }
} }
@@ -121,18 +137,25 @@ export function initializePwa() {
const updateDisplayMode = () => emit({ standalone: isStandalone() }) const updateDisplayMode = () => emit({ standalone: isStandalone() })
window.addEventListener('online', updateConnection) window.addEventListener('online', updateConnection)
window.addEventListener('offline', updateConnection) window.addEventListener('offline', updateConnection)
const modernListener = (displayMode as unknown as { const modernListener = (
addEventListener?: (type: 'change', listener: () => void) => void displayMode as unknown as {
}).addEventListener addEventListener?: (type: 'change', listener: () => void) => void
}
).addEventListener
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode) if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
else (displayMode as unknown as { addListener?: (listener: () => void) => void }) else
.addListener?.call(displayMode, updateDisplayMode) (displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
displayMode,
updateDisplayMode,
)
window.addEventListener('beforeinstallprompt', event => { window.addEventListener('beforeinstallprompt', event => {
event.preventDefault() event.preventDefault()
emit({ installPrompt: event as BeforeInstallPromptEvent }) emit({ installPrompt: event as BeforeInstallPromptEvent })
}) })
window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true })) window.addEventListener('appinstalled', () =>
emit({ installPrompt: undefined, standalone: true }),
)
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
@@ -151,13 +174,17 @@ export function initializePwa() {
void snapshot.registration?.update() void snapshot.registration?.update()
} }
}) })
window.setInterval(() => { window.setInterval(
if (navigator.onLine) void snapshot.registration?.update() () => {
}, 60 * 60 * 1000) if (navigator.onLine) void snapshot.registration?.update()
},
60 * 60 * 1000,
)
} }
export function authRoute(name: 'login' | 'register' | 'setup'): string { export function authRoute(name: 'login' | 'register' | 'setup'): string {
const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/') const inControlScope =
location.pathname === '/control' || location.pathname.startsWith('/control/')
return inControlScope ? `/control/${name}` : `/${name}` return inControlScope ? `/control/${name}` : `/${name}`
} }
@@ -165,7 +192,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
useEffect(() => { useEffect(() => {
if (active) updateBlockers.set(key, reason) if (active) updateBlockers.set(key, reason)
else updateBlockers.delete(key) else updateBlockers.delete(key)
return () => { updateBlockers.delete(key) } return () => {
updateBlockers.delete(key)
}
}, [active, key, reason]) }, [active, key, reason])
} }
@@ -182,31 +211,39 @@ function applyUpdate() {
if (!worker) return if (!worker) return
const reasons = [...new Set(updateBlockers.values())] const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) { if (reasons.length > 0) {
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`) window.alert(
translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }),
)
return return
} }
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?') const confirmed = window.confirm(translate('pwa.confirm_update'))
if (!confirmed) return if (!confirmed) return
reloadForUpdate = true reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' }) worker.postMessage({ type: 'SKIP_WAITING' })
} }
export function PwaControls() { export function PwaControls() {
const { t } = useI18n()
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const canInstall = Boolean(state.installPrompt) && !state.standalone const canInstall = Boolean(state.installPrompt) && !state.standalone
if (state.online && !state.waitingWorker && !canInstall) return null if (state.online && !state.waitingWorker && !canInstall) return null
return ( return (
<div className="pwa-controls" aria-live="polite"> <div className="pwa-controls" aria-live="polite">
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>} {!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
{t('pwa.offline')}
</span>
)}
{state.waitingWorker && ( {state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}> <button type="button" className="pwa-action update" onClick={applyUpdate}>
更新可用 {t('pwa.update')}
</button> </button>
)} )}
{canInstall && ( {canInstall && (
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}> <button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
安装到设备 {t('pwa.install')}
</button> </button>
)} )}
</div> </div>
+657
View File
@@ -0,0 +1,657 @@
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: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', 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: var(--component-decoration-line, 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: var(--component-decoration-line, 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-mark-note,
.song-waveform {
display: none;
}
.song-current-copy {
position: relative;
z-index: 1;
display: grid;
min-width: 0;
gap: 0.12em;
}
.song-current-copy,
.song-queue > header,
.song-row,
.song-queue-empty {
filter: brightness(var(--component-font-brightness, 1.3));
}
.song-current-copy small {
color: var(--component-song-requester-color, var(--theme-compact-user));
font-size: 0.68em;
}
.song-current-copy strong {
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere;
font-size: 1.35em;
line-height: 1.08;
text-shadow: 0 0 20px rgba(133, 255, 232, 0.2);
}
.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: var(--component-decoration-line, 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);
align-items: center;
gap: 0.55em;
padding: 0.4em 0.62em;
border: var(--component-decoration-line, 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-copy {
display: grid;
min-width: 0;
gap: 0.12em;
}
.song-row strong {
min-width: 0;
color: var(--component-song-title-color, var(--theme-text));
overflow-wrap: anywhere;
}
.song-index {
color: var(--theme-accent);
font-variant-numeric: tabular-nums;
}
.song-requester {
min-width: 0;
overflow: hidden;
color: var(--component-song-requester-color, var(--theme-compact-user));
text-align: left;
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-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;
}
}
/* Moonlit Water follows the reference's restrained broadcast typography: a
compact current-song lockup above a borderless three-column programme. */
.song-overlay.theme-moonlit-water {
gap: clamp(12px, 2vmin, 24px);
padding: clamp(10px, 1.8vmin, 22px);
color: #c6ae7a;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
font-size: var(--song-moonlit-font-size, var(--song-font-size));
letter-spacing: 0.055em;
}
.theme-moonlit-water .song-current,
.theme-moonlit-water .song-queue {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.theme-moonlit-water .song-current::after,
.theme-moonlit-water .song-queue::after {
display: none;
}
.theme-moonlit-water .song-current {
min-height: var(--song-moonlit-current-min, 88px);
grid-template-columns: auto minmax(0, 1fr) auto;
gap: clamp(10px, 1.5vmin, 18px);
padding: clamp(6px, 0.8vmin, 10px) 0 clamp(12px, 1.6vmin, 20px);
border-bottom: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.26);
animation: moonlit-song-current 0.68s ease-out both;
}
.theme-moonlit-water .song-current-mark {
position: relative;
width: clamp(46px, 3.7em, 68px);
border: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.62);
border-radius: 50%;
color: #d2b468;
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
box-shadow:
inset 0 0 0 3px rgba(15, 57, 64, 0.2),
0 0 16px rgba(198, 174, 122, 0.08);
font-size: 1.3em;
}
.theme-moonlit-water .song-current-mark::before {
content: '';
position: absolute;
top: -0.58em;
right: -0.18em;
width: 0.72em;
height: 0.42em;
border: var(--component-decoration-line, 1px) solid rgba(116, 166, 157, 0.52);
border-width: var(--component-decoration-line, 1px) 0 0 var(--component-decoration-line, 1px);
border-radius: 100% 0;
transform: rotate(-24deg);
}
.theme-moonlit-water .song-current-mark-label {
display: none;
}
.theme-moonlit-water .song-current-mark-note {
display: block;
line-height: 1;
}
.theme-moonlit-water .song-current-copy {
gap: 0.18em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
}
.theme-moonlit-water .song-current-copy small,
.theme-moonlit-water .song-requester,
.theme-moonlit-water .song-queue > header {
color: var(--component-song-requester-color, #a9b79b);
}
.theme-moonlit-water .song-current-copy strong {
color: var(--component-song-title-color, #c6ae7a);
font-size: 1.52em;
font-weight: 600;
letter-spacing: 0.09em;
line-height: 1.16;
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
}
.theme-moonlit-water .song-index {
color: #c9b06f;
}
.theme-moonlit-water .song-waveform {
display: flex;
height: 0.8em;
align-items: end;
gap: 0.16em;
opacity: 0.34;
}
.theme-moonlit-water .song-waveform i {
width: var(--component-decoration-line, 2px);
height: 25%;
background: #6a9f99;
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
}
.theme-moonlit-water .song-waveform i:nth-child(2),
.theme-moonlit-water .song-waveform i:nth-child(7) {
height: 48%;
animation-delay: -0.4s;
}
.theme-moonlit-water .song-waveform i:nth-child(3),
.theme-moonlit-water .song-waveform i:nth-child(6) {
height: 76%;
animation-delay: -0.9s;
}
.theme-moonlit-water .song-waveform i:nth-child(4),
.theme-moonlit-water .song-waveform i:nth-child(5) {
height: 100%;
animation-delay: -1.25s;
}
.theme-moonlit-water .song-queue > header {
justify-content: flex-start;
gap: 0.6em;
padding: 0.28em 0 0.72em;
border-bottom: 0;
color: #a9b79b;
letter-spacing: 0.2em;
}
.theme-moonlit-water .song-queue > header::before {
content: '✦';
color: #6daaa3;
font-size: 0.66em;
}
.theme-moonlit-water .song-queue > header b {
display: none;
}
.theme-moonlit-water .song-queue-track {
gap: 0;
padding: 0;
}
.theme-moonlit-water .song-row {
grid-template-columns: 2.4em minmax(0, 1fr);
gap: 0.82em;
padding: 0.66em 1.3em 0.66em 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
animation: moonlit-song-row-insert 0.54s ease-out both;
animation-delay: var(--song-row-delay, 0ms);
}
.theme-moonlit-water .song-row::before {
display: none;
}
.theme-moonlit-water .song-row::after {
content: '✦';
top: 50%;
right: 0.15em;
bottom: auto;
width: auto;
height: auto;
border-radius: 0;
color: #8eb5a4;
background: none;
filter: drop-shadow(0 0 3px rgba(142, 181, 164, 0.38));
font-size: 0.68em;
transform: translateY(-50%);
}
.theme-moonlit-water .song-row strong {
color: var(--component-song-title-color, #c6ae7a);
font-weight: 500;
letter-spacing: 0.09em;
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.74);
}
.theme-moonlit-water .song-index {
font-size: 0.9em;
font-weight: 400;
letter-spacing: 0.08em;
}
.theme-moonlit-water .song-requester {
color: var(--component-song-requester-color, #a9b79b);
font-size: 0.78em;
letter-spacing: 0.06em;
}
.theme-moonlit-water .song-particle-layer {
opacity: 0.22;
}
.theme-moonlit-water .song-particle-layer > :nth-child(n + 5) {
display: none;
}
.theme-moonlit-water .song-particle.star {
background: #e7c982;
filter: none;
}
.theme-moonlit-water .song-particle.floret {
background:
radial-gradient(circle at 50% 17%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #d9e0c1 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #e7c982 0 18%, transparent 21%);
}
.song-overlay.theme-moonlit-water.song-narrow .song-current {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.song-overlay.theme-moonlit-water.song-short {
gap: 6px;
padding-block: 6px;
}
.song-overlay.theme-moonlit-water.song-short .song-current {
min-height: 58px;
padding-block: 3px 7px;
}
.song-overlay.theme-moonlit-water.song-short .song-current-mark {
width: 2.6em;
}
.song-overlay.theme-moonlit-water.song-short .song-current-copy strong {
font-size: 1.18em;
}
.song-overlay.theme-moonlit-water.song-short .song-row {
padding-block: 0.42em;
}
@keyframes moonlit-song-current {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes moonlit-song-row-insert {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes moonlit-song-wave {
from {
opacity: 0.32;
transform: scaleY(0.5);
}
to {
opacity: 0.88;
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.theme-moonlit-water .song-waveform i {
animation: none;
}
}
+384
View File
@@ -0,0 +1,384 @@
/** Transparent OBS renderer for the durable song request queue. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, RefObject } from 'react'
import { normalizeSongRequestItem, normalizeSongRequestSettings } from './api'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { ThemeEdges } from './ThemeEdges'
import { defaultSongRequestSettings } from './types'
import type { SongRequestItem, SongRequestSettings } from './types'
import { typographyVariables } from './typography'
type QueueState = {
initialized: boolean
revision: number
current?: SongRequestItem
queued: SongRequestItem[]
}
type SnapshotDraft = {
id: string
revision: number
current?: SongRequestItem
totalQueued: number
items: SongRequestItem[]
}
function previewData(): { current: SongRequestItem; queued: SongRequestItem[] } {
const now = new Date().toISOString()
const titles = translate('song.overlay.preview_titles').split('|')
const viewers = translate('song.overlay.preview_viewers').split('|')
return {
current: {
id: 'preview-current',
title: translate('song.overlay.preview_title'),
requester: { uid: '10001', name: translate('song.overlay.preview_viewer') },
status: 'current',
queuePosition: 0,
requestedAt: now,
startedAt: now,
},
queued: titles.map((title, index) => ({
id: `preview-${index}`,
title,
requester: { uid: String(10002 + index), name: viewers[index] ?? viewers[0] },
status: 'queued',
queuePosition: index + 1,
requestedAt: now,
})),
}
}
function payloadRecord(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function ordered(items: SongRequestItem[]): SongRequestItem[] {
return [...items]
.filter(item => item.status === 'queued')
.sort((left, right) => left.queuePosition - right.queuePosition)
}
function useSongQueue(preview: boolean, language: string, stream?: ComponentStream) {
const initialPreview = previewData()
const [settings, setSettings] = useState(defaultSongRequestSettings)
const [queue, setQueue] = useState<QueueState>(() => ({
initialized: preview,
revision: 0,
current: preview ? initialPreview.current : undefined,
queued: preview ? initialPreview.queued : [],
}))
const queueRef = useRef(queue)
const draftRef = useRef<SnapshotDraft | undefined>(undefined)
const lastSequenceRef = useRef(0)
useEffect(() => {
queueRef.current = queue
}, [queue])
useEffect(() => {
if (!preview) return
const translated = previewData()
const next = {
initialized: true,
revision: 0,
current: translated.current,
queued: translated.queued,
}
queueRef.current = next
setQueue(next)
}, [language, preview])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
for (const message of pending) {
lastSequenceRef.current = message.sequence
const { type, payload: rawPayload } = message.envelope
const payload = payloadRecord(rawPayload)
if (type === 'component.settings.snapshot' || type === 'component.settings.updated') {
setSettings(normalizeSongRequestSettings(payload.settings))
continue
}
if (type === 'song.queue.snapshot.begin') {
draftRef.current = {
id: String(payload.snapshotId ?? ''),
revision: Number(payload.revision ?? 0),
current: normalizeSongRequestItem(payload.current),
totalQueued: Number(payload.totalQueued ?? 0),
items: [],
}
continue
}
if (type === 'song.queue.snapshot.page') {
const draft = draftRef.current
if (
!draft ||
draft.id !== String(payload.snapshotId) ||
draft.revision !== Number(payload.revision) ||
draft.items.length !== Number(payload.offset)
) {
stream.resync()
return
}
const items = (Array.isArray(payload.items) ? payload.items : [])
.map(normalizeSongRequestItem)
.filter((item): item is SongRequestItem => Boolean(item))
draft.items.push(...items)
continue
}
if (type === 'song.queue.snapshot.end') {
const draft = draftRef.current
if (
!draft ||
draft.id !== String(payload.snapshotId) ||
draft.revision !== Number(payload.revision) ||
draft.items.length !== draft.totalQueued
) {
stream.resync()
return
}
draftRef.current = undefined
const nextQueue = {
initialized: true,
revision: draft.revision,
current: draft.current,
queued: ordered(draft.items),
}
// Keep the reducer reference current before React commits. A delta may
// follow snapshot.end in this same buffered batch.
queueRef.current = nextQueue
setQueue(nextQueue)
continue
}
if (type !== 'song.queue.changed') continue
const revision = Number(payload.revision)
const previous = queueRef.current
if (!previous.initialized || !Number.isSafeInteger(revision)) {
stream.resync()
return
}
if (revision <= previous.revision) continue
if (revision !== previous.revision + 1) {
stream.resync()
return
}
const operation = String(payload.operation ?? '')
const itemId = String(payload.itemId ?? '')
const item = normalizeSongRequestItem(payload.item)
const current = normalizeSongRequestItem(payload.current)
let queued =
operation === 'cleared'
? []
: previous.queued.filter(entry => entry.id !== itemId && entry.id !== current?.id)
if (item?.status === 'queued') queued.push(item)
if (operation === 'promoted' && item) {
queued = queued.map(entry =>
entry.id === item.id ? { ...entry, queuePosition: 1 } : entry,
)
}
const nextQueue = {
initialized: true,
revision,
current,
queued: ordered(queued),
}
queueRef.current = nextQueue
setQueue(nextQueue)
}
}, [preview, stream, stream?.messages])
return { settings, queue }
}
/** Theme-owned stars and florets used inside the compact current-song card. */
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
return (
<div className="song-particle-layer" aria-hidden="true">
{theme.ornaments.particles.slice(0, count).map((kind, index) => (
<i className={`song-particle ${kind}`} key={`${kind}-${index}`} />
))}
</div>
)
}
function useBounceScroll(
viewport: RefObject<HTMLDivElement | null>,
track: RefObject<HTMLDivElement | null>,
speed: number,
pauseSeconds: number,
dependency: unknown,
) {
useEffect(() => {
const container = viewport.current
const content = track.current
if (!container || !content) return
let frame = 0
let direction = 1
let last = performance.now()
let pausedUntil = last + pauseSeconds * 1000
// OBS can ship an older Chromium that rounds scrollTop to whole pixels.
// Keep fractional movement here so low configured speeds still advance.
let pendingPixels = 0
const resize = new ResizeObserver(() => {
container.scrollTop = Math.min(
container.scrollTop,
Math.max(0, content.scrollHeight - container.clientHeight),
)
})
resize.observe(container)
resize.observe(content)
const tick = (now: number) => {
const maximum = Math.max(0, content.scrollHeight - container.clientHeight)
const elapsed = Math.min(80, now - last)
last = now
if (maximum === 0) container.scrollTop = 0
else if (now >= pausedUntil) {
pendingPixels += Math.max(5, speed) * (elapsed / 1000)
const wholePixels = Math.floor(pendingPixels)
if (wholePixels > 0) {
container.scrollTop += direction * wholePixels
pendingPixels -= wholePixels
}
if (container.scrollTop >= maximum - 0.5) {
container.scrollTop = maximum
direction = -1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000
} else if (container.scrollTop <= 0.5) {
container.scrollTop = 0
direction = 1
pendingPixels = 0
pausedUntil = now + pauseSeconds * 1000
}
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(frame)
resize.disconnect()
}
}, [dependency, pauseSeconds, speed, track, viewport])
}
export function SongRequestOverlay({
preview = false,
previewSettings,
stream,
}: {
preview?: boolean
previewSettings?: SongRequestSettings
stream?: ComponentStream
}) {
const { language } = useI18n()
const { settings: remoteSettings, queue } = useSongQueue(preview, language, stream)
const settings = previewSettings ?? remoteSettings
const theme = getOverlayTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
const viewport = useRef<HTMLDivElement>(null)
const track = useRef<HTMLDivElement>(null)
const [bounds, setBounds] = useState({ width: 500, height: 500 })
useEffect(() => {
const element = root.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(element)
return () => observer.disconnect()
}, [])
useBounceScroll(
viewport,
track,
settings.scrollSpeedPixelsPerSecond,
settings.edgePauseSeconds,
queue.queued.map(item => item.id).join(':'),
)
return (
<main
ref={root}
className={`song-overlay ${theme.className} ${bounds.width < 520 ? 'song-narrow' : ''} ${bounds.height < 250 ? 'song-short' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={
{
...themeCssVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
['--song-moonlit-current-min' as string]: `${Math.min(118, Math.max(74, bounds.height * 0.135))}px`,
} as CSSProperties
}
>
<ThemeEdges />
<section
className="song-current"
aria-label={translate('song.overlay.current_aria')}
key={queue.current?.id ?? 'empty-current'}
>
<SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true">
<span className="song-current-mark-label">{translate('song.overlay.sing_mark')}</span>
<span className="song-current-mark-note">♫</span>
</div>
{queue.current ? (
<div className="song-current-copy">
<small>
{translate('song.overlay.now_singing', { viewer: queue.current.requester.name })}
</small>
<strong>{queue.current.title}</strong>
</div>
) : (
<div className="song-current-copy empty">
<small>{translate('song.overlay.waiting')}</small>
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
<header>
<span>{translate('song.overlay.queue_title')}</span>
<b>{queue.queued.length}</b>
</header>
<div className="song-queue-viewport" ref={viewport}>
<div className="song-queue-track" ref={track}>
{queue.queued.map((item, index) => (
<article
className="song-row"
key={item.id}
style={{ ['--song-row-delay' as string]: `${Math.min(index, 6) * 35}ms` }}
>
<span className="song-index">{String(index + 1).padStart(2, '0')}</span>
<div className="song-row-copy">
<span className="song-requester">{item.requester.name}</span>
<strong>{item.title}</strong>
</div>
</article>
))}
{queue.queued.length === 0 && (
<div className="song-queue-empty">{translate('song.overlay.queue_empty')}</div>
)}
</div>
</div>
</section>
</main>
)
}
+146
View File
@@ -0,0 +1,146 @@
/** 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
language?: 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 [language, setLanguage] = 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)
setLanguage(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
language?: string
}
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
setComponentKind(envelope.componentKind)
setLanguage(envelope.language)
setConnection('connected')
return
}
if (envelope.type === 'component.language.updated') {
setLanguage(String(envelope.payload?.language ?? ''))
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, language, connection, messages, resync }
}
+281 -1
View File
@@ -1 +1,281 @@
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}} * {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
font-family: 'Noto Serif SC', 'Microsoft YaHei', serif;
}
body {
background: transparent;
color: var(--theme-text, #dcfffa);
}
.overlay {
width: 100%;
height: 100%;
padding: clamp(8px, 2vw, 22px);
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: radial-gradient(ellipse at 100% 50%, rgba(21, 94, 96, 0.2), transparent 62%);
}
.wall {
width: min(100%, 480px);
display: flex;
flex-direction: column;
gap: 9px;
filter: drop-shadow(0 10px 28px rgba(0, 11, 19, 0.36));
}
.cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.card {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: var(--component-decoration-line, 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: var(
--theme-card-background,
linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61))
);
backdrop-filter: blur(15px);
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: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
transform: translateX(-120%);
animation: var(--theme-motion-sheen, sheen) calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s
both;
}
.copy {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: clamp(12px, 2.7vw, 17px);
}
.copy b {
color: var(--theme-user, #e6fff9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy span {
color: var(--theme-text, #a9dbd4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy em {
font-style: normal;
color: var(--theme-price, #ffdc89);
font-size: 0.84em;
}
.gift-art {
z-index: 1;
width: 48px;
height: 48px;
flex: none;
border-radius: 12px;
background: radial-gradient(circle, #2e9f9e, transparent 66%);
display: grid;
place-items: center;
}
.gift-art img {
width: 100%;
height: 100%;
object-fit: contain;
}
.gift.high {
min-height: 72px;
border-color: rgba(102, 255, 229, 0.65);
background: linear-gradient(105deg, rgba(5, 59, 78, 0.92), rgba(14, 119, 103, 0.7));
}
.gift.featured {
min-height: 128px;
border-color: #b6ffdc;
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: var(--theme-motion-featured, featured) calc(2.8s - 1.5s * var(--motion)) ease-in-out
infinite;
}
.gift.featured .gift-art {
width: 92px;
height: 92px;
}
.particles {
position: absolute;
right: 9px;
top: 6px;
color: #ceffe4;
letter-spacing: 9px;
animation: var(--theme-motion-float, float) 2.2s ease-in-out infinite;
}
.narrow .overlay {
padding: 6px;
}
.narrow .wall {
gap: 5px;
}
.narrow .card {
min-height: 45px;
padding: 7px 9px;
border-radius: 10px;
}
.narrow .gift-art {
width: 34px;
height: 34px;
}
.narrow .gift.featured {
min-height: 90px;
align-items: flex-start;
}
.narrow .gift.featured .gift-art {
width: 58px;
height: 58px;
}
.short .cards .card:nth-child(n + 4) {
display: none;
}
.low-motion * {
animation: none !important;
}
.control {
min-height: 100%;
padding: clamp(20px, 5vw, 56px);
display: grid;
grid-template-columns: minmax(300px, 520px) minmax(280px, 1fr);
gap: 36px;
background: linear-gradient(135deg, #061923, #092c32 55%, #06151f);
color: #d9fff6;
}
.control h1 {
margin: 0;
color: #bffff0;
}
.control h2 {
margin: 0;
}
.control p {
color: #88bdb5;
}
.control label {
display: block;
margin: 14px 0;
color: #bcebe3;
}
.control input:not([type='checkbox']):not([type='range']) {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #357e79;
border-radius: 8px;
background: #071d28;
color: #e6fff9;
}
.control input[type='range'] {
margin-left: 10px;
accent-color: #5ae7c6;
}
.control output {
margin-left: 8px;
}
.control fieldset {
border: 1px solid #28645f;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 3px 12px;
}
.control fieldset label {
margin: 8px 0;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.control button {
padding: 10px 14px;
border: 0;
border-radius: 8px;
background: #55dcb9;
color: #06211e;
font-weight: bold;
cursor: pointer;
}
.control button.secondary {
background: #173e4b;
color: #d4fff7;
}
.preview-frame {
height: 600px;
resize: both;
overflow: auto;
border: 1px dashed #4dafa4;
background: linear-gradient(135deg, rgba(71, 190, 172, 0.12), transparent);
}
.login {
display: grid;
place-content: center;
grid-template-columns: 360px;
background: #071923;
}
.login form {
display: flex;
flex-direction: column;
gap: 12px;
}
@keyframes arrive {
from {
opacity: 0;
transform: translateX(38px) scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheen {
to {
transform: translateX(135%);
}
}
@keyframes featured {
50% {
filter: brightness(1.18);
transform: scale(1.015);
}
}
@keyframes float {
50% {
transform: translateY(-8px);
opacity: 0.5;
}
}
@media (max-width: 720px) {
.control {
grid-template-columns: 1fr;
}
.preview-frame {
height: 480px;
}
}
+127
View File
@@ -0,0 +1,127 @@
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
It uses inline SVG so the control setting changes actual stroke width rather
than vertically stretching a thin bitmap/mask. */
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
) {
--moonlit-edge-height: clamp(22px, 3.6vmin, 44px);
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
--moonlit-edge-space: clamp(16px, 2.5vmin, 30px);
position: relative;
isolation: isolate;
}
.theme-edge-set {
display: none;
}
:is(
.overlay.theme-moonlit-water,
.song-overlay.theme-moonlit-water,
.gift-menu-overlay.gift-menu-theme-moonlit-water
)
> .theme-edge-set {
position: absolute;
z-index: 5;
inset: 0;
display: block;
overflow: hidden;
pointer-events: none;
user-select: none;
}
.theme-edge-art {
position: absolute;
left: clamp(8px, 1.4vmin, 18px);
width: calc(100% - clamp(16px, 2.8vmin, 36px));
height: var(--moonlit-edge-height);
overflow: visible;
filter: drop-shadow(0 0 3px rgba(205, 172, 88, 0.28));
animation: moonlit-edge-breathe 7s ease-in-out infinite;
}
.theme-edge-top {
top: var(--moonlit-edge-offset);
}
.theme-edge-bottom {
bottom: var(--moonlit-edge-offset);
transform: rotate(180deg);
animation-delay: -3.5s;
}
.theme-edge-rule,
.theme-edge-water {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: var(--component-decoration-edge-stroke, 3.2px);
}
.theme-edge-rule path,
.theme-edge-water path {
vector-effect: non-scaling-stroke;
}
.theme-edge-rule {
stroke: #d2b35f;
opacity: 0.94;
}
.theme-edge-water {
stroke: #5f978b;
opacity: 0.9;
}
.theme-edge-cloud {
fill: #568b80;
opacity: 0.76;
}
.theme-edge-leaves {
fill: #86aa92;
opacity: 0.88;
}
.theme-edge-lotus {
fill: #d4b45d;
opacity: 0.96;
filter: drop-shadow(0 0 2px rgba(221, 190, 104, 0.36));
}
.theme-edge-dots {
fill: #e4c978;
opacity: 0.94;
}
.overlay.theme-moonlit-water {
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
}
.song-overlay.theme-moonlit-water {
padding-block: calc(clamp(10px, 1.8vmin, 22px) + var(--moonlit-edge-space));
}
.gift-menu-overlay.gift-menu-theme-moonlit-water {
height: 100%;
align-items: center;
padding-block: calc(clamp(8px, 1.3vmin, 16px) + var(--moonlit-edge-space));
}
@keyframes moonlit-edge-breathe {
0%,
100% {
opacity: 0.78;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.theme-edge-art {
animation: none;
}
}
+49
View File
@@ -0,0 +1,49 @@
import { jadeScrollTheme } from './jadeScroll'
import { moonlitWaterTheme } from './moonlitWater'
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, moonlitWaterTheme]
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-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
'--theme-pattern-lotus': `url("${theme.ornaments.patterns.lotus}")`,
'--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,
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.svg'
/** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = {
id: 'jade-scroll',
nameKey: 'theme.jade_scroll.name',
descriptionKey: 'theme.jade_scroll.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, edge, lotus },
},
motion: {
arrive: 'arrive',
sheen: 'sheen',
featured: 'featured',
float: 'float',
sparkle: 'card-sparkle',
unfurl: 'scroll-unfurl',
railsOpen: 'scroll-rails-open',
},
}
+46
View File
@@ -0,0 +1,46 @@
import type { OverlayThemeDefinition } from './types'
const divider = '/assets/floral-divider.svg'
const cluster = '/assets/floral-cluster.svg'
const vine = '/assets/floral-vine.svg'
const edge = '/assets/moonlit-edge.svg'
const lotus = '/assets/lotus-mark.svg'
/**
* A deliberately sparse, text-led theme inspired by moonlight on still water.
*
* Unlike the original glass theme, its surfaces are fully transparent: the
* broadcaster's scene is the background and the overlay contributes only type,
* fine rules, a few drifting petals, and a restrained moonlit shimmer.
*/
export const moonlitWaterTheme: OverlayThemeDefinition = {
id: 'moonlit-water',
nameKey: 'theme.moonlit_water.name',
descriptionKey: 'theme.moonlit_water.description',
className: 'theme-moonlit-water',
palette: {
text: '#c6ae7a',
user: '#b7ad83',
compactUser: '#b7ad83',
accent: '#e7c982',
price: '#f2d79c',
},
surfaces: {
cardBorder: 'transparent',
cardBackground: 'transparent',
},
ornaments: {
variantCount: 4,
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
patterns: { divider, cluster, vine, edge, lotus },
},
motion: {
arrive: 'moonlit-line-arrive',
sheen: 'moonlit-line-sheen',
featured: 'moonlit-gift-breathe',
float: 'moonlit-drift',
sparkle: 'moonlit-sparkle',
unfurl: 'moonlit-reveal',
railsOpen: 'moonlit-rule-reveal',
},
}
+50
View File
@@ -0,0 +1,50 @@
/** Stable IDs persisted in component settings and emitted over WebSocket. */
export const overlayThemeIds = ['jade-scroll', 'moonlit-water'] 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
nameKey: string
descriptionKey: 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
edge: string
lotus: string
}
}
motion: {
arrive: string
sheen: string
featured: string
float: string
sparkle: string
unfurl: string
railsOpen: string
}
}
+217 -1
View File
@@ -1,5 +1,21 @@
/**
* Shared control-console view models.
*
* These types describe sanitized API responses and editable settings, not raw
* 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'
import type { FontFamilyId } from './typography'
export type OverlaySettings = { export type OverlaySettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
viewerColor: string | null
danmakuColor: string | null
fontScale: number fontScale: number
decorationLineWeight: number
showDanmaku: boolean showDanmaku: boolean
showEnter: boolean showEnter: boolean
showGift: boolean showGift: boolean
@@ -8,6 +24,7 @@ export type OverlaySettings = {
showLike: boolean showLike: boolean
showShare: boolean showShare: boolean
maxVisible: number maxVisible: number
expandNewDanmaku: boolean
collapseAfterSeconds: number collapseAfterSeconds: number
unfoldDurationMs: number unfoldDurationMs: number
motionIntensity: number motionIntensity: number
@@ -19,7 +36,13 @@ export type OverlaySettings = {
} }
export const defaultOverlaySettings: OverlaySettings = { export const defaultOverlaySettings: OverlaySettings = {
themeId: 'jade-scroll',
fontFamily: 'fang-song',
fontBrightness: 130,
viewerColor: null,
danmakuColor: null,
fontScale: 140, fontScale: 140,
decorationLineWeight: 160,
showDanmaku: true, showDanmaku: true,
showEnter: true, showEnter: true,
showGift: true, showGift: true,
@@ -28,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
showLike: false, showLike: false,
showShare: false, showShare: false,
maxVisible: 5, maxVisible: 5,
expandNewDanmaku: true,
collapseAfterSeconds: 12, collapseAfterSeconds: 12,
unfoldDurationMs: 1000, unfoldDurationMs: 1000,
motionIntensity: 70, motionIntensity: 70,
@@ -38,6 +62,198 @@ export const defaultOverlaySettings: OverlaySettings = {
featuredValueThreshold: 100_000, featuredValueThreshold: 100_000,
} }
/** Renderer and anti-spam settings for the built-in song request component. */
export type SongRequestSettings = {
themeId: OverlayThemeId
fontFamily: FontFamilyId
fontBrightness: number
requesterColor: string | null
songTitleColor: string | null
fontScale: number
decorationLineWeight: 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',
fontFamily: 'fang-song',
fontBrightness: 130,
requesterColor: null,
songTitleColor: null,
fontScale: 100,
decorationLineWeight: 160,
scrollSpeedPixelsPerSecond: 28,
edgePauseSeconds: 2,
maxQueueSize: 0,
maxRequestsPerViewer: 0,
requestCooldownSeconds: 0,
}
export type GiftEffectThemeId = 'jade-starfall' | 'moonlit-water'
export type MeteorTierSettings = {
count: number
size: number
speed: number
}
/** Full-viewport visual settings for gifts. */
export type GiftEffectSettings = {
themeId: GiftEffectThemeId
fontFamily: FontFamilyId
fontBrightness: number
highValueThreshold: number
featuredValueThreshold: number
normal: MeteorTierSettings
high: MeteorTierSettings
featured: MeteorTierSettings
trailIntensity: number
queueCapacity: number
lowPerformanceMode: boolean
}
export const defaultGiftEffectSettings: GiftEffectSettings = {
themeId: 'jade-starfall',
fontFamily: 'fang-song',
fontBrightness: 130,
highValueThreshold: 10_000,
featuredValueThreshold: 100_000,
normal: { count: 3, size: 88, speed: 560 },
high: { count: 6, size: 126, speed: 720 },
featured: { count: 10, size: 168, speed: 880 },
trailIntensity: 78,
queueCapacity: 256,
lowPerformanceMode: false,
}
export type GuardEffectThemeId = 'jade-starfall' | 'moonlit-water'
/** Full-viewport visual settings for membership-purchase celebrations. */
export type GuardEffectSettings = {
themeId: GuardEffectThemeId
fontFamily: FontFamilyId
fontBrightness: number
starCount: number
effectDurationMs: number
titleTemplate: string
closingText: string
queueCapacity: number
lowPerformanceMode: boolean
}
export const defaultGuardEffectSettings: GuardEffectSettings = {
themeId: 'jade-starfall',
fontFamily: 'fang-song',
fontBrightness: 130,
starCount: 48,
effectDurationMs: 5_200,
titleTemplate: '{guard}启航',
closingText: '相伴前行',
queueCapacity: 256,
lowPerformanceMode: false,
}
export type GiftCatalogItem = {
id: number
name: string
coinType: string
batteryValue: number
unitPrice: number
imageUrl?: string
animationUrl?: string
}
export type GiftMenuTrigger =
| {
kind: 'gift'
giftId: number
giftName: string
imageUrl?: string
unitPrice: number
}
| { kind: 'guard'; level: 'captain' | 'admiral' | 'governor' }
| { kind: 'battery'; amount: number }
export type GiftMenuItem = {
id: string
trigger: GiftMenuTrigger
description: string
}
export type GiftMenuThemeId = 'jade-banquet' | 'moonlit-water'
export type GiftMenuSettings = {
themeId: GiftMenuThemeId
fontFamily: FontFamilyId
fontBrightness: number
items: GiftMenuItem[]
visibleRows: number
rowHeight: number
scrollSpeedPixelsPerSecond: number
pageIntervalMs: number
highlightDurationMs: number
fontScale: number
decorationLineWeight: number
motionIntensity: number
lowPerformanceMode: boolean
}
export const defaultGiftMenuSettings: GiftMenuSettings = {
themeId: 'jade-banquet',
fontFamily: 'fang-song',
fontBrightness: 130,
items: [],
visibleRows: 4,
rowHeight: 88,
scrollSpeedPixelsPerSecond: 28,
pageIntervalMs: 6_000,
highlightDurationMs: 3_800,
fontScale: 100,
decorationLineWeight: 160,
motionIntensity: 78,
lowPerformanceMode: false,
}
export type ComponentSettings =
| OverlaySettings
| SongRequestSettings
| GiftEffectSettings
| GuardEffectSettings
| GiftMenuSettings
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
}
export type SongQueueSummary = {
activeCount: number
queuedCount: number
completedCount: number
cancelledCount: 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 UserRole = 'system_admin' | 'user' | string
export type AuthUser = { export type AuthUser = {
@@ -60,7 +276,7 @@ export type ComponentSummary = {
kind: string kind: string
name: string name: string
enabled?: boolean enabled?: boolean
settings?: OverlaySettings settings?: ComponentSettings
updatedAt?: string updatedAt?: string
} }
+35
View File
@@ -0,0 +1,35 @@
import type { CSSProperties } from 'react'
/** Stable IDs persisted by the backend; each maps to a bundled, same-origin webfont. */
export const fontFamilyIds = ['song', 'fang-song', 'kai', 'liyu-shoushu'] as const
export type FontFamilyId = (typeof fontFamilyIds)[number]
export const fontFamilies: ReadonlyArray<{ id: FontFamilyId; nameKey: string }> = [
{ id: 'song', nameKey: 'settings.font_family.song' },
{ id: 'fang-song', nameKey: 'settings.font_family.fang_song' },
{ id: 'kai', nameKey: 'settings.font_family.kai' },
{ id: 'liyu-shoushu', nameKey: 'settings.font_family.liyu_shoushu' },
]
const fontStacks: Record<FontFamilyId, string> = {
song: '"Noto Serif SC", serif',
'fang-song': '"ZCOOL XiaoWei", serif',
kai: '"LXGW WenKai", serif',
'liyu-shoushu': '"Liyu Shoushu", serif',
}
export function normalizeFontFamilyId(value: unknown): FontFamilyId {
return fontFamilyIds.includes(value as FontFamilyId) ? (value as FontFamilyId) : 'fang-song'
}
export function typographyVariables(
fontFamily: FontFamilyId,
fontBrightness: number,
): CSSProperties {
const brightness = Math.min(180, Math.max(70, Number(fontBrightness) || 130))
return {
['--component-font-family' as string]: fontStacks[normalizeFontFamilyId(fontFamily)],
['--component-font-brightness' as string]: `${brightness / 100}`,
}
}
+4
View File
@@ -1,3 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __PWA_BUILD_ID__: string declare const __PWA_BUILD_ID__: string
declare const __I18N_RESOURCE__: {
default_language: string
locales: Record<string, { name: string; messages: Record<string, string> }>
}
+16 -1
View File
@@ -1,4 +1,19 @@
{ {
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"] "include": ["src"]
} }
+74
View File
@@ -1,27 +1,101 @@
/**
* Vite build configuration and PWA asset emission.
*
* The same per-build identifier is compiled into the browser bundle and the
* service worker. This makes an hourly/visibility update check discover a new
* deployment while still leaving activation under explicit user control.
*/
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { parse } from 'smol-toml'
// A new URL makes the browser check and stage every deployed service worker. // A new URL makes the browser check and stage every deployed service worker.
// The waiting worker is still activated explicitly by the user in the console. // The waiting worker is still activated explicitly by the user in the console.
const pwaBuildId = Date.now().toString(36) const pwaBuildId = Date.now().toString(36)
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8') const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
const bundledFontLicenses = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai'].map(font => ({
fileName: `fonts/licenses/${font}-OFL.txt`,
source: readFileSync(
new URL(`./node_modules/@fontsource/${font}/LICENSE`, import.meta.url),
'utf8',
),
}))
const i18nResource = parse(
readFileSync(new URL('../../resources/i18n.toml', import.meta.url), 'utf8'),
) as unknown as {
default_language: string
locales: Record<string, { messages: Record<string, string> }>
}
const defaultLanguage = i18nResource.default_language
const defaultMessages = i18nResource.locales[defaultLanguage].messages
const manifest = {
id: '/control',
name: defaultMessages['pwa.manifest.name'],
short_name: defaultMessages['pwa.manifest.short_name'],
description: defaultMessages['pwa.manifest.description'],
lang: defaultLanguage,
dir: 'auto',
start_url: '/control/?source=pwa',
scope: '/control/',
display: 'standalone',
orientation: 'any',
background_color: '#03121a',
theme_color: '#0a302f',
categories: ['utilities', 'productivity'],
icons: [
{ src: '/pwa/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/pwa/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
{
name: 'same-origin-woff2-fonts',
enforce: 'pre',
transform(source, id) {
if (!id.includes('/node_modules/@fontsource/') || !id.endsWith('.css')) return
return source
.replaceAll('font-display: swap', 'font-display: block')
.replace(/,\s*url\([^)]*\.woff\) format\(['"]woff['"]\)/g, '')
},
},
{ {
name: 'control-pwa-assets', name: 'control-pwa-assets',
transformIndexHtml(html) {
return html
.replaceAll('__DEFAULT_LANGUAGE__', defaultLanguage)
.replaceAll('__APP_NAME__', defaultMessages['pwa.manifest.name'])
.replaceAll('__APP_SHORT_NAME__', defaultMessages['pwa.manifest.short_name'])
.replaceAll('__APP_DESCRIPTION__', defaultMessages['pwa.manifest.description'])
},
generateBundle() { generateBundle() {
for (const license of bundledFontLicenses) {
this.emitFile({ type: 'asset', ...license })
}
this.emitFile({ this.emitFile({
type: 'asset', type: 'asset',
fileName: 'control/sw.js', fileName: 'control/sw.js',
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId), source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
}) })
this.emitFile({
type: 'asset',
fileName: 'control/manifest.webmanifest',
source: JSON.stringify(manifest, null, 2),
})
}, },
}, },
], ],
define: { define: {
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId), __PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
__I18N_RESOURCE__: JSON.stringify(i18nResource),
}, },
}) })
+147 -2409
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -7,22 +7,30 @@ edition = "2024"
axum = { version = "0.8", features = ["ws", "json"] } axum = { version = "0.8", features = ["ws", "json"] }
async-trait = "0.1" async-trait = "0.1"
base64 = "0.22" base64 = "0.22"
# Patched local copy of the published blivedm_rs crate. The patch preserves chrono = { version = "0.4", default-features = false, features = [
# the upstream raw payload so the application can retain UID, price and event "clock",
# identifiers for atomic accounting and gift de-duplication. "serde",
blivedm = { path = "../../vendor/blivedm", default-features = false } ] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
deadpool-postgres = "0.14" deadpool-postgres = "0.14"
futures-channel = "0.3" # Kept as a sibling checkout during development. Docker Compose supplies the
# same directory as a named BuildKit context at `/libilibili`.
libilibili = { git = "http://gitea.home.arpa/felis/libilibili.git" }
rand = "0.9" rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
subtle = "2" subtle = "2"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-serde_json-1", "with-uuid-1", "with-chrono-0_4"] } tokio-postgres = { version = "0.7", features = [
"with-serde_json-1",
"with-uuid-1",
"with-chrono-0_4",
] }
tokio-util = "0.7" tokio-util = "0.7"
toml = "0.8" toml = "0.8"
totp-rs = { version = "5.7", features = ["gen_secret", "qr", "zeroize"] } totp-rs = { version = "5.7", features = ["gen_secret", "qr", "zeroize"] }
+59
View File
@@ -0,0 +1,59 @@
# Rust 后端
这是多租户直播组件服务的可复用核心和唯一运行时进程。`src/main.rs` 只处理进程启动;主要行为由 library
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、静态资源与安全响应头 |
| `i18n` | 嵌入共享 TOML 语言目录并验证 locale 与键完整性 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | account event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
| `song_request` | 点歌命令、事务队列、快照和管理服务 |
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
| `guard_effect` | 独立大航海主题、感谢文案与 `live.guard.buy` 事件订阅 |
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
## 重要不变量
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
- 同一组件 kind 可以有多个实例;settings、token、持久状态和广播通道必须继续按 component ID 隔离。
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
- projection 无副作用;可靠业务动作必须使用幂等 handler。
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
## 本地质量检查
在仓库根目录执行:
```bash
cargo fmt --manifest-path apps/server-rust/Cargo.toml --check
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
```
直播协议、WBI 与 WebSocket 解包由相邻的 `libilibili`
crate 维护;本项目只测试强类型命令到领域事件的映射。
更多设计说明:
- [总体架构](../../docs/architecture.md)
- [组件开发](../../docs/components/README.md)
- [实时协议](../../docs/protocol.md)
- [安全模型](../../docs/security.md)
@@ -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);
@@ -0,0 +1,26 @@
-- Move live-listener ownership fully to the account boundary.
--
-- `live_sources.owner_user_id` and `cookiecloud_credentials.user_id` already
-- enforce one listener configuration and one credential source per account.
-- The component-level `source_id` foreign key was therefore redundant and,
-- more importantly, made new component kinds look as though they selected
-- their own upstream listener. Components now subscribe to their owner's
-- canonical event stream and retain only their account ownership.
DROP INDEX IF EXISTS component_instances_source_idx;
ALTER TABLE component_instances
DROP CONSTRAINT IF EXISTS component_instances_owner_user_id_source_id_fkey;
ALTER TABLE component_instances
DROP COLUMN IF EXISTS source_id;
CREATE INDEX IF NOT EXISTS component_instances_owner_enabled_idx
ON component_instances(owner_user_id, enabled, created_at);
COMMENT ON TABLE live_sources IS
'One immutable Bilibili live-listener configuration per account.';
COMMENT ON TABLE cookiecloud_credentials IS
'One encrypted CookieCloud credential source per account.';
COMMENT ON TABLE component_instances IS
'Account-owned consumers of the owning account live-event stream.';
@@ -0,0 +1,31 @@
-- Treat an invitation room as the account's initial room rather than an
-- immutable lifetime binding. Room IDs remain unique across accounts so two
-- listeners cannot accidentally claim the same upstream room.
DROP TRIGGER IF EXISTS users_room_id_immutable ON users;
DROP FUNCTION IF EXISTS prevent_user_room_id_change();
CREATE OR REPLACE FUNCTION enforce_live_source_account_room()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM public.users
WHERE id = NEW.owner_user_id AND room_id = NEW.room_id
) THEN
RAISE EXCEPTION 'live source room_id must equal its owning account room_id';
END IF;
IF TG_OP = 'UPDATE'
AND NEW.owner_user_id IS DISTINCT FROM OLD.owner_user_id THEN
RAISE EXCEPTION 'live source ownership is immutable';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON COLUMN users.room_id IS
'Current account-level Bilibili room; initialized by invitation and user-switchable.';
COMMENT ON COLUMN live_sources.room_id IS
'Current room of the owning account; updated atomically with users.room_id.';
@@ -0,0 +1,23 @@
-- Account language preference. The browser keeps a local pre-login choice;
-- authenticated saves become authoritative for every control session and OBS
-- component owned by the account.
CREATE TABLE IF NOT EXISTS account_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Supported locale codes are validated against resources/i18n.toml by the
-- repository. Keeping the column open avoids a schema migration per locale.
language TEXT NOT NULL DEFAULT 'zh-CN',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO account_preferences(user_id)
SELECT id FROM users
ON CONFLICT(user_id) DO NOTHING;
ALTER TABLE account_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE account_preferences FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS account_preferences_owner ON account_preferences;
CREATE POLICY account_preferences_owner ON account_preferences
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
@@ -0,0 +1,7 @@
-- Every account owns exactly one built-in full-screen gift-effect component.
-- Existing accounts are backfilled from the validated Rust defaults during
-- startup; the partial index keeps concurrent starts idempotent.
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_effect
ON component_instances(owner_user_id, kind)
WHERE kind = 'gift_effect';
@@ -0,0 +1,6 @@
-- Every account owns exactly one built-in gift-menu component. Its ordered
-- entries remain in validated component settings and inherit component RLS.
CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_menu
ON component_instances(owner_user_id, kind)
WHERE kind = 'gift_menu';
@@ -0,0 +1,31 @@
-- Short-lived, tenant-owned TOTP replacement enrollments.
--
-- The current TOTP secret remains authoritative until the replacement secret
-- has produced a valid code. Raw enrollment tokens are never persisted, and
-- pending secrets use the same authenticated encryption boundary as account
-- TOTP secrets.
CREATE TABLE IF NOT EXISTS pending_totp_resets (
id UUID PRIMARY KEY,
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
enrollment_token_digest BYTEA NOT NULL UNIQUE
CHECK (octet_length(enrollment_token_digest) = 32),
totp_secret_ciphertext BYTEA NOT NULL
CHECK (octet_length(totp_secret_ciphertext) >= 16),
totp_secret_nonce BYTEA NOT NULL
CHECK (octet_length(totp_secret_nonce) = 24),
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (user_id, id)
);
CREATE INDEX IF NOT EXISTS pending_totp_resets_expiry_idx
ON pending_totp_resets(expires_at);
ALTER TABLE pending_totp_resets ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_totp_resets FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS pending_totp_resets_owner ON pending_totp_resets;
CREATE POLICY pending_totp_resets_owner ON pending_totp_resets
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
@@ -0,0 +1,10 @@
-- Built-in component kinds are instance-based. An account may create multiple
-- instances of the same kind, each with independent settings, OBS credentials,
-- realtime fanout, and component-owned durable state.
DROP INDEX IF EXISTS component_instances_single_song_request;
DROP INDEX IF EXISTS component_instances_single_gift_effect;
DROP INDEX IF EXISTS component_instances_single_gift_menu;
COMMENT ON TABLE component_instances IS
'Tenant-owned component instances. Multiple rows of the same kind may belong to one account.';
+4
View File
@@ -0,0 +1,4 @@
edition = "2024"
max_width = 100
newline_style = "Unix"
use_small_heuristics = "Default"
+58 -1
View File
@@ -1,3 +1,10 @@
//! Application composition root and process-wide dependencies.
//!
//! [`AppState::build`] wires the database, authentication service, component
//! registry, live-source supervisor and realtime router in dependency order.
//! Tenant-owned runtime state stays in PostgreSQL or the source supervisor;
//! this module only owns cloneable handles shared by Axum handlers.
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use async_trait::async_trait; use async_trait::async_trait;
@@ -17,9 +24,13 @@ use crate::{
bilibili::BilibiliProvider, bilibili::BilibiliProvider,
supervisor::{ProviderFactory, SourceSupervisor}, supervisor::{ProviderFactory, SourceSupervisor},
}, },
overlay::GiftCatalogRegistry,
rate_limit::AuthRateLimiter, rate_limit::AuthRateLimiter,
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter}, realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
repository::TenantRepository, repository::TenantRepository,
song_request::{
SONG_REQUEST_KIND, SongRequestHandler, SongRequestService, SongRequestSnapshotProvider,
},
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -35,6 +46,8 @@ pub struct AppState {
pub enrollment_limiter: AuthRateLimiter, pub enrollment_limiter: AuthRateLimiter,
pub component_socket_slots: Arc<Semaphore>, pub component_socket_slots: Arc<Semaphore>,
pub http: reqwest::Client, pub http: reqwest::Client,
pub song_requests: SongRequestService,
pub gift_catalogs: GiftCatalogRegistry,
} }
impl AppState { impl AppState {
@@ -53,9 +66,27 @@ impl AppState {
let registry = ComponentRegistry::with_builtin_components(); let registry = ComponentRegistry::with_builtin_components();
let component_cache = Arc::new(InMemoryComponentStore::default()); let component_cache = Arc::new(InMemoryComponentStore::default());
let hub = EventHub::new(512); let hub = EventHub::new(512);
let song_requests = SongRequestService::new(db.clone(), hub.clone());
let gift_catalogs = GiftCatalogRegistry::default();
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 router = SourceEventRouter::new(registry.clone(), component_cache.clone(), hub.clone());
let repository = let repository =
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone()); TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
repository
.ensure_builtin_components()
.await
.map_err(|error| error.to_string())?;
repository repository
.hydrate_all() .hydrate_all()
.await .await
@@ -70,6 +101,7 @@ impl AppState {
auth: auth.clone(), auth: auth.clone(),
config: config.clone(), config: config.clone(),
http: http.clone(), http: http.clone(),
gift_catalogs: gift_catalogs.clone(),
}); });
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512); let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
let supervisor = SourceSupervisor::new(provider_factory, source_events); let supervisor = SourceSupervisor::new(provider_factory, source_events);
@@ -106,6 +138,8 @@ impl AppState {
), ),
component_socket_slots: Arc::new(Semaphore::new(128)), component_socket_slots: Arc::new(Semaphore::new(128)),
http, http,
song_requests,
gift_catalogs,
}; };
state.start_all_sources().await?; state.start_all_sources().await?;
Ok(state) Ok(state)
@@ -200,6 +234,7 @@ struct BilibiliProviderFactory {
auth: AuthService, auth: AuthService,
config: Arc<Config>, config: Arc<Config>,
http: reqwest::Client, http: reqwest::Client,
gift_catalogs: GiftCatalogRegistry,
} }
#[async_trait] #[async_trait]
@@ -213,12 +248,14 @@ impl ProviderFactory for BilibiliProviderFactory {
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?; .ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
self.config.allowed_cookiecloud_host(&stored.host)?; self.config.allowed_cookiecloud_host(&stored.host)?;
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?; let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
Ok(Arc::new(BilibiliProvider::new( let gift_catalog = self.gift_catalogs.catalog(source.owner_id).await;
Ok(Arc::new(BilibiliProvider::with_gift_catalog(
cookie, cookie,
self.config.gift_refresh_seconds, self.config.gift_refresh_seconds,
self.config.gift_request_timeout_seconds, self.config.gift_request_timeout_seconds,
self.config.emoticon_refresh_seconds, self.config.emoticon_refresh_seconds,
self.config.emoticon_request_timeout_seconds, self.config.emoticon_request_timeout_seconds,
gift_catalog,
))) )))
} }
} }
@@ -249,6 +286,26 @@ async fn migrate(db: &Db) -> Result<(), String> {
), ),
(3_i32, include_str!("../migrations/003_multitenancy.sql")), (3_i32, include_str!("../migrations/003_multitenancy.sql")),
(4_i32, include_str!("../migrations/004_auth_hardening.sql")), (4_i32, include_str!("../migrations/004_auth_hardening.sql")),
(5_i32, include_str!("../migrations/005_song_request.sql")),
(
6_i32,
include_str!("../migrations/006_account_live_source.sql"),
),
(
7_i32,
include_str!("../migrations/007_switchable_account_room.sql"),
),
(
8_i32,
include_str!("../migrations/008_account_language.sql"),
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
(
12_i32,
include_str!("../migrations/012_component_instances.sql"),
),
] { ] {
let applied = transaction let applied = transaction
.query_one( .query_one(
+500 -43
View File
@@ -1,3 +1,11 @@
//! Passwordless authentication, enrollment and secret-storage domain service.
//!
//! This module owns invite redemption, TOTP replay protection, recovery codes,
//! session issuance and tenant credential encryption. Raw sessions, recovery
//! codes and component tokens are returned only at creation time; persistent
//! rows contain hashes or authenticated ciphertext. HTTP-specific cookie and
//! origin policy deliberately live in `http_api`, not here.
use std::{fmt, sync::Arc, time::Duration}; use std::{fmt, sync::Arc, time::Duration};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -11,14 +19,19 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tokio_postgres::Transaction; use tokio_postgres::{Transaction, error::SqlState};
use totp_rs::{Algorithm, Secret, TOTP}; use totp_rs::{Algorithm, Secret, TOTP};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host}, credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError}, db::{Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
i18n,
overlay::OverlaySettings, overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
}; };
const TOTP_DIGITS: usize = 6; const TOTP_DIGITS: usize = 6;
@@ -470,7 +483,11 @@ impl AuthService {
&self, &self,
enrollment_token: &str, enrollment_token: &str,
totp_code: &str, totp_code: &str,
language: &str,
) -> Result<RegistrationComplete, AuthError> { ) -> Result<RegistrationComplete, AuthError> {
if !i18n::is_supported(language) {
return Err(AuthError::InvalidInput("language is not supported"));
}
let digest = token_digest(enrollment_token.trim()); let digest = token_digest(enrollment_token.trim());
let mut client = self.db.get().await?; let mut client = self.db.get().await?;
let transaction = client.transaction().await?; let transaction = client.transaction().await?;
@@ -589,6 +606,12 @@ impl AuthService {
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?; let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
let session = insert_session(&transaction, user_id, self.session_ttl).await?; let session = insert_session(&transaction, user_id, self.session_ttl).await?;
Db::set_tenant(&transaction, user_id).await?; Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2)",
&[&user_id, &language],
)
.await?;
let default_source_id = Uuid::new_v4(); let default_source_id = Uuid::new_v4();
transaction transaction
.execute( .execute(
@@ -602,13 +625,82 @@ impl AuthService {
.expect("OverlaySettings is always JSON serializable"); .expect("OverlaySettings is always JSON serializable");
transaction transaction
.execute( .execute(
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \ "INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)", VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
&[&default_component_id, &user_id, &default_settings],
)
.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,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[ &[
&default_component_id, &song_component_id,
&user_id, &user_id,
&default_source_id, &SONG_REQUEST_KIND,
&default_settings, &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?;
let gift_component_id = Uuid::new_v4();
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&gift_component_id,
&user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
let guard_component_id = Uuid::new_v4();
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&guard_component_id,
&user_id,
&GUARD_EFFECT_KIND,
&GUARD_EFFECT_NAME,
&guard_settings,
],
)
.await?;
let menu_component_id = Uuid::new_v4();
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable");
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&menu_component_id,
&user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
], ],
) )
.await?; .await?;
@@ -817,6 +909,251 @@ impl AuthService {
Ok(codes) Ok(codes)
} }
/// Begin replacement of the current account's TOTP secret after explicit
/// step-up authentication. The existing secret remains valid until
/// `totp_reset_confirm` commits the replacement.
pub async fn totp_reset_start(
&self,
user_id: Uuid,
current_code: &str,
) -> Result<TotpResetStart, AuthError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
let row = transaction
.query_opt(
"SELECT username,room_id,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \
FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&user_id],
)
.await?
.ok_or(AuthError::InvalidCredentials)?;
let username: String = row.get(0);
let room_id: String = row.get(1);
let proof_kind = if is_totp_code(current_code) {
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(2),
nonce: row.get(3),
},
format!("user-totp:{user_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
current_code,
Utc::now().timestamp(),
row.get(4),
)?
.ok_or(AuthError::InvalidCredentials)?;
let changed = transaction
.execute(
"UPDATE users SET last_totp_step=$1,updated_at=now() \
WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)",
&[&accepted_step, &user_id],
)
.await?;
if changed != 1 {
return Err(AuthError::TotpReplay);
}
"totp"
} else {
let recovery_digest = token_digest(current_code.trim());
let recovery_id = transaction
.query_opt(
"SELECT id FROM recovery_codes \
WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE",
&[&user_id, &recovery_digest],
)
.await?
.ok_or(AuthError::InvalidCredentials)?
.get::<_, Uuid>(0);
transaction
.execute(
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
&[&recovery_id],
)
.await?;
"recovery"
};
let reset_id = Uuid::new_v4();
let enrollment_token = random_token("totp-reset", 32);
let enrollment_digest = token_digest(&enrollment_token);
let secret = Secret::generate_secret();
let secret_bytes = secret
.to_bytes()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let encoded = secret.to_encoded();
let encoded_secret = match &encoded {
Secret::Encoded(value) => value.clone(),
Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"),
};
let totp = build_totp(&secret_bytes, &self.issuer, &username)?;
let otpauth_uri = totp.get_url();
let qr = totp
.get_qr_base64()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let qr_data_url = if qr.starts_with("data:") {
qr
} else {
format!("data:image/png;base64,{qr}")
};
let encrypted = self.cipher.encrypt(
&secret_bytes,
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let expires_at = future_time(self.enrollment_ttl)?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"DELETE FROM pending_totp_resets WHERE user_id=$1",
&[&user_id],
)
.await?;
transaction
.execute(
"INSERT INTO pending_totp_resets \
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
VALUES($1,$2,$3,$4,$5,$6)",
&[
&reset_id,
&user_id,
&enrollment_digest,
&encrypted.ciphertext,
&encrypted.nonce,
&expires_at,
],
)
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.started",
"user",
Some(user_id.to_string()),
json!({"proof":proof_kind}),
)
.await?;
transaction.commit().await?;
Ok(TotpResetStart {
enrollment_token,
username,
room_id,
secret: encoded_secret,
otpauth_uri,
qr_data_url,
expires_at,
})
}
/// Confirm a pending replacement, rotate recovery codes, and revoke every
/// other browser session atomically. The session performing the reset is
/// retained so it can display the one-time recovery codes.
pub async fn totp_reset_confirm(
&self,
user_id: Uuid,
current_session_id: Uuid,
enrollment_token: &str,
new_totp_code: &str,
) -> Result<Vec<String>, AuthError> {
let digest = token_digest(enrollment_token.trim());
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, user_id).await?;
let row = transaction
.query_opt(
"SELECT r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
r.failed_attempts,u.username \
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
&[&user_id, &digest],
)
.await?
.ok_or(AuthError::TotpResetUnavailable)?;
let reset_id: Uuid = row.get(0);
let expires_at: DateTime<Utc> = row.get(3);
let failed_attempts: i32 = row.get(4);
if expires_at <= Utc::now() {
return Err(AuthError::TotpResetUnavailable);
}
let username: String = row.get(5);
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(1),
nonce: row.get(2),
},
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
new_totp_code,
Utc::now().timestamp(),
None,
)?;
let Some(accepted_step) = accepted_step else {
if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES {
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
} else {
transaction
.execute(
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
&[&reset_id],
)
.await?;
}
transaction.commit().await?;
return Err(AuthError::InvalidTotp);
};
let encrypted = self
.cipher
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
let updated = transaction
.execute(
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
WHERE id=$4 AND status='active'",
&[
&encrypted.ciphertext,
&encrypted.nonce,
&accepted_step,
&user_id,
],
)
.await?;
if updated != 1 {
return Err(AuthError::TotpResetUnavailable);
}
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
let revoked_sessions = transaction
.execute(
"UPDATE user_sessions SET revoked_at=now() \
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
&[&user_id, &current_session_id],
)
.await?;
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.completed",
"user",
Some(user_id.to_string()),
json!({"revokedSessions":revoked_sessions}),
)
.await?;
transaction.commit().await?;
Ok(recovery_codes)
}
pub async fn authenticate_session( pub async fn authenticate_session(
&self, &self,
raw_session_token: &str, raw_session_token: &str,
@@ -882,6 +1219,100 @@ impl AuthService {
user_id: Uuid, user_id: Uuid,
credentials: &CookieCloudCredentials, credentials: &CookieCloudCredentials,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
Db::set_tenant(&transaction, user_id).await?;
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
insert_audit(
&transaction,
Some(user_id),
"cookiecloud.credentials.updated",
"user",
Some(user_id.to_string()),
json!({"host":host}),
)
.await?;
transaction.commit().await?;
Ok(())
}
/// Atomically changes the account's current room and encrypted CookieCloud
/// source. The live source keeps its stable ID, allowing the supervisor to
/// cancel the old room connection and start the replacement without
/// changing any component or OBS token.
pub async fn set_account_live_source(
&self,
user_id: Uuid,
room_id: &str,
credentials: &CookieCloudCredentials,
) -> Result<(), AuthError> {
let room_id = room_id.trim();
validate_room_id(room_id)?;
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
let old_room_id: String = transaction
.query_one("SELECT room_id FROM users WHERE id=$1", &[&user_id])
.await?
.get(0);
let room_in_use: bool = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1 AND id<>$2)",
&[&room_id, &user_id],
)
.await?
.get(0);
if room_in_use {
return Err(AuthError::RoomUnavailable);
}
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"UPDATE users SET room_id=$1,updated_at=now() WHERE id=$2 AND status='active'",
&[&room_id, &user_id],
)
.await
.map_err(map_room_write_error)?;
let changed = transaction
.execute(
"UPDATE live_sources SET room_id=$1,updated_at=now() \
WHERE owner_user_id=$2",
&[&room_id, &user_id],
)
.await
.map_err(map_room_write_error)?;
if changed != 1 {
return Err(AuthError::DatabaseInvariant(
"account must own exactly one live source",
));
}
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
insert_audit(
&transaction,
Some(user_id),
"account.live_source.updated",
"user",
Some(user_id.to_string()),
json!({
"host": host,
"oldRoomId": old_room_id,
"newRoomId": room_id,
"roomChanged": old_room_id != room_id,
}),
)
.await?;
transaction.commit().await?;
Ok(())
}
fn encrypt_cookiecloud_credentials(
&self,
user_id: Uuid,
credentials: &CookieCloudCredentials,
) -> Result<(String, EncryptedSecret), AuthError> {
let host = normalize_cookiecloud_host(&credentials.host) let host = normalize_cookiecloud_host(&credentials.host)
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?; .map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() { if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
@@ -897,31 +1328,7 @@ impl AuthService {
let encrypted = self let encrypted = self
.cipher .cipher
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?; .encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
let mut client = self.db.get().await?; Ok((host, encrypted))
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"INSERT INTO cookiecloud_credentials \
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
)
.await?;
insert_audit(
&transaction,
Some(user_id),
"cookiecloud.credentials.updated",
"user",
Some(user_id.to_string()),
json!({"host":host}),
)
.await?;
transaction.commit().await?;
Ok(())
} }
pub async fn get_cookiecloud_credentials( pub async fn get_cookiecloud_credentials(
@@ -1207,13 +1614,6 @@ impl AuthService {
.ok_or(AuthError::InvalidCredentials)? .ok_or(AuthError::InvalidCredentials)?
.get(0); .get(0);
Db::set_tenant(&transaction, owner_user_id).await?; Db::set_tenant(&transaction, owner_user_id).await?;
let source_id: Uuid = transaction
.query_one(
"SELECT id FROM live_sources WHERE owner_user_id=$1",
&[&owner_user_id],
)
.await?
.get(0);
let component_id = if let Some(row) = transaction let component_id = if let Some(row) = transaction
.query_opt( .query_opt(
"SELECT id FROM component_instances \ "SELECT id FROM component_instances \
@@ -1235,9 +1635,9 @@ impl AuthService {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
transaction transaction
.execute( .execute(
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \ "INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)", VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
&[&id, &owner_user_id, &source_id, &settings], &[&id, &owner_user_id, &settings],
) )
.await?; .await?;
id id
@@ -1254,6 +1654,33 @@ impl AuthService {
} }
} }
async fn upsert_cookiecloud_credentials(
transaction: &Transaction<'_>,
user_id: Uuid,
host: &str,
encrypted: &EncryptedSecret,
) -> Result<(), AuthError> {
transaction
.execute(
"INSERT INTO cookiecloud_credentials \
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
)
.await?;
Ok(())
}
fn map_room_write_error(error: tokio_postgres::Error) -> AuthError {
if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
AuthError::RoomUnavailable
} else {
error.into()
}
}
#[derive(Clone)] #[derive(Clone)]
struct SecretCipher { struct SecretCipher {
cipher: Arc<XChaCha20Poly1305>, cipher: Arc<XChaCha20Poly1305>,
@@ -1346,6 +1773,11 @@ fn accepted_totp_step(
Ok(None) Ok(None)
} }
fn is_totp_code(code: &str) -> bool {
let code = code.trim();
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
}
async fn require_system_admin( async fn require_system_admin(
transaction: &Transaction<'_>, transaction: &Transaction<'_>,
user_id: Uuid, user_id: Uuid,
@@ -1626,6 +2058,18 @@ pub struct RegistrationStart {
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TotpResetStart {
pub enrollment_token: String,
pub username: String,
pub room_id: String,
pub secret: String,
pub otpauth_uri: String,
pub qr_data_url: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RegistrationComplete { pub struct RegistrationComplete {
@@ -1683,6 +2127,7 @@ pub enum AuthError {
Forbidden, Forbidden,
InvitationUnavailable, InvitationUnavailable,
EnrollmentUnavailable, EnrollmentUnavailable,
TotpResetUnavailable,
AccountUnavailable, AccountUnavailable,
RoomUnavailable, RoomUnavailable,
InvalidTotp, InvalidTotp,
@@ -1715,6 +2160,9 @@ impl fmt::Display for AuthError {
formatter, formatter,
"registration enrollment is invalid or unavailable" "registration enrollment is invalid or unavailable"
), ),
Self::TotpResetUnavailable => {
write!(formatter, "TOTP reset is invalid or unavailable")
}
Self::AccountUnavailable => write!(formatter, "username or room is unavailable"), Self::AccountUnavailable => write!(formatter, "username or room is unavailable"),
Self::RoomUnavailable => write!(formatter, "room is already assigned"), Self::RoomUnavailable => write!(formatter, "room is already assigned"),
Self::InvalidTotp => write!(formatter, "invalid TOTP code"), Self::InvalidTotp => write!(formatter, "invalid TOTP code"),
@@ -1804,11 +2252,20 @@ mod tests {
); );
} }
#[test]
fn step_up_proof_only_classifies_exact_six_digit_totp_codes() {
assert!(is_totp_code(" 012345 "));
assert!(!is_totp_code("12345"));
assert!(!is_totp_code("1234567"));
assert!(!is_totp_code("12a456"));
assert!(!is_totp_code("recovery-example"));
}
#[test] #[test]
fn username_and_room_validation_are_canonical() { fn username_and_room_validation_are_canonical() {
assert_eq!( assert_eq!(
normalize_username(" LuoXingCi ").expect("username"), normalize_username(" ExampleStreamer ").expect("username"),
("LuoXingCi".into(), "luoxingci".into()) ("ExampleStreamer".into(), "examplestreamer".into())
); );
assert!(normalize_username("ab").is_err()); assert!(normalize_username("ab").is_err());
assert!(normalize_username("bad name").is_err()); assert!(normalize_username("bad name").is_err());
+163 -3
View File
@@ -1,3 +1,10 @@
//! Extensible component registry and event-processing contracts.
//!
//! A component kind supplies a versioned settings definition, a pure browser
//! projection and optional durable handlers. The live provider and router know
//! only these traits, so adding a gift wall or song-request component does not
//! require branching on component kinds in the ingestion pipeline.
use std::{ use std::{
collections::{BTreeSet, HashMap}, collections::{BTreeSet, HashMap},
error::Error, error::Error,
@@ -13,7 +20,11 @@ use uuid::Uuid;
use crate::{ use crate::{
domain::{ComponentMessage, LiveEvent, LiveEventKind}, domain::{ComponentMessage, LiveEvent, LiveEventKind},
gift_effect::GiftEffectDefinition,
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
guard_effect::GuardEffectDefinition,
overlay::OverlaySettings, overlay::OverlaySettings,
song_request::{SongRequestDefinition, SongRequestProjection},
}; };
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay"; pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
@@ -76,7 +87,10 @@ pub struct ComponentInstance {
pub id: Uuid, pub id: Uuid,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub owner_id: Uuid, pub owner_id: Uuid,
pub source_id: Uuid, /// Runtime identity of the owner's account-level source. This value is
/// derived while loading the component and is not stored on its row.
#[serde(rename = "sourceId")]
pub account_source_id: Uuid,
pub kind: String, pub kind: String,
pub name: String, pub name: String,
pub enabled: bool, pub enabled: bool,
@@ -87,7 +101,7 @@ pub struct ComponentInstance {
impl ComponentInstance { impl ComponentInstance {
pub fn new( pub fn new(
owner_id: Uuid, owner_id: Uuid,
source_id: Uuid, account_source_id: Uuid,
kind: impl Into<String>, kind: impl Into<String>,
name: impl Into<String>, name: impl Into<String>,
settings_version: u32, settings_version: u32,
@@ -96,7 +110,7 @@ impl ComponentInstance {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
owner_id, owner_id,
source_id, account_source_id,
kind: kind.into(), kind: kind.into(),
name: name.into(), name: name.into(),
enabled: true, enabled: true,
@@ -173,6 +187,19 @@ pub trait EventProjection: Send + Sync {
/// `async-trait` dependency. /// `async-trait` dependency.
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>; 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 /// An active handler may perform durable side effects (for example recording a
/// song request). It runs independently of WebSocket receiver count and should /// song request). It runs independently of WebSocket receiver count and should
/// implement idempotency in its persistence layer. /// implement idempotency in its persistence layer.
@@ -273,6 +300,7 @@ pub struct ComponentRuntime {
definition: Arc<dyn ComponentDefinition>, definition: Arc<dyn ComponentDefinition>,
projection: Arc<dyn EventProjection>, projection: Arc<dyn EventProjection>,
handlers: Vec<Arc<dyn EventHandler>>, handlers: Vec<Arc<dyn EventHandler>>,
snapshot_provider: Option<Arc<dyn ComponentSnapshotProvider>>,
} }
impl ComponentRuntime { impl ComponentRuntime {
@@ -313,6 +341,17 @@ impl ComponentRuntime {
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> { pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
self.handlers.clone() 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)] #[derive(Clone)]
@@ -340,6 +379,27 @@ impl ComponentRegistry {
) )
.expect("built-in component kinds are unique"); .expect("built-in component kinds are unique");
registry registry
.register(
Arc::new(SongRequestDefinition),
Arc::new(SongRequestProjection),
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(GiftEffectDefinition),
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
.register(
Arc::new(GuardEffectDefinition),
Arc::new(PassthroughProjection),
)
.expect("built-in component kinds are unique");
registry
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
.expect("built-in component kinds are unique");
registry
} }
pub fn register( pub fn register(
@@ -364,6 +424,7 @@ impl ComponentRegistry {
definition, definition,
projection, projection,
handlers: Vec::new(), handlers: Vec::new(),
snapshot_provider: None,
}, },
); );
Ok(()) Ok(())
@@ -385,6 +446,22 @@ impl ComponentRegistry {
Ok(()) 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> { pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
self.entries self.entries
.read() .read()
@@ -462,6 +539,89 @@ mod tests {
assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); 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 builtin_gift_effect_only_subscribes_to_gifts() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
let runtime = registry.runtime("gift_effect").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"gift_effect",
"礼物星雨",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn builtin_guard_effect_only_subscribes_to_guard_purchases() {
let registry = ComponentRegistry::default();
assert!(registry.kinds().contains(&"guard_effect".to_owned()));
let runtime = registry.runtime("guard_effect").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"guard_effect",
"大航海特效",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn builtin_gift_menu_is_registered_as_a_gift_and_guard_projection() {
let registry = ComponentRegistry::default();
let runtime = registry.runtime("gift_menu").unwrap();
let instance = ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
"gift_menu",
"礼物菜单",
1,
runtime.definition().default_settings(),
);
let subscriptions = runtime.subscriptions(&instance).unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test] #[test]
fn duplicate_component_kinds_are_rejected() { fn duplicate_component_kinds_are_rejected() {
let registry = ComponentRegistry::default(); let registry = ComponentRegistry::default();
+31 -2
View File
@@ -1,10 +1,21 @@
//! TOML configuration loading, validation and legacy bootstrap compatibility.
//!
//! Deployment-wide policy (bind address, encryption key, allowed CookieCloud
//! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component
//! settings become tenant-owned database records after enrollment; the legacy
//! TOML fields are import inputs and must not be treated as global live state.
use std::{env, fs, net::IpAddr, path::PathBuf}; use std::{env, fs, net::IpAddr, path::PathBuf};
use base64::{Engine, engine::general_purpose::STANDARD}; use base64::{Engine, engine::general_purpose::STANDARD};
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::{credentials::normalize_cookiecloud_host, overlay::OverlaySettings}; use crate::{
credentials::normalize_cookiecloud_host,
overlay::{OverlaySettings, OverlayThemeId},
typography::FontFamilyId,
};
/// Process-level configuration. Tenant-owned room, CookieCloud and component /// Process-level configuration. Tenant-owned room, CookieCloud and component
/// settings are imported from the legacy sections once and then live in /// settings are imported from the legacy sections once and then live in
@@ -116,8 +127,15 @@ struct EmoticonsConfig {
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
struct OverlayFileConfig { struct OverlayFileConfig {
theme_id: Option<OverlayThemeId>,
font_family: Option<FontFamilyId>,
font_brightness: Option<u16>,
viewer_color: Option<String>,
danmaku_color: Option<String>,
font_scale: Option<u16>, font_scale: Option<u16>,
decoration_line_weight: Option<u16>,
max_visible: Option<u8>, max_visible: Option<u8>,
expand_new_danmaku: Option<bool>,
collapse_after_seconds: Option<u16>, collapse_after_seconds: Option<u16>,
unfold_duration_ms: Option<u16>, unfold_duration_ms: Option<u16>,
motion_intensity: Option<u8>, motion_intensity: Option<u8>,
@@ -207,7 +225,7 @@ impl Config {
legacy_obs_access_token: file.obs.access_token, legacy_obs_access_token: file.obs.access_token,
legacy_overlay_defaults: overlay_defaults(file.overlay), legacy_overlay_defaults: overlay_defaults(file.overlay),
log_filter: file.logging.filter.unwrap_or_else(|| { log_filter: file.logging.filter.unwrap_or_else(|| {
"lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into() "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn".into()
}), }),
gift_refresh_seconds: file gift_refresh_seconds: file
.gifts .gifts
@@ -311,7 +329,15 @@ fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] {
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings { fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
let default = OverlaySettings::default(); let default = OverlaySettings::default();
OverlaySettings { OverlaySettings {
theme_id: file.theme_id.unwrap_or(default.theme_id),
font_family: file.font_family.unwrap_or(default.font_family),
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
viewer_color: file.viewer_color.or(default.viewer_color),
danmaku_color: file.danmaku_color.or(default.danmaku_color),
font_scale: file.font_scale.unwrap_or(default.font_scale), font_scale: file.font_scale.unwrap_or(default.font_scale),
decoration_line_weight: file
.decoration_line_weight
.unwrap_or(default.decoration_line_weight),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku), show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter), show_enter: file.events.enter.unwrap_or(default.show_enter),
show_gift: file.events.gift.unwrap_or(default.show_gift), show_gift: file.events.gift.unwrap_or(default.show_gift),
@@ -320,6 +346,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
show_like: file.events.like.unwrap_or(default.show_like), show_like: file.events.like.unwrap_or(default.show_like),
show_share: file.events.share.unwrap_or(default.show_share), show_share: file.events.share.unwrap_or(default.show_share),
max_visible: file.max_visible.unwrap_or(default.max_visible), max_visible: file.max_visible.unwrap_or(default.max_visible),
expand_new_danmaku: file
.expand_new_danmaku
.unwrap_or(default.expand_new_danmaku),
collapse_after_seconds: file collapse_after_seconds: file
.collapse_after_seconds .collapse_after_seconds
.unwrap_or(default.collapse_after_seconds), .unwrap_or(default.collapse_after_seconds),
+7
View File
@@ -1,3 +1,10 @@
//! CookieCloud boundary and Bilibili cookie extraction.
//!
//! Tenant input reaches an outbound HTTP client only after canonical URL
//! validation and exact allow-list matching in the caller. Redirects are
//! disabled by the shared client, the synchronization key is encoded as one
//! path segment, and only the minimum Bilibili cookie fields leave this module.
use reqwest::{ use reqwest::{
Url, Url,
header::{REFERER, USER_AGENT}, header::{REFERER, USER_AGENT},
+16 -4
View File
@@ -1,3 +1,10 @@
//! PostgreSQL pool, migrations and low-level tenant-scoped queries.
//!
//! Multi-tenant tables use PostgreSQL row-level security in addition to owner
//! columns and composite foreign keys. Every tenant query must execute inside a
//! transaction after [`Db::set_tenant`], which uses `SET LOCAL` so pooled
//! connections cannot retain the previous request's identity.
use std::{fmt, str::FromStr}; use std::{fmt, str::FromStr};
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime}; use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
@@ -101,8 +108,11 @@ impl Db {
Self::set_tenant(&transaction, user_id).await?; Self::set_tenant(&transaction, user_id).await?;
let rows = transaction let rows = transaction
.query( .query(
"SELECT id,source_id,kind,name,settings,settings_version,enabled \ "SELECT component.id,source.id,component.kind,component.name,component.settings,\
FROM component_instances WHERE owner_user_id=$1 ORDER BY created_at", component.settings_version,component.enabled \
FROM component_instances AS component \
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
WHERE component.owner_user_id=$1 ORDER BY component.created_at",
&[&user_id], &[&user_id],
) )
.await?; .await?;
@@ -112,7 +122,7 @@ impl Db {
.map(|row| ComponentRecord { .map(|row| ComponentRecord {
id: row.get(0), id: row.get(0),
owner_user_id: user_id, owner_user_id: user_id,
source_id: row.get(1), account_source_id: row.get(1),
kind: row.get(2), kind: row.get(2),
name: row.get(3), name: row.get(3),
settings: row.get(4), settings: row.get(4),
@@ -134,7 +144,9 @@ pub struct ActiveTenant {
pub struct ComponentRecord { pub struct ComponentRecord {
pub id: Uuid, pub id: Uuid,
pub owner_user_id: Uuid, pub owner_user_id: Uuid,
pub source_id: Uuid, /// Derived from the account's singleton live source; it is not component
/// configuration and is never persisted on `component_instances`.
pub account_source_id: Uuid,
pub kind: String, pub kind: String,
pub name: String, pub name: String,
pub settings: Value, pub settings: Value,
+36
View File
@@ -1,3 +1,10 @@
//! Provider-independent live events and the versioned component wire envelope.
//!
//! Provider adapters normalize platform packets into [`LiveEvent`]. Components
//! subscribe to stable [`LiveEventKind`] values and project them to
//! [`ComponentMessage`], keeping Bilibili command names and raw payloads out of
//! browser contracts and future provider implementations.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
@@ -44,6 +51,10 @@ impl LiveEventKind {
pub struct PlatformViewer { pub struct PlatformViewer {
pub uid: String, pub uid: String,
pub name: String, pub name: String,
/// Optional public avatar supplied by the live platform. Older event
/// formats omit it, so renderers must keep a local fallback.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -88,6 +99,9 @@ pub struct GiftDetails {
pub id: Option<i64>, pub id: Option<i64>,
pub name: String, pub name: String,
pub coin_type: String, pub coin_type: String,
/// Display value in Bilibili batteries. Upstream gift prices are gold
/// coin values, where 100 gold coins equal one battery.
pub battery_value: i64,
pub unit_price: i64, pub unit_price: i64,
pub total_price: i64, pub total_price: i64,
pub price_cny: f64, pub price_cny: f64,
@@ -270,6 +284,27 @@ pub struct ComponentMessage {
} }
impl 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.account_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( pub fn from_live_event(
component_id: Uuid, component_id: Uuid,
event: &LiveEvent, event: &LiveEvent,
@@ -300,6 +335,7 @@ mod tests {
PlatformViewer { PlatformViewer {
uid: "42".into(), uid: "42".into(),
name: "观众".into(), name: "观众".into(),
avatar_url: None,
} }
} }
+207
View File
@@ -0,0 +1,207 @@
//! Full-screen gift effect component.
//!
//! The component is deliberately passive: it subscribes only to canonical gift
//! events and projects them to its own authenticated OBS stream.
//! All visual differentiation is settings-driven in the browser, so receiving
//! an effect never creates database writes or depends on an OBS connection.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GIFT_EFFECT_KIND: &str = "gift_effect";
pub const GIFT_EFFECT_NAME: &str = "礼物星雨";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftEffectThemeId {
#[default]
JadeStarfall,
MoonlitWater,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MeteorTierSettings {
pub count: u8,
pub size: u16,
pub speed: u16,
}
impl MeteorTierSettings {
fn sanitize(mut self) -> Self {
self.count = self.count.clamp(1, 24);
self.size = self.size.clamp(24, 480);
self.speed = self.speed.clamp(100, 2_500);
self
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftEffectSettings {
#[serde(default)]
pub theme_id: GiftEffectThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
pub high_value_threshold: i64,
pub featured_value_threshold: i64,
pub normal: MeteorTierSettings,
pub high: MeteorTierSettings,
pub featured: MeteorTierSettings,
pub trail_intensity: u8,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: u16,
pub low_performance_mode: bool,
}
impl Default for GiftEffectSettings {
fn default() -> Self {
Self {
theme_id: GiftEffectThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
high_value_threshold: 10_000,
featured_value_threshold: 100_000,
normal: MeteorTierSettings {
count: 3,
size: 88,
speed: 560,
},
high: MeteorTierSettings {
count: 6,
size: 126,
speed: 720,
},
featured: MeteorTierSettings {
count: 10,
size: 168,
speed: 880,
},
trail_intensity: 78,
queue_capacity: default_queue_capacity(),
low_performance_mode: false,
}
}
}
impl GiftEffectSettings {
pub fn sanitize(mut self) -> Self {
self.high_value_threshold = self.high_value_threshold.max(0);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.featured_value_threshold =
self.featured_value_threshold.max(self.high_value_threshold);
self.normal = self.normal.sanitize();
self.high = self.high.sanitize();
self.featured = self.featured.sanitize();
self.trail_intensity = self.trail_intensity.min(100);
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
self
}
}
const fn default_queue_capacity() -> u16 {
256
}
pub struct GiftEffectDefinition;
impl GiftEffectDefinition {
fn parse(&self, settings: Value) -> Result<GiftEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftEffectDefinition {
fn kind(&self) -> &'static str {
GIFT_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftEffectSettings::default())
.expect("GiftEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GIFT_EFFECT_KIND.to_owned(),
detail: error.to_string(),
}
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([LiveEventKind::Gift]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_bound_each_value_tier_and_keep_threshold_order() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["normal"]["count"] = Value::from(0);
settings["fontBrightness"] = Value::from(999);
settings["featured"]["size"] = Value::from(9_999);
settings["highValueThreshold"] = Value::from(50_000);
settings["featuredValueThreshold"] = Value::from(10_000);
settings["queueCapacity"] = Value::from(9_999);
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["normal"]["count"], 1);
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["featured"]["size"], 480);
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
assert_eq!(sanitized["queueCapacity"], 1_000);
}
#[test]
fn component_only_subscribes_to_durable_gifts() {
let definition = GiftEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
}
#[test]
fn old_settings_receive_queue_capacity_default() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings.as_object_mut().unwrap().remove("queueCapacity");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["queueCapacity"], 256);
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GiftEffectDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+569
View File
@@ -0,0 +1,569 @@
//! Configurable gift-to-content menu component.
//!
//! Menu entries live in the component's versioned JSON settings because they
//! are a small, ordered presentation configuration rather than an event log.
//! The projection is authoritative for trigger matching and emits only matched
//! item IDs; the browser never has to reinterpret Bilibili guard names.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::{
components::{
ComponentDefinition, ComponentError, ComponentInstance, EventProjection, EventSubscription,
},
domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload},
overlay::normalize_image_url,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GIFT_MENU_KIND: &str = "gift_menu";
pub const GIFT_MENU_NAME: &str = "礼物菜单";
const MAX_MENU_ITEMS: usize = 100;
const fn default_page_interval_ms() -> u16 {
6_000
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GiftMenuThemeId {
#[default]
JadeBanquet,
MoonlitWater,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardLevel {
Captain,
Admiral,
Governor,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(
tag = "kind",
rename_all = "lowercase",
rename_all_fields = "camelCase"
)]
pub enum GiftMenuTrigger {
Gift {
gift_id: i64,
gift_name: String,
image_url: Option<String>,
unit_price: i64,
},
Guard {
level: GuardLevel,
},
Battery {
amount: i64,
},
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuItem {
pub id: Uuid,
pub trigger: GiftMenuTrigger,
pub description: String,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMenuSettings {
#[serde(default)]
pub theme_id: GiftMenuThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
#[serde(default)]
pub items: Vec<GiftMenuItem>,
pub visible_rows: u8,
pub row_height: u16,
pub scroll_speed_pixels_per_second: u16,
/// Delay between pages for non-scrolling themes. Kept separate from the
/// pixel speed so switching themes never destroys either preference.
#[serde(default = "default_page_interval_ms")]
pub page_interval_ms: u16,
pub highlight_duration_ms: u16,
pub font_scale: u16,
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub motion_intensity: u8,
pub low_performance_mode: bool,
}
impl Default for GiftMenuSettings {
fn default() -> Self {
Self {
theme_id: GiftMenuThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
items: Vec::new(),
visible_rows: 4,
row_height: 88,
scroll_speed_pixels_per_second: 28,
page_interval_ms: default_page_interval_ms(),
highlight_duration_ms: 3_800,
font_scale: 100,
decoration_line_weight: default_decoration_line_weight(),
motion_intensity: 78,
low_performance_mode: false,
}
}
}
impl GiftMenuSettings {
fn sanitize(mut self) -> Result<Self, String> {
if self.items.len() > MAX_MENU_ITEMS {
return Err(format!("gift menu accepts at most {MAX_MENU_ITEMS} items"));
}
let mut ids = HashSet::with_capacity(self.items.len());
let mut triggers = HashSet::with_capacity(self.items.len());
for item in &mut self.items {
if item.id.is_nil() || !ids.insert(item.id) {
return Err("gift menu item IDs must be unique and non-zero".into());
}
item.description = normalize_text(&item.description, 200)?;
let trigger_key = sanitize_trigger(&mut item.trigger)?;
if !triggers.insert(trigger_key) {
return Err("gift menu triggers must be unique".into());
}
}
self.visible_rows = self.visible_rows.clamp(1, 20);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.row_height = self.row_height.clamp(44, 240);
self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240);
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
self.font_scale = self.font_scale.clamp(50, 220);
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.motion_intensity = self.motion_intensity.min(100);
Ok(self)
}
}
const fn default_decoration_line_weight() -> u16 {
160
}
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let count = normalized.chars().count();
if count == 0 || count > max_chars {
return Err(format!("text must contain 1-{max_chars} characters"));
}
Ok(normalized)
}
fn sanitize_trigger(trigger: &mut GiftMenuTrigger) -> Result<String, String> {
match trigger {
GiftMenuTrigger::Gift {
gift_id,
gift_name,
image_url,
unit_price,
} => {
if *gift_id <= 0 {
return Err("gift ID must be positive".into());
}
*gift_name = normalize_text(gift_name, 80)?;
*unit_price = (*unit_price).clamp(0, 1_000_000_000);
*image_url = image_url.as_deref().and_then(normalize_image_url);
Ok(format!("gift:{gift_id}"))
}
GiftMenuTrigger::Guard { level } => Ok(format!("guard:{level:?}")),
GiftMenuTrigger::Battery { amount } => {
if *amount <= 0 || *amount > 1_000_000_000 {
return Err("battery amount must be between 1 and 1000000000".into());
}
Ok(format!("battery:{amount}"))
}
}
}
pub struct GiftMenuDefinition;
impl GiftMenuDefinition {
fn parse(&self, settings: Value) -> Result<GiftMenuSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GiftMenuDefinition {
fn kind(&self) -> &'static str {
GIFT_MENU_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GiftMenuSettings::default())
.expect("GiftMenuSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
let settings =
self.parse(settings)?
.sanitize()
.map_err(|detail| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail,
})?;
serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GIFT_MENU_KIND.to_owned(),
detail: error.to_string(),
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([
LiveEventKind::Gift,
LiveEventKind::GuardPurchase,
]))
}
}
#[derive(Default)]
pub struct GiftMenuProjection;
impl EventProjection for GiftMenuProjection {
fn project(
&self,
component: &ComponentInstance,
event: &LiveEvent,
) -> Result<Option<ComponentMessage>, ComponentError> {
let settings: GiftMenuSettings = serde_json::from_value(component.settings.clone())
.map_err(|error| ComponentError::Projection(error.to_string()))?;
let (matched, viewer) = match &event.payload {
LiveEventPayload::Gift(gift) => {
// Concrete gift identities take precedence over price tiers.
// Only fall back to battery matching when no configured gift
// ID (or name, when the event has no ID) matched.
let concrete = settings
.items
.iter()
.filter(|item| match &item.trigger {
GiftMenuTrigger::Gift {
gift_id, gift_name, ..
} => gift
.gift
.id
.map_or(gift.gift.name == *gift_name, |id| id == *gift_id),
_ => false,
})
.map(|item| item.id)
.collect::<Vec<_>>();
let matched = if concrete.is_empty() {
settings
.items
.iter()
.filter(|item| {
matches!(
&item.trigger,
GiftMenuTrigger::Battery { amount }
if gift.gift.battery_value == *amount
)
})
.map(|item| item.id)
.collect::<Vec<_>>()
} else {
concrete
};
(matched, &gift.viewer)
}
LiveEventPayload::GuardPurchase(guard) => {
let Some(level) = guard_level(&guard.guard_name) else {
return Ok(None);
};
(
settings
.items
.iter()
.filter(|item| {
matches!(&item.trigger, GiftMenuTrigger::Guard { level: candidate } if *candidate == level)
})
.map(|item| item.id)
.collect::<Vec<_>>(),
&guard.viewer,
)
}
_ => return Ok(None),
};
if matched.is_empty() {
return Ok(None);
}
let mut message = ComponentMessage::from_live_event(component.id, event)
.map_err(|error| ComponentError::Projection(error.to_string()))?;
message.event_type = "gift-menu.triggered".into();
message.payload = json!({
"itemIds": matched,
"viewer": viewer,
"sourceEventId": event.id,
});
Ok(Some(message))
}
}
fn guard_level(name: &str) -> Option<GuardLevel> {
let name = name.trim().to_lowercase();
if name.contains("总督") || name.contains("governor") {
Some(GuardLevel::Governor)
} else if name.contains("提督") || name.contains("admiral") {
Some(GuardLevel::Admiral)
} else if name.contains("舰长") || name.contains("captain") {
Some(GuardLevel::Captain)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEventPayload, PlatformViewer,
};
fn viewer() -> PlatformViewer {
PlatformViewer {
uid: "42".into(),
name: "观众".into(),
avatar_url: None,
}
}
fn component(items: Vec<GiftMenuItem>) -> ComponentInstance {
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
ComponentInstance::new(
Uuid::new_v4(),
Uuid::new_v4(),
GIFT_MENU_KIND,
GIFT_MENU_NAME,
1,
serde_json::to_value(settings).unwrap(),
)
}
#[test]
fn settings_reject_duplicate_triggers_and_bound_renderer_values() {
let trigger = GiftMenuTrigger::Battery { amount: 150 };
let items = vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: trigger.clone(),
description: "点歌".into(),
},
GiftMenuItem {
id: Uuid::new_v4(),
trigger,
description: "学歌".into(),
},
];
let definition = GiftMenuDefinition;
let settings = GiftMenuSettings {
items,
..GiftMenuSettings::default()
};
assert!(
definition
.validate_settings(serde_json::to_value(settings).unwrap())
.is_err()
);
let settings = GiftMenuSettings {
visible_rows: 255,
row_height: 1,
scroll_speed_pixels_per_second: u16::MAX,
page_interval_ms: u16::MAX,
font_scale: 1,
font_brightness: 1,
decoration_line_weight: 1,
..GiftMenuSettings::default()
};
let sanitized = definition
.validate_settings(serde_json::to_value(settings).unwrap())
.unwrap();
assert_eq!(sanitized["visibleRows"], 20);
assert_eq!(sanitized["rowHeight"], 44);
assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240);
assert_eq!(sanitized["pageIntervalMs"], 30_000);
assert_eq!(sanitized["fontScale"], 50);
assert_eq!(sanitized["fontBrightness"], 70);
assert_eq!(sanitized["decorationLineWeight"], 50);
}
#[test]
fn old_settings_receive_the_default_page_interval() {
let definition = GiftMenuDefinition;
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
settings.as_object_mut().unwrap().remove("pageIntervalMs");
settings
.as_object_mut()
.unwrap()
.remove("decorationLineWeight");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
assert_eq!(sanitized["decorationLineWeight"], 160);
}
#[test]
fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() {
let specific = Uuid::new_v4();
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: specific,
trigger: GiftMenuTrigger::Gift {
gift_id: 31039,
gift_name: "心动盲盒".into(),
image_url: None,
unit_price: 15_000,
},
description: "点歌".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: crate::overlay::gift_price_to_batteries(15_000),
unit_price: 15_000,
total_price: 30_000,
price_cny: 30.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 2,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.event_type, "gift-menu.triggered");
assert_eq!(message.payload["itemIds"], json!([specific]));
}
#[test]
fn gift_projection_falls_back_to_the_unit_battery_value() {
let battery = Uuid::new_v4();
let component = component(vec![
GiftMenuItem {
id: Uuid::new_v4(),
trigger: GiftMenuTrigger::Gift {
gift_id: 999,
gift_name: "其他礼物".into(),
image_url: None,
unit_price: 15_000,
},
description: "其他内容".into(),
},
GiftMenuItem {
id: battery,
trigger: GiftMenuTrigger::Battery { amount: 150 },
description: "任选挑战".into(),
},
]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::Gift(GiftEvent {
viewer: viewer(),
gift: GiftDetails {
id: Some(31039),
name: "心动盲盒".into(),
coin_type: "gold".into(),
battery_value: 150,
unit_price: 15_000,
total_price: 15_000,
price_cny: 15.0,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
quantity: 1,
source_event_id: "event".into(),
}),
);
let message = GiftMenuProjection
.project(&component, &event)
.unwrap()
.unwrap();
assert_eq!(message.payload["itemIds"], json!([battery]));
}
#[test]
fn guard_projection_normalizes_all_membership_levels() {
let id = Uuid::new_v4();
let component = component(vec![GiftMenuItem {
id,
trigger: GiftMenuTrigger::Guard {
level: GuardLevel::Admiral,
},
description: "专属节目".into(),
}]);
let event = LiveEvent::new(
component.owner_id,
component.account_source_id,
"bilibili",
"123",
LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(),
guard_name: "提督".into(),
quantity: 1,
price: 199_800,
}),
);
assert!(
GiftMenuProjection
.project(&component, &event)
.unwrap()
.is_some()
);
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GiftMenuDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+247
View File
@@ -0,0 +1,247 @@
//! Full-screen membership-purchase effect component.
//!
//! This component is deliberately separate from gift effects: every instance
//! owns its settings, read-only OBS token, event subscription, and WebSocket
//! channel, and consumes only canonical guard-purchase events.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuardEffectThemeId {
#[default]
JadeStarfall,
MoonlitWater,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GuardEffectSettings {
#[serde(default)]
pub theme_id: GuardEffectThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
pub star_count: u8,
pub effect_duration_ms: u16,
#[serde(default = "default_title_template")]
pub title_template: String,
#[serde(default = "default_closing_text")]
pub closing_text: String,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: u16,
pub low_performance_mode: bool,
}
impl Default for GuardEffectSettings {
fn default() -> Self {
Self {
theme_id: GuardEffectThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
star_count: 48,
effect_duration_ms: 5_200,
title_template: default_title_template(),
closing_text: default_closing_text(),
queue_capacity: default_queue_capacity(),
low_performance_mode: false,
}
}
}
impl GuardEffectSettings {
pub fn sanitize(mut self) -> Self {
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.star_count = self.star_count.clamp(8, 96);
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
self.title_template = sanitize_copy(self.title_template, &default_title_template());
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
self
}
}
const fn default_queue_capacity() -> u16 {
256
}
/// Preserve the membership-related portion of the first legacy gift-effect
/// instance when an existing account receives its initial guard component.
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable");
for (legacy, current) in [
("themeId", "themeId"),
("fontFamily", "fontFamily"),
("fontBrightness", "fontBrightness"),
("guardStarCount", "starCount"),
("guardEffectDurationMs", "effectDurationMs"),
("guardTitleTemplate", "titleTemplate"),
("guardClosingText", "closingText"),
("lowPerformanceMode", "lowPerformanceMode"),
] {
if let Some(candidate) = value.get(legacy) {
migrated[current] = candidate.clone();
}
}
serde_json::from_value::<GuardEffectSettings>(migrated)
.unwrap_or_default()
.sanitize()
}
fn default_title_template() -> String {
"{guard}启航".into()
}
fn default_closing_text() -> String {
"相伴前行".into()
}
fn sanitize_copy(value: String, fallback: &str) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
fallback.to_owned()
} else {
normalized.chars().take(80).collect()
}
}
pub struct GuardEffectDefinition;
impl GuardEffectDefinition {
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GuardEffectDefinition {
fn kind(&self) -> &'static str {
GUARD_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
}
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_are_bounded_and_copy_is_sanitized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["fontBrightness"] = Value::from(999);
settings["starCount"] = Value::from(255);
settings["effectDurationMs"] = Value::from(200);
settings["queueCapacity"] = Value::from(9_999);
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
settings["closingText"] = Value::from(" ");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["starCount"], 96);
assert_eq!(sanitized["effectDurationMs"], 1_000);
assert_eq!(sanitized["queueCapacity"], 1_000);
assert_eq!(
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
80
);
assert_eq!(sanitized["closingText"], "相伴前行");
}
#[test]
fn old_settings_receive_copy_defaults() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings.as_object_mut().unwrap().remove("titleTemplate");
settings.as_object_mut().unwrap().remove("closingText");
settings.as_object_mut().unwrap().remove("queueCapacity");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
assert_eq!(sanitized["closingText"], "相伴前行");
assert_eq!(sanitized["queueCapacity"], 256);
}
#[test]
fn legacy_gift_settings_preserve_membership_configuration() {
let legacy = serde_json::json!({
"themeId": "moonlit-water",
"fontFamily": "kai",
"fontBrightness": 145,
"guardStarCount": 72,
"guardEffectDurationMs": 8_500,
"guardTitleTemplate": "{guard}出发",
"guardClosingText": "一路顺风",
"lowPerformanceMode": true,
"normal": { "count": 20 }
});
let migrated = settings_from_legacy_gift(&legacy);
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
assert_eq!(migrated.star_count, 72);
assert_eq!(migrated.effect_duration_ms, 8_500);
assert_eq!(migrated.title_template, "{guard}出发");
assert_eq!(migrated.closing_text, "一路顺风");
assert!(migrated.low_performance_mode);
}
#[test]
fn component_only_subscribes_to_guard_purchases() {
let definition = GuardEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+478 -36
View File
@@ -1,9 +1,17 @@
//! Axum HTTP/WebSocket adapter and public trust boundary.
//!
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
//! request bodies are never trusted. This module also enforces same-origin
//! mutation checks, response cache policy, component-token WebSocket
//! authentication, bounded request bodies and security headers for the static
//! control console and OBS entry point.
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{ extract::{
DefaultBodyLimit, Path, Request, State, DefaultBodyLimit, Path, Query, Request, State,
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code}, ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
}, },
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
@@ -26,9 +34,11 @@ use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie}, credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
domain::{ domain::{
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent, COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer, GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
}, },
gift_menu::GIFT_MENU_KIND,
repository::{ComponentView, RepositoryError}, repository::{ComponentView, RepositoryError},
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
}; };
const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session"; const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session";
@@ -44,6 +54,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/auth/register/confirm", post(enrollment_confirm)) .route("/api/v1/auth/register/confirm", post(enrollment_confirm))
.route("/api/v1/auth/login", post(login)) .route("/api/v1/auth/login", post(login))
.route("/api/v1/auth/logout", post(logout)) .route("/api/v1/auth/logout", post(logout))
.route("/api/v1/auth/totp/reset/start", post(totp_reset_start))
.route("/api/v1/auth/totp/reset/confirm", post(totp_reset_confirm))
.route( .route(
"/api/v1/invitations", "/api/v1/invitations",
get(list_invitations).post(create_invitation), get(list_invitations).post(create_invitation),
@@ -51,6 +63,18 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/invitations/{id}", delete(revoke_invitation)) .route("/api/v1/invitations/{id}", delete(revoke_invitation))
.route("/api/v1/source", get(get_source).put(put_source)) .route("/api/v1/source", get(get_source).put(put_source))
.route("/api/v1/source/reconnect", post(reconnect_source)) .route("/api/v1/source/reconnect", post(reconnect_source))
.route(
"/api/v1/account/live-source",
get(get_source).put(put_source),
)
.route(
"/api/v1/account/live-source/reconnect",
post(reconnect_source),
)
.route(
"/api/v1/account/preferences",
get(get_account_preferences).put(put_account_preferences),
)
.route( .route(
"/api/v1/components", "/api/v1/components",
get(list_components).post(create_component), get(list_components).post(create_component),
@@ -68,10 +92,38 @@ pub fn router(state: AppState) -> Router {
"/api/v1/components/{id}/test-events", "/api/v1/components/{id}/test-events",
post(component_test_event), post(component_test_event),
) )
.route(
"/api/v1/components/{id}/gift-catalog",
get(component_gift_catalog),
)
.route(
"/api/v1/components/{id}/gift-catalog/refresh",
post(refresh_component_gift_catalog),
)
.route( .route(
"/api/v1/components/{public_id}/stream", "/api/v1/components/{public_id}/stream",
get(component_stream), get(component_stream),
) )
.route(
"/api/v1/components/{id}/song-requests",
get(list_song_requests),
)
.route(
"/api/v1/components/{id}/song-requests/clear",
post(clear_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("/api/{*path}", any(api_not_found))
.route("/", get(spa_page)) .route("/", get(spa_page))
.route("/login", get(spa_page)) .route("/login", get(spa_page))
@@ -83,6 +135,7 @@ pub fn router(state: AppState) -> Router {
.route("/control/register", get(spa_page)) .route("/control/register", get(spa_page))
.route("/control/setup", get(spa_page)) .route("/control/setup", get(spa_page))
.route("/control/invitations", get(spa_page)) .route("/control/invitations", get(spa_page))
.route("/control/account", get(spa_page))
.route("/obs/{public_id}", get(spa_page)) .route("/obs/{public_id}", get(spa_page))
.fallback_service( .fallback_service(
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")), ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
@@ -241,6 +294,7 @@ async fn register_start(
struct EnrollmentConfirmRequest { struct EnrollmentConfirmRequest {
enrollment_token: String, enrollment_token: String,
code: String, code: String,
language: Option<String>,
} }
async fn enrollment_confirm( async fn enrollment_confirm(
@@ -250,9 +304,20 @@ async fn enrollment_confirm(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
same_origin(&state, &headers)?; same_origin(&state, &headers)?;
consume_enrollment_budget(&state, &headers).await?; consume_enrollment_budget(&state, &headers).await?;
let requested_language = body
.language
.unwrap_or_else(|| crate::i18n::default_language().to_owned());
let language = requested_language.trim();
if !crate::i18n::is_supported(language) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_input",
"Language is not supported",
));
}
let result = state let result = state
.auth .auth
.registration_confirm(&body.enrollment_token, &body.code) .registration_confirm(&body.enrollment_token, &body.code, language)
.await?; .await?;
if result.user.role == UserRole::SystemAdmin { if result.user.role == UserRole::SystemAdmin {
if let Err(error) = state if let Err(error) = state
@@ -326,7 +391,7 @@ async fn login(
Ok(result) => result, Ok(result) => result,
Err(error) => { Err(error) => {
state.login_limiter.failure(&body.username, &ip).await; state.login_limiter.failure(&body.username, &ip).await;
return Err(ApiError::from(error).as_generic_login()); return Err(ApiError::from(error).into_generic_login());
} }
}; };
state.login_limiter.success(&body.username, &ip).await; state.login_limiter.success(&body.username, &ip).await;
@@ -370,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
Ok(response) Ok(response)
} }
#[derive(Deserialize)]
struct TotpResetStartRequest {
code: String,
}
async fn totp_reset_start(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetStartRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let enrollment = state
.auth
.totp_reset_start(session.user.id, &body.code)
.await?;
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TotpResetConfirmRequest {
enrollment_token: String,
code: String,
}
async fn totp_reset_confirm(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetConfirmRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let recovery_codes = state
.auth
.totp_reset_confirm(
session.user.id,
session.session_id,
&body.enrollment_token,
&body.code,
)
.await?;
Ok(Json(json!({
"ok": true,
"recoveryCodes": recovery_codes,
})))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CreateInvitationRequest { struct CreateInvitationRequest {
@@ -486,13 +601,7 @@ async fn put_source(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?; same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?; let session = require_session(&state, &headers).await?;
if body.room_id.trim() != session.user.room_id { let room_id = body.room_id.trim().to_owned();
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"room_is_immutable",
"The room bound by the invitation cannot be changed",
));
}
let existing = state let existing = state
.auth .auth
.get_cookiecloud_credentials(session.user.id) .get_cookiecloud_credentials(session.user.id)
@@ -543,7 +652,7 @@ async fn put_source(
})?; })?;
state state
.auth .auth
.set_cookiecloud_credentials(session.user.id, &credentials) .set_account_live_source(session.user.id, &room_id, &credentials)
.await?; .await?;
state state
.restart_user_source(session.user.id) .restart_user_source(session.user.id)
@@ -565,6 +674,50 @@ async fn reconnect_source(
Ok(Json(json!({"ok":true}))) Ok(Json(json!({"ok":true})))
} }
async fn get_account_preferences(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let session = require_session(&state, &headers).await?;
let language = state.repository.account_language(session.user.id).await?;
Ok(Json(json!({"language":language})))
}
#[derive(Deserialize)]
struct PutAccountPreferences {
language: String,
}
async fn put_account_preferences(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PutAccountPreferences>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let language = state
.repository
.set_account_language(session.user.id, body.language.trim())
.await?;
let room_id = state.repository.room_id(session.user.id).await?;
for view in state.repository.list_components(session.user.id).await? {
let component = state
.repository
.get_component(session.user.id, view.id)
.await?;
state.hub.publish(
component.id,
Arc::new(ComponentMessage::new(
&component,
&room_id,
"component.language.updated",
json!({"language":language}),
)),
);
}
Ok(Json(json!({"language":language})))
}
async fn list_components( async fn list_components(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -637,12 +790,199 @@ async fn put_component_settings(
let message = settings_message( let message = settings_message(
&component, &component,
&session.user.room_id, &session.user.room_id,
"overlay.settings.updated", "component.settings.updated",
); );
state.hub.publish(component.id, Arc::new(message)); 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}))) Ok(Json(json!({"settings":component.settings})))
} }
async fn component_gift_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?;
if component.kind != GIFT_MENU_KIND {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"Gift catalog is unavailable for this component",
));
}
let catalog = state.gift_catalogs.catalog(session.user.id).await;
if catalog.is_empty().await {
catalog
.refresh(
&session.user.room_id,
state.config.gift_request_timeout_seconds,
)
.await
.map_err(internal)?;
}
Ok(Json(json!({"gifts":catalog.list().await})))
}
async fn refresh_component_gift_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<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?;
if component.kind != GIFT_MENU_KIND {
return Err(ApiError::new(
StatusCode::NOT_FOUND,
"not_found",
"Gift catalog is unavailable for this component",
));
}
let catalog = state.gift_catalogs.catalog(session.user.id).await;
catalog
.refresh(
&session.user.room_id,
state.config.gift_request_timeout_seconds,
)
.await
.map_err(internal)?;
Ok(Json(json!({"gifts":catalog.list().await})))
}
#[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 clear_song_requests(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<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)?;
let cancelled_count = state
.song_requests
.clear_active(&component, &session.user.room_id)
.await?;
Ok(Json(json!({
"ok": true,
"cancelledCount": cancelled_count,
})))
}
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( async fn get_component_token(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -704,9 +1044,19 @@ enum TestEventRequest {
name: String, name: String,
#[serde(rename = "giftName")] #[serde(rename = "giftName")]
gift_name: String, gift_name: String,
#[serde(rename = "giftId")]
gift_id: Option<i64>,
battery: i32, battery: i32,
quantity: i32, quantity: i32,
}, },
Guard {
uid: String,
name: String,
#[serde(rename = "guardName")]
guard_name: String,
quantity: i32,
price: i64,
},
} }
async fn component_test_event( async fn component_test_event(
@@ -718,7 +1068,11 @@ async fn component_test_event(
same_origin(&state, &headers)?; same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?; let session = require_session(&state, &headers).await?;
let component = state.repository.get_component(session.user.id, id).await?; let component = state.repository.get_component(session.user.id, id).await?;
let viewer = |uid: String, name: String| PlatformViewer { uid, name }; let viewer = |uid: String, name: String| PlatformViewer {
uid,
name,
avatar_url: None,
};
let payload = match body { let payload = match body {
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent { TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
viewer: viewer(uid, name), viewer: viewer(uid, name),
@@ -732,18 +1086,23 @@ async fn component_test_event(
uid, uid,
name, name,
gift_name, gift_name,
gift_id,
battery, battery,
quantity, quantity,
} => { } => {
let quantity = quantity.max(1); let quantity = quantity.max(1);
let unit_price = i64::from(battery.max(0)); // The test API accepts the user-facing battery value while the
// canonical event retains Bilibili's raw gold-coin price.
let battery_value = i64::from(battery.max(0));
let unit_price = battery_value.saturating_mul(100);
let total_price = unit_price.saturating_mul(i64::from(quantity)); let total_price = unit_price.saturating_mul(i64::from(quantity));
LiveEventPayload::Gift(GiftEvent { LiveEventPayload::Gift(GiftEvent {
viewer: viewer(uid, name), viewer: viewer(uid, name),
gift: GiftDetails { gift: GiftDetails {
id: None, id: gift_id,
name: gift_name, name: gift_name,
coin_type: "gold".into(), coin_type: "gold".into(),
battery_value,
unit_price, unit_price,
total_price, total_price,
price_cny: total_price as f64 / 1000.0, price_cny: total_price as f64 / 1000.0,
@@ -756,10 +1115,22 @@ async fn component_test_event(
source_event_id: format!("test-{}", Uuid::new_v4()), source_event_id: format!("test-{}", Uuid::new_v4()),
}) })
} }
TestEventRequest::Guard {
uid,
name,
guard_name,
quantity,
price,
} => LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
viewer: viewer(uid, name),
guard_name,
quantity: quantity.max(1),
price: price.max(0),
}),
}; };
let mut event = LiveEvent::new( let mut event = LiveEvent::new(
component.owner_id, component.owner_id,
component.source_id, component.account_source_id,
"test", "test",
session.user.room_id, session.user.room_id,
payload, payload,
@@ -867,9 +1238,20 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
} }
}; };
let mut receiver = state.hub.subscribe(component_id); let mut receiver = state.hub.subscribe(component_id);
let language = match state
.repository
.account_language(identity.owner_user_id)
.await
{
Ok(language) => language,
Err(_) => {
close_ws(&mut socket, "Component is unavailable").await;
return;
}
};
if socket if socket
.send(Message::Text( .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,"language":language})
.to_string() .to_string()
.into(), .into(),
)) ))
@@ -885,19 +1267,54 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
return; 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) if send_component_message(&mut socket, &snapshot)
.await .await
.is_err() .is_err()
{ {
return; 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 { loop {
tokio::select! { tokio::select! {
event = receiver.recv() => match event { event = receiver.recv() => match event {
Ok(message) => { Ok(message) => {
if send_component_message(&mut socket, &message).await.is_err() { break; } 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::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break, Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}, },
@@ -937,17 +1354,12 @@ fn settings_message(
room_id: &str, room_id: &str,
event_type: &str, event_type: &str,
) -> ComponentMessage { ) -> ComponentMessage {
ComponentMessage { ComponentMessage::new(
owner_id: component.owner_id, component,
component_id: component.id, room_id,
source_id: component.source_id, event_type,
version: COMPONENT_PROTOCOL_VERSION, json!({"settings":component.settings}),
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}),
}
} }
async fn require_session( async fn require_session(
@@ -1117,9 +1529,11 @@ fn constant_time_secret_eq(provided: &str, expected: &str) -> bool {
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorBody { struct ErrorBody {
error: &'static str, error: &'static str,
code: &'static str, code: &'static str,
message_key: String,
message: String, message: String,
} }
@@ -1146,7 +1560,7 @@ impl ApiError {
self self
} }
fn as_generic_login(mut self) -> Self { fn into_generic_login(mut self) -> Self {
if self.status != StatusCode::TOO_MANY_REQUESTS { if self.status != StatusCode::TOO_MANY_REQUESTS {
self.status = StatusCode::UNAUTHORIZED; self.status = StatusCode::UNAUTHORIZED;
self.code = "invalid_credentials"; self.code = "invalid_credentials";
@@ -1166,14 +1580,15 @@ impl IntoResponse for ApiError {
Json(ErrorBody { Json(ErrorBody {
error: self.code, error: self.code,
code: self.code, code: self.code,
message_key: format!("api.error.{}", self.code),
message: self.message, message: self.message,
}), }),
) )
.into_response(); .into_response();
if let Some(retry_after) = self.retry_after { if let Some(retry_after) = self.retry_after
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) { && let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
response.headers_mut().insert(header::RETRY_AFTER, value); {
} response.headers_mut().insert(header::RETRY_AFTER, value);
} }
response response
} }
@@ -1201,11 +1616,19 @@ impl From<AuthError> for ApiError {
"enrollment_unavailable", "enrollment_unavailable",
error.to_string(), error.to_string(),
), ),
AuthError::AccountUnavailable | AuthError::RoomUnavailable => Self::new( AuthError::TotpResetUnavailable => Self::new(
StatusCode::BAD_REQUEST,
"totp_reset_unavailable",
error.to_string(),
),
AuthError::AccountUnavailable => Self::new(
StatusCode::CONFLICT, StatusCode::CONFLICT,
"account_unavailable", "account_unavailable",
error.to_string(), error.to_string(),
), ),
AuthError::RoomUnavailable => {
Self::new(StatusCode::CONFLICT, "room_unavailable", error.to_string())
}
AuthError::InvalidTotp AuthError::InvalidTotp
| AuthError::TotpReplay | AuthError::TotpReplay
| AuthError::InvalidCredentials | AuthError::InvalidCredentials
@@ -1248,6 +1671,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 { fn internal(error: impl std::fmt::Display) -> ApiError {
error!(%error, "internal operation failed"); error!(%error, "internal operation failed");
ApiError::new( ApiError::new(
+65
View File
@@ -0,0 +1,65 @@
//! Shared language-resource validation.
//!
//! Rust embeds the same TOML file that Vite compiles into the browser bundle.
//! Backend persistence accepts only locale codes declared by that resource, so
//! the database and deployed frontend cannot drift into unsupported values.
use std::{collections::HashMap, sync::OnceLock};
use serde::Deserialize;
const RESOURCE: &str = include_str!("../../../resources/i18n.toml");
#[derive(Deserialize)]
struct Catalog {
default_language: String,
locales: HashMap<String, Locale>,
}
#[derive(Deserialize)]
struct Locale {
#[allow(dead_code)]
name: String,
messages: HashMap<String, String>,
}
fn catalog() -> &'static Catalog {
static CATALOG: OnceLock<Catalog> = OnceLock::new();
CATALOG.get_or_init(|| {
let catalog: Catalog = toml::from_str(RESOURCE).expect("resources/i18n.toml must be valid");
let default = catalog
.locales
.get(&catalog.default_language)
.expect("default language must be declared");
for (code, locale) in &catalog.locales {
for key in default.messages.keys() {
assert!(
locale.messages.contains_key(key),
"locale {code} is missing translation key {key}"
);
}
}
catalog
})
}
pub fn default_language() -> &'static str {
&catalog().default_language
}
pub fn is_supported(language: &str) -> bool {
catalog().locales.contains_key(language)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_catalog_is_complete_and_declares_supported_languages() {
assert_eq!(default_language(), "zh-CN");
assert!(is_supported("zh-CN"));
assert!(is_supported("en-US"));
assert!(!is_supported("unknown"));
}
}
+6
View File
@@ -12,9 +12,15 @@ pub mod config;
pub mod credentials; pub mod credentials;
pub mod db; pub mod db;
pub mod domain; pub mod domain;
pub mod gift_effect;
pub mod gift_menu;
pub mod guard_effect;
pub mod http_api; pub mod http_api;
pub mod i18n;
pub mod live; pub mod live;
pub mod overlay; pub mod overlay;
pub mod rate_limit; pub mod rate_limit;
pub mod realtime; pub mod realtime;
pub mod repository; pub mod repository;
pub mod song_request;
pub mod typography;
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -1,3 +1,10 @@
//! Live-provider abstraction and source lifecycle types.
//!
//! A provider receives trusted tenant/source context and emits canonical
//! [`LiveEvent`] values plus observable connection status. Cancellation and
//! restart ownership live in [`supervisor::SourceSupervisor`], keeping provider
//! implementations focused on one upstream connection.
pub mod bilibili; pub mod bilibili;
pub mod supervisor; pub mod supervisor;
@@ -35,9 +42,9 @@ impl SourceStatus {
#[derive(Clone)] #[derive(Clone)]
pub struct SourceContext { pub struct SourceContext {
pub owner_id: Uuid, pub owner_id: Uuid,
/// Stable database identity for the account's single fixed live source. /// Stable identity for the account's single fixed listener. Components do
/// It remains distinct from the owner id so future provider/source models /// not persist or select this ID; it remains in event envelopes so clients
/// do not leak the current one-room product rule into event contracts. /// can identify the shared upstream connection.
pub source_id: Uuid, pub source_id: Uuid,
pub room_id: String, pub room_id: String,
} }
+7 -1
View File
@@ -1,3 +1,9 @@
//! Per-account source task ownership, restart and cancellation.
//!
//! The supervisor guarantees at most one provider generation for a source ID.
//! Reconfiguration cancels the old task before a replacement starts, preventing
//! duplicate Bilibili listeners and duplicate downstream events.
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
@@ -21,7 +27,7 @@ struct RunningSource {
status: watch::Receiver<SourceStatus>, status: watch::Receiver<SourceStatus>,
} }
/// Owns exactly one provider task per account/source. Restart always cancels /// Owns exactly one provider task per account. Restart always cancels
/// the prior generation before starting another, which prevents the duplicate /// the prior generation before starting another, which prevents the duplicate
/// listeners produced by the legacy `/reconnect` handler. /// listeners produced by the legacy `/reconnect` handler.
#[derive(Clone)] #[derive(Clone)]
+6
View File
@@ -1,3 +1,9 @@
//! Executable entry point for the livestream component host.
//!
//! Keep this file limited to process concerns: configuration, logging, socket
//! binding and graceful shutdown. All application behavior belongs in the
//! library crate so it can be exercised without starting a real HTTP server.
use lxc_stream_server::{app::AppState, config::Config, http_api}; use lxc_stream_server::{app::AppState, config::Config, http_api};
use tracing::{info, warn}; use tracing::{info, warn};
+215 -22
View File
@@ -1,14 +1,54 @@
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
//!
//! Settings are sanitized before persistence or projection. Catalog refreshes
//! replace the in-memory snapshot only after a complete valid response, so a
//! transient upstream failure preserves the last known gift images, prices and
//! emoticon URLs instead of breaking live rendering.
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid;
use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness};
/// 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,
MoonlitWater,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OverlaySettings { pub struct OverlaySettings {
/// Visual theme selected for this tenant-owned component instance.
#[serde(default)]
pub theme_id: OverlayThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
/// Optional per-component overrides. `None` keeps the selected theme's
/// palette, which makes future theme changes immediately visible.
#[serde(default)]
pub viewer_color: Option<String>,
#[serde(default)]
pub danmaku_color: Option<String>,
#[serde(default = "default_font_scale")] #[serde(default = "default_font_scale")]
pub font_scale: u16, pub font_scale: u16,
/// Relative weight of theme borders, rules, and ornamental edge artwork.
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub show_danmaku: bool, pub show_danmaku: bool,
pub show_enter: bool, pub show_enter: bool,
pub show_gift: bool, pub show_gift: bool,
@@ -17,6 +57,8 @@ pub struct OverlaySettings {
pub show_like: bool, pub show_like: bool,
pub show_share: bool, pub show_share: bool,
pub max_visible: u8, pub max_visible: u8,
#[serde(default = "default_expand_new_danmaku")]
pub expand_new_danmaku: bool,
pub collapse_after_seconds: u16, pub collapse_after_seconds: u16,
#[serde(default = "default_unfold_duration_ms")] #[serde(default = "default_unfold_duration_ms")]
pub unfold_duration_ms: u16, pub unfold_duration_ms: u16,
@@ -34,7 +76,13 @@ pub struct OverlaySettings {
impl Default for OverlaySettings { impl Default for OverlaySettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
theme_id: OverlayThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
viewer_color: None,
danmaku_color: None,
font_scale: default_font_scale(), font_scale: default_font_scale(),
decoration_line_weight: default_decoration_line_weight(),
show_danmaku: true, show_danmaku: true,
show_enter: true, show_enter: true,
show_gift: true, show_gift: true,
@@ -43,6 +91,7 @@ impl Default for OverlaySettings {
show_like: false, show_like: false,
show_share: false, show_share: false,
max_visible: 5, max_visible: 5,
expand_new_danmaku: default_expand_new_danmaku(),
collapse_after_seconds: 12, collapse_after_seconds: 12,
unfold_duration_ms: default_unfold_duration_ms(), unfold_duration_ms: default_unfold_duration_ms(),
motion_intensity: 70, motion_intensity: 70,
@@ -58,7 +107,11 @@ impl Default for OverlaySettings {
impl OverlaySettings { impl OverlaySettings {
pub fn sanitize(mut self) -> Self { pub fn sanitize(mut self) -> Self {
self.max_visible = self.max_visible.clamp(1, 12); self.max_visible = self.max_visible.clamp(1, 12);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
self.font_scale = self.font_scale.clamp(50, 300); self.font_scale = self.font_scale.clamp(50, 300);
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120); self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000); self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
self.motion_intensity = self.motion_intensity.min(100); self.motion_intensity = self.motion_intensity.min(100);
@@ -71,14 +124,30 @@ impl OverlaySettings {
} }
} }
fn sanitize_optional_hex_color(value: Option<String>) -> Option<String> {
let value = value?.trim().to_ascii_uppercase();
(value.len() == 7
&& value.starts_with('#')
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
.then_some(value)
}
fn default_font_scale() -> u16 { fn default_font_scale() -> u16 {
140 140
} }
fn default_decoration_line_weight() -> u16 {
160
}
fn default_unfold_duration_ms() -> u16 { fn default_unfold_duration_ms() -> u16 {
1_000 1_000
} }
fn default_expand_new_danmaku() -> bool {
true
}
fn default_particle_count() -> u8 { fn default_particle_count() -> u8 {
8 8
} }
@@ -93,6 +162,7 @@ pub struct GiftMeta {
pub id: Option<i64>, pub id: Option<i64>,
pub name: String, pub name: String,
pub coin_type: String, pub coin_type: String,
pub battery_value: i64,
pub unit_price: i64, pub unit_price: i64,
pub image_url: Option<String>, pub image_url: Option<String>,
pub animation_url: Option<String>, pub animation_url: Option<String>,
@@ -106,6 +176,24 @@ pub struct GiftCatalog {
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>, by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
} }
/// Account-keyed catalog handles shared by the live provider and control API.
/// Each account receives a distinct [`GiftCatalog`], so switching or refreshing
/// one room can never replace another tenant's gift metadata.
#[derive(Clone, Default)]
pub struct GiftCatalogRegistry {
catalogs: Arc<RwLock<HashMap<Uuid, GiftCatalog>>>,
}
impl GiftCatalogRegistry {
pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog {
if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() {
return catalog;
}
let mut catalogs = self.catalogs.write().await;
catalogs.entry(owner_id).or_default().clone()
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct EmoticonMeta { pub struct EmoticonMeta {
pub emoji: String, pub emoji: String,
@@ -123,16 +211,30 @@ pub struct EmoticonCatalog {
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>, by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
} }
// Parser return aliases keep the atomic cache-replacement contract visible:
// both lookup maps and their source count are produced before either lock is
// updated, so readers never observe a half-refreshed catalog.
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
type EmoticonCatalogSnapshot = (
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
);
impl EmoticonCatalog { impl EmoticonCatalog {
pub async fn len(&self) -> usize { pub async fn len(&self) -> usize {
self.by_unique.read().await.len() self.by_unique.read().await.len()
} }
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> { pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
if let Some(unique) = unique.filter(|value| !value.is_empty()) { if let Some(unique) = unique.filter(|value| !value.is_empty())
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() { && let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
return Some(emoticon); {
} return Some(emoticon);
} }
self.by_emoji.read().await.get(emoji).cloned() self.by_emoji.read().await.get(emoji).cloned()
} }
@@ -184,11 +286,15 @@ impl GiftCatalog {
self.by_id.read().await.len() self.by_id.read().await.len()
} }
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> { pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
if let Some(id) = id { if let Some(id) = id
if let Some(gift) = self.by_id.read().await.get(&id).cloned() { && let Some(gift) = self.by_id.read().await.get(&id).cloned()
return Some(gift); {
} return Some(gift);
} }
self.by_name self.by_name
.read() .read()
@@ -197,6 +303,21 @@ impl GiftCatalog {
.cloned() .cloned()
} }
/// Return a stable, deduplicated control-console view ordered by price and
/// name. ID-backed entries are preferred because names are not unique.
pub async fn list(&self) -> Vec<GiftMeta> {
let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect();
if gifts.is_empty() {
gifts.extend(self.by_name.read().await.values().cloned());
}
gifts.sort_by(|left, right| {
left.unit_price
.cmp(&right.unit_price)
.then_with(|| left.name.cmp(&right.name))
});
gifts
}
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> { pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds)) .timeout(std::time::Duration::from_secs(timeout_seconds))
@@ -231,9 +352,7 @@ impl GiftCatalog {
} }
} }
fn parse_catalog( fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
payload: &Value,
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
let list = payload let list = payload
.pointer("/data/gift_config/base_config/list") .pointer("/data/gift_config/base_config/list")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -245,6 +364,7 @@ fn parse_catalog(
continue; continue;
}; };
let id = item.get("id").and_then(Value::as_i64); let id = item.get("id").and_then(Value::as_i64);
let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0);
let gift = GiftMeta { let gift = GiftMeta {
id, id,
name: name.to_owned(), name: name.to_owned(),
@@ -253,7 +373,8 @@ fn parse_catalog(
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("gold") .unwrap_or("gold")
.to_owned(), .to_owned(),
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0), battery_value: gift_price_to_batteries(unit_price),
unit_price,
image_url: string_field(item, "img_basic"), image_url: string_field(item, "img_basic"),
animation_url: string_field(item, "gif"), animation_url: string_field(item, "gif"),
effect_type: item.get("effect").and_then(|value| match value { effect_type: item.get("effect").and_then(|value| match value {
@@ -274,6 +395,12 @@ fn parse_catalog(
Ok((ids, names, list.len())) Ok((ids, names, list.len()))
} }
/// Bilibili's gift panel and live messages express paid gift prices in gold
/// coins rather than batteries. One battery is 100 gold coins (and ¥0.1).
pub fn gift_price_to_batteries(raw_price: i64) -> i64 {
raw_price.max(0) / 100
}
fn string_field(value: &Value, name: &str) -> Option<String> { fn string_field(value: &Value, name: &str) -> Option<String> {
value value
.get(name) .get(name)
@@ -282,16 +409,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
fn parse_emoticon_catalog( fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
payload: &Value,
) -> Result<
(
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
),
String,
> {
let packages = payload let packages = payload
.pointer("/data/data") .pointer("/data/data")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -383,6 +501,29 @@ fn normalize_name(name: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn gift_catalog_registry_isolates_account_snapshots() {
let registry = GiftCatalogRegistry::default();
let first = registry.catalog(Uuid::new_v4()).await;
let second = registry.catalog(Uuid::new_v4()).await;
first.by_id.write().await.insert(
1,
GiftMeta {
id: Some(1),
name: "小花花".into(),
coin_type: "gold".into(),
battery_value: 1,
unit_price: 100,
image_url: None,
animation_url: None,
effect_type: None,
stay_time: None,
},
);
assert_eq!(first.list().await.len(), 1);
assert!(second.list().await.is_empty());
}
use serde_json::json; use serde_json::json;
#[test] #[test]
@@ -394,6 +535,7 @@ mod tests {
let (ids, names, count) = parse_catalog(&payload).expect("catalog"); let (ids, names, count) = parse_catalog(&payload).expect("catalog");
assert_eq!(count, 1); assert_eq!(count, 1);
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000); assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
assert_eq!(ids.get(&42).expect("id index").battery_value, 300);
assert_eq!( assert_eq!(
names names
.get("青玉灯") .get("青玉灯")
@@ -404,6 +546,13 @@ mod tests {
); );
} }
#[test]
fn converts_raw_gold_coin_prices_to_batteries() {
assert_eq!(gift_price_to_batteries(100), 1);
assert_eq!(gift_price_to_batteries(15_000), 150);
assert_eq!(gift_price_to_batteries(-1), 0);
}
#[test] #[test]
fn rejects_an_empty_catalog_without_replacing_the_cache() { fn rejects_an_empty_catalog_without_replacing_the_cache() {
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}}); let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
@@ -441,7 +590,12 @@ mod tests {
fn settings_are_bounded_before_persistence() { fn settings_are_bounded_before_persistence() {
let settings = OverlaySettings { let settings = OverlaySettings {
font_scale: 999, font_scale: 999,
font_brightness: 1,
viewer_color: Some(" #a1b2c3 ".into()),
danmaku_color: Some("not-css".into()),
decoration_line_weight: 999,
max_visible: 99, max_visible: 99,
expand_new_danmaku: false,
collapse_after_seconds: 1, collapse_after_seconds: 1,
unfold_duration_ms: 9_000, unfold_duration_ms: 9_000,
motion_intensity: 200, motion_intensity: 200,
@@ -453,7 +607,12 @@ mod tests {
} }
.sanitize(); .sanitize();
assert_eq!(settings.font_scale, 300); assert_eq!(settings.font_scale, 300);
assert_eq!(settings.font_brightness, 70);
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.decoration_line_weight, 300);
assert_eq!(settings.max_visible, 12); assert_eq!(settings.max_visible, 12);
assert!(!settings.expand_new_danmaku);
assert_eq!(settings.collapse_after_seconds, 2); assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000); assert_eq!(settings.unfold_duration_ms, 5_000);
assert_eq!(settings.motion_intensity, 100); assert_eq!(settings.motion_intensity, 100);
@@ -475,13 +634,47 @@ mod tests {
let object = value.as_object_mut().expect("settings object"); let object = value.as_object_mut().expect("settings object");
object.insert("title".into(), json!("旧版弹幕栏标题")); object.insert("title".into(), json!("旧版弹幕栏标题"));
object.remove("fontScale"); object.remove("fontScale");
object.remove("fontFamily");
object.remove("fontBrightness");
object.remove("viewerColor");
object.remove("danmakuColor");
object.remove("decorationLineWeight");
object.remove("expandNewDanmaku");
object.remove("unfoldDurationMs"); object.remove("unfoldDurationMs");
object.remove("particleCount"); object.remove("particleCount");
object.remove("particleSpeed"); object.remove("particleSpeed");
object.remove("themeId");
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings"); let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
assert_eq!(settings.font_family, FontFamilyId::FangSong);
assert_eq!(settings.font_brightness, 130);
assert_eq!(settings.viewer_color, None);
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.font_scale, 140); assert_eq!(settings.font_scale, 140);
assert_eq!(settings.decoration_line_weight, 160);
assert!(settings.expand_new_danmaku);
assert_eq!(settings.unfold_duration_ms, 1_000); assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8); assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100); 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"));
}
#[test]
fn moonlit_water_theme_is_a_valid_persisted_identifier() {
let mut value = json!(OverlaySettings::default());
value["themeId"] = json!("moonlit-water");
let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme");
assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater);
assert_eq!(
serde_json::to_value(settings).unwrap()["themeId"],
"moonlit-water"
);
}
} }
+6
View File
@@ -1,3 +1,9 @@
//! In-memory abuse limits for anonymous authentication and enrollment routes.
//!
//! Limits are evaluated across both account and network-derived keys while
//! returning one generic result to callers. This reduces brute-force attempts
//! without turning timing or error messages into a username-enumeration API.
use std::{ use std::{
collections::{HashMap, VecDeque}, collections::{HashMap, VecDeque},
sync::Arc, sync::Arc,
+46 -31
View File
@@ -1,3 +1,10 @@
//! Tenant-aware event routing and component-scoped realtime fanout.
//!
//! The router resolves every enabled instance owned by the account, validates its
//! settings/subscriptions, runs durable handlers, then publishes passive
//! projections. Each component owns a separate broadcast channel; there is no
//! global receiver that could accidentally observe another tenant's events.
use std::{ use std::{
collections::HashMap, collections::HashMap,
error::Error, error::Error,
@@ -119,15 +126,11 @@ impl Error for ComponentStoreError {}
pub type ComponentStoreFuture<'a> = pub type ComponentStoreFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>; Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
/// Persistence port used by source routing. A PostgreSQL implementation should /// Persistence port used by account-stream routing. A provider event is offered
/// always scope its query by both owner and source; the router repeats that /// to every enabled component owned by that account; each component definition
/// check as defense in depth. /// then filters it through its event subscription.
pub trait ComponentInstanceStore: Send + Sync { pub trait ComponentInstanceStore: Send + Sync {
fn list_enabled_for_source<'a>( fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a>;
&'a self,
owner_id: Uuid,
source_id: Uuid,
) -> ComponentStoreFuture<'a>;
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -166,22 +169,14 @@ impl InMemoryComponentStore {
} }
impl ComponentInstanceStore for InMemoryComponentStore { impl ComponentInstanceStore for InMemoryComponentStore {
fn list_enabled_for_source<'a>( fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a> {
&'a self,
owner_id: Uuid,
source_id: Uuid,
) -> ComponentStoreFuture<'a> {
Box::pin(async move { Box::pin(async move {
Ok(self Ok(self
.instances .instances
.read() .read()
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
.iter() .iter()
.filter(|instance| { .filter(|instance| instance.enabled && instance.owner_id == owner_id)
instance.enabled
&& instance.owner_id == owner_id
&& instance.source_id == source_id
})
.cloned() .cloned()
.collect()) .collect())
}) })
@@ -222,7 +217,7 @@ pub enum RouteError {
impl fmt::Display for RouteError { impl fmt::Display for RouteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Store(error) => write!(formatter, "cannot resolve source components: {error}"), Self::Store(error) => write!(formatter, "cannot resolve account components: {error}"),
} }
} }
} }
@@ -275,7 +270,7 @@ impl SourceEventRouter {
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> { pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
let components = self let components = self
.components .components
.list_enabled_for_source(event.owner_id, event.source_id) .list_enabled_for_owner(event.owner_id)
.await?; .await?;
let mut report = RouteReport { let mut report = RouteReport {
considered: components.len(), considered: components.len(),
@@ -283,14 +278,11 @@ impl SourceEventRouter {
}; };
for component in components { for component in components {
if !component.enabled if !component.enabled || component.owner_id != event.owner_id {
|| component.owner_id != event.owner_id
|| component.source_id != event.source_id
{
report.failures.push(RouteFailure { report.failures.push(RouteFailure {
component_id: component.id, component_id: component.id,
stage: RouteStage::Scope, stage: RouteStage::Scope,
detail: "component owner/source does not match the source event".into(), detail: "component owner does not match the account event stream".into(),
}); });
continue; continue;
} }
@@ -342,7 +334,7 @@ impl SourceEventRouter {
match runtime.project(&component, &event) { match runtime.project(&component, &event) {
Ok(Some(message)) => { Ok(Some(message)) => {
if message.owner_id != component.owner_id if message.owner_id != component.owner_id
|| message.source_id != component.source_id || message.source_id != event.source_id
|| message.component_id != component.id || message.component_id != component.id
{ {
report.failures.push(RouteFailure { report.failures.push(RouteFailure {
@@ -410,6 +402,7 @@ mod tests {
viewer: PlatformViewer { viewer: PlatformViewer {
uid: "42".into(), uid: "42".into(),
name: "观众".into(), name: "观众".into(),
avatar_url: None,
}, },
text: "晚上好".into(), text: "晚上好".into(),
segments: Vec::new(), segments: Vec::new(),
@@ -510,16 +503,38 @@ mod tests {
assert!(report.failures.is_empty()); assert!(report.failures.is_empty());
} }
#[tokio::test]
async fn one_account_event_is_offered_to_all_owned_components() {
let owner_id = Uuid::new_v4();
let account_source_id = Uuid::new_v4();
let first = overlay(owner_id, account_source_id);
let second = overlay(owner_id, account_source_id);
let first_id = first.id;
let second_id = second.id;
let hub = EventHub::new(8);
let mut first_rx = hub.subscribe(first_id);
let mut second_rx = hub.subscribe(second_id);
let store = Arc::new(InMemoryComponentStore::new(vec![first, second]));
let router = SourceEventRouter::new(ComponentRegistry::default(), store, hub);
let report = router
.route(danmaku(owner_id, account_source_id))
.await
.unwrap();
assert_eq!(report.considered, 2);
assert_eq!(report.projected, 2);
assert_eq!(first_rx.try_recv().unwrap().component_id, first_id);
assert_eq!(second_rx.try_recv().unwrap().component_id, second_id);
assert!(report.failures.is_empty());
}
struct LeakyStore { struct LeakyStore {
instances: Vec<ComponentInstance>, instances: Vec<ComponentInstance>,
} }
impl ComponentInstanceStore for LeakyStore { impl ComponentInstanceStore for LeakyStore {
fn list_enabled_for_source<'a>( fn list_enabled_for_owner<'a>(&'a self, _owner_id: Uuid) -> ComponentStoreFuture<'a> {
&'a self,
_owner_id: Uuid,
_source_id: Uuid,
) -> ComponentStoreFuture<'a> {
Box::pin(async move { Ok(self.instances.clone()) }) Box::pin(async move { Ok(self.instances.clone()) })
} }
} }
+274 -7
View File
@@ -1,3 +1,10 @@
//! Repository facade for tenant components, settings and the routing cache.
//!
//! PostgreSQL is authoritative. Successful writes are reflected into the
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
//! account-source restarts hydrate that cache from tenant-scoped rows before events are
//! routed.
use std::{fmt, sync::Arc}; use std::{fmt, sync::Arc};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
@@ -8,9 +15,18 @@ use uuid::Uuid;
use crate::{ use crate::{
components::{ComponentInstance, ComponentRegistry}, components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError}, db::{ComponentRecord, Db, DbError},
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
guard_effect::{
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
},
i18n,
realtime::InMemoryComponentStore, realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
}; };
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
#[derive(Clone)] #[derive(Clone)]
pub struct TenantRepository { pub struct TenantRepository {
db: Db, db: Db,
@@ -38,6 +54,164 @@ impl TenantRepository {
Ok(()) Ok(())
} }
/// Ensure every active tenant has an initial instance of each built-in
/// component. Registration normally creates these rows; this startup
/// backfill keeps upgrades from older releases complete. Additional
/// instances are created explicitly through the component API.
pub async fn ensure_builtin_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?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&tenant.user_id],
)
.await?;
let existing = transaction
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&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,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
&[
&id,
&tenant.user_id,
&SONG_REQUEST_KIND,
&SONG_REQUEST_NAME,
&settings,
],
)
.await?;
transaction
.query_opt(
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &SONG_REQUEST_KIND],
)
.await?
.ok_or_else(|| {
RepositoryError::Invalid(
"failed to create the initial song request component".into(),
)
})?
.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?;
let has_gift_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?
.get::<_, bool>(0);
if !has_gift_effect {
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_EFFECT_KIND,
&GIFT_EFFECT_NAME,
&gift_settings,
],
)
.await?;
}
let has_guard_effect = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GUARD_EFFECT_KIND],
)
.await?
.get::<_, bool>(0);
if !has_guard_effect {
let legacy_gift_settings = transaction
.query_opt(
"SELECT settings FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
&[&tenant.user_id, &GIFT_EFFECT_KIND],
)
.await?
.map(|row| row.get::<_, Value>(0));
let guard_settings = legacy_gift_settings
.as_ref()
.map(settings_from_legacy_gift)
.unwrap_or_else(GuardEffectSettings::default);
let guard_settings = serde_json::to_value(guard_settings)
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GUARD_EFFECT_KIND,
&GUARD_EFFECT_NAME,
&guard_settings,
],
)
.await?;
}
let has_gift_menu = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM component_instances \
WHERE owner_user_id=$1 AND kind=$2)",
&[&tenant.user_id, &GIFT_MENU_KIND],
)
.await?
.get::<_, bool>(0);
if !has_gift_menu {
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
transaction
.execute(
"INSERT INTO component_instances \
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,1,true)",
&[
&Uuid::new_v4(),
&tenant.user_id,
&GIFT_MENU_KIND,
&GIFT_MENU_NAME,
&menu_settings,
],
)
.await?;
}
transaction.commit().await?;
}
Ok(())
}
pub async fn hydrate_tenant( pub async fn hydrate_tenant(
&self, &self,
owner_id: Uuid, owner_id: Uuid,
@@ -87,15 +261,34 @@ impl TenantRepository {
let mut client = self.db.get().await?; let mut client = self.db.get().await?;
let transaction = client.transaction().await?; let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?; Db::set_tenant(&transaction, owner_id).await?;
// Serialize instance-count checks for this account. Without the owner
// row lock, concurrent requests could both pass the per-kind limit.
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&owner_id],
)
.await?;
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &component.kind],
)
.await?
.get::<_, i64>(0);
if instance_count >= MAX_COMPONENT_INSTANCES_PER_KIND {
return Err(RepositoryError::Invalid(format!(
"a component kind accepts at most {MAX_COMPONENT_INSTANCES_PER_KIND} instances"
)));
}
transaction transaction
.execute( .execute(
"INSERT INTO component_instances \ "INSERT INTO component_instances \
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \ (id,owner_user_id,kind,name,settings,settings_version,enabled) \
VALUES($1,$2,$3,$4,$5,$6,$7,true)", VALUES($1,$2,$3,$4,$5,$6,true)",
&[ &[
&component.id, &component.id,
&owner_id, &owner_id,
&source_id,
&component.kind, &component.kind,
&component.name, &component.name,
&component.settings, &component.settings,
@@ -103,6 +296,15 @@ impl TenantRepository {
], ],
) )
.await?; .await?;
if component.kind == SONG_REQUEST_KIND {
transaction
.execute(
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
VALUES($1,$2)",
&[&owner_id, &component.id],
)
.await?;
}
transaction.commit().await?; transaction.commit().await?;
self.cache.upsert(component.clone()); self.cache.upsert(component.clone());
Ok(component) Ok(component)
@@ -116,6 +318,30 @@ impl TenantRepository {
let mut client = self.db.get().await?; let mut client = self.db.get().await?;
let transaction = client.transaction().await?; let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?; Db::set_tenant(&transaction, owner_id).await?;
transaction
.query_one(
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&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);
let instance_count = transaction
.query_one(
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
&[&owner_id, &kind],
)
.await?
.get::<_, i64>(0);
if instance_count <= 1 {
return Err(RepositoryError::Forbidden);
}
let changed = transaction let changed = transaction
.execute( .execute(
"DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2", "DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2",
@@ -140,8 +366,11 @@ impl TenantRepository {
Db::set_tenant(&transaction, owner_id).await?; Db::set_tenant(&transaction, owner_id).await?;
let row = transaction let row = transaction
.query_opt( .query_opt(
"SELECT id,source_id,kind,name,settings,settings_version,enabled \ "SELECT component.id,source.id,component.kind,component.name,component.settings,\
FROM component_instances WHERE owner_user_id=$1 AND id=$2", component.settings_version,component.enabled \
FROM component_instances AS component \
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
WHERE component.owner_user_id=$1 AND component.id=$2",
&[&owner_id, &component_id], &[&owner_id, &component_id],
) )
.await? .await?
@@ -150,7 +379,7 @@ impl TenantRepository {
self.validate_loaded_component(component_from_record(ComponentRecord { self.validate_loaded_component(component_from_record(ComponentRecord {
id: row.get(0), id: row.get(0),
owner_user_id: owner_id, owner_user_id: owner_id,
source_id: row.get(1), account_source_id: row.get(1),
kind: row.get(2), kind: row.get(2),
name: row.get(3), name: row.get(3),
settings: row.get(4), settings: row.get(4),
@@ -219,6 +448,44 @@ impl TenantRepository {
Ok(row.get(0)) Ok(row.get(0))
} }
pub async fn account_language(&self, owner_id: Uuid) -> Result<String, RepositoryError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
let language = transaction
.query_opt(
"SELECT language FROM account_preferences WHERE user_id=$1",
&[&owner_id],
)
.await?
.map(|row| row.get(0))
.unwrap_or_else(|| i18n::default_language().to_owned());
transaction.commit().await?;
Ok(language)
}
pub async fn set_account_language(
&self,
owner_id: Uuid,
language: &str,
) -> Result<String, RepositoryError> {
if !i18n::is_supported(language) {
return Err(RepositoryError::Invalid("language is not supported".into()));
}
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
transaction
.execute(
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2) \
ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language,updated_at=now()",
&[&owner_id, &language],
)
.await?;
transaction.commit().await?;
Ok(language.to_owned())
}
pub async fn token_summary( pub async fn token_summary(
&self, &self,
owner_id: Uuid, owner_id: Uuid,
@@ -343,7 +610,7 @@ fn component_from_record(record: ComponentRecord) -> Result<ComponentInstance, R
Ok(ComponentInstance { Ok(ComponentInstance {
id: record.id, id: record.id,
owner_id: record.owner_user_id, owner_id: record.owner_user_id,
source_id: record.source_id, account_source_id: record.account_source_id,
kind: record.kind, kind: record.kind,
name: record.name, name: record.name,
enabled: record.enabled, enabled: record.enabled,
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
//! Shared, validated typography settings for all built-in OBS components.
//!
//! Font IDs map to fixed, same-origin WOFF2 assets in the frontend rather than
//! accepting arbitrary CSS. This keeps settings portable between OBS hosts and
//! prevents untrusted component settings from becoming a CSS injection boundary.
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FontFamilyId {
Song,
#[default]
FangSong,
Kai,
LiyuShoushu,
}
pub const fn default_font_brightness() -> u16 {
130
}
pub fn sanitize_font_brightness(value: u16) -> u16 {
value.clamp(70, 180)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn font_ids_are_stable_and_brightness_is_bounded() {
assert_eq!(
serde_json::to_string(&FontFamilyId::FangSong).unwrap(),
"\"fang-song\""
);
assert_eq!(sanitize_font_brightness(1), 70);
assert_eq!(sanitize_font_brightness(u16::MAX), 180);
assert_eq!(
serde_json::to_string(&FontFamilyId::LiyuShoushu).unwrap(),
"\"liyu-shoushu\""
);
}
}
+7 -3
View File
@@ -1,9 +1,13 @@
services: services:
app: app:
build: . build:
context: .
additional_contexts:
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
libilibili: ../libilibili
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
user: "${APP_UID:-1000}:${APP_GID:-1000}" user: '${APP_UID:-1000}:${APP_GID:-1000}'
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev - /tmp:size=16m,noexec,nosuid,nodev
@@ -15,7 +19,7 @@ services:
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。 # 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
- ./config.toml:/app/config.toml:ro - ./config.toml:/app/config.toml:ro
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9719/health"] test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1:9719/health']
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+19 -6
View File
@@ -1,4 +1,4 @@
# 洛星瓷多用户直播组件服务配置。 # 多用户直播组件服务配置。
# #
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件 # 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件
# 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要 # 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要
@@ -6,8 +6,8 @@
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。 # Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
# 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次 # 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次
# 创建的 system_admin,并且此后不可修改。后续账号的 room_id 由系统 # 创建的 system_admin,作为该账户的初始房间。后续账号的初始 room_id
# 管理员创建邀请码时指定,不读取此字段。 # 由系统管理员创建邀请码时指定;注册后用户可在控制台自行切换。
# 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。 # 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。
[connection] [connection]
room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。 room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。
@@ -87,8 +87,21 @@ request_timeout_seconds = 10
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此 # 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
# 10000 = 10 元、100000 = 100 元。 # 10000 = 10 元、100000 = 100 元。
[overlay] [overlay]
# 主题 ID。弹幕姬与点歌姬支持 jade-scroll(青玉花卷)和 moonlit-water(静夜曲水)。
# 未来主题会继续使用稳定 ID,避免改名。
# 影响已经保存到 PostgreSQL 的组件设置。
theme_id = "jade-scroll"
# 中文字体:song(宋体)、fang-song(仿宋)或 kai(楷体)。
font_family = "fang-song"
# 文字亮度百分比,范围 70-180;深色直播背景建议从 130 开始。
font_brightness = 130
# 可选的弹幕昵称与正文颜色,只接受 #RRGGBB;不填写则跟随所选主题。
# viewer_color = "#B7AD83"
# danmaku_color = "#C6AE7A"
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。 # 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
font_scale = 140 font_scale = 140
# 主题装饰线、边框与上下古风边缘的相对粗细;允许范围 50-300。
decoration_line_weight = 160
max_visible = 5 max_visible = 5
collapse_after_seconds = 12 collapse_after_seconds = 12
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。 # 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
@@ -112,7 +125,7 @@ guard = true
like = false like = false
share = false share = false
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。 # 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。 # libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
[logging] [logging]
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn" filter = "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn"
+88
View File
@@ -0,0 +1,88 @@
# 系统架构
本文描述直播组件服务的运行边界、数据流和扩展点。实现代码分别位于 `apps/server-rust` 与
`apps/overlay`。
## 核心目标
- 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。同一 kind 可以拥有多个实例,每个实例独立保存名称、设置、OBS
token 和实时通道。
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
## 运行时数据流
```mermaid
flowchart LR
CC[CookieCloud] --> PF[ProviderFactory]
BL[Bilibili Live] <--> BP[BilibiliProvider]
PF --> BP
BP --> LE[LiveEvent]
LE --> SR[SourceEventRouter]
PG[(PostgreSQL)] --> CR[Component repository/cache]
CR --> SR
SR --> EH[Durable handlers]
SR --> PJ[Passive projection]
PJ --> HUB[Component-scoped EventHub]
HUB --> WS[Authenticated WebSocket]
WS --> OBS[OBS overlay]
```
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据构造 `libilibili`
客户端;crate 负责 WBI、WebSocket 与压缩包解析,adapter 再把强类型命令转换成 `LiveEvent`。
3. `SourceEventRouter` 按 `owner_id`
取得该账户所有启用组件,再由各组件的订阅声明筛选事件类型;`source_id`
只标识账户级监听,不存储在组件行中。
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
6. OBS 使用实例级只读 token 订阅一个组件实例,不能读取控制台 API;同类型的其他实例拥有不同
`component_id`,可在不同 OBS 场景中使用不同样式。
## 状态所有权
| 状态 | 权威来源 | 内存副本 | 说明 |
| ---------------- | ------------ | ------------------------ | ----------------------------- |
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 每账户一份,不返回浏览器 |
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每账户一个可切换房间和连接 |
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
| 点歌队列与历史 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
| 账户语言偏好 | PostgreSQL | React/OBS runtime | TOML 目录验证、RLS 隔离 |
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
## 启动顺序
`AppState::build` 按以下顺序启动,避免事件进入未完成的运行时:
1. 创建 PostgreSQL 连接池并执行嵌入式迁移。
2. 构造加密和 passwordless 鉴权服务。
3. 注册内建组件定义。
4. 从 PostgreSQL hydrate 组件热路径缓存。
5. 创建有界源事件队列、路由器和每组件广播中心。
6. 为数据库中所有启用的直播源启动 provider。
7. 最后由 `main.rs` 绑定 HTTP 监听端口。
关机时先停止接收 HTTP,再取消所有 provider task,防止容器退出期间继续拉取上游事件。
## 进程与部署边界
- Node 只存在于 Docker 的前端构建阶段。
- 最终镜像仅运行 Rust 可执行文件,并从 `/app/web` 同域托管静态资源。
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
- `libilibili` 是源码树中的相邻 crate,由 Compose named build context 注入 Rust 构建阶段。
- `resources/i18n.toml` 同时输入 Vite 和 Rust 构建;缺少默认语言键的 locale 会使后端测试失败。
## 代码导航
- 后端模块职责:[`apps/server-rust/README.md`](../apps/server-rust/README.md)
- 前端路由和状态:[`apps/overlay/README.md`](../apps/overlay/README.md)
- 组件扩展指南:[`components/README.md`](components/README.md)
- WebSocket 协议:[`protocol.md`](protocol.md)
- 安全边界:[`security.md`](security.md)

Some files were not shown because too many files have changed in this diff Show More