Compare commits
16
Commits
edb6d2b5b4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1020c75e37 | ||
|
|
e5186f62bd | ||
|
|
845b0f5900 | ||
|
|
a36d511d39 | ||
|
|
acc4c117f7 | ||
|
|
4eda93cf24 | ||
|
|
97a3f1be48 | ||
|
|
ac80cd1db4 | ||
|
|
7eb9d00a50 | ||
|
|
716c6f3f2f | ||
|
|
c4eca7b8bf | ||
|
|
f79852d8e6 | ||
|
|
53ae90ec13 | ||
|
|
1598d3c403 | ||
|
|
f599672a30 | ||
|
|
994854d104 |
@@ -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
|
||||
@@ -27,12 +27,3 @@ data/
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.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/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.git/
|
||||
**/dist/
|
||||
**/node_modules/
|
||||
**/target/
|
||||
vendor/
|
||||
config.toml
|
||||
*.png
|
||||
*.svg
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "always",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -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
@@ -2,27 +2,28 @@ FROM node:22-bookworm-slim AS web-build
|
||||
WORKDIR /app/apps/overlay
|
||||
COPY apps/overlay/package.json apps/overlay/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY resources /app/resources
|
||||
COPY apps/overlay ./
|
||||
RUN npm run 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
|
||||
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/migrations ./apps/server-rust/migrations
|
||||
COPY resources ./resources
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target-cache \
|
||||
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
|
||||
|
||||
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
|
||||
# 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=web-build /app/apps/overlay/dist /app/web
|
||||
EXPOSE 9719
|
||||
|
||||
@@ -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` 导出:
|
||||
|
||||
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
||||
- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
||||
- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。
|
||||
- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。
|
||||
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs`
|
||||
的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
||||
- `components.rs`
|
||||
注册组件定义、设置 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
|
||||
@@ -25,13 +62,45 @@ openssl rand -hex 32 # 填入兼容字段 admin.session_secret
|
||||
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
|
||||
```
|
||||
|
||||
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
||||
填写系统管理员用户名和 `config.toml` 中的
|
||||
`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
||||
|
||||
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
||||
|
||||
@@ -50,9 +120,16 @@ https://danmaku.luoxingci.com/control/setup
|
||||
- 所有账户都是 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
|
||||
|
||||
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
||||
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持
|
||||
`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
@@ -103,36 +181,67 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
||||
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置
|
||||
`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
||||
|
||||
## 控制台 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 地址
|
||||
|
||||
登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
||||
登录 `/control/`
|
||||
后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
||||
|
||||
```text
|
||||
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 源。
|
||||
|
||||
花边 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]` 调整。
|
||||
|
||||
@@ -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)
|
||||
@@ -1,18 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="__DEFAULT_LANGUAGE__">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0a302f" />
|
||||
<meta name="description" content="洛星瓷直播组件与 OBS 浏览器源控制台" />
|
||||
<meta name="application-name" content="洛星瓷直播云台" />
|
||||
<meta name="description" content="__APP_DESCRIPTION__" />
|
||||
<meta name="application-name" content="__APP_NAME__" />
|
||||
<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-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="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
|
||||
<title>洛星瓷直播云台</title>
|
||||
<title>__APP_NAME__</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+61
@@ -8,6 +8,9 @@
|
||||
"name": "lxc-stream-overlay",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
@@ -15,6 +18,8 @@
|
||||
"@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"
|
||||
}
|
||||
@@ -743,6 +748,33 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1573,6 +1605,22 @@
|
||||
"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": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
@@ -1665,6 +1713,19 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -2,7 +2,27 @@
|
||||
"name": "lxc-stream-overlay",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
|
||||
"dependencies": { "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", "typescript": "5.9.2", "vite": "7.3.6" }
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build && node scripts/check-font-assets.mjs",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
- License: CC0 1.0 / public domain
|
||||
- Retrieved: 2026-07-15
|
||||
|
||||
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
|
||||
Paul Bürck”.
|
||||
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
|
||||
|
||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
|
||||
- Original artist: Paul Bürck, 1899
|
||||
@@ -16,8 +15,7 @@ Paul Bürck”.
|
||||
- License: original public domain; SVG adaptation CC0 1.0
|
||||
- Retrieved: 2026-07-15
|
||||
|
||||
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
|
||||
Paul Bürck”.
|
||||
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
|
||||
|
||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
|
||||
- Original artist: Paul Bürck, 1899
|
||||
@@ -25,5 +23,15 @@ Paul Bürck”.
|
||||
- License: original public domain; SVG adaptation CC0 1.0
|
||||
- Retrieved: 2026-07-15
|
||||
|
||||
The overlay uses these SVGs as recolorable CSS masks inside message cards and
|
||||
bundles them locally, so OBS never needs to load the source websites.
|
||||
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
|
||||
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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
@@ -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 cacheName = `lxc-control-v2-shell-${buildId}`
|
||||
const shellPath = '/control/'
|
||||
@@ -24,8 +31,9 @@ async function precacheShell() {
|
||||
|
||||
const html = await response.clone().text()
|
||||
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
|
||||
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)]
|
||||
.map(match => match[1])
|
||||
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
|
||||
match => match[1],
|
||||
)
|
||||
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
||||
}
|
||||
|
||||
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
|
||||
})
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names
|
||||
await Promise.all(
|
||||
names
|
||||
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
|
||||
.map(name => caches.delete(name)))
|
||||
.map(name => caches.delete(name)),
|
||||
)
|
||||
await self.clients.claim()
|
||||
})())
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
@@ -52,9 +64,12 @@ function isControlShell(pathname) {
|
||||
}
|
||||
|
||||
function isStaticAsset(pathname) {
|
||||
return pathname.startsWith('/assets/')
|
||||
|| pathname.startsWith('/pwa/')
|
||||
|| pathname === '/control/manifest.webmanifest'
|
||||
return (
|
||||
pathname.startsWith('/assets/') ||
|
||||
pathname.startsWith('/fonts/') ||
|
||||
pathname.startsWith('/pwa/') ||
|
||||
pathname === '/control/manifest.webmanifest'
|
||||
)
|
||||
}
|
||||
|
||||
async function navigationResponse(request) {
|
||||
@@ -62,16 +77,20 @@ async function navigationResponse(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
if (shellRoutes.includes(pathname)
|
||||
&& response.ok
|
||||
&& response.headers.get('content-type')?.includes('text/html')) {
|
||||
if (
|
||||
shellRoutes.includes(pathname) &&
|
||||
response.ok &&
|
||||
response.headers.get('content-type')?.includes('text/html')
|
||||
) {
|
||||
await cache.put(pathname, response.clone())
|
||||
}
|
||||
return response
|
||||
} catch {
|
||||
return (await cache.match(request, { ignoreSearch: true }))
|
||||
|| (await cache.match(shellPath))
|
||||
|| Response.error()
|
||||
return (
|
||||
(await cache.match(request, { ignoreSearch: true })) ||
|
||||
(await cache.match(shellPath)) ||
|
||||
Response.error()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.')
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+314
-35
@@ -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 {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
GuardEffectSettings,
|
||||
GiftEffectSettings,
|
||||
GiftCatalogItem,
|
||||
GiftMenuItem,
|
||||
GiftMenuSettings,
|
||||
Invitation,
|
||||
OverlaySettings,
|
||||
Session,
|
||||
SongRequestItem,
|
||||
SongRequestPage,
|
||||
SongRequestSettings,
|
||||
TotpEnrollment,
|
||||
} 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 {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly messageKey?: 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)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.messageKey = messageKey
|
||||
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> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
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)
|
||||
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-language', currentLanguage())
|
||||
|
||||
const response = await fetch(path, {
|
||||
...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/')) {
|
||||
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(
|
||||
response.status,
|
||||
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
|
||||
typeof error.code === 'string' ? error.code : undefined,
|
||||
localized ??
|
||||
String(
|
||||
error.message ?? error.error ?? translate('api.http_error', { status: response.status }),
|
||||
),
|
||||
code,
|
||||
messageKey,
|
||||
error.fieldErrors && typeof error.fieldErrors === 'object'
|
||||
? error.fieldErrors as Record<string, string>
|
||||
? (error.fieldErrors as Record<string, string>)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
@@ -71,24 +125,29 @@ export function json(method: string, body?: unknown): RequestInit {
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
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 {
|
||||
const root = object(value)
|
||||
const candidate = root.user
|
||||
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
|
||||
const candidate =
|
||||
root.user ??
|
||||
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
|
||||
? root
|
||||
: null)
|
||||
const raw = object(candidate)
|
||||
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 ?? ''),
|
||||
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
||||
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
||||
role: typeof raw.role === 'string' ? raw.role : 'user',
|
||||
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
||||
} : null
|
||||
}
|
||||
: null
|
||||
return { user, setupRequired: root.setupRequired === true }
|
||||
}
|
||||
|
||||
@@ -98,15 +157,22 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
||||
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
|
||||
return {
|
||||
enrollmentToken: String(enrollmentToken ?? ''),
|
||||
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
|
||||
qrDataUrl: typeof totp.qrDataUrl === 'string'
|
||||
qrSvg:
|
||||
typeof totp.qrSvg === 'string'
|
||||
? totp.qrSvg
|
||||
: typeof root.qrSvg === 'string'
|
||||
? root.qrSvg
|
||||
: undefined,
|
||||
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'
|
||||
otpauthUri:
|
||||
typeof totp.otpauthUri === 'string'
|
||||
? totp.otpauthUri
|
||||
: typeof root.otpauthUri === 'string'
|
||||
? root.otpauthUri
|
||||
@@ -119,53 +185,252 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
||||
export function normalizeRecoveryCodes(value: unknown): string[] {
|
||||
const root = object(value)
|
||||
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[] {
|
||||
const root = object(value)
|
||||
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
||||
return list.map((entry) => {
|
||||
const list = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(root.components)
|
||||
? root.components
|
||||
: root.component && typeof root.component === 'object'
|
||||
? [root.component]
|
||||
: []
|
||||
return list
|
||||
.map(entry => {
|
||||
const item = object(entry)
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
||||
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
||||
name: String(item.name ?? '弹幕姬'),
|
||||
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)
|
||||
})
|
||||
.filter(item => item.id)
|
||||
}
|
||||
|
||||
export function normalizeSettings(value: unknown): OverlaySettings {
|
||||
const root = object(value)
|
||||
return (root.settings ?? value) as OverlaySettings
|
||||
const settings = object(root.settings ?? value)
|
||||
return {
|
||||
...defaultOverlaySettings,
|
||||
...(settings as Partial<OverlaySettings>),
|
||||
themeId: normalizeThemeId(settings.themeId),
|
||||
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 {
|
||||
const root = object(value)
|
||||
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)
|
||||
return {
|
||||
roomId: String(source.roomId ?? root.roomId ?? ''),
|
||||
cookieCloud: {
|
||||
host: String(cookieCloud.host ?? ''),
|
||||
key: '',
|
||||
keyConfigured: cookieCloud.keyConfigured === true
|
||||
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
||||
passwordConfigured: cookieCloud.passwordConfigured === true
|
||||
|| cookieCloud.configured === true
|
||||
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
||||
keyConfigured:
|
||||
cookieCloud.keyConfigured === true ||
|
||||
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
||||
passwordConfigured:
|
||||
cookieCloud.passwordConfigured === true ||
|
||||
cookieCloud.configured === true ||
|
||||
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
||||
},
|
||||
connected: typeof source.connected === 'boolean'
|
||||
connected:
|
||||
typeof source.connected === 'boolean'
|
||||
? source.connected
|
||||
: typeof status.connected === 'boolean'
|
||||
? status.connected
|
||||
: undefined,
|
||||
detail: typeof source.detail === 'string'
|
||||
detail:
|
||||
typeof source.detail === 'string'
|
||||
? source.detail
|
||||
: typeof status.detail === 'string'
|
||||
? status.detail
|
||||
@@ -176,8 +441,13 @@ export function normalizeSource(value: unknown): CookieCloudSource {
|
||||
|
||||
export function normalizeInvitations(value: unknown): Invitation[] {
|
||||
const root = object(value)
|
||||
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
|
||||
return list.map((entry) => {
|
||||
const list = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(root.invitations)
|
||||
? root.invitations
|
||||
: []
|
||||
return list
|
||||
.map(entry => {
|
||||
const item = object(entry)
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
@@ -187,13 +457,22 @@ export function normalizeInvitations(value: unknown): Invitation[] {
|
||||
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
||||
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,
|
||||
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)
|
||||
})
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
+159
-79
@@ -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 type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
@@ -9,19 +17,31 @@ import {
|
||||
normalizeRecoveryCodes,
|
||||
} from './api'
|
||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||
import { LanguageSelect, useI18n } from './i18n'
|
||||
import type { TotpEnrollment } from './types'
|
||||
|
||||
function AuthShell({ eyebrow, title, children, footer }: {
|
||||
function AuthShell({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<div className="auth-toolbar">
|
||||
<LanguageSelect />
|
||||
<PwaControls />
|
||||
<div className="auth-mark" aria-hidden="true">星</div>
|
||||
</div>
|
||||
<div className="auth-mark" aria-hidden="true">
|
||||
{t('common.star')}
|
||||
</div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{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(() => {
|
||||
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
|
||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||
|
||||
return (
|
||||
<div className="totp-enrollment">
|
||||
<div className="totp-qr">
|
||||
{source
|
||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
||||
{source ? (
|
||||
<img src={source} alt={t('auth.totp_qr_alt')} />
|
||||
) : (
|
||||
<span>{t('auth.qr_unavailable')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="totp-copy">
|
||||
<h2>绑定动态验证器</h2>
|
||||
<h2>{t('auth.bind_authenticator')}</h2>
|
||||
<ol>
|
||||
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
|
||||
<li>若无法扫码,选择“输入设置密钥”。</li>
|
||||
<li>输入应用中出现的 6 位动态码完成绑定。</li>
|
||||
<li>{t('auth.totp_step_scan')}</li>
|
||||
<li>{t('auth.totp_step_manual')}</li>
|
||||
<li>{t('auth.totp_step_confirm')}</li>
|
||||
</ol>
|
||||
<label>
|
||||
手工设置密钥
|
||||
{t('auth.manual_key')}
|
||||
<div className="secret-row">
|
||||
<code>{enrollment.manualKey || '未提供'}</code>
|
||||
<code>{enrollment.manualKey || t('common.not_provided')}</code>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
@@ -69,58 +97,71 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
}
|
||||
|
||||
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = codes.join('\n')
|
||||
const download = () => {
|
||||
const blob = new Blob([
|
||||
'洛星瓷直播组件恢复码\n',
|
||||
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
|
||||
text,
|
||||
'\n',
|
||||
], { type: 'text/plain;charset=utf-8' })
|
||||
const blob = new Blob(
|
||||
[`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
|
||||
{ type: 'text/plain;charset=utf-8' },
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'luoxingci-recovery-codes.txt'
|
||||
anchor.download = 'live-component-recovery-codes.txt'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
||||
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
|
||||
{codes.length > 0
|
||||
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
|
||||
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
|
||||
<AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
|
||||
<p className="auth-lead">{t('auth.recovery_description')}</p>
|
||||
{codes.length > 0 ? (
|
||||
<div className="recovery-grid">
|
||||
{codes.map(code => (
|
||||
<code key={code}>{code}</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
{codes.length > 0 && (
|
||||
<>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
<button
|
||||
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 type="button" className="secondary" onClick={download}>下载文本</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
||||
<button type="button" onClick={onContinue}>
|
||||
{t('auth.recovery_saved')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
export function LoginPage({
|
||||
onAuthenticated,
|
||||
setupRequired,
|
||||
}: {
|
||||
onAuthenticated: () => Promise<void>
|
||||
setupRequired: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [username, setUsername] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
usePwaUpdateBlocker(
|
||||
'login-form',
|
||||
'完成或清空正在填写的登录表单',
|
||||
busy || Boolean(username || totpCode),
|
||||
)
|
||||
usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -132,7 +173,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '用户名或动态验证码不正确'))
|
||||
setError(errorMessage(reason, t('auth.login_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -140,20 +181,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow="洛星瓷直播组件"
|
||||
title="回到你的云台"
|
||||
footer={(
|
||||
eyebrow={t('auth.product_eyebrow')}
|
||||
title={t('auth.login_title')}
|
||||
footer={
|
||||
<p>
|
||||
{setupRequired
|
||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
||||
</p>
|
||||
{setupRequired ? (
|
||||
<>
|
||||
{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 className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||
<p className="auth-lead">{t('auth.passwordless_description')}</p>
|
||||
<form className="stack-form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
{t('auth.username')}
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
@@ -163,7 +211,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
|
||||
{useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
|
||||
<input
|
||||
required
|
||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||
@@ -171,11 +219,15 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||
maxLength={useRecoveryCode ? 64 : 6}
|
||||
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
||||
placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
|
||||
value={totpCode}
|
||||
onChange={event => setTotpCode(useRecoveryCode
|
||||
onChange={event =>
|
||||
setTotpCode(
|
||||
useRecoveryCode
|
||||
? event.target.value.trimStart().slice(0, 64)
|
||||
: event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
@@ -186,19 +238,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
setTotpCode('')
|
||||
}}
|
||||
>
|
||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||
{useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
|
||||
</button>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
export function EnrollmentPage({
|
||||
mode,
|
||||
onAuthenticated,
|
||||
}: {
|
||||
mode: 'setup' | 'register'
|
||||
onAuthenticated: () => Promise<void>
|
||||
}) {
|
||||
const { language, t } = useI18n()
|
||||
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
||||
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -211,10 +271,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
const isSetup = mode === 'setup'
|
||||
usePwaUpdateBlocker(
|
||||
'totp-enrollment',
|
||||
enrollment || recoveryCodes
|
||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||
: '完成或清空正在填写的注册表单',
|
||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
||||
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
|
||||
busy ||
|
||||
Boolean(
|
||||
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
||||
),
|
||||
)
|
||||
|
||||
const start = async (event: FormEvent) => {
|
||||
@@ -231,7 +292,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
}),
|
||||
)
|
||||
const next = normalizeEnrollment(payload)
|
||||
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
|
||||
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
|
||||
setEnrollment(next)
|
||||
setBootstrapPassword('')
|
||||
if (!isSetup) {
|
||||
@@ -239,7 +300,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
history.replaceState(null, '', authRoute('register'))
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '无法开始安全注册流程'))
|
||||
setError(errorMessage(reason, t('auth.registration_start_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -253,7 +314,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
||||
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
|
||||
json('POST', {
|
||||
enrollmentToken: enrollment.enrollmentToken,
|
||||
code: totpCode,
|
||||
language,
|
||||
}),
|
||||
)
|
||||
setTotpCode('')
|
||||
// Drop QR/manual-key material from React state as soon as enrollment is
|
||||
@@ -261,7 +326,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
setEnrollment(undefined)
|
||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
|
||||
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -276,11 +341,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
|
||||
if (enrollment) {
|
||||
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} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
验证器中的 6 位动态码
|
||||
{t('auth.authenticator_code')}
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
@@ -294,8 +362,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy || totpCode.length !== 6}>
|
||||
{busy ? t('auth.confirming') : t('auth.confirm_create')}
|
||||
</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
@@ -303,19 +377,21 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
||||
eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
|
||||
title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
|
||||
footer={
|
||||
<p>
|
||||
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p className="auth-lead">
|
||||
{isSetup
|
||||
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
|
||||
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
|
||||
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
|
||||
</p>
|
||||
<form className="stack-form" onSubmit={start}>
|
||||
{!isSetup && (
|
||||
<label>
|
||||
邀请码
|
||||
{t('auth.invitation')}
|
||||
<input
|
||||
required
|
||||
autoFocus={!inviteFromFragment}
|
||||
@@ -326,7 +402,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
用户名
|
||||
{t('auth.username')}
|
||||
<input
|
||||
required
|
||||
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
||||
@@ -339,7 +415,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
一次性初始化口令
|
||||
{t('auth.bootstrap_password')}
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
@@ -347,11 +423,15 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
value={bootstrapPassword}
|
||||
onChange={event => setBootstrapPassword(event.target.value)}
|
||||
/>
|
||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||
<small>{t('auth.bootstrap_help')}</small>
|
||||
</label>
|
||||
)}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
|
||||
+1129
-196
File diff suppressed because it is too large
Load Diff
+2822
-216
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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})`,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 { createRoot } from 'react-dom/client'
|
||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||
import { EnrollmentPage, LoginPage } from './auth'
|
||||
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
|
||||
import {
|
||||
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 { 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 '@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 './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 }) {
|
||||
const { t } = useI18n()
|
||||
useEffect(() => {
|
||||
location.replace(to)
|
||||
}, [to])
|
||||
return <main className="route-loading">正在前往云台…</main>
|
||||
return <main className="route-loading">{t('main.redirecting')}</main>
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel not-found">
|
||||
<div className="auth-mark" aria-hidden="true">云</div>
|
||||
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
|
||||
<h1>这里没有组件</h1>
|
||||
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
|
||||
<a className="button-link" href="/control/">返回控制台</a>
|
||||
<div className="auth-mark" aria-hidden="true">
|
||||
{t('common.cloud')}
|
||||
</div>
|
||||
<p className="eyebrow">{t('main.not_found_eyebrow')}</p>
|
||||
<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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { setLanguage, t } = useI18n()
|
||||
const [session, setSession] = useState<Session>()
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
@@ -40,15 +95,24 @@ function App() {
|
||||
setLoadError('')
|
||||
try {
|
||||
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) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
return
|
||||
}
|
||||
setLoadError(errorMessage(error, '无法连接认证服务'))
|
||||
setLoadError(errorMessage(error, translate('main.auth_unavailable')))
|
||||
}
|
||||
}, [])
|
||||
}, [setLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession()
|
||||
@@ -83,16 +147,20 @@ function App() {
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
||||
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
||||
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
|
||||
<p className="eyebrow">
|
||||
{t(offline ? 'main.offline_eyebrow' : 'main.connection_error_eyebrow')}
|
||||
</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>
|
||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
|
||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
|
||||
{t('main.retry')}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (!session) return <main className="route-loading">正在验证安全会话…</main>
|
||||
if (!session) return <main className="route-loading">{t('main.verifying_session')}</main>
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -117,14 +185,30 @@ function App() {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
||||
}
|
||||
const songRequestsMatch = path.match(/^\/control\/components\/([^/]+)\/song-requests$/)
|
||||
if (songRequestsMatch) {
|
||||
if (!session.user) return <Redirect to="/control/login" />
|
||||
return (
|
||||
<SongRequestsPage
|
||||
user={session.user}
|
||||
componentId={decodeURIComponent(songRequestsMatch[1])}
|
||||
onLogout={logout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (path === '/control') {
|
||||
if (location.pathname === '/control') return <Redirect to="/control/" />
|
||||
if (!session.user) return <Redirect to="/control/login" />
|
||||
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 (!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 <NotFoundPage />
|
||||
@@ -142,8 +226,16 @@ if (obsMatch) {
|
||||
} catch {
|
||||
publicId = ''
|
||||
}
|
||||
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
|
||||
root.render(
|
||||
<I18nProvider>
|
||||
<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />
|
||||
</I18nProvider>,
|
||||
)
|
||||
} else {
|
||||
initializePwa()
|
||||
root.render(<App />)
|
||||
root.render(
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
+300
-146
@@ -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 type { OverlaySettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
|
||||
type Envelope = {
|
||||
id: string
|
||||
@@ -13,7 +28,7 @@ type Envelope = {
|
||||
}
|
||||
|
||||
type LivePayload = {
|
||||
viewer?: { uid?: string; name?: string }
|
||||
viewer?: { uid?: string; name?: string; avatarUrl?: string }
|
||||
text?: string
|
||||
message?: string
|
||||
guardName?: string
|
||||
@@ -51,13 +66,9 @@ type DanmakuSegment =
|
||||
type OverlayProps = {
|
||||
preview?: boolean
|
||||
previewSettings?: OverlaySettings
|
||||
publicId?: string
|
||||
accessToken?: string
|
||||
stream?: ComponentStream
|
||||
}
|
||||
|
||||
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
|
||||
const decorVariantCount = 6
|
||||
|
||||
function stableHash(value: string) {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -67,21 +78,33 @@ function stableHash(value: string) {
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
function chooseDecorVariant(seed: string, previous?: number) {
|
||||
function chooseDecorVariant(seed: string, variantCount: number, previous?: number) {
|
||||
const hash = stableHash(seed)
|
||||
const base = hash % decorVariantCount
|
||||
const safeCount = Math.max(1, variantCount)
|
||||
const base = hash % safeCount
|
||||
if (previous === undefined || base !== previous) return base
|
||||
return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount
|
||||
if (safeCount === 1) return base
|
||||
return (base + 1 + ((hash >>> 8) % (safeCount - 1))) % safeCount
|
||||
}
|
||||
|
||||
function CardDecor({ count, variant }: { count: number; variant: number }) {
|
||||
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0)))
|
||||
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount
|
||||
function CardDecor({
|
||||
count,
|
||||
variant,
|
||||
theme,
|
||||
}: {
|
||||
count: number
|
||||
variant: number
|
||||
theme: OverlayThemeDefinition
|
||||
}) {
|
||||
const particles = theme.ornaments.particles
|
||||
const visible = Math.min(particles.length, Math.max(0, Math.round(count || 0)))
|
||||
const variantCount = Math.max(1, theme.ornaments.variantCount)
|
||||
const normalized = ((variant % variantCount) + variantCount) % variantCount
|
||||
return (
|
||||
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
|
||||
<i className="card-decor-surface" />
|
||||
<div className="card-particle-layer">
|
||||
{cardParticles.slice(0, visible).map((kind, index) => (
|
||||
{particles.slice(0, visible).map((kind, index) => (
|
||||
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
@@ -89,86 +112,52 @@ function CardDecor({ count, variant }: { count: number; variant: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function streamUrl(publicId: string) {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
|
||||
}
|
||||
|
||||
function enabled(type: string, settings: OverlaySettings) {
|
||||
return (type === 'live.danmaku' && settings.showDanmaku)
|
||||
|| (type === 'live.enter' && settings.showEnter)
|
||||
|| (type.startsWith('live.gift') && settings.showGift)
|
||||
|| (type === 'live.superchat' && settings.showSuperchat)
|
||||
|| (type === 'live.guard.buy' && settings.showGuard)
|
||||
|| (type === 'live.like' && settings.showLike)
|
||||
|| (type === 'live.share' && settings.showShare)
|
||||
return (
|
||||
(type === 'live.danmaku' && settings.showDanmaku) ||
|
||||
(type === 'live.enter' && settings.showEnter) ||
|
||||
(type.startsWith('live.gift') && settings.showGift) ||
|
||||
(type === 'live.superchat' && settings.showSuperchat) ||
|
||||
(type === 'live.guard.buy' && settings.showGuard) ||
|
||||
(type === 'live.like' && settings.showLike) ||
|
||||
(type === 'live.share' && settings.showShare)
|
||||
)
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): OverlaySettings {
|
||||
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
||||
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
|
||||
const candidate = { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
|
||||
return { ...candidate, themeId: normalizeThemeId(candidate.themeId) }
|
||||
}
|
||||
|
||||
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
|
||||
settings: OverlaySettings
|
||||
items: Item[]
|
||||
setItems: Dispatch<SetStateAction<Item[]>>
|
||||
connection: 'idle' | 'connecting' | 'connected' | 'denied'
|
||||
} {
|
||||
function useEvents(preview: boolean, stream?: ComponentStream) {
|
||||
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
|
||||
const settingsRef = useRef(settings)
|
||||
const lastSequenceRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !publicId || !accessToken) {
|
||||
setConnection('idle')
|
||||
return
|
||||
}
|
||||
|
||||
let dead = false
|
||||
let socket: WebSocket | undefined
|
||||
let timer = 0
|
||||
let retries = 0
|
||||
|
||||
const open = () => {
|
||||
setConnection('connecting')
|
||||
socket = new WebSocket(streamUrl(publicId))
|
||||
socket.onopen = () => {
|
||||
retries = 0
|
||||
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (dead) return
|
||||
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
||||
setConnection('denied')
|
||||
return
|
||||
}
|
||||
setConnection('connecting')
|
||||
const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
|
||||
retries += 1
|
||||
timer = window.setTimeout(open, delay)
|
||||
}
|
||||
socket.onmessage = event => {
|
||||
try {
|
||||
const envelope = JSON.parse(event.data) as Envelope
|
||||
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
|
||||
setConnection('connected')
|
||||
return
|
||||
}
|
||||
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
|
||||
setConnection('denied')
|
||||
socket?.close(1008, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
setConnection('connected')
|
||||
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
|
||||
setSettings(parseSettings(envelope.payload?.settings))
|
||||
return
|
||||
if (preview || !stream) return
|
||||
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
|
||||
for (const message of pending) {
|
||||
lastSequenceRef.current = message.sequence
|
||||
const envelope = message.envelope as Envelope
|
||||
if (
|
||||
envelope.type === 'overlay.settings.snapshot' ||
|
||||
envelope.type === 'overlay.settings.updated' ||
|
||||
envelope.type === 'component.settings.snapshot' ||
|
||||
envelope.type === 'component.settings.updated'
|
||||
) {
|
||||
const nextSettings = parseSettings(envelope.payload?.settings)
|
||||
// Update the ref immediately so a live event in the same React batch
|
||||
// observes the settings frame that preceded it.
|
||||
settingsRef.current = nextSettings
|
||||
setSettings(nextSettings)
|
||||
continue
|
||||
}
|
||||
setItems(old => {
|
||||
const current = settingsRef.current
|
||||
@@ -176,30 +165,28 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
|
||||
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)
|
||||
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))
|
||||
})
|
||||
} catch {
|
||||
// A malformed upstream event must not break a long-running OBS source.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open()
|
||||
return () => {
|
||||
dead = true
|
||||
window.clearTimeout(timer)
|
||||
socket?.close()
|
||||
}
|
||||
}, [accessToken, disabled, publicId])
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
setItems(current => current.slice(0, settings.maxVisible))
|
||||
setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
|
||||
}, [settings.maxVisible])
|
||||
|
||||
return { settings, items, setItems, connection }
|
||||
return { settings, items, setItems }
|
||||
}
|
||||
|
||||
function giftTier(item: Item, settings: OverlaySettings) {
|
||||
@@ -212,11 +199,12 @@ function giftTier(item: Item, settings: OverlaySettings) {
|
||||
}
|
||||
|
||||
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
|
||||
'live.enter': () => '踏入了云台',
|
||||
'live.enter': () => translate('overlay.enter'),
|
||||
'live.superchat': payload => payload.message || '',
|
||||
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`,
|
||||
'live.like': () => '点亮了一颗星',
|
||||
'live.share': () => '分享了直播间',
|
||||
'live.guard.buy': payload =>
|
||||
translate('overlay.guard', { guard: payload.guardName || translate('common.guard') }),
|
||||
'live.like': () => translate('overlay.like'),
|
||||
'live.share': () => translate('overlay.share'),
|
||||
}
|
||||
|
||||
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
|
||||
@@ -239,60 +227,121 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
|
||||
}
|
||||
|
||||
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 || ''}</>
|
||||
return <>{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>)}</>
|
||||
return (
|
||||
<>
|
||||
{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 viewer = payload.viewer || {}
|
||||
const gift = payload.gift
|
||||
const isDanmaku = item.type === 'live.danmaku'
|
||||
const tier = gift ? giftTier(item, settings) : ''
|
||||
const body = gift
|
||||
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
|
||||
: isDanmaku
|
||||
? <DanmakuBody payload={payload} />
|
||||
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
|
||||
const body = gift ? (
|
||||
translate('overlay.gift', {
|
||||
gift: gift.name || translate('common.gift'),
|
||||
quantity: payload.quantity || 1,
|
||||
})
|
||||
) : isDanmaku ? (
|
||||
<DanmakuBody payload={payload} />
|
||||
) : (
|
||||
eventRenderers[item.type]?.(payload) || translate('overlay.interaction')
|
||||
)
|
||||
|
||||
return (
|
||||
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
|
||||
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
|
||||
<article
|
||||
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
|
||||
>
|
||||
<CardDecor count={settings.particleCount} variant={item.decorVariant} theme={theme} />
|
||||
{gift && (
|
||||
<div className="gift-art">
|
||||
{gift.animationUrl || gift.imageUrl
|
||||
? (
|
||||
{gift.animationUrl || gift.imageUrl ? (
|
||||
<img
|
||||
src={gift.animationUrl || gift.imageUrl}
|
||||
alt={gift.name || '礼物'}
|
||||
alt={gift.name || translate('common.gift')}
|
||||
onError={event => {
|
||||
const image = event.currentTarget
|
||||
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
|
||||
else image.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span>✦</span>}
|
||||
) : (
|
||||
<span>✦</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
|
||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||
</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 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 settings = previewSettings || events.settings
|
||||
const theme = getOverlayTheme(settings.themeId)
|
||||
const [shape, setShape] = useState('standard')
|
||||
const [expandedKey, setExpandedKey] = useState<string>()
|
||||
const fontFactor = settings.fontScale / 100
|
||||
@@ -308,16 +357,8 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview && !items.length) {
|
||||
if (preview) {
|
||||
setItems([
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
|
||||
},
|
||||
{
|
||||
id: 'gift-preview',
|
||||
key: 'gift-preview',
|
||||
@@ -325,17 +366,38 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
||||
decorVariant: 3,
|
||||
type: 'live.gift',
|
||||
payload: {
|
||||
viewer: { name: '星光旅人' },
|
||||
viewer: { name: translate('overlay.preview_gift_viewer') },
|
||||
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(() => {
|
||||
const newest = items[0]
|
||||
if (!settings.expandNewDanmaku) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
}
|
||||
const newest = items[items.length - 1]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
@@ -343,33 +405,125 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
||||
setExpandedKey(newest.key)
|
||||
const densityFactor = shape === 'short' ? 0.6 : 1
|
||||
const timer = window.setTimeout(
|
||||
() => setExpandedKey(key => key === newest.key ? undefined : key),
|
||||
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
|
||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||
)
|
||||
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 (
|
||||
<main
|
||||
ref={root}
|
||||
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
data-connection={events.connection}
|
||||
style={{
|
||||
className={`overlay ${theme.className} ${shape} ${settings.expandNewDanmaku ? '' : 'stack-arrival'} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
style={
|
||||
{
|
||||
...themeCssVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-viewer-color' as string]: settings.viewerColor || undefined,
|
||||
['--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">
|
||||
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>}
|
||||
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
|
||||
<div className="cards">
|
||||
<div className="cards" ref={cardsRef}>
|
||||
{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>
|
||||
</section>
|
||||
|
||||
+60
-23
@@ -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 { translate, useI18n } from './i18n'
|
||||
|
||||
interface InstallChoice {
|
||||
outcome: 'accepted' | 'dismissed'
|
||||
@@ -58,7 +68,8 @@ function addManifest() {
|
||||
function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||
emit({
|
||||
registration,
|
||||
waitingWorker: registration.waiting && navigator.serviceWorker.controller
|
||||
waitingWorker:
|
||||
registration.waiting && navigator.serviceWorker.controller
|
||||
? registration.waiting
|
||||
: snapshot.waitingWorker,
|
||||
})
|
||||
@@ -76,24 +87,29 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||
|
||||
async function removeLegacyRootWorker() {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(async registration => {
|
||||
await Promise.all(
|
||||
registrations.map(async registration => {
|
||||
const scopePath = new URL(registration.scope).pathname
|
||||
const workers = [registration.installing, registration.waiting, registration.active]
|
||||
const isLegacy = scopePath === '/'
|
||||
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
||||
const isLegacy =
|
||||
scopePath === '/' &&
|
||||
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
||||
if (isLegacy) await registration.unregister()
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames
|
||||
await Promise.all(
|
||||
cacheNames
|
||||
.filter(name => name.startsWith('lxc-control-shell-'))
|
||||
.map(name => caches.delete(name)))
|
||||
.map(name => caches.delete(name)),
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupLegacyPwa() {
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||
void removeLegacyRootWorker().catch(error => {
|
||||
console.warn('旧版 PWA 清理失败', error)
|
||||
console.warn('Legacy PWA cleanup failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,7 +123,7 @@ async function registerWorker() {
|
||||
observeRegistration(registration)
|
||||
} catch (error) {
|
||||
// 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() })
|
||||
window.addEventListener('online', updateConnection)
|
||||
window.addEventListener('offline', updateConnection)
|
||||
const modernListener = (displayMode as unknown as {
|
||||
const modernListener = (
|
||||
displayMode as unknown as {
|
||||
addEventListener?: (type: 'change', listener: () => void) => void
|
||||
}).addEventListener
|
||||
}
|
||||
).addEventListener
|
||||
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
|
||||
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
|
||||
.addListener?.call(displayMode, updateDisplayMode)
|
||||
else
|
||||
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
|
||||
displayMode,
|
||||
updateDisplayMode,
|
||||
)
|
||||
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault()
|
||||
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
|
||||
|
||||
@@ -151,13 +174,17 @@ export function initializePwa() {
|
||||
void snapshot.registration?.update()
|
||||
}
|
||||
})
|
||||
window.setInterval(() => {
|
||||
window.setInterval(
|
||||
() => {
|
||||
if (navigator.onLine) void snapshot.registration?.update()
|
||||
}, 60 * 60 * 1000)
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -165,7 +192,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
|
||||
useEffect(() => {
|
||||
if (active) updateBlockers.set(key, reason)
|
||||
else updateBlockers.delete(key)
|
||||
return () => { updateBlockers.delete(key) }
|
||||
return () => {
|
||||
updateBlockers.delete(key)
|
||||
}
|
||||
}, [active, key, reason])
|
||||
}
|
||||
|
||||
@@ -182,31 +211,39 @@ function applyUpdate() {
|
||||
if (!worker) return
|
||||
const reasons = [...new Set(updateBlockers.values())]
|
||||
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
|
||||
}
|
||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
||||
const confirmed = window.confirm(translate('pwa.confirm_update'))
|
||||
if (!confirmed) return
|
||||
reloadForUpdate = true
|
||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
const { t } = useI18n()
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
const canInstall = Boolean(state.installPrompt) && !state.standalone
|
||||
if (state.online && !state.waitingWorker && !canInstall) return null
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||
更新可用
|
||||
{t('pwa.update')}
|
||||
</button>
|
||||
)}
|
||||
{canInstall && (
|
||||
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
||||
安装到设备
|
||||
{t('pwa.install')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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 = {
|
||||
themeId: OverlayThemeId
|
||||
fontFamily: FontFamilyId
|
||||
fontBrightness: number
|
||||
viewerColor: string | null
|
||||
danmakuColor: string | null
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
showDanmaku: boolean
|
||||
showEnter: boolean
|
||||
showGift: boolean
|
||||
@@ -8,6 +24,7 @@ export type OverlaySettings = {
|
||||
showLike: boolean
|
||||
showShare: boolean
|
||||
maxVisible: number
|
||||
expandNewDanmaku: boolean
|
||||
collapseAfterSeconds: number
|
||||
unfoldDurationMs: number
|
||||
motionIntensity: number
|
||||
@@ -19,7 +36,13 @@ export type OverlaySettings = {
|
||||
}
|
||||
|
||||
export const defaultOverlaySettings: OverlaySettings = {
|
||||
themeId: 'jade-scroll',
|
||||
fontFamily: 'fang-song',
|
||||
fontBrightness: 130,
|
||||
viewerColor: null,
|
||||
danmakuColor: null,
|
||||
fontScale: 140,
|
||||
decorationLineWeight: 160,
|
||||
showDanmaku: true,
|
||||
showEnter: true,
|
||||
showGift: true,
|
||||
@@ -28,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
showLike: false,
|
||||
showShare: false,
|
||||
maxVisible: 5,
|
||||
expandNewDanmaku: true,
|
||||
collapseAfterSeconds: 12,
|
||||
unfoldDurationMs: 1000,
|
||||
motionIntensity: 70,
|
||||
@@ -38,6 +62,198 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
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 AuthUser = {
|
||||
@@ -60,7 +276,7 @@ export type ComponentSummary = {
|
||||
kind: string
|
||||
name: string
|
||||
enabled?: boolean
|
||||
settings?: OverlaySettings
|
||||
settings?: ComponentSettings
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -1,3 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __PWA_BUILD_ID__: string
|
||||
declare const __I18N_RESOURCE__: {
|
||||
default_language: string
|
||||
locales: Record<string, { name: string; messages: Record<string, string> }>
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 react from '@vitejs/plugin-react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'smol-toml'
|
||||
|
||||
// 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.
|
||||
const pwaBuildId = Date.now().toString(36)
|
||||
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({
|
||||
plugins: [
|
||||
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',
|
||||
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() {
|
||||
for (const license of bundledFontLicenses) {
|
||||
this.emitFile({ type: 'asset', ...license })
|
||||
}
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'control/sw.js',
|
||||
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
|
||||
})
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'control/manifest.webmanifest',
|
||||
source: JSON.stringify(manifest, null, 2),
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
define: {
|
||||
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
|
||||
__I18N_RESOURCE__: JSON.stringify(i18nResource),
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+147
-2409
File diff suppressed because it is too large
Load Diff
@@ -7,22 +7,30 @@ edition = "2024"
|
||||
axum = { version = "0.8", features = ["ws", "json"] }
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
# Patched local copy of the published blivedm_rs crate. The patch preserves
|
||||
# the upstream raw payload so the application can retain UID, price and event
|
||||
# identifiers for atomic accounting and gift de-duplication.
|
||||
blivedm = { path = "../../vendor/blivedm", default-features = false }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
chrono = { version = "0.4", default-features = false, features = [
|
||||
"clock",
|
||||
"serde",
|
||||
] }
|
||||
chacha20poly1305 = "0.10"
|
||||
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"
|
||||
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_json = "1"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
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"
|
||||
toml = "0.8"
|
||||
totp-rs = { version = "5.7", features = ["gen_secret", "qr", "zeroize"] }
|
||||
|
||||
@@ -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.';
|
||||
@@ -0,0 +1,4 @@
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
newline_style = "Unix"
|
||||
use_small_heuristics = "Default"
|
||||
@@ -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 async_trait::async_trait;
|
||||
@@ -17,9 +24,13 @@ use crate::{
|
||||
bilibili::BilibiliProvider,
|
||||
supervisor::{ProviderFactory, SourceSupervisor},
|
||||
},
|
||||
overlay::GiftCatalogRegistry,
|
||||
rate_limit::AuthRateLimiter,
|
||||
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
|
||||
repository::TenantRepository,
|
||||
song_request::{
|
||||
SONG_REQUEST_KIND, SongRequestHandler, SongRequestService, SongRequestSnapshotProvider,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -35,6 +46,8 @@ pub struct AppState {
|
||||
pub enrollment_limiter: AuthRateLimiter,
|
||||
pub component_socket_slots: Arc<Semaphore>,
|
||||
pub http: reqwest::Client,
|
||||
pub song_requests: SongRequestService,
|
||||
pub gift_catalogs: GiftCatalogRegistry,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -53,9 +66,27 @@ impl AppState {
|
||||
let registry = ComponentRegistry::with_builtin_components();
|
||||
let component_cache = Arc::new(InMemoryComponentStore::default());
|
||||
let hub = EventHub::new(512);
|
||||
let song_requests = SongRequestService::new(db.clone(), hub.clone());
|
||||
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 repository =
|
||||
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
|
||||
repository
|
||||
.ensure_builtin_components()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
repository
|
||||
.hydrate_all()
|
||||
.await
|
||||
@@ -70,6 +101,7 @@ impl AppState {
|
||||
auth: auth.clone(),
|
||||
config: config.clone(),
|
||||
http: http.clone(),
|
||||
gift_catalogs: gift_catalogs.clone(),
|
||||
});
|
||||
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
|
||||
let supervisor = SourceSupervisor::new(provider_factory, source_events);
|
||||
@@ -106,6 +138,8 @@ impl AppState {
|
||||
),
|
||||
component_socket_slots: Arc::new(Semaphore::new(128)),
|
||||
http,
|
||||
song_requests,
|
||||
gift_catalogs,
|
||||
};
|
||||
state.start_all_sources().await?;
|
||||
Ok(state)
|
||||
@@ -200,6 +234,7 @@ struct BilibiliProviderFactory {
|
||||
auth: AuthService,
|
||||
config: Arc<Config>,
|
||||
http: reqwest::Client,
|
||||
gift_catalogs: GiftCatalogRegistry,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -213,12 +248,14 @@ impl ProviderFactory for BilibiliProviderFactory {
|
||||
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
|
||||
self.config.allowed_cookiecloud_host(&stored.host)?;
|
||||
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,
|
||||
self.config.gift_refresh_seconds,
|
||||
self.config.gift_request_timeout_seconds,
|
||||
self.config.emoticon_refresh_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")),
|
||||
(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
|
||||
.query_one(
|
||||
|
||||
+500
-43
@@ -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 base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -11,14 +19,19 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio_postgres::Transaction;
|
||||
use tokio_postgres::{Transaction, error::SqlState};
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
|
||||
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,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
};
|
||||
|
||||
const TOTP_DIGITS: usize = 6;
|
||||
@@ -470,7 +483,11 @@ impl AuthService {
|
||||
&self,
|
||||
enrollment_token: &str,
|
||||
totp_code: &str,
|
||||
language: &str,
|
||||
) -> Result<RegistrationComplete, AuthError> {
|
||||
if !i18n::is_supported(language) {
|
||||
return Err(AuthError::InvalidInput("language is not supported"));
|
||||
}
|
||||
let digest = token_digest(enrollment_token.trim());
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
@@ -589,6 +606,12 @@ impl AuthService {
|
||||
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
||||
let session = insert_session(&transaction, user_id, self.session_ttl).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();
|
||||
transaction
|
||||
.execute(
|
||||
@@ -602,13 +625,82 @@ impl AuthService {
|
||||
.expect("OverlaySettings is always JSON serializable");
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
||||
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||
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,
|
||||
&default_source_id,
|
||||
&default_settings,
|
||||
&SONG_REQUEST_KIND,
|
||||
&SONG_REQUEST_NAME,
|
||||
&song_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO song_request_state(owner_user_id,component_instance_id) VALUES($1,$2)",
|
||||
&[&user_id, &song_component_id],
|
||||
)
|
||||
.await?;
|
||||
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?;
|
||||
@@ -817,6 +909,251 @@ impl AuthService {
|
||||
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, ¤t_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(
|
||||
&self,
|
||||
raw_session_token: &str,
|
||||
@@ -882,6 +1219,100 @@ impl AuthService {
|
||||
user_id: Uuid,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> 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)
|
||||
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
|
||||
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
|
||||
@@ -897,31 +1328,7 @@ impl AuthService {
|
||||
let encrypted = self
|
||||
.cipher
|
||||
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
|
||||
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?;
|
||||
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(())
|
||||
Ok((host, encrypted))
|
||||
}
|
||||
|
||||
pub async fn get_cookiecloud_credentials(
|
||||
@@ -1207,13 +1614,6 @@ impl AuthService {
|
||||
.ok_or(AuthError::InvalidCredentials)?
|
||||
.get(0);
|
||||
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
|
||||
.query_opt(
|
||||
"SELECT id FROM component_instances \
|
||||
@@ -1235,9 +1635,9 @@ impl AuthService {
|
||||
let id = Uuid::new_v4();
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
||||
&[&id, &owner_user_id, &source_id, &settings],
|
||||
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||
VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
|
||||
&[&id, &owner_user_id, &settings],
|
||||
)
|
||||
.await?;
|
||||
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)]
|
||||
struct SecretCipher {
|
||||
cipher: Arc<XChaCha20Poly1305>,
|
||||
@@ -1346,6 +1773,11 @@ fn accepted_totp_step(
|
||||
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(
|
||||
transaction: &Transaction<'_>,
|
||||
user_id: Uuid,
|
||||
@@ -1626,6 +2058,18 @@ pub struct RegistrationStart {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegistrationComplete {
|
||||
@@ -1683,6 +2127,7 @@ pub enum AuthError {
|
||||
Forbidden,
|
||||
InvitationUnavailable,
|
||||
EnrollmentUnavailable,
|
||||
TotpResetUnavailable,
|
||||
AccountUnavailable,
|
||||
RoomUnavailable,
|
||||
InvalidTotp,
|
||||
@@ -1715,6 +2160,9 @@ impl fmt::Display for AuthError {
|
||||
formatter,
|
||||
"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::RoomUnavailable => write!(formatter, "room is already assigned"),
|
||||
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]
|
||||
fn username_and_room_validation_are_canonical() {
|
||||
assert_eq!(
|
||||
normalize_username(" LuoXingCi ").expect("username"),
|
||||
("LuoXingCi".into(), "luoxingci".into())
|
||||
normalize_username(" ExampleStreamer ").expect("username"),
|
||||
("ExampleStreamer".into(), "examplestreamer".into())
|
||||
);
|
||||
assert!(normalize_username("ab").is_err());
|
||||
assert!(normalize_username("bad name").is_err());
|
||||
|
||||
@@ -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::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
error::Error,
|
||||
@@ -13,7 +20,11 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
gift_effect::GiftEffectDefinition,
|
||||
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||
guard_effect::GuardEffectDefinition,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||
};
|
||||
|
||||
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
|
||||
@@ -76,7 +87,10 @@ pub struct ComponentInstance {
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing)]
|
||||
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 name: String,
|
||||
pub enabled: bool,
|
||||
@@ -87,7 +101,7 @@ pub struct ComponentInstance {
|
||||
impl ComponentInstance {
|
||||
pub fn new(
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
account_source_id: Uuid,
|
||||
kind: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
settings_version: u32,
|
||||
@@ -96,7 +110,7 @@ impl ComponentInstance {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
owner_id,
|
||||
source_id,
|
||||
account_source_id,
|
||||
kind: kind.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
@@ -173,6 +187,19 @@ pub trait EventProjection: Send + Sync {
|
||||
/// `async-trait` dependency.
|
||||
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
|
||||
|
||||
/// Future used by component-specific durable state snapshots during WebSocket
|
||||
/// authentication. Snapshot messages share the normal component envelope.
|
||||
pub type SnapshotFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Vec<ComponentMessage>, ComponentError>> + Send + 'a>>;
|
||||
|
||||
pub trait ComponentSnapshotProvider: Send + Sync {
|
||||
fn snapshot<'a>(
|
||||
&'a self,
|
||||
component: &'a ComponentInstance,
|
||||
room_id: &'a str,
|
||||
) -> SnapshotFuture<'a>;
|
||||
}
|
||||
|
||||
/// An active handler may perform durable side effects (for example recording a
|
||||
/// song request). It runs independently of WebSocket receiver count and should
|
||||
/// implement idempotency in its persistence layer.
|
||||
@@ -273,6 +300,7 @@ pub struct ComponentRuntime {
|
||||
definition: Arc<dyn ComponentDefinition>,
|
||||
projection: Arc<dyn EventProjection>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
snapshot_provider: Option<Arc<dyn ComponentSnapshotProvider>>,
|
||||
}
|
||||
|
||||
impl ComponentRuntime {
|
||||
@@ -313,6 +341,17 @@ impl ComponentRuntime {
|
||||
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
|
||||
self.handlers.clone()
|
||||
}
|
||||
|
||||
pub async fn snapshot(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
room_id: &str,
|
||||
) -> Result<Vec<ComponentMessage>, ComponentError> {
|
||||
match &self.snapshot_provider {
|
||||
Some(provider) => provider.snapshot(component, room_id).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -340,6 +379,27 @@ impl ComponentRegistry {
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(
|
||||
Arc::new(SongRequestDefinition),
|
||||
Arc::new(SongRequestProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.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(
|
||||
@@ -364,6 +424,7 @@ impl ComponentRegistry {
|
||||
definition,
|
||||
projection,
|
||||
handlers: Vec::new(),
|
||||
snapshot_provider: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -385,6 +446,22 @@ impl ComponentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_snapshot_provider(
|
||||
&self,
|
||||
kind: &str,
|
||||
provider: Arc<dyn ComponentSnapshotProvider>,
|
||||
) -> Result<(), ComponentError> {
|
||||
let mut entries = self
|
||||
.entries
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let runtime = entries
|
||||
.get_mut(kind)
|
||||
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?;
|
||||
runtime.snapshot_provider = Some(provider);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
|
||||
self.entries
|
||||
.read()
|
||||
@@ -462,6 +539,89 @@ mod tests {
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_song_request_is_registered_with_bounded_unlimited_defaults() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let runtime = registry.runtime("song_request").unwrap();
|
||||
let mut settings = runtime.definition().default_settings();
|
||||
settings["scrollSpeedPixelsPerSecond"] = Value::from(999);
|
||||
let validated = registry
|
||||
.validate_settings("song_request", 1, settings)
|
||||
.unwrap();
|
||||
assert_eq!(validated["scrollSpeedPixelsPerSecond"], 200);
|
||||
assert_eq!(validated["maxQueueSize"], 0);
|
||||
assert_eq!(validated["maxRequestsPerViewer"], 0);
|
||||
assert_eq!(validated["requestCooldownSeconds"], 0);
|
||||
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"song_request",
|
||||
"点歌姬",
|
||||
1,
|
||||
validated,
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Danmaku));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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]
|
||||
fn duplicate_component_kinds_are_rejected() {
|
||||
let registry = ComponentRegistry::default();
|
||||
|
||||
@@ -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 base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{credentials::normalize_cookiecloud_host, overlay::OverlaySettings};
|
||||
use crate::{
|
||||
credentials::normalize_cookiecloud_host,
|
||||
overlay::{OverlaySettings, OverlayThemeId},
|
||||
typography::FontFamilyId,
|
||||
};
|
||||
|
||||
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
||||
/// settings are imported from the legacy sections once and then live in
|
||||
@@ -116,8 +127,15 @@ struct EmoticonsConfig {
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
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>,
|
||||
decoration_line_weight: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
expand_new_danmaku: Option<bool>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
motion_intensity: Option<u8>,
|
||||
@@ -207,7 +225,7 @@ impl Config {
|
||||
legacy_obs_access_token: file.obs.access_token,
|
||||
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
||||
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
|
||||
.gifts
|
||||
@@ -311,7 +329,15 @@ fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] {
|
||||
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
let default = OverlaySettings::default();
|
||||
OverlaySettings {
|
||||
theme_id: file.theme_id.unwrap_or(default.theme_id),
|
||||
font_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),
|
||||
decoration_line_weight: file
|
||||
.decoration_line_weight
|
||||
.unwrap_or(default.decoration_line_weight),
|
||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||
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_share: file.events.share.unwrap_or(default.show_share),
|
||||
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
|
||||
.unwrap_or(default.collapse_after_seconds),
|
||||
|
||||
@@ -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::{
|
||||
Url,
|
||||
header::{REFERER, USER_AGENT},
|
||||
|
||||
@@ -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 deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
||||
@@ -101,8 +108,11 @@ impl Db {
|
||||
Self::set_tenant(&transaction, user_id).await?;
|
||||
let rows = transaction
|
||||
.query(
|
||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
||||
FROM component_instances WHERE owner_user_id=$1 ORDER BY created_at",
|
||||
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||
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],
|
||||
)
|
||||
.await?;
|
||||
@@ -112,7 +122,7 @@ impl Db {
|
||||
.map(|row| ComponentRecord {
|
||||
id: row.get(0),
|
||||
owner_user_id: user_id,
|
||||
source_id: row.get(1),
|
||||
account_source_id: row.get(1),
|
||||
kind: row.get(2),
|
||||
name: row.get(3),
|
||||
settings: row.get(4),
|
||||
@@ -134,7 +144,9 @@ pub struct ActiveTenant {
|
||||
pub struct ComponentRecord {
|
||||
pub 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 name: String,
|
||||
pub settings: Value,
|
||||
|
||||
@@ -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_json::Value;
|
||||
use uuid::Uuid;
|
||||
@@ -44,6 +51,10 @@ impl LiveEventKind {
|
||||
pub struct PlatformViewer {
|
||||
pub uid: 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)]
|
||||
@@ -88,6 +99,9 @@ pub struct GiftDetails {
|
||||
pub id: Option<i64>,
|
||||
pub name: 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 total_price: i64,
|
||||
pub price_cny: f64,
|
||||
@@ -270,6 +284,27 @@ pub struct ComponentMessage {
|
||||
}
|
||||
|
||||
impl ComponentMessage {
|
||||
/// Build a component-owned message that does not originate directly from a
|
||||
/// provider packet, such as a durable queue snapshot or settings update.
|
||||
pub fn new(
|
||||
component: &crate::components::ComponentInstance,
|
||||
room_id: impl Into<String>,
|
||||
event_type: impl Into<String>,
|
||||
payload: Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
owner_id: component.owner_id,
|
||||
component_id: component.id,
|
||||
source_id: component.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(
|
||||
component_id: Uuid,
|
||||
event: &LiveEvent,
|
||||
@@ -300,6 +335,7 @@ mod tests {
|
||||
PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 axum::{
|
||||
Json, Router,
|
||||
extract::{
|
||||
DefaultBodyLimit, Path, Request, State,
|
||||
DefaultBodyLimit, Path, Query, Request, State,
|
||||
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
||||
},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
@@ -26,9 +34,11 @@ use crate::{
|
||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
|
||||
domain::{
|
||||
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},
|
||||
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
|
||||
};
|
||||
|
||||
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/login", post(login))
|
||||
.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(
|
||||
"/api/v1/invitations",
|
||||
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/source", get(get_source).put(put_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(
|
||||
"/api/v1/components",
|
||||
get(list_components).post(create_component),
|
||||
@@ -68,10 +92,38 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/v1/components/{id}/test-events",
|
||||
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(
|
||||
"/api/v1/components/{public_id}/stream",
|
||||
get(component_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/song-requests",
|
||||
get(list_song_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/components/{id}/song-requests/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("/", 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/setup", get(spa_page))
|
||||
.route("/control/invitations", get(spa_page))
|
||||
.route("/control/account", get(spa_page))
|
||||
.route("/obs/{public_id}", get(spa_page))
|
||||
.fallback_service(
|
||||
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
||||
@@ -241,6 +294,7 @@ async fn register_start(
|
||||
struct EnrollmentConfirmRequest {
|
||||
enrollment_token: String,
|
||||
code: String,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
async fn enrollment_confirm(
|
||||
@@ -250,9 +304,20 @@ async fn enrollment_confirm(
|
||||
) -> Result<Response, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
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
|
||||
.auth
|
||||
.registration_confirm(&body.enrollment_token, &body.code)
|
||||
.registration_confirm(&body.enrollment_token, &body.code, language)
|
||||
.await?;
|
||||
if result.user.role == UserRole::SystemAdmin {
|
||||
if let Err(error) = state
|
||||
@@ -326,7 +391,7 @@ async fn login(
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
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;
|
||||
@@ -370,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateInvitationRequest {
|
||||
@@ -486,13 +601,7 @@ async fn put_source(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
if body.room_id.trim() != session.user.room_id {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"room_is_immutable",
|
||||
"The room bound by the invitation cannot be changed",
|
||||
));
|
||||
}
|
||||
let room_id = body.room_id.trim().to_owned();
|
||||
let existing = state
|
||||
.auth
|
||||
.get_cookiecloud_credentials(session.user.id)
|
||||
@@ -543,7 +652,7 @@ async fn put_source(
|
||||
})?;
|
||||
state
|
||||
.auth
|
||||
.set_cookiecloud_credentials(session.user.id, &credentials)
|
||||
.set_account_live_source(session.user.id, &room_id, &credentials)
|
||||
.await?;
|
||||
state
|
||||
.restart_user_source(session.user.id)
|
||||
@@ -565,6 +674,50 @@ async fn reconnect_source(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -637,12 +790,199 @@ async fn put_component_settings(
|
||||
let message = settings_message(
|
||||
&component,
|
||||
&session.user.room_id,
|
||||
"overlay.settings.updated",
|
||||
"component.settings.updated",
|
||||
);
|
||||
state.hub.publish(component.id, Arc::new(message));
|
||||
if component.kind == "danmaku_overlay" {
|
||||
state.hub.publish(
|
||||
component.id,
|
||||
Arc::new(settings_message(
|
||||
&component,
|
||||
&session.user.room_id,
|
||||
"overlay.settings.updated",
|
||||
)),
|
||||
);
|
||||
}
|
||||
Ok(Json(json!({"settings":component.settings})))
|
||||
}
|
||||
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -704,9 +1044,19 @@ enum TestEventRequest {
|
||||
name: String,
|
||||
#[serde(rename = "giftName")]
|
||||
gift_name: String,
|
||||
#[serde(rename = "giftId")]
|
||||
gift_id: Option<i64>,
|
||||
battery: i32,
|
||||
quantity: i32,
|
||||
},
|
||||
Guard {
|
||||
uid: String,
|
||||
name: String,
|
||||
#[serde(rename = "guardName")]
|
||||
guard_name: String,
|
||||
quantity: i32,
|
||||
price: i64,
|
||||
},
|
||||
}
|
||||
|
||||
async fn component_test_event(
|
||||
@@ -718,7 +1068,11 @@ async fn component_test_event(
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).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 {
|
||||
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
||||
viewer: viewer(uid, name),
|
||||
@@ -732,18 +1086,23 @@ async fn component_test_event(
|
||||
uid,
|
||||
name,
|
||||
gift_name,
|
||||
gift_id,
|
||||
battery,
|
||||
quantity,
|
||||
} => {
|
||||
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));
|
||||
LiveEventPayload::Gift(GiftEvent {
|
||||
viewer: viewer(uid, name),
|
||||
gift: GiftDetails {
|
||||
id: None,
|
||||
id: gift_id,
|
||||
name: gift_name,
|
||||
coin_type: "gold".into(),
|
||||
battery_value,
|
||||
unit_price,
|
||||
total_price,
|
||||
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()),
|
||||
})
|
||||
}
|
||||
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(
|
||||
component.owner_id,
|
||||
component.source_id,
|
||||
component.account_source_id,
|
||||
"test",
|
||||
session.user.room_id,
|
||||
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 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
|
||||
.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()
|
||||
.into(),
|
||||
))
|
||||
@@ -885,19 +1267,54 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
|
||||
return;
|
||||
}
|
||||
};
|
||||
let snapshot = settings_message(&component, &room_id, "overlay.settings.snapshot");
|
||||
let snapshot = settings_message(&component, &room_id, "component.settings.snapshot");
|
||||
if send_component_message(&mut socket, &snapshot)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if component.kind == "danmaku_overlay"
|
||||
&& send_component_message(
|
||||
&mut socket,
|
||||
&settings_message(&component, &room_id, "overlay.settings.snapshot"),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let runtime = match state.registry.runtime(&component.kind) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(_) => {
|
||||
close_ws(&mut socket, "Component is unavailable").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let component_snapshots = match runtime.snapshot(&component, &room_id).await {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => {
|
||||
warn!(component_id = %component.id, %error, "component snapshot failed");
|
||||
close_ws(&mut socket, "Component snapshot is unavailable").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
for message in component_snapshots {
|
||||
if send_component_message(&mut socket, &message).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = receiver.recv() => match event {
|
||||
Ok(message) => {
|
||||
if send_component_message(&mut socket, &message).await.is_err() { break; }
|
||||
}
|
||||
// A durable component cannot safely guess dropped deltas. A
|
||||
// disconnect makes the browser obtain a fresh paged snapshot;
|
||||
// passive visual components can keep their legacy best-effort
|
||||
// behavior.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) if component.kind == SONG_REQUEST_KIND => break,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
},
|
||||
@@ -937,17 +1354,12 @@ fn settings_message(
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
) -> ComponentMessage {
|
||||
ComponentMessage {
|
||||
owner_id: component.owner_id,
|
||||
component_id: component.id,
|
||||
source_id: component.source_id,
|
||||
version: COMPONENT_PROTOCOL_VERSION,
|
||||
id: Uuid::new_v4(),
|
||||
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
room_id: room_id.to_owned(),
|
||||
event_type: event_type.into(),
|
||||
payload: json!({"settings":component.settings}),
|
||||
}
|
||||
ComponentMessage::new(
|
||||
component,
|
||||
room_id,
|
||||
event_type,
|
||||
json!({"settings":component.settings}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn require_session(
|
||||
@@ -1117,9 +1529,11 @@ fn constant_time_secret_eq(provided: &str, expected: &str) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ErrorBody {
|
||||
error: &'static str,
|
||||
code: &'static str,
|
||||
message_key: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
@@ -1146,7 +1560,7 @@ impl ApiError {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_generic_login(mut self) -> Self {
|
||||
fn into_generic_login(mut self) -> Self {
|
||||
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
||||
self.status = StatusCode::UNAUTHORIZED;
|
||||
self.code = "invalid_credentials";
|
||||
@@ -1166,15 +1580,16 @@ impl IntoResponse for ApiError {
|
||||
Json(ErrorBody {
|
||||
error: self.code,
|
||||
code: self.code,
|
||||
message_key: format!("api.error.{}", self.code),
|
||||
message: self.message,
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
if let Some(retry_after) = self.retry_after {
|
||||
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) {
|
||||
if let Some(retry_after) = self.retry_after
|
||||
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
|
||||
{
|
||||
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
@@ -1201,11 +1616,19 @@ impl From<AuthError> for ApiError {
|
||||
"enrollment_unavailable",
|
||||
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,
|
||||
"account_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
AuthError::RoomUnavailable => {
|
||||
Self::new(StatusCode::CONFLICT, "room_unavailable", error.to_string())
|
||||
}
|
||||
AuthError::InvalidTotp
|
||||
| AuthError::TotpReplay
|
||||
| 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 {
|
||||
error!(%error, "internal operation failed");
|
||||
ApiError::new(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,15 @@ pub mod config;
|
||||
pub mod credentials;
|
||||
pub mod db;
|
||||
pub mod domain;
|
||||
pub mod gift_effect;
|
||||
pub mod gift_menu;
|
||||
pub mod guard_effect;
|
||||
pub mod http_api;
|
||||
pub mod i18n;
|
||||
pub mod live;
|
||||
pub mod overlay;
|
||||
pub mod rate_limit;
|
||||
pub mod realtime;
|
||||
pub mod repository;
|
||||
pub mod song_request;
|
||||
pub mod typography;
|
||||
|
||||
+1130
-161
File diff suppressed because it is too large
Load Diff
@@ -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 supervisor;
|
||||
|
||||
@@ -35,9 +42,9 @@ impl SourceStatus {
|
||||
#[derive(Clone)]
|
||||
pub struct SourceContext {
|
||||
pub owner_id: Uuid,
|
||||
/// Stable database identity for the account's single fixed live source.
|
||||
/// It remains distinct from the owner id so future provider/source models
|
||||
/// do not leak the current one-room product rule into event contracts.
|
||||
/// Stable identity for the account's single fixed listener. Components do
|
||||
/// not persist or select this ID; it remains in event envelopes so clients
|
||||
/// can identify the shared upstream connection.
|
||||
pub source_id: Uuid,
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
@@ -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 async_trait::async_trait;
|
||||
@@ -21,7 +27,7 @@ struct RunningSource {
|
||||
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
|
||||
/// listeners produced by the legacy `/reconnect` handler.
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -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 tracing::{info, warn};
|
||||
|
||||
|
||||
+215
-22
@@ -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 serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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")]
|
||||
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_enter: bool,
|
||||
pub show_gift: bool,
|
||||
@@ -17,6 +57,8 @@ pub struct OverlaySettings {
|
||||
pub show_like: bool,
|
||||
pub show_share: bool,
|
||||
pub max_visible: u8,
|
||||
#[serde(default = "default_expand_new_danmaku")]
|
||||
pub expand_new_danmaku: bool,
|
||||
pub collapse_after_seconds: u16,
|
||||
#[serde(default = "default_unfold_duration_ms")]
|
||||
pub unfold_duration_ms: u16,
|
||||
@@ -34,7 +76,13 @@ pub struct OverlaySettings {
|
||||
impl Default for OverlaySettings {
|
||||
fn default() -> 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(),
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
show_gift: true,
|
||||
@@ -43,6 +91,7 @@ impl Default for OverlaySettings {
|
||||
show_like: false,
|
||||
show_share: false,
|
||||
max_visible: 5,
|
||||
expand_new_danmaku: default_expand_new_danmaku(),
|
||||
collapse_after_seconds: 12,
|
||||
unfold_duration_ms: default_unfold_duration_ms(),
|
||||
motion_intensity: 70,
|
||||
@@ -58,7 +107,11 @@ impl Default for OverlaySettings {
|
||||
impl OverlaySettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
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.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
||||
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 {
|
||||
140
|
||||
}
|
||||
|
||||
fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_expand_new_danmaku() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_particle_count() -> u8 {
|
||||
8
|
||||
}
|
||||
@@ -93,6 +162,7 @@ pub struct GiftMeta {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
pub coin_type: String,
|
||||
pub battery_value: i64,
|
||||
pub unit_price: i64,
|
||||
pub image_url: Option<String>,
|
||||
pub animation_url: Option<String>,
|
||||
@@ -106,6 +176,24 @@ pub struct GiftCatalog {
|
||||
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)]
|
||||
pub struct EmoticonMeta {
|
||||
pub emoji: String,
|
||||
@@ -123,16 +211,30 @@ pub struct EmoticonCatalog {
|
||||
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 {
|
||||
pub async fn len(&self) -> usize {
|
||||
self.by_unique.read().await.len()
|
||||
}
|
||||
|
||||
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(emoticon) = self.by_unique.read().await.get(unique).cloned() {
|
||||
return Some(emoticon);
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.len().await == 0
|
||||
}
|
||||
|
||||
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
||||
if let Some(unique) = unique.filter(|value| !value.is_empty())
|
||||
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
|
||||
{
|
||||
return Some(emoticon);
|
||||
}
|
||||
self.by_emoji.read().await.get(emoji).cloned()
|
||||
}
|
||||
@@ -184,11 +286,15 @@ impl GiftCatalog {
|
||||
self.by_id.read().await.len()
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
||||
if let Some(id) = id {
|
||||
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
|
||||
return Some(gift);
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.len().await == 0
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
||||
if let Some(id) = id
|
||||
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
|
||||
{
|
||||
return Some(gift);
|
||||
}
|
||||
self.by_name
|
||||
.read()
|
||||
@@ -197,6 +303,21 @@ impl GiftCatalog {
|
||||
.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> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
||||
@@ -231,9 +352,7 @@ impl GiftCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_catalog(
|
||||
payload: &Value,
|
||||
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
|
||||
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||
let list = payload
|
||||
.pointer("/data/gift_config/base_config/list")
|
||||
.and_then(Value::as_array)
|
||||
@@ -245,6 +364,7 @@ fn parse_catalog(
|
||||
continue;
|
||||
};
|
||||
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 {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
@@ -253,7 +373,8 @@ fn parse_catalog(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gold")
|
||||
.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"),
|
||||
animation_url: string_field(item, "gif"),
|
||||
effect_type: item.get("effect").and_then(|value| match value {
|
||||
@@ -274,6 +395,12 @@ fn parse_catalog(
|
||||
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> {
|
||||
value
|
||||
.get(name)
|
||||
@@ -282,16 +409,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_emoticon_catalog(
|
||||
payload: &Value,
|
||||
) -> Result<
|
||||
(
|
||||
HashMap<String, EmoticonMeta>,
|
||||
HashMap<String, EmoticonMeta>,
|
||||
usize,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
|
||||
let packages = payload
|
||||
.pointer("/data/data")
|
||||
.and_then(Value::as_array)
|
||||
@@ -383,6 +501,29 @@ fn normalize_name(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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;
|
||||
|
||||
#[test]
|
||||
@@ -394,6 +535,7 @@ mod tests {
|
||||
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
|
||||
assert_eq!(count, 1);
|
||||
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!(
|
||||
names
|
||||
.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]
|
||||
fn rejects_an_empty_catalog_without_replacing_the_cache() {
|
||||
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
|
||||
@@ -441,7 +590,12 @@ mod tests {
|
||||
fn settings_are_bounded_before_persistence() {
|
||||
let settings = OverlaySettings {
|
||||
font_scale: 999,
|
||||
font_brightness: 1,
|
||||
viewer_color: Some(" #a1b2c3 ".into()),
|
||||
danmaku_color: Some("not-css".into()),
|
||||
decoration_line_weight: 999,
|
||||
max_visible: 99,
|
||||
expand_new_danmaku: false,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
motion_intensity: 200,
|
||||
@@ -453,7 +607,12 @@ mod tests {
|
||||
}
|
||||
.sanitize();
|
||||
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!(!settings.expand_new_danmaku);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
assert_eq!(settings.motion_intensity, 100);
|
||||
@@ -475,13 +634,47 @@ mod tests {
|
||||
let object = value.as_object_mut().expect("settings object");
|
||||
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
||||
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("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
object.remove("themeId");
|
||||
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
|
||||
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
|
||||
assert_eq!(settings.font_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.decoration_line_weight, 160);
|
||||
assert!(settings.expand_new_danmaku);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
assert_eq!(settings.particle_speed, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_theme_ids_are_rejected() {
|
||||
let mut value = json!(OverlaySettings::default());
|
||||
value["themeId"] = json!("theme-that-is-not-installed");
|
||||
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
|
||||
assert!(error.to_string().contains("unknown variant"));
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::Arc,
|
||||
|
||||
@@ -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::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
@@ -119,15 +126,11 @@ impl Error for ComponentStoreError {}
|
||||
pub type ComponentStoreFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
||||
|
||||
/// Persistence port used by source routing. A PostgreSQL implementation should
|
||||
/// always scope its query by both owner and source; the router repeats that
|
||||
/// check as defense in depth.
|
||||
/// Persistence port used by account-stream routing. A provider event is offered
|
||||
/// to every enabled component owned by that account; each component definition
|
||||
/// then filters it through its event subscription.
|
||||
pub trait ComponentInstanceStore: Send + Sync {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a>;
|
||||
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -166,22 +169,14 @@ impl InMemoryComponentStore {
|
||||
}
|
||||
|
||||
impl ComponentInstanceStore for InMemoryComponentStore {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a> {
|
||||
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||
Box::pin(async move {
|
||||
Ok(self
|
||||
.instances
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.iter()
|
||||
.filter(|instance| {
|
||||
instance.enabled
|
||||
&& instance.owner_id == owner_id
|
||||
&& instance.source_id == source_id
|
||||
})
|
||||
.filter(|instance| instance.enabled && instance.owner_id == owner_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
})
|
||||
@@ -222,7 +217,7 @@ pub enum RouteError {
|
||||
impl fmt::Display for RouteError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
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> {
|
||||
let components = self
|
||||
.components
|
||||
.list_enabled_for_source(event.owner_id, event.source_id)
|
||||
.list_enabled_for_owner(event.owner_id)
|
||||
.await?;
|
||||
let mut report = RouteReport {
|
||||
considered: components.len(),
|
||||
@@ -283,14 +278,11 @@ impl SourceEventRouter {
|
||||
};
|
||||
|
||||
for component in components {
|
||||
if !component.enabled
|
||||
|| component.owner_id != event.owner_id
|
||||
|| component.source_id != event.source_id
|
||||
{
|
||||
if !component.enabled || component.owner_id != event.owner_id {
|
||||
report.failures.push(RouteFailure {
|
||||
component_id: component.id,
|
||||
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;
|
||||
}
|
||||
@@ -342,7 +334,7 @@ impl SourceEventRouter {
|
||||
match runtime.project(&component, &event) {
|
||||
Ok(Some(message)) => {
|
||||
if message.owner_id != component.owner_id
|
||||
|| message.source_id != component.source_id
|
||||
|| message.source_id != event.source_id
|
||||
|| message.component_id != component.id
|
||||
{
|
||||
report.failures.push(RouteFailure {
|
||||
@@ -410,6 +402,7 @@ mod tests {
|
||||
viewer: PlatformViewer {
|
||||
uid: "42".into(),
|
||||
name: "观众".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
text: "晚上好".into(),
|
||||
segments: Vec::new(),
|
||||
@@ -510,16 +503,38 @@ mod tests {
|
||||
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 {
|
||||
instances: Vec<ComponentInstance>,
|
||||
}
|
||||
|
||||
impl ComponentInstanceStore for LeakyStore {
|
||||
fn list_enabled_for_source<'a>(
|
||||
&'a self,
|
||||
_owner_id: Uuid,
|
||||
_source_id: Uuid,
|
||||
) -> ComponentStoreFuture<'a> {
|
||||
fn list_enabled_for_owner<'a>(&'a self, _owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||
Box::pin(async move { Ok(self.instances.clone()) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 chrono::{DateTime, Utc};
|
||||
@@ -8,9 +15,18 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
components::{ComponentInstance, ComponentRegistry},
|
||||
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,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
};
|
||||
|
||||
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantRepository {
|
||||
db: Db,
|
||||
@@ -38,6 +54,164 @@ impl TenantRepository {
|
||||
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(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
@@ -87,15 +261,34 @@ impl TenantRepository {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().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
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,true)",
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,true)",
|
||||
&[
|
||||
&component.id,
|
||||
&owner_id,
|
||||
&source_id,
|
||||
&component.kind,
|
||||
&component.name,
|
||||
&component.settings,
|
||||
@@ -103,6 +296,15 @@ impl TenantRepository {
|
||||
],
|
||||
)
|
||||
.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?;
|
||||
self.cache.upsert(component.clone());
|
||||
Ok(component)
|
||||
@@ -116,6 +318,30 @@ impl TenantRepository {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().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
|
||||
.execute(
|
||||
"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?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
||||
FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||
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],
|
||||
)
|
||||
.await?
|
||||
@@ -150,7 +379,7 @@ impl TenantRepository {
|
||||
self.validate_loaded_component(component_from_record(ComponentRecord {
|
||||
id: row.get(0),
|
||||
owner_user_id: owner_id,
|
||||
source_id: row.get(1),
|
||||
account_source_id: row.get(1),
|
||||
kind: row.get(2),
|
||||
name: row.get(3),
|
||||
settings: row.get(4),
|
||||
@@ -219,6 +448,44 @@ impl TenantRepository {
|
||||
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(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
@@ -343,7 +610,7 @@ fn component_from_record(record: ComponentRecord) -> Result<ComponentInstance, R
|
||||
Ok(ComponentInstance {
|
||||
id: record.id,
|
||||
owner_id: record.owner_user_id,
|
||||
source_id: record.source_id,
|
||||
account_source_id: record.account_source_id,
|
||||
kind: record.kind,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,9 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
additional_contexts:
|
||||
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
|
||||
libilibili: ../libilibili
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,noexec,nosuid,nodev
|
||||
@@ -15,7 +19,7 @@ services:
|
||||
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
||||
- ./config.toml:/app/config.toml:ro
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+19
-6
@@ -1,4 +1,4 @@
|
||||
# 洛星瓷多用户直播组件服务配置。
|
||||
# 多用户直播组件服务配置。
|
||||
#
|
||||
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件
|
||||
# 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要
|
||||
@@ -6,8 +6,8 @@
|
||||
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
|
||||
|
||||
# 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次
|
||||
# 创建的 system_admin,并且此后不可修改。后续账号的 room_id 由系统
|
||||
# 管理员创建邀请码时指定,不读取此字段。
|
||||
# 创建的 system_admin,作为该账户的初始房间。后续账号的初始 room_id
|
||||
# 由系统管理员创建邀请码时指定;注册后用户可在控制台自行切换。
|
||||
# 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。
|
||||
[connection]
|
||||
room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。
|
||||
@@ -87,8 +87,21 @@ request_timeout_seconds = 10
|
||||
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
|
||||
# 10000 = 10 元、100000 = 100 元。
|
||||
[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。
|
||||
font_scale = 140
|
||||
# 主题装饰线、边框与上下古风边缘的相对粗细;允许范围 50-300。
|
||||
decoration_line_weight = 160
|
||||
max_visible = 5
|
||||
collapse_after_seconds = 12
|
||||
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
|
||||
@@ -112,7 +125,7 @@ guard = true
|
||||
like = false
|
||||
share = false
|
||||
|
||||
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
|
||||
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
|
||||
# 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
|
||||
# libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
|
||||
[logging]
|
||||
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn"
|
||||
filter = "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user