Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Only the vendored blivedm crate source and its manifests are required to
|
|
||||||
# compile the local patch. Exclude its upstream docs, examples and tooling.
|
|
||||||
vendor/blivedm/*
|
|
||||||
!vendor/blivedm/Cargo.toml
|
|
||||||
!vendor/blivedm/Cargo.lock
|
|
||||||
!vendor/blivedm/LICENSE
|
|
||||||
!vendor/blivedm/src/
|
|
||||||
!vendor/blivedm/src/**
|
|
||||||
|
|||||||
@@ -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
|
WORKDIR /app/apps/overlay
|
||||||
COPY apps/overlay/package.json apps/overlay/package-lock.json ./
|
COPY apps/overlay/package.json apps/overlay/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
COPY resources /app/resources
|
||||||
COPY apps/overlay ./
|
COPY apps/overlay ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM rust:1.97-bookworm AS rust-build
|
FROM rust:1.97-bookworm AS rust-build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
|
COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/
|
||||||
COPY vendor/blivedm ./vendor/blivedm
|
# `libilibili` is a sibling checkout in local development. Compose exposes it
|
||||||
|
# as a named build context so the image always compiles the same local source
|
||||||
|
# without copying a second, drifting crate into this repository.
|
||||||
|
COPY --from=libilibili . /libilibili
|
||||||
COPY apps/server-rust/src ./apps/server-rust/src
|
COPY apps/server-rust/src ./apps/server-rust/src
|
||||||
COPY apps/server-rust/migrations ./apps/server-rust/migrations
|
COPY apps/server-rust/migrations ./apps/server-rust/migrations
|
||||||
|
COPY resources ./resources
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/app/target-cache \
|
--mount=type=cache,target=/app/target-cache \
|
||||||
CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \
|
CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \
|
||||||
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
|
cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# The upstream client logs full upstream HTTP responses at INFO, which can include
|
|
||||||
# account metadata and short-lived connection tokens. Keep application lifecycle
|
|
||||||
# logs while suppressing dependency INFO output by default.
|
|
||||||
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
|
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
|
||||||
COPY --from=web-build /app/apps/overlay/dist /app/web
|
COPY --from=web-build /app/apps/overlay/dist /app/web
|
||||||
EXPOSE 9719
|
EXPOSE 9719
|
||||||
|
|||||||
@@ -1,20 +1,56 @@
|
|||||||
# 洛星瓷直播组件服务
|
# 多用户直播组件服务
|
||||||
|
|
||||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
|
||||||
|
`gift_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)
|
||||||
|
- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
|
||||||
|
- [WebSocket 实时协议](docs/protocol.md)
|
||||||
|
- [租户、Secret 与部署安全](docs/security.md)
|
||||||
|
- [完整配置注释](config.toml.example)
|
||||||
|
|
||||||
## 后端结构
|
## 后端结构
|
||||||
|
|
||||||
`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出:
|
`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出:
|
||||||
|
|
||||||
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs`
|
||||||
- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
||||||
- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。
|
- `components.rs`
|
||||||
- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。
|
注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
||||||
|
- `realtime.rs` 按 component
|
||||||
|
UUID 建立独立广播通道;账户直播事件先按 owner 提供给全部启用组件,再由组件订阅筛选,副作用 handler 不依赖 OBS
|
||||||
|
WebSocket 是否在线。
|
||||||
|
- `auth.rs`、`repository.rs` 和 PostgreSQL
|
||||||
|
RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs`
|
||||||
|
从服务端会话推导 owner,不接受客户端自报 owner ID。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
源码目录需要保持为相邻 checkout,Compose 会把 `libilibili` 作为独立 BuildKit 上下文传入镜像:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source/
|
||||||
|
├── libilibili/
|
||||||
|
└── lxc-streamutils/
|
||||||
|
```
|
||||||
|
|
||||||
先复制配置并生成独立密钥:
|
先复制配置并生成独立密钥:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -25,13 +61,45 @@ openssl rand -hex 32 # 填入兼容字段 admin.session_secret
|
|||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。
|
Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config`
|
||||||
|
读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719`
|
||||||
|
直接暴露到公网。
|
||||||
|
|
||||||
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。
|
容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将
|
||||||
|
`config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置
|
||||||
|
`APP_UID` 与 `APP_GID`。
|
||||||
|
|
||||||
`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。
|
`[database].url`
|
||||||
|
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
|
||||||
|
RLS 约束,HTTP
|
||||||
|
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
|
||||||
|
`account_preferences` 表中。
|
||||||
|
|
||||||
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
|
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
|
||||||
|
Secret 与每个用户的 CookieCloud
|
||||||
|
Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。
|
||||||
|
|
||||||
|
## 开发与代码质量
|
||||||
|
|
||||||
|
Rust 使用仓库内 `rustfmt.toml`,前端和文档使用固定版本 Prettier。推荐提交前执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --manifest-path apps/server-rust/Cargo.toml
|
||||||
|
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
|
||||||
|
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
npm --prefix apps/overlay run format
|
||||||
|
npm --prefix apps/overlay run format:check
|
||||||
|
npm --prefix apps/overlay run docs:check
|
||||||
|
npm --prefix apps/overlay run build
|
||||||
|
|
||||||
|
docker compose config --quiet
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git
|
||||||
|
ignore 会排除依赖、构建产物、PNG/SVG 和包含真实 Secret 的 `config.toml`。`libilibili`
|
||||||
|
是独立 crate;协议解析能力应在该项目中维护,本仓库只维护领域事件适配。
|
||||||
|
|
||||||
## 首次初始化与登录
|
## 首次初始化与登录
|
||||||
|
|
||||||
@@ -41,7 +109,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
|
|||||||
https://danmaku.luoxingci.com/control/setup
|
https://danmaku.luoxingci.com/control/setup
|
||||||
```
|
```
|
||||||
|
|
||||||
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
填写系统管理员用户名和 `config.toml` 中的
|
||||||
|
`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
||||||
|
|
||||||
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
||||||
|
|
||||||
@@ -50,9 +119,16 @@ https://danmaku.luoxingci.com/control/setup
|
|||||||
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
|
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
|
||||||
- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。
|
- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。
|
||||||
|
|
||||||
系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
|
系统管理员可在 `/control/invitations`
|
||||||
|
创建和撤销邀请码。每个邀请码只能使用一次,并指定一个尚未占用的初始 Bilibili
|
||||||
|
`room_id`。受邀用户完成注册后,可以在 `/control/account`
|
||||||
|
自行切换到其他尚未被账户占用的直播间;切换不会改变组件、队列或 OBS 地址。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。
|
||||||
|
|
||||||
每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。
|
每个用户在 `/control/account`
|
||||||
|
配置账户级直播监听和自己的 CookieCloud 同步 UUID/Key 与密码。该配置只创建一条上游连接,并供账户下全部现有和未来组件使用。地址必须位于部署管理员配置的
|
||||||
|
`security.cookiecloud_allowed_hosts`
|
||||||
|
白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili
|
||||||
|
`SESSDATA`。
|
||||||
|
|
||||||
### 旧单用户配置迁移
|
### 旧单用户配置迁移
|
||||||
|
|
||||||
@@ -67,7 +143,8 @@ https://danmaku.luoxingci.com/control/setup
|
|||||||
|
|
||||||
## 必须使用 HTTPS
|
## 必须使用 HTTPS
|
||||||
|
|
||||||
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持
|
||||||
|
`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
@@ -103,36 +180,63 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置
|
||||||
|
`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
||||||
|
|
||||||
## 控制台 PWA
|
## 控制台 PWA
|
||||||
|
|
||||||
通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control` 会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 + TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和 `/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。
|
通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control`
|
||||||
|
会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 +
|
||||||
|
TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和
|
||||||
|
`/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。
|
||||||
|
|
||||||
PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*` 浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有 `/api/*` 响应始终在线直连且由服务端返回 `Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
|
PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*`
|
||||||
|
浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有
|
||||||
|
`/api/*` 响应始终在线直连且由服务端返回
|
||||||
|
`Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。
|
||||||
|
|
||||||
部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。
|
部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。
|
||||||
|
|
||||||
## 组件与 OBS 地址
|
## 组件与 OBS 地址
|
||||||
|
|
||||||
登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
登录 `/control/`
|
||||||
|
后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
`publicId` 是组件 UUID。`#token=...` 位于 URL fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 `/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
`publicId` 是组件 UUID。`#token=...` 位于 URL
|
||||||
|
fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向
|
||||||
|
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
|
||||||
|
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
||||||
|
|
||||||
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
每个账户会自动拥有不可删除的点歌姬、全屏礼物特效和礼物菜单组件。观众发送 `点歌 歌名` 加入队列,发送
|
||||||
|
`打分 1-5`
|
||||||
|
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
|
||||||
|
|
||||||
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
|
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性
|
||||||
|
`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。
|
||||||
|
|
||||||
|
礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
||||||
|
|
||||||
|
旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer
|
||||||
|
token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。
|
||||||
|
|
||||||
|
OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从
|
||||||
|
`360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。
|
||||||
|
|
||||||
## 弹幕姬展示
|
## 弹幕姬展示
|
||||||
|
|
||||||
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。
|
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。
|
||||||
|
|
||||||
花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
|
花边 SVG 已本地打包,来源及公版/CC0 许可记录在
|
||||||
|
`apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
|
||||||
|
|
||||||
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。
|
后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过
|
||||||
|
`[gifts]` 调整。
|
||||||
|
|
||||||
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。
|
普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的
|
||||||
|
`SESSDATA`
|
||||||
|
刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过
|
||||||
|
`[emoticons]` 调整。
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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。
|
||||||
|
|
||||||
|
## 文件职责
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
| ----------------------- | ---------------------------------------------------- |
|
||||||
|
| `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/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 翻译。
|
||||||
|
|
||||||
|
## 格式化与构建
|
||||||
|
|
||||||
|
```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/gift-menu.md)
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="__DEFAULT_LANGUAGE__">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#0a302f" />
|
<meta name="theme-color" content="#0a302f" />
|
||||||
<meta name="description" content="洛星瓷直播组件与 OBS 浏览器源控制台" />
|
<meta name="description" content="__APP_DESCRIPTION__" />
|
||||||
<meta name="application-name" content="洛星瓷直播云台" />
|
<meta name="application-name" content="__APP_NAME__" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="直播云台" />
|
<meta name="apple-mobile-web-app-title" content="__APP_SHORT_NAME__" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
|
||||||
<title>洛星瓷直播云台</title>
|
<title>__APP_NAME__</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+31
@@ -15,6 +15,8 @@
|
|||||||
"@types/react": "19.1.10",
|
"@types/react": "19.1.10",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.7",
|
||||||
"@vitejs/plugin-react": "5.0.2",
|
"@vitejs/plugin-react": "5.0.2",
|
||||||
|
"prettier": "3.9.5",
|
||||||
|
"smol-toml": "1.4.2",
|
||||||
"typescript": "5.9.2",
|
"typescript": "5.9.2",
|
||||||
"vite": "7.3.6"
|
"vite": "7.3.6"
|
||||||
}
|
}
|
||||||
@@ -1573,6 +1575,22 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
|
||||||
|
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||||
@@ -1665,6 +1683,19 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/smol-toml": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cyyynthia"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -2,7 +2,24 @@
|
|||||||
"name": "lxc-stream-overlay",
|
"name": "lxc-stream-overlay",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
|
"scripts": {
|
||||||
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
|
"build": "tsc -b && vite build",
|
||||||
"devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" }
|
"dev": "vite",
|
||||||
|
"docs:check": "node scripts/check-doc-links.mjs ../..",
|
||||||
|
"format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml",
|
||||||
|
"format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"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
|
- License: CC0 1.0 / public domain
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
|
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
|
||||||
Paul Bürck”.
|
|
||||||
|
|
||||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
|
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
|
||||||
- Original artist: Paul Bürck, 1899
|
- Original artist: Paul Bürck, 1899
|
||||||
@@ -16,8 +15,7 @@ Paul Bürck”.
|
|||||||
- License: original public domain; SVG adaptation CC0 1.0
|
- License: original public domain; SVG adaptation CC0 1.0
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
|
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
|
||||||
Paul Bürck”.
|
|
||||||
|
|
||||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
|
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
|
||||||
- Original artist: Paul Bürck, 1899
|
- Original artist: Paul Bürck, 1899
|
||||||
@@ -25,5 +23,8 @@ Paul Bürck”.
|
|||||||
- License: original public domain; SVG adaptation CC0 1.0
|
- License: original public domain; SVG adaptation CC0 1.0
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
The overlay uses these SVGs as recolorable CSS masks inside message cards and
|
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
|
||||||
bundles them locally, so OBS never needs to load the source websites.
|
so OBS never needs to load the source websites.
|
||||||
|
|
||||||
|
`moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by
|
||||||
|
the Moonlit Water danmaku, song-request, and gift-menu components.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */
|
/**
|
||||||
|
* Network policy for the `/control/` application shell.
|
||||||
|
*
|
||||||
|
* `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a
|
||||||
|
* network-first strategy so fresh HTML can reference the newest hashed bundle;
|
||||||
|
* known static assets use cache-first. The worker never handles API, WebSocket
|
||||||
|
* or OBS routes because they are outside both this fetch policy and its scope.
|
||||||
|
*/
|
||||||
const buildId = '__PWA_BUILD_ID__'
|
const buildId = '__PWA_BUILD_ID__'
|
||||||
const cacheName = `lxc-control-v2-shell-${buildId}`
|
const cacheName = `lxc-control-v2-shell-${buildId}`
|
||||||
const shellPath = '/control/'
|
const shellPath = '/control/'
|
||||||
@@ -24,8 +31,9 @@ async function precacheShell() {
|
|||||||
|
|
||||||
const html = await response.clone().text()
|
const html = await response.clone().text()
|
||||||
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
|
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
|
||||||
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)]
|
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
|
||||||
.map(match => match[1])
|
match => match[1],
|
||||||
|
)
|
||||||
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
event.waitUntil((async () => {
|
event.waitUntil(
|
||||||
const names = await caches.keys()
|
(async () => {
|
||||||
await Promise.all(names
|
const names = await caches.keys()
|
||||||
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
|
await Promise.all(
|
||||||
.map(name => caches.delete(name)))
|
names
|
||||||
await self.clients.claim()
|
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
|
||||||
})())
|
.map(name => caches.delete(name)),
|
||||||
|
)
|
||||||
|
await self.clients.claim()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('message', event => {
|
self.addEventListener('message', event => {
|
||||||
@@ -52,9 +64,11 @@ function isControlShell(pathname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStaticAsset(pathname) {
|
function isStaticAsset(pathname) {
|
||||||
return pathname.startsWith('/assets/')
|
return (
|
||||||
|| pathname.startsWith('/pwa/')
|
pathname.startsWith('/assets/') ||
|
||||||
|| pathname === '/control/manifest.webmanifest'
|
pathname.startsWith('/pwa/') ||
|
||||||
|
pathname === '/control/manifest.webmanifest'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigationResponse(request) {
|
async function navigationResponse(request) {
|
||||||
@@ -62,16 +76,20 @@ async function navigationResponse(request) {
|
|||||||
const pathname = new URL(request.url).pathname
|
const pathname = new URL(request.url).pathname
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request)
|
const response = await fetch(request)
|
||||||
if (shellRoutes.includes(pathname)
|
if (
|
||||||
&& response.ok
|
shellRoutes.includes(pathname) &&
|
||||||
&& response.headers.get('content-type')?.includes('text/html')) {
|
response.ok &&
|
||||||
|
response.headers.get('content-type')?.includes('text/html')
|
||||||
|
) {
|
||||||
await cache.put(pathname, response.clone())
|
await cache.put(pathname, response.clone())
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
} catch {
|
} catch {
|
||||||
return (await cache.match(request, { ignoreSearch: true }))
|
return (
|
||||||
|| (await cache.match(shellPath))
|
(await cache.match(request, { ignoreSearch: true })) ||
|
||||||
|| Response.error()
|
(await cache.match(shellPath)) ||
|
||||||
|
Response.error()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.`)
|
||||||
|
}
|
||||||
+341
-76
@@ -1,23 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Same-origin API client and defensive wire-format normalizers.
|
||||||
|
*
|
||||||
|
* The backend is authoritative and may evolve response wrappers independently
|
||||||
|
* from a deployed frontend. Normalizers accept those compatible wrappers while
|
||||||
|
* producing strict UI models. Mutations are refused while offline and are never
|
||||||
|
* queued for background replay, which avoids applying an old user's action
|
||||||
|
* after logout or tenant switching.
|
||||||
|
*/
|
||||||
import type {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
ComponentSummary,
|
ComponentSummary,
|
||||||
CookieCloudSource,
|
CookieCloudSource,
|
||||||
|
GiftEffectSettings,
|
||||||
|
GiftCatalogItem,
|
||||||
|
GiftMenuItem,
|
||||||
|
GiftMenuSettings,
|
||||||
Invitation,
|
Invitation,
|
||||||
OverlaySettings,
|
OverlaySettings,
|
||||||
Session,
|
Session,
|
||||||
|
SongRequestItem,
|
||||||
|
SongRequestPage,
|
||||||
|
SongRequestSettings,
|
||||||
TotpEnrollment,
|
TotpEnrollment,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { normalizeThemeId } from './themes'
|
||||||
|
import { normalizeFontFamilyId } from './typography'
|
||||||
|
import { normalizeGiftEffectThemeId } from './giftThemes'
|
||||||
|
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
|
||||||
|
import {
|
||||||
|
defaultGiftEffectSettings,
|
||||||
|
defaultGiftMenuSettings,
|
||||||
|
defaultOverlaySettings,
|
||||||
|
defaultSongRequestSettings,
|
||||||
|
} from './types'
|
||||||
|
import { currentLanguage, hasTranslation, translate } from './i18n'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number
|
readonly status: number
|
||||||
readonly code?: string
|
readonly code?: string
|
||||||
|
readonly messageKey?: string
|
||||||
readonly fieldErrors?: Record<string, string>
|
readonly fieldErrors?: Record<string, string>
|
||||||
|
|
||||||
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
|
constructor(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
code?: string,
|
||||||
|
messageKey?: string,
|
||||||
|
fieldErrors?: Record<string, string>,
|
||||||
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.status = status
|
this.status = status
|
||||||
this.code = code
|
this.code = code
|
||||||
|
this.messageKey = messageKey
|
||||||
this.fieldErrors = fieldErrors
|
this.fieldErrors = fieldErrors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,11 +68,13 @@ async function parseResponse(response: Response): Promise<unknown> {
|
|||||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const method = (init.method ?? 'GET').toUpperCase()
|
const method = (init.method ?? 'GET').toUpperCase()
|
||||||
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
|
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
|
||||||
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
|
throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.offline')
|
||||||
}
|
}
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
|
if (init.body != null && !headers.has('content-type'))
|
||||||
|
headers.set('content-type', 'application/json')
|
||||||
headers.set('accept', 'application/json')
|
headers.set('accept', 'application/json')
|
||||||
|
headers.set('accept-language', currentLanguage())
|
||||||
|
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
...init,
|
...init,
|
||||||
@@ -49,13 +86,27 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
|
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
|
||||||
window.dispatchEvent(new Event('lxc:session-expired'))
|
window.dispatchEvent(new Event('lxc:session-expired'))
|
||||||
}
|
}
|
||||||
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||||
|
const code = typeof error.code === 'string' ? error.code : undefined
|
||||||
|
const messageKey =
|
||||||
|
typeof error.messageKey === 'string'
|
||||||
|
? error.messageKey
|
||||||
|
: typeof error.message_key === 'string'
|
||||||
|
? error.message_key
|
||||||
|
: code
|
||||||
|
? `api.error.${code}`
|
||||||
|
: undefined
|
||||||
|
const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
|
localized ??
|
||||||
typeof error.code === 'string' ? error.code : undefined,
|
String(
|
||||||
|
error.message ?? error.error ?? translate('api.http_error', { status: response.status }),
|
||||||
|
),
|
||||||
|
code,
|
||||||
|
messageKey,
|
||||||
error.fieldErrors && typeof error.fieldErrors === 'object'
|
error.fieldErrors && typeof error.fieldErrors === 'object'
|
||||||
? error.fieldErrors as Record<string, string>
|
? (error.fieldErrors as Record<string, string>)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,24 +122,29 @@ export function json(method: string, body?: unknown): RequestInit {
|
|||||||
|
|
||||||
function object(value: unknown): Record<string, unknown> {
|
function object(value: unknown): Record<string, unknown> {
|
||||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? (value as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSession(value: unknown): Session {
|
export function normalizeSession(value: unknown): Session {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const candidate = root.user
|
const candidate =
|
||||||
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
|
root.user ??
|
||||||
|
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
|
||||||
|
? root
|
||||||
|
: null)
|
||||||
const raw = object(candidate)
|
const raw = object(candidate)
|
||||||
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
|
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
|
||||||
const user: AuthUser | null = hasUser ? {
|
const user: AuthUser | null = hasUser
|
||||||
id: String(raw.id ?? ''),
|
? {
|
||||||
username: String(raw.username ?? ''),
|
id: String(raw.id ?? ''),
|
||||||
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
username: String(raw.username ?? ''),
|
||||||
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
||||||
role: typeof raw.role === 'string' ? raw.role : 'user',
|
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
||||||
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
role: typeof raw.role === 'string' ? raw.role : 'user',
|
||||||
} : null
|
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
||||||
|
}
|
||||||
|
: null
|
||||||
return { user, setupRequired: root.setupRequired === true }
|
return { user, setupRequired: root.setupRequired === true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,19 +154,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
|||||||
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
|
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
|
||||||
return {
|
return {
|
||||||
enrollmentToken: String(enrollmentToken ?? ''),
|
enrollmentToken: String(enrollmentToken ?? ''),
|
||||||
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
|
qrSvg:
|
||||||
qrDataUrl: typeof totp.qrDataUrl === 'string'
|
typeof totp.qrSvg === 'string'
|
||||||
? totp.qrDataUrl
|
? totp.qrSvg
|
||||||
: typeof totp.qrCodeDataUrl === 'string'
|
: typeof root.qrSvg === 'string'
|
||||||
? totp.qrCodeDataUrl
|
? root.qrSvg
|
||||||
: typeof root.qrDataUrl === 'string'
|
: undefined,
|
||||||
? root.qrDataUrl
|
qrDataUrl:
|
||||||
|
typeof totp.qrDataUrl === 'string'
|
||||||
|
? totp.qrDataUrl
|
||||||
|
: typeof totp.qrCodeDataUrl === 'string'
|
||||||
|
? totp.qrCodeDataUrl
|
||||||
|
: typeof root.qrDataUrl === 'string'
|
||||||
|
? root.qrDataUrl
|
||||||
|
: undefined,
|
||||||
|
otpauthUri:
|
||||||
|
typeof totp.otpauthUri === 'string'
|
||||||
|
? totp.otpauthUri
|
||||||
|
: typeof root.otpauthUri === 'string'
|
||||||
|
? root.otpauthUri
|
||||||
: undefined,
|
: undefined,
|
||||||
otpauthUri: typeof totp.otpauthUri === 'string'
|
|
||||||
? totp.otpauthUri
|
|
||||||
: typeof root.otpauthUri === 'string'
|
|
||||||
? root.otpauthUri
|
|
||||||
: undefined,
|
|
||||||
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
|
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
|
||||||
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
|
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
|
||||||
}
|
}
|
||||||
@@ -119,81 +182,283 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
|||||||
export function normalizeRecoveryCodes(value: unknown): string[] {
|
export function normalizeRecoveryCodes(value: unknown): string[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const codes = root.recoveryCodes ?? object(root.recovery).codes
|
const codes = root.recoveryCodes ?? object(root.recovery).codes
|
||||||
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : []
|
return Array.isArray(codes)
|
||||||
|
? codes.filter((code): code is string => typeof code === 'string')
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
||||||
return list.map((entry) => {
|
return list
|
||||||
const item = object(entry)
|
.map(entry => {
|
||||||
return {
|
const item = object(entry)
|
||||||
id: String(item.id ?? ''),
|
return {
|
||||||
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
id: String(item.id ?? ''),
|
||||||
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
||||||
name: String(item.name ?? '弹幕姬'),
|
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
||||||
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
name: String(item.name ?? translate('components.danmaku_type')),
|
||||||
settings: item.settings as OverlaySettings | undefined,
|
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
||||||
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : 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 {
|
export function normalizeSettings(value: unknown): OverlaySettings {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
return (root.settings ?? value) as OverlaySettings
|
const settings = object(root.settings ?? value)
|
||||||
|
return {
|
||||||
|
...defaultOverlaySettings,
|
||||||
|
...(settings as Partial<OverlaySettings>),
|
||||||
|
themeId: normalizeThemeId(settings.themeId),
|
||||||
|
fontFamily: normalizeFontFamilyId(settings.fontFamily),
|
||||||
|
viewerColor: normalizeOptionalHexColor(settings.viewerColor),
|
||||||
|
danmakuColor: normalizeOptionalHexColor(settings.danmakuColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalHexColor(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const normalized = value.trim().toUpperCase()
|
||||||
|
return /^#[0-9A-F]{6}$/.test(normalized) ? normalized : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSongRequestSettings(value: unknown): SongRequestSettings {
|
||||||
|
const root = object(value)
|
||||||
|
const settings = object(root.settings ?? value)
|
||||||
|
return {
|
||||||
|
...defaultSongRequestSettings,
|
||||||
|
...(settings as Partial<SongRequestSettings>),
|
||||||
|
themeId: normalizeThemeId(settings.themeId),
|
||||||
|
fontFamily: normalizeFontFamilyId(settings.fontFamily),
|
||||||
|
requesterColor: normalizeOptionalHexColor(settings.requesterColor),
|
||||||
|
songTitleColor: normalizeOptionalHexColor(settings.songTitleColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings {
|
||||||
|
const root = object(value)
|
||||||
|
const settings = object(root.settings ?? value)
|
||||||
|
const normal = object(settings.normal)
|
||||||
|
const high = object(settings.high)
|
||||||
|
const featured = object(settings.featured)
|
||||||
|
return {
|
||||||
|
...defaultGiftEffectSettings,
|
||||||
|
...(settings as Partial<GiftEffectSettings>),
|
||||||
|
themeId: normalizeGiftEffectThemeId(settings.themeId),
|
||||||
|
fontFamily: normalizeFontFamilyId(settings.fontFamily),
|
||||||
|
normal: { ...defaultGiftEffectSettings.normal, ...normal },
|
||||||
|
high: { ...defaultGiftEffectSettings.high, ...high },
|
||||||
|
featured: { ...defaultGiftEffectSettings.featured, ...featured },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
averageScore:
|
||||||
|
typeof item.averageScore === 'number' || item.averageScore === null
|
||||||
|
? item.averageScore
|
||||||
|
: undefined,
|
||||||
|
ratingCount: Number(item.ratingCount ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSongRequestPage(value: unknown): SongRequestPage {
|
||||||
|
const root = object(value)
|
||||||
|
const current = normalizeSongRequestItem(root.current)
|
||||||
|
const summary = object(root.summary)
|
||||||
|
return {
|
||||||
|
revision: Number(root.revision ?? 0),
|
||||||
|
current: current ?? null,
|
||||||
|
items: (Array.isArray(root.items) ? root.items : [])
|
||||||
|
.map(normalizeSongRequestItem)
|
||||||
|
.filter((item): item is SongRequestItem => Boolean(item)),
|
||||||
|
nextCursor: typeof root.nextCursor === 'number' ? root.nextCursor : null,
|
||||||
|
summary: {
|
||||||
|
activeCount: Number(summary.activeCount ?? 0),
|
||||||
|
queuedCount: Number(summary.queuedCount ?? 0),
|
||||||
|
completedCount: Number(summary.completedCount ?? 0),
|
||||||
|
cancelledCount: Number(summary.cancelledCount ?? 0),
|
||||||
|
ratingCount: Number(summary.ratingCount ?? 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSource(value: unknown): CookieCloudSource {
|
export function normalizeSource(value: unknown): CookieCloudSource {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const source = object(root.source ?? root)
|
const source = object(root.source ?? root)
|
||||||
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud)
|
const cookieCloud = object(
|
||||||
|
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
|
||||||
|
)
|
||||||
const status = object(source.status)
|
const status = object(source.status)
|
||||||
return {
|
return {
|
||||||
roomId: String(source.roomId ?? root.roomId ?? ''),
|
roomId: String(source.roomId ?? root.roomId ?? ''),
|
||||||
cookieCloud: {
|
cookieCloud: {
|
||||||
host: String(cookieCloud.host ?? ''),
|
host: String(cookieCloud.host ?? ''),
|
||||||
key: '',
|
key: '',
|
||||||
keyConfigured: cookieCloud.keyConfigured === true
|
keyConfigured:
|
||||||
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
cookieCloud.keyConfigured === true ||
|
||||||
passwordConfigured: cookieCloud.passwordConfigured === true
|
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
||||||
|| cookieCloud.configured === true
|
passwordConfigured:
|
||||||
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
cookieCloud.passwordConfigured === true ||
|
||||||
|
cookieCloud.configured === true ||
|
||||||
|
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
||||||
},
|
},
|
||||||
connected: typeof source.connected === 'boolean'
|
connected:
|
||||||
? source.connected
|
typeof source.connected === 'boolean'
|
||||||
: typeof status.connected === 'boolean'
|
? source.connected
|
||||||
? status.connected
|
: typeof status.connected === 'boolean'
|
||||||
: undefined,
|
? status.connected
|
||||||
detail: typeof source.detail === 'string'
|
: undefined,
|
||||||
? source.detail
|
detail:
|
||||||
: typeof status.detail === 'string'
|
typeof source.detail === 'string'
|
||||||
? status.detail
|
? source.detail
|
||||||
: undefined,
|
: typeof status.detail === 'string'
|
||||||
|
? status.detail
|
||||||
|
: undefined,
|
||||||
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
|
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInvitations(value: unknown): Invitation[] {
|
export function normalizeInvitations(value: unknown): Invitation[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
|
const list = Array.isArray(value)
|
||||||
return list.map((entry) => {
|
? value
|
||||||
const item = object(entry)
|
: Array.isArray(root.invitations)
|
||||||
return {
|
? root.invitations
|
||||||
id: String(item.id ?? ''),
|
: []
|
||||||
code: typeof item.code === 'string' ? item.code : undefined,
|
return list
|
||||||
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
|
.map(entry => {
|
||||||
roomId: String(item.roomId ?? ''),
|
const item = object(entry)
|
||||||
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
return {
|
||||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
|
id: String(item.id ?? ''),
|
||||||
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
code: typeof item.code === 'string' ? item.code : undefined,
|
||||||
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
|
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
|
||||||
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
|
roomId: String(item.roomId ?? ''),
|
||||||
}
|
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
||||||
}).filter(item => item.id)
|
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
|
||||||
|
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
||||||
|
consumedAt:
|
||||||
|
typeof item.consumedAt === 'string' || item.consumedAt === null
|
||||||
|
? item.consumedAt
|
||||||
|
: undefined,
|
||||||
|
revokedAt:
|
||||||
|
typeof item.revokedAt === 'string' || item.revokedAt === null
|
||||||
|
? item.revokedAt
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
|
export function errorMessage(error: unknown, fallback = translate('common.failed')): string {
|
||||||
|
if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey))
|
||||||
|
return translate(error.messageKey)
|
||||||
return error instanceof Error && error.message ? error.message : fallback
|
return error instanceof Error && error.message ? error.message : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-80
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Passwordless login and one-time TOTP enrollment screens.
|
||||||
|
*
|
||||||
|
* QR material, manual keys and recovery codes exist only in React memory and
|
||||||
|
* are dropped as soon as their enrollment phase completes. PWA activation is
|
||||||
|
* blocked while those values or partially completed forms are visible so an
|
||||||
|
* update cannot erase information that the server will not reveal again.
|
||||||
|
*/
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { FormEvent, ReactNode } from 'react'
|
import type { FormEvent, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -9,19 +17,31 @@ import {
|
|||||||
normalizeRecoveryCodes,
|
normalizeRecoveryCodes,
|
||||||
} from './api'
|
} from './api'
|
||||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||||
|
import { LanguageSelect, useI18n } from './i18n'
|
||||||
import type { TotpEnrollment } from './types'
|
import type { TotpEnrollment } from './types'
|
||||||
|
|
||||||
function AuthShell({ eyebrow, title, children, footer }: {
|
function AuthShell({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
eyebrow: string
|
eyebrow: string
|
||||||
title: string
|
title: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<section className="auth-card jade-panel">
|
<section className="auth-card jade-panel">
|
||||||
<PwaControls />
|
<div className="auth-toolbar">
|
||||||
<div className="auth-mark" aria-hidden="true">星</div>
|
<LanguageSelect />
|
||||||
|
<PwaControls />
|
||||||
|
</div>
|
||||||
|
<div className="auth-mark" aria-hidden="true">
|
||||||
|
{t('common.star')}
|
||||||
|
</div>
|
||||||
<p className="eyebrow">{eyebrow}</p>
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{children}
|
{children}
|
||||||
@@ -32,33 +52,41 @@ function AuthShell({ eyebrow, title, children, footer }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
||||||
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
|
if (enrollment.qrSvg)
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
|
||||||
return undefined
|
return undefined
|
||||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-enrollment">
|
<div className="totp-enrollment">
|
||||||
<div className="totp-qr">
|
<div className="totp-qr">
|
||||||
{source
|
{source ? (
|
||||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
<img src={source} alt={t('auth.totp_qr_alt')} />
|
||||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
) : (
|
||||||
|
<span>{t('auth.qr_unavailable')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="totp-copy">
|
<div className="totp-copy">
|
||||||
<h2>绑定动态验证器</h2>
|
<h2>{t('auth.bind_authenticator')}</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
|
<li>{t('auth.totp_step_scan')}</li>
|
||||||
<li>若无法扫码,选择“输入设置密钥”。</li>
|
<li>{t('auth.totp_step_manual')}</li>
|
||||||
<li>输入应用中出现的 6 位动态码完成绑定。</li>
|
<li>{t('auth.totp_step_confirm')}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<label>
|
<label>
|
||||||
手工设置密钥
|
{t('auth.manual_key')}
|
||||||
<div className="secret-row">
|
<div className="secret-row">
|
||||||
<code>{enrollment.manualKey || '未提供'}</code>
|
<code>{enrollment.manualKey || t('common.not_provided')}</code>
|
||||||
{enrollment.manualKey && (
|
{enrollment.manualKey && (
|
||||||
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
|
<button
|
||||||
复制
|
type="button"
|
||||||
|
className="text-button"
|
||||||
|
onClick={() => void copyToClipboard(enrollment.manualKey)}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -69,58 +97,71 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const text = codes.join('\n')
|
const text = codes.join('\n')
|
||||||
const download = () => {
|
const download = () => {
|
||||||
const blob = new Blob([
|
const blob = new Blob(
|
||||||
'洛星瓷直播组件恢复码\n',
|
[`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
|
||||||
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
|
{ type: 'text/plain;charset=utf-8' },
|
||||||
text,
|
)
|
||||||
'\n',
|
|
||||||
], { type: 'text/plain;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
anchor.download = 'luoxingci-recovery-codes.txt'
|
anchor.download = 'live-component-recovery-codes.txt'
|
||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
<AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
|
||||||
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
|
<p className="auth-lead">{t('auth.recovery_description')}</p>
|
||||||
{codes.length > 0
|
{codes.length > 0 ? (
|
||||||
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
|
<div className="recovery-grid">
|
||||||
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
|
{codes.map(code => (
|
||||||
|
<code key={code}>{code}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
|
||||||
|
)}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
{codes.length > 0 && (
|
{codes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
<button
|
||||||
{copied ? '已复制' : '复制全部'}
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => void copyToClipboard(text).then(setCopied)}
|
||||||
|
>
|
||||||
|
{copied ? t('common.copied') : t('auth.copy_all')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="secondary" onClick={download}>
|
||||||
|
{t('auth.download_text')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="secondary" onClick={download}>下载文本</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
<button type="button" onClick={onContinue}>
|
||||||
|
{t('auth.recovery_saved')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
export function LoginPage({
|
||||||
|
onAuthenticated,
|
||||||
|
setupRequired,
|
||||||
|
}: {
|
||||||
onAuthenticated: () => Promise<void>
|
onAuthenticated: () => Promise<void>
|
||||||
setupRequired: boolean
|
setupRequired: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [totpCode, setTotpCode] = useState('')
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
usePwaUpdateBlocker(
|
usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
|
||||||
'login-form',
|
|
||||||
'完成或清空正在填写的登录表单',
|
|
||||||
busy || Boolean(username || totpCode),
|
|
||||||
)
|
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -132,7 +173,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
await onAuthenticated()
|
await onAuthenticated()
|
||||||
location.assign('/control/')
|
location.assign('/control/')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(errorMessage(reason, '用户名或动态验证码不正确'))
|
setError(errorMessage(reason, t('auth.login_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -140,20 +181,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
eyebrow="洛星瓷直播组件"
|
eyebrow={t('auth.product_eyebrow')}
|
||||||
title="回到你的云台"
|
title={t('auth.login_title')}
|
||||||
footer={(
|
footer={
|
||||||
<p>
|
<p>
|
||||||
{setupRequired
|
{setupRequired ? (
|
||||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
<>
|
||||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
{t('auth.first_deployment')} <a href={authRoute('setup')}>{t('auth.create_admin')}</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('auth.have_invitation')}{' '}
|
||||||
|
<a href={authRoute('register')}>{t('auth.register_account')}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
<p className="auth-lead">{t('auth.passwordless_description')}</p>
|
||||||
<form className="stack-form" onSubmit={submit}>
|
<form className="stack-form" onSubmit={submit}>
|
||||||
<label>
|
<label>
|
||||||
用户名
|
{t('auth.username')}
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
@@ -163,7 +211,7 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
|
{useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||||
@@ -171,11 +219,15 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||||
maxLength={useRecoveryCode ? 64 : 6}
|
maxLength={useRecoveryCode ? 64 : 6}
|
||||||
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={event => setTotpCode(useRecoveryCode
|
onChange={event =>
|
||||||
? event.target.value.trimStart().slice(0, 64)
|
setTotpCode(
|
||||||
: event.target.value.replace(/\D/g, '').slice(0, 6))}
|
useRecoveryCode
|
||||||
|
? event.target.value.trimStart().slice(0, 64)
|
||||||
|
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -186,19 +238,27 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
setTotpCode('')
|
setTotpCode('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
{useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
|
||||||
</button>
|
</button>
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
export function EnrollmentPage({
|
||||||
|
mode,
|
||||||
|
onAuthenticated,
|
||||||
|
}: {
|
||||||
mode: 'setup' | 'register'
|
mode: 'setup' | 'register'
|
||||||
onAuthenticated: () => Promise<void>
|
onAuthenticated: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
||||||
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
@@ -211,10 +271,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
const isSetup = mode === 'setup'
|
const isSetup = mode === 'setup'
|
||||||
usePwaUpdateBlocker(
|
usePwaUpdateBlocker(
|
||||||
'totp-enrollment',
|
'totp-enrollment',
|
||||||
enrollment || recoveryCodes
|
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
|
||||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
busy ||
|
||||||
: '完成或清空正在填写的注册表单',
|
Boolean(
|
||||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = async (event: FormEvent) => {
|
const start = async (event: FormEvent) => {
|
||||||
@@ -231,7 +292,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const next = normalizeEnrollment(payload)
|
const next = normalizeEnrollment(payload)
|
||||||
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
|
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
|
||||||
setEnrollment(next)
|
setEnrollment(next)
|
||||||
setBootstrapPassword('')
|
setBootstrapPassword('')
|
||||||
if (!isSetup) {
|
if (!isSetup) {
|
||||||
@@ -239,7 +300,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
history.replaceState(null, '', authRoute('register'))
|
history.replaceState(null, '', authRoute('register'))
|
||||||
}
|
}
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(errorMessage(reason, '无法开始安全注册流程'))
|
setError(errorMessage(reason, t('auth.registration_start_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -253,7 +314,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
try {
|
try {
|
||||||
const payload = await api<unknown>(
|
const payload = await api<unknown>(
|
||||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
||||||
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
|
json('POST', {
|
||||||
|
enrollmentToken: enrollment.enrollmentToken,
|
||||||
|
code: totpCode,
|
||||||
|
language,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
setTotpCode('')
|
setTotpCode('')
|
||||||
// Drop QR/manual-key material from React state as soon as enrollment is
|
// Drop QR/manual-key material from React state as soon as enrollment is
|
||||||
@@ -261,7 +326,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
setEnrollment(undefined)
|
setEnrollment(undefined)
|
||||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
|
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -276,11 +341,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
|
|
||||||
if (enrollment) {
|
if (enrollment) {
|
||||||
return (
|
return (
|
||||||
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
|
<AuthShell
|
||||||
|
eyebrow={isSetup ? t('auth.setup_step_two') : t('auth.register_step_two')}
|
||||||
|
title={t('auth.require_totp')}
|
||||||
|
>
|
||||||
<TotpQr enrollment={enrollment} />
|
<TotpQr enrollment={enrollment} />
|
||||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||||
<label>
|
<label>
|
||||||
验证器中的 6 位动态码
|
{t('auth.authenticator_code')}
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -294,8 +362,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button disabled={busy || totpCode.length !== 6}>
|
||||||
|
{busy ? t('auth.confirming') : t('auth.confirm_create')}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
@@ -303,19 +377,21 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
|
||||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
|
||||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
footer={
|
||||||
|
<p>
|
||||||
|
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="auth-lead">
|
<p className="auth-lead">
|
||||||
{isSetup
|
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
|
||||||
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
|
|
||||||
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
|
|
||||||
</p>
|
</p>
|
||||||
<form className="stack-form" onSubmit={start}>
|
<form className="stack-form" onSubmit={start}>
|
||||||
{!isSetup && (
|
{!isSetup && (
|
||||||
<label>
|
<label>
|
||||||
邀请码
|
{t('auth.invitation')}
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
autoFocus={!inviteFromFragment}
|
autoFocus={!inviteFromFragment}
|
||||||
@@ -326,7 +402,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
用户名
|
{t('auth.username')}
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
||||||
@@ -339,7 +415,7 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
</label>
|
</label>
|
||||||
{isSetup && (
|
{isSetup && (
|
||||||
<label>
|
<label>
|
||||||
一次性初始化口令
|
{t('auth.bootstrap_password')}
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
@@ -347,11 +423,15 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
value={bootstrapPassword}
|
value={bootstrapPassword}
|
||||||
onChange={event => setBootstrapPassword(event.target.value)}
|
onChange={event => setBootstrapPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
<small>{t('auth.bootstrap_help')}</small>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
|
|||||||
+1045
-190
File diff suppressed because it is too large
Load Diff
+2297
-242
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,793 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(--gift-night);
|
||||||
|
animation: var(--gift-motion-guard, gift-guard-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: gift-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(--gift-cyan) 58%, var(--gift-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(--gift-cyan));
|
||||||
|
animation: var(--gift-motion-star, gift-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: gift-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-copy span {
|
||||||
|
color: var(--gift-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(--gift-rose);
|
||||||
|
font-size: clamp(16px, 2.3vw, 44px);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-low-motion .meteor-spark,
|
||||||
|
.gift-low-motion .guard-nebula,
|
||||||
|
.gift-low-motion .guard-halo {
|
||||||
|
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-meteor-sparkle {
|
||||||
|
from {
|
||||||
|
opacity: 0.25;
|
||||||
|
transform: rotate(0) scale(0.55);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(45deg) scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gift-guard-reveal {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
8%,
|
||||||
|
86% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gift-star-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.08;
|
||||||
|
transform: rotate(0) scale(0.45);
|
||||||
|
}
|
||||||
|
48% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(50deg) scale(1.32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gift-nebula-turn {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gift-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.gift-meteor {
|
||||||
|
animation-duration: max(var(--meteor-duration), 6s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meteor-spark,
|
||||||
|
.guard-nebula,
|
||||||
|
.guard-stars i,
|
||||||
|
.guard-halo {
|
||||||
|
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,
|
||||||
|
.guard-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reuse the membership DOM but make its backdrop transparent and turn the
|
||||||
|
generic nebula into a quiet, full-scene moon-and-water celebration. */
|
||||||
|
.gift-theme-moonlit-water .guard-celebration {
|
||||||
|
color: #eadfc3;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-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));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-theme-moonlit-water .guard-halo i {
|
||||||
|
border-color: rgba(203, 219, 184, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-theme-moonlit-water .guard-copy {
|
||||||
|
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-theme-moonlit-water .guard-copy span,
|
||||||
|
.gift-theme-moonlit-water .guard-copy b {
|
||||||
|
color: #e7c982;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gift-theme-moonlit-water .guard-copy strong {
|
||||||
|
color: #eadfc3;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moonlit-guard-water {
|
||||||
|
50% {
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: scale(1.04) translateY(-1.4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
/** Full-viewport gift meteor and guard celebration renderer. */
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { normalizeGiftEffectSettings } from './api'
|
||||||
|
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
|
||||||
|
guardName?: string
|
||||||
|
price?: number
|
||||||
|
settings?: Partial<GiftEffectSettings>
|
||||||
|
}
|
||||||
|
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
|
||||||
|
type VisualEffect = {
|
||||||
|
id: string
|
||||||
|
kind: 'gift' | 'guard'
|
||||||
|
payload: EffectPayload
|
||||||
|
receivedAt: number
|
||||||
|
expiresAt: number
|
||||||
|
/** 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 | 'guard'
|
||||||
|
|
||||||
|
/** 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: 'preview', name: translate('gift.preview.viewer') }
|
||||||
|
if (mode === 'guard') {
|
||||||
|
return {
|
||||||
|
id: `preview-guard-${nonce}`,
|
||||||
|
type: 'live.guard.buy',
|
||||||
|
payload: { viewer, guardName: translate('gift.preview.guard'), quantity: 1, price: 198_000 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
guardDuration: number,
|
||||||
|
previewTier?: GiftTier,
|
||||||
|
): VisualEffect | undefined {
|
||||||
|
const kind =
|
||||||
|
envelope.type === 'live.gift'
|
||||||
|
? 'gift'
|
||||||
|
: envelope.type === 'live.guard.buy'
|
||||||
|
? 'guard'
|
||||||
|
: undefined
|
||||||
|
if (!kind) return undefined
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
id: envelope.id,
|
||||||
|
kind,
|
||||||
|
payload: envelope.payload ?? {},
|
||||||
|
receivedAt: now,
|
||||||
|
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
|
||||||
|
previewTier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGiftEffects(
|
||||||
|
preview: boolean,
|
||||||
|
stream: ComponentStream | undefined,
|
||||||
|
previewSettings: GiftEffectSettings | undefined,
|
||||||
|
previewMode: GiftEffectPreviewMode,
|
||||||
|
previewNonce: number,
|
||||||
|
language: string,
|
||||||
|
) {
|
||||||
|
const [settings, setSettings] = useState(defaultGiftEffectSettings)
|
||||||
|
const [effects, setEffects] = useState<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, settingsRef.current.guardEffectDurationMs)
|
||||||
|
if (!effect) continue
|
||||||
|
setEffects(current =>
|
||||||
|
[effect, ...current.filter(item => item.id !== effect.id)].slice(
|
||||||
|
0,
|
||||||
|
settingsRef.current.maxConcurrentEffects,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [preview, stream, stream?.messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
const effect = toVisualEffect(
|
||||||
|
previewEnvelope(previewMode, previewNonce),
|
||||||
|
previewSettings?.guardEffectDurationMs ?? settingsRef.current.guardEffectDurationMs,
|
||||||
|
previewMode === 'guard' ? undefined : previewMode,
|
||||||
|
)
|
||||||
|
if (effect) setEffects(current => [effect, ...current].slice(0, 4))
|
||||||
|
}, [language, preview, previewMode, previewNonce, previewSettings?.guardEffectDurationMs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
setEffects(current => current.filter(effect => effect.expiresAt > now))
|
||||||
|
}, 1_000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { settings, effects }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
effect: VisualEffect
|
||||||
|
tier: GiftTier
|
||||||
|
tierSettings: MeteorTierSettings
|
||||||
|
scale: number
|
||||||
|
viewportWidth: number
|
||||||
|
trailIntensity: number
|
||||||
|
lowPerformance: boolean
|
||||||
|
}) {
|
||||||
|
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)
|
||||||
|
return (
|
||||||
|
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
|
||||||
|
{Array.from({ length: count }, (_, index) => {
|
||||||
|
const seed = hash(`${effect.id}:${index}`)
|
||||||
|
const startY = 7 + (seed % 78)
|
||||||
|
const drift = ((seed >>> 8) % 37) - 18
|
||||||
|
const delay = index * 95 + ((seed >>> 16) % 260)
|
||||||
|
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
|
||||||
|
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 }: { effect: VisualEffect }) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 }: { effect: VisualEffect; tier: GiftTier }) {
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuardCelebration({
|
||||||
|
effect,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
effect: VisualEffect
|
||||||
|
settings: GiftEffectSettings
|
||||||
|
}) {
|
||||||
|
const count = settings.lowPerformanceMode
|
||||||
|
? Math.min(24, settings.guardStarCount)
|
||||||
|
: settings.guardStarCount
|
||||||
|
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('gift.guard_aria')}
|
||||||
|
style={
|
||||||
|
{ ['--guard-duration' as string]: `${settings.guardEffectDurationMs}ms` } as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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('gift.guard_salute')}</span>
|
||||||
|
<strong>{translate('gift.guard_title', { guard })}</strong>
|
||||||
|
<b>{translate('gift.guard_viewer', { viewer })}</b>
|
||||||
|
</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 guard = remote.effects.find(effect => effect.kind === 'guard')
|
||||||
|
const moonlit = theme.id === 'moonlit-water'
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={root}
|
||||||
|
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
|
||||||
|
data-theme={theme.id}
|
||||||
|
data-connection={stream?.connection || 'idle'}
|
||||||
|
style={{
|
||||||
|
...giftThemeVariables(theme),
|
||||||
|
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="meteor-sky" aria-live="polite">
|
||||||
|
{remote.effects
|
||||||
|
.filter(effect => effect.kind === 'gift')
|
||||||
|
.map(effect => {
|
||||||
|
const tier = tierFor(effect, settings)
|
||||||
|
const gift = effect.payload.gift
|
||||||
|
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
|
||||||
|
if (moonlit) {
|
||||||
|
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
|
||||||
|
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
|
||||||
|
) : (
|
||||||
|
<MoonlitGiftWhisper effect={effect} key={effect.id} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MeteorBurst
|
||||||
|
effect={effect}
|
||||||
|
tier={tier}
|
||||||
|
tierSettings={settings[tier]}
|
||||||
|
scale={scale}
|
||||||
|
viewportWidth={bounds.width}
|
||||||
|
trailIntensity={settings.trailIntensity}
|
||||||
|
lowPerformance={settings.lowPerformanceMode}
|
||||||
|
key={effect.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{guard && <GuardCelebration effect={guard} settings={settings} />}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
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: 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: 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: 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: 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: 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,424 @@
|
|||||||
|
/** 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 { 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),
|
||||||
|
['--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'}
|
||||||
|
>
|
||||||
|
<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,81 @@
|
|||||||
|
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
|
||||||
|
rose: string
|
||||||
|
night: string
|
||||||
|
}
|
||||||
|
motion: {
|
||||||
|
meteor: string
|
||||||
|
guardReveal: string
|
||||||
|
starPulse: 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',
|
||||||
|
rose: '#ffd0e5',
|
||||||
|
night: '#020c18',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
meteor: 'gift-meteor-flight',
|
||||||
|
guardReveal: 'gift-guard-reveal',
|
||||||
|
starPulse: 'gift-star-pulse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
rose: '#dbc6a0',
|
||||||
|
night: 'transparent',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
meteor: 'moonlit-offering-sweep',
|
||||||
|
guardReveal: 'moonlit-guard-reveal',
|
||||||
|
starPulse: 'moonlit-star-pulse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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-rose' as string]: theme.palette.rose,
|
||||||
|
['--gift-night' as string]: theme.palette.night,
|
||||||
|
['--gift-motion-meteor' as string]: theme.motion.meteor,
|
||||||
|
['--gift-motion-guard' as string]: theme.motion.guardReveal,
|
||||||
|
['--gift-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+103
-18
@@ -1,36 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Browser entry point and intentionally small client-side router.
|
||||||
|
*
|
||||||
|
* `/obs/:publicId` is selected before the control application is initialized.
|
||||||
|
* That early split is a security and reliability boundary: OBS sources never
|
||||||
|
* register the control-console PWA or execute authenticated dashboard requests.
|
||||||
|
* All other routes share the session bootstrap and passwordless auth flow.
|
||||||
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||||
import { EnrollmentPage, LoginPage } from './auth'
|
import { EnrollmentPage, LoginPage } from './auth'
|
||||||
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
|
import {
|
||||||
|
AccountLiveSourcePage,
|
||||||
|
ComponentsPage,
|
||||||
|
ForbiddenPage,
|
||||||
|
InvitationsPage,
|
||||||
|
SongRequestsPage,
|
||||||
|
} from './control'
|
||||||
|
import { GiftEffectOverlay } from './giftEffect'
|
||||||
|
import { GiftMenuOverlay } from './giftMenu'
|
||||||
import { Overlay, tokenFromFragment } from './overlay'
|
import { Overlay, tokenFromFragment } from './overlay'
|
||||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||||
|
import { SongRequestOverlay } from './songOverlay'
|
||||||
|
import { useComponentStream } from './stream'
|
||||||
|
import { I18nProvider, translate, useI18n } from './i18n'
|
||||||
import type { Session } from './types'
|
import type { Session } from './types'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import './control.css'
|
import './control.css'
|
||||||
|
import './giftEffect.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 === 'gift_menu') return <GiftMenuOverlay stream={stream} />
|
||||||
|
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
|
||||||
|
return <Overlay stream={stream} />
|
||||||
|
return <main className="obs-status pending">{t('main.obs_connecting')}</main>
|
||||||
|
}
|
||||||
|
|
||||||
function Redirect({ to }: { to: string }) {
|
function Redirect({ to }: { to: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.replace(to)
|
location.replace(to)
|
||||||
}, [to])
|
}, [to])
|
||||||
return <main className="route-loading">正在前往云台…</main>
|
return <main className="route-loading">{t('main.redirecting')}</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotFoundPage() {
|
function NotFoundPage() {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<section className="auth-card jade-panel not-found">
|
<section className="auth-card jade-panel not-found">
|
||||||
<div className="auth-mark" aria-hidden="true">云</div>
|
<div className="auth-mark" aria-hidden="true">
|
||||||
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
|
{t('common.cloud')}
|
||||||
<h1>这里没有组件</h1>
|
</div>
|
||||||
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
|
<p className="eyebrow">{t('main.not_found_eyebrow')}</p>
|
||||||
<a className="button-link" href="/control/">返回控制台</a>
|
<h1>{t('main.not_found_title')}</h1>
|
||||||
|
<p className="auth-lead">{t('main.not_found_description')}</p>
|
||||||
|
<a className="button-link" href="/control/">
|
||||||
|
{t('main.back_to_console')}
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { setLanguage, t } = useI18n()
|
||||||
const [session, setSession] = useState<Session>()
|
const [session, setSession] = useState<Session>()
|
||||||
const [loadError, setLoadError] = useState('')
|
const [loadError, setLoadError] = useState('')
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
@@ -40,15 +88,24 @@ function App() {
|
|||||||
setLoadError('')
|
setLoadError('')
|
||||||
try {
|
try {
|
||||||
const payload = await api<unknown>('/api/v1/auth/me')
|
const payload = await api<unknown>('/api/v1/auth/me')
|
||||||
setSession(normalizeSession(payload))
|
const next = normalizeSession(payload)
|
||||||
|
setSession(next)
|
||||||
|
if (next.user) {
|
||||||
|
try {
|
||||||
|
const preferences = await api<{ language?: string }>('/api/v1/account/preferences')
|
||||||
|
if (preferences.language) setLanguage(preferences.language)
|
||||||
|
} catch {
|
||||||
|
// The local pre-login choice remains usable if preferences cannot load.
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
setSession({ user: null, setupRequired: false })
|
setSession({ user: null, setupRequired: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoadError(errorMessage(error, '无法连接认证服务'))
|
setLoadError(errorMessage(error, translate('main.auth_unavailable')))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setLanguage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshSession()
|
void refreshSession()
|
||||||
@@ -83,16 +140,20 @@ function App() {
|
|||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<section className="auth-card jade-panel">
|
<section className="auth-card jade-panel">
|
||||||
<PwaControls />
|
<PwaControls />
|
||||||
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
<p className="eyebrow">
|
||||||
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
{t(offline ? 'main.offline_eyebrow' : 'main.connection_error_eyebrow')}
|
||||||
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
|
</p>
|
||||||
|
<h1>{offline ? t('main.offline_title') : t('main.unavailable_title')}</h1>
|
||||||
|
{offline && <p className="auth-lead">{t('main.offline_description')}</p>}
|
||||||
<div className="notice error">{loadError}</div>
|
<div className="notice error">{loadError}</div>
|
||||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
|
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
|
||||||
|
{t('main.retry')}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!session) return <main className="route-loading">正在验证安全会话…</main>
|
if (!session) return <main className="route-loading">{t('main.verifying_session')}</main>
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,14 +178,30 @@ function App() {
|
|||||||
if (session.user) return <Redirect to="/control/" />
|
if (session.user) return <Redirect to="/control/" />
|
||||||
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
||||||
}
|
}
|
||||||
|
const songRequestsMatch = path.match(/^\/control\/components\/([^/]+)\/song-requests$/)
|
||||||
|
if (songRequestsMatch) {
|
||||||
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
|
return (
|
||||||
|
<SongRequestsPage
|
||||||
|
user={session.user}
|
||||||
|
componentId={decodeURIComponent(songRequestsMatch[1])}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (path === '/control') {
|
if (path === '/control') {
|
||||||
if (location.pathname === '/control') return <Redirect to="/control/" />
|
if (location.pathname === '/control') return <Redirect to="/control/" />
|
||||||
if (!session.user) return <Redirect to="/control/login" />
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
return <ComponentsPage user={session.user} onLogout={logout} />
|
return <ComponentsPage user={session.user} onLogout={logout} />
|
||||||
}
|
}
|
||||||
|
if (path === '/control/account') {
|
||||||
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
|
return <AccountLiveSourcePage user={session.user} onLogout={logout} />
|
||||||
|
}
|
||||||
if (path === '/control/invitations') {
|
if (path === '/control/invitations') {
|
||||||
if (!session.user) return <Redirect to="/control/login" />
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
if (session.user.role !== 'system_admin') return <ForbiddenPage user={session.user} onLogout={logout} />
|
if (session.user.role !== 'system_admin')
|
||||||
|
return <ForbiddenPage user={session.user} onLogout={logout} />
|
||||||
return <InvitationsPage user={session.user} onLogout={logout} />
|
return <InvitationsPage user={session.user} onLogout={logout} />
|
||||||
}
|
}
|
||||||
return <NotFoundPage />
|
return <NotFoundPage />
|
||||||
@@ -142,8 +219,16 @@ if (obsMatch) {
|
|||||||
} catch {
|
} catch {
|
||||||
publicId = ''
|
publicId = ''
|
||||||
}
|
}
|
||||||
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
|
root.render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />
|
||||||
|
</I18nProvider>,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
initializePwa()
|
initializePwa()
|
||||||
root.render(<App />)
|
root.render(
|
||||||
|
<I18nProvider>
|
||||||
|
<App />
|
||||||
|
</I18nProvider>,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-155
@@ -1,7 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { ComponentStream } from './stream'
|
||||||
|
import { translate, useI18n } from './i18n'
|
||||||
|
import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes'
|
||||||
|
import type { OverlayThemeDefinition } from './themes'
|
||||||
import { defaultOverlaySettings } from './types'
|
import { defaultOverlaySettings } from './types'
|
||||||
import type { OverlaySettings } from './types'
|
import type { OverlaySettings } from './types'
|
||||||
|
import { typographyVariables } from './typography'
|
||||||
|
|
||||||
type Envelope = {
|
type Envelope = {
|
||||||
id: string
|
id: string
|
||||||
@@ -13,7 +27,7 @@ type Envelope = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LivePayload = {
|
type LivePayload = {
|
||||||
viewer?: { uid?: string; name?: string }
|
viewer?: { uid?: string; name?: string; avatarUrl?: string }
|
||||||
text?: string
|
text?: string
|
||||||
message?: string
|
message?: string
|
||||||
guardName?: string
|
guardName?: string
|
||||||
@@ -51,13 +65,9 @@ type DanmakuSegment =
|
|||||||
type OverlayProps = {
|
type OverlayProps = {
|
||||||
preview?: boolean
|
preview?: boolean
|
||||||
previewSettings?: OverlaySettings
|
previewSettings?: OverlaySettings
|
||||||
publicId?: string
|
stream?: ComponentStream
|
||||||
accessToken?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
|
|
||||||
const decorVariantCount = 6
|
|
||||||
|
|
||||||
function stableHash(value: string) {
|
function stableHash(value: string) {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
@@ -67,21 +77,33 @@ function stableHash(value: string) {
|
|||||||
return hash >>> 0
|
return hash >>> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseDecorVariant(seed: string, previous?: number) {
|
function chooseDecorVariant(seed: string, variantCount: number, previous?: number) {
|
||||||
const hash = stableHash(seed)
|
const hash = stableHash(seed)
|
||||||
const base = hash % decorVariantCount
|
const safeCount = Math.max(1, variantCount)
|
||||||
|
const base = hash % safeCount
|
||||||
if (previous === undefined || base !== previous) return base
|
if (previous === undefined || base !== previous) return base
|
||||||
return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount
|
if (safeCount === 1) return base
|
||||||
|
return (base + 1 + ((hash >>> 8) % (safeCount - 1))) % safeCount
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDecor({ count, variant }: { count: number; variant: number }) {
|
function CardDecor({
|
||||||
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0)))
|
count,
|
||||||
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount
|
variant,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
count: number
|
||||||
|
variant: number
|
||||||
|
theme: OverlayThemeDefinition
|
||||||
|
}) {
|
||||||
|
const particles = theme.ornaments.particles
|
||||||
|
const visible = Math.min(particles.length, Math.max(0, Math.round(count || 0)))
|
||||||
|
const variantCount = Math.max(1, theme.ornaments.variantCount)
|
||||||
|
const normalized = ((variant % variantCount) + variantCount) % variantCount
|
||||||
return (
|
return (
|
||||||
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
|
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
|
||||||
<i className="card-decor-surface" />
|
<i className="card-decor-surface" />
|
||||||
<div className="card-particle-layer">
|
<div className="card-particle-layer">
|
||||||
{cardParticles.slice(0, visible).map((kind, index) => (
|
{particles.slice(0, visible).map((kind, index) => (
|
||||||
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
|
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,117 +111,80 @@ function CardDecor({ count, variant }: { count: number; variant: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamUrl(publicId: string) {
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
|
|
||||||
}
|
|
||||||
|
|
||||||
function enabled(type: string, settings: OverlaySettings) {
|
function enabled(type: string, settings: OverlaySettings) {
|
||||||
return (type === 'live.danmaku' && settings.showDanmaku)
|
return (
|
||||||
|| (type === 'live.enter' && settings.showEnter)
|
(type === 'live.danmaku' && settings.showDanmaku) ||
|
||||||
|| (type.startsWith('live.gift') && settings.showGift)
|
(type === 'live.enter' && settings.showEnter) ||
|
||||||
|| (type === 'live.superchat' && settings.showSuperchat)
|
(type.startsWith('live.gift') && settings.showGift) ||
|
||||||
|| (type === 'live.guard.buy' && settings.showGuard)
|
(type === 'live.superchat' && settings.showSuperchat) ||
|
||||||
|| (type === 'live.like' && settings.showLike)
|
(type === 'live.guard.buy' && settings.showGuard) ||
|
||||||
|| (type === 'live.share' && settings.showShare)
|
(type === 'live.like' && settings.showLike) ||
|
||||||
|
(type === 'live.share' && settings.showShare)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSettings(value: unknown): OverlaySettings {
|
function parseSettings(value: unknown): OverlaySettings {
|
||||||
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
||||||
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
|
const candidate = { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
|
||||||
|
return { ...candidate, themeId: normalizeThemeId(candidate.themeId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
|
function useEvents(preview: boolean, stream?: ComponentStream) {
|
||||||
settings: OverlaySettings
|
|
||||||
items: Item[]
|
|
||||||
setItems: Dispatch<SetStateAction<Item[]>>
|
|
||||||
connection: 'idle' | 'connecting' | 'connected' | 'denied'
|
|
||||||
} {
|
|
||||||
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
||||||
const [items, setItems] = useState<Item[]>([])
|
const [items, setItems] = useState<Item[]>([])
|
||||||
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
|
|
||||||
const settingsRef = useRef(settings)
|
const settingsRef = useRef(settings)
|
||||||
|
const lastSequenceRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsRef.current = settings
|
settingsRef.current = settings
|
||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled || !publicId || !accessToken) {
|
if (preview || !stream) return
|
||||||
setConnection('idle')
|
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
|
||||||
return
|
for (const message of pending) {
|
||||||
}
|
lastSequenceRef.current = message.sequence
|
||||||
|
const envelope = message.envelope as Envelope
|
||||||
let dead = false
|
if (
|
||||||
let socket: WebSocket | undefined
|
envelope.type === 'overlay.settings.snapshot' ||
|
||||||
let timer = 0
|
envelope.type === 'overlay.settings.updated' ||
|
||||||
let retries = 0
|
envelope.type === 'component.settings.snapshot' ||
|
||||||
|
envelope.type === 'component.settings.updated'
|
||||||
const open = () => {
|
) {
|
||||||
setConnection('connecting')
|
const nextSettings = parseSettings(envelope.payload?.settings)
|
||||||
socket = new WebSocket(streamUrl(publicId))
|
// Update the ref immediately so a live event in the same React batch
|
||||||
socket.onopen = () => {
|
// observes the settings frame that preceded it.
|
||||||
retries = 0
|
settingsRef.current = nextSettings
|
||||||
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
setSettings(nextSettings)
|
||||||
}
|
continue
|
||||||
socket.onclose = (event) => {
|
|
||||||
if (dead) return
|
|
||||||
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
|
||||||
setConnection('denied')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setConnection('connecting')
|
|
||||||
const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
|
|
||||||
retries += 1
|
|
||||||
timer = window.setTimeout(open, delay)
|
|
||||||
}
|
|
||||||
socket.onmessage = event => {
|
|
||||||
try {
|
|
||||||
const envelope = JSON.parse(event.data) as Envelope
|
|
||||||
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
|
|
||||||
setConnection('connected')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
|
|
||||||
setConnection('denied')
|
|
||||||
socket?.close(1008, 'Unauthorized')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setConnection('connected')
|
|
||||||
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
|
|
||||||
setSettings(parseSettings(envelope.payload?.settings))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setItems(old => {
|
|
||||||
const current = settingsRef.current
|
|
||||||
if (!enabled(envelope.type, current)) return old
|
|
||||||
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
|
|
||||||
const key = combo ? `combo:${combo}` : envelope.id
|
|
||||||
const existing = old.find(item => item.key === key)
|
|
||||||
const decorVariant = existing?.decorVariant
|
|
||||||
?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
|
|
||||||
return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)]
|
|
||||||
.slice(0, current.maxVisible)
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// A malformed upstream event must not break a long-running OBS source.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setItems(old => {
|
||||||
|
const current = settingsRef.current
|
||||||
|
if (!enabled(envelope.type, current)) return old
|
||||||
|
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
|
||||||
|
const key = combo ? `combo:${combo}` : envelope.id
|
||||||
|
const existing = old.find(item => item.key === key)
|
||||||
|
const theme = getOverlayTheme(current.themeId)
|
||||||
|
const decorVariant =
|
||||||
|
existing?.decorVariant ??
|
||||||
|
chooseDecorVariant(
|
||||||
|
`${envelope.type}:${key}`,
|
||||||
|
theme.ornaments.variantCount,
|
||||||
|
old[0]?.decorVariant,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||||
|
...old.filter(item => item.key !== key),
|
||||||
|
].slice(0, current.maxVisible)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}, [preview, stream, stream?.messages])
|
||||||
open()
|
|
||||||
return () => {
|
|
||||||
dead = true
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
socket?.close()
|
|
||||||
}
|
|
||||||
}, [accessToken, disabled, publicId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setItems(current => current.slice(0, settings.maxVisible))
|
setItems(current => current.slice(0, settings.maxVisible))
|
||||||
}, [settings.maxVisible])
|
}, [settings.maxVisible])
|
||||||
|
|
||||||
return { settings, items, setItems, connection }
|
return { settings, items, setItems }
|
||||||
}
|
}
|
||||||
|
|
||||||
function giftTier(item: Item, settings: OverlaySettings) {
|
function giftTier(item: Item, settings: OverlaySettings) {
|
||||||
@@ -212,11 +197,12 @@ function giftTier(item: Item, settings: OverlaySettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
|
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
|
||||||
'live.enter': () => '踏入了云台',
|
'live.enter': () => translate('overlay.enter'),
|
||||||
'live.superchat': payload => payload.message || '',
|
'live.superchat': payload => payload.message || '',
|
||||||
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`,
|
'live.guard.buy': payload =>
|
||||||
'live.like': () => '点亮了一颗星',
|
translate('overlay.guard', { guard: payload.guardName || translate('common.guard') }),
|
||||||
'live.share': () => '分享了直播间',
|
'live.like': () => translate('overlay.like'),
|
||||||
|
'live.share': () => translate('overlay.share'),
|
||||||
}
|
}
|
||||||
|
|
||||||
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
|
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
|
||||||
@@ -239,60 +225,117 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
||||||
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined
|
const segments = Array.isArray(payload.segments)
|
||||||
|
? (payload.segments as DanmakuSegment[])
|
||||||
|
: undefined
|
||||||
if (!segments?.length) return <>{payload.text || ''}</>
|
if (!segments?.length) return <>{payload.text || ''}</>
|
||||||
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url
|
return (
|
||||||
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
|
<>
|
||||||
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</>
|
{segments.map((segment, index) =>
|
||||||
|
segment.type === 'emoticon' && segment.url ? (
|
||||||
|
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
|
||||||
|
) : (
|
||||||
|
<span className="danmaku-text" key={`text:${index}`}>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) {
|
function ViewerAvatar({ viewer }: { viewer: NonNullable<LivePayload['viewer']> }) {
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
const name = viewer.name?.trim() || translate('common.viewer')
|
||||||
|
const initial = Array.from(name)[0] || '观'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="viewer-avatar" aria-hidden="true">
|
||||||
|
{viewer.avatarUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={viewer.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{initial}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
item,
|
||||||
|
settings,
|
||||||
|
expanded,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
item: Item
|
||||||
|
settings: OverlaySettings
|
||||||
|
expanded: boolean
|
||||||
|
theme: OverlayThemeDefinition
|
||||||
|
}) {
|
||||||
const payload = item.payload || {}
|
const payload = item.payload || {}
|
||||||
const viewer = payload.viewer || {}
|
const viewer = payload.viewer || {}
|
||||||
const gift = payload.gift
|
const gift = payload.gift
|
||||||
const isDanmaku = item.type === 'live.danmaku'
|
const isDanmaku = item.type === 'live.danmaku'
|
||||||
const tier = gift ? giftTier(item, settings) : ''
|
const tier = gift ? giftTier(item, settings) : ''
|
||||||
const body = gift
|
const body = gift ? (
|
||||||
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
|
translate('overlay.gift', {
|
||||||
: isDanmaku
|
gift: gift.name || translate('common.gift'),
|
||||||
? <DanmakuBody payload={payload} />
|
quantity: payload.quantity || 1,
|
||||||
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
|
})
|
||||||
|
) : isDanmaku ? (
|
||||||
|
<DanmakuBody payload={payload} />
|
||||||
|
) : (
|
||||||
|
eventRenderers[item.type]?.(payload) || translate('overlay.interaction')
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
|
<article
|
||||||
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
|
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
|
||||||
|
>
|
||||||
|
<CardDecor count={settings.particleCount} variant={item.decorVariant} theme={theme} />
|
||||||
{gift && (
|
{gift && (
|
||||||
<div className="gift-art">
|
<div className="gift-art">
|
||||||
{gift.animationUrl || gift.imageUrl
|
{gift.animationUrl || gift.imageUrl ? (
|
||||||
? (
|
<img
|
||||||
<img
|
src={gift.animationUrl || gift.imageUrl}
|
||||||
src={gift.animationUrl || gift.imageUrl}
|
alt={gift.name || translate('common.gift')}
|
||||||
alt={gift.name || '礼物'}
|
onError={event => {
|
||||||
onError={event => {
|
const image = event.currentTarget
|
||||||
const image = event.currentTarget
|
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
|
||||||
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
|
else image.style.display = 'none'
|
||||||
else image.style.display = 'none'
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
)
|
<span>✦</span>
|
||||||
: <span>✦</span>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="copy">
|
<div className="copy">
|
||||||
<b>{viewer.name || '直播间观众'}</b>
|
{isDanmaku && <ViewerAvatar viewer={viewer} />}
|
||||||
|
<b>{viewer.name || translate('common.viewer')}</b>
|
||||||
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
|
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
|
||||||
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>}
|
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
|
||||||
|
<em>¥ {gift.priceCny.toFixed(2)}</em>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) {
|
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
|
||||||
|
const { language } = useI18n()
|
||||||
const root = useRef<HTMLDivElement>(null)
|
const root = useRef<HTMLDivElement>(null)
|
||||||
const events = useEvents(preview, publicId, accessToken)
|
const events = useEvents(preview, stream)
|
||||||
const { items, setItems } = events
|
const { items, setItems } = events
|
||||||
const settings = previewSettings || events.settings
|
const settings = previewSettings || events.settings
|
||||||
|
const theme = getOverlayTheme(settings.themeId)
|
||||||
const [shape, setShape] = useState('standard')
|
const [shape, setShape] = useState('standard')
|
||||||
const [expandedKey, setExpandedKey] = useState<string>()
|
const [expandedKey, setExpandedKey] = useState<string>()
|
||||||
const fontFactor = settings.fontScale / 100
|
const fontFactor = settings.fontScale / 100
|
||||||
@@ -308,7 +351,7 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preview && !items.length) {
|
if (preview) {
|
||||||
setItems([
|
setItems([
|
||||||
{
|
{
|
||||||
id: 'text-preview',
|
id: 'text-preview',
|
||||||
@@ -316,7 +359,10 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
received: Date.now(),
|
received: Date.now(),
|
||||||
decorVariant: 0,
|
decorVariant: 0,
|
||||||
type: 'live.danmaku',
|
type: 'live.danmaku',
|
||||||
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
|
payload: {
|
||||||
|
viewer: { name: translate('overlay.preview_viewer') },
|
||||||
|
text: translate('overlay.preview_message'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gift-preview',
|
id: 'gift-preview',
|
||||||
@@ -325,14 +371,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
decorVariant: 3,
|
decorVariant: 3,
|
||||||
type: 'live.gift',
|
type: 'live.gift',
|
||||||
payload: {
|
payload: {
|
||||||
viewer: { name: '星光旅人' },
|
viewer: { name: translate('overlay.preview_gift_viewer') },
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
|
gift: {
|
||||||
|
name: translate('overlay.preview_gift'),
|
||||||
|
totalPrice: 12_000,
|
||||||
|
priceCny: 12,
|
||||||
|
imageUrl: '',
|
||||||
|
animationUrl: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}, [items.length, preview, setItems])
|
}, [items.length, language, preview, setItems])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newest = items[0]
|
const newest = items[0]
|
||||||
@@ -343,33 +395,43 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
setExpandedKey(newest.key)
|
setExpandedKey(newest.key)
|
||||||
const densityFactor = shape === 'short' ? 0.6 : 1
|
const densityFactor = shape === 'short' ? 0.6 : 1
|
||||||
const timer = window.setTimeout(
|
const timer = window.setTimeout(
|
||||||
() => setExpandedKey(key => key === newest.key ? undefined : key),
|
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
|
||||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||||
)
|
)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [items, settings.collapseAfterSeconds, shape])
|
}, [items, settings.collapseAfterSeconds, shape])
|
||||||
|
|
||||||
const missingAccess = !preview && (!publicId || !accessToken)
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
ref={root}
|
ref={root}
|
||||||
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
className={`overlay ${theme.className} ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||||
data-connection={events.connection}
|
data-theme={theme.id}
|
||||||
style={{
|
data-connection={stream?.connection || 'idle'}
|
||||||
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
style={
|
||||||
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
{
|
||||||
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
...themeCssVariables(theme),
|
||||||
['--font-body' as string]: `${18 * fontFactor}px`,
|
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||||
['--font-expanded' as string]: `${26 * fontFactor}px`,
|
['--component-viewer-color' as string]: settings.viewerColor || undefined,
|
||||||
['--font-compact' as string]: `${15 * fontFactor}px`,
|
['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
|
||||||
}}
|
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
||||||
|
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
||||||
|
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
||||||
|
['--font-body' as string]: `${18 * fontFactor}px`,
|
||||||
|
['--font-expanded' as string]: `${26 * fontFactor}px`,
|
||||||
|
['--font-compact' as string]: `${15 * fontFactor}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<section className="wall">
|
<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">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} />
|
<Card
|
||||||
|
item={item}
|
||||||
|
settings={settings}
|
||||||
|
expanded={item.key === expandedKey}
|
||||||
|
theme={theme}
|
||||||
|
key={item.key}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+68
-31
@@ -1,4 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Install, offline and explicit-update lifecycle for the control-console PWA.
|
||||||
|
*
|
||||||
|
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
|
||||||
|
* the scope. The worker caches only the public application shell and static
|
||||||
|
* assets. API responses, WebSockets and secrets remain network-only. A waiting
|
||||||
|
* worker activates only after user confirmation and after every registered
|
||||||
|
* one-time-secret or dirty-form blocker has cleared.
|
||||||
|
*/
|
||||||
import { useEffect, useSyncExternalStore } from 'react'
|
import { useEffect, useSyncExternalStore } from 'react'
|
||||||
|
import { translate, useI18n } from './i18n'
|
||||||
|
|
||||||
interface InstallChoice {
|
interface InstallChoice {
|
||||||
outcome: 'accepted' | 'dismissed'
|
outcome: 'accepted' | 'dismissed'
|
||||||
@@ -58,9 +68,10 @@ function addManifest() {
|
|||||||
function observeRegistration(registration: ServiceWorkerRegistration) {
|
function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||||
emit({
|
emit({
|
||||||
registration,
|
registration,
|
||||||
waitingWorker: registration.waiting && navigator.serviceWorker.controller
|
waitingWorker:
|
||||||
? registration.waiting
|
registration.waiting && navigator.serviceWorker.controller
|
||||||
: snapshot.waitingWorker,
|
? registration.waiting
|
||||||
|
: snapshot.waitingWorker,
|
||||||
})
|
})
|
||||||
|
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
@@ -76,24 +87,29 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
|
|||||||
|
|
||||||
async function removeLegacyRootWorker() {
|
async function removeLegacyRootWorker() {
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
await Promise.all(registrations.map(async registration => {
|
await Promise.all(
|
||||||
const scopePath = new URL(registration.scope).pathname
|
registrations.map(async registration => {
|
||||||
const workers = [registration.installing, registration.waiting, registration.active]
|
const scopePath = new URL(registration.scope).pathname
|
||||||
const isLegacy = scopePath === '/'
|
const workers = [registration.installing, registration.waiting, registration.active]
|
||||||
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
const isLegacy =
|
||||||
if (isLegacy) await registration.unregister()
|
scopePath === '/' &&
|
||||||
}))
|
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
||||||
|
if (isLegacy) await registration.unregister()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const cacheNames = await caches.keys()
|
const cacheNames = await caches.keys()
|
||||||
await Promise.all(cacheNames
|
await Promise.all(
|
||||||
.filter(name => name.startsWith('lxc-control-shell-'))
|
cacheNames
|
||||||
.map(name => caches.delete(name)))
|
.filter(name => name.startsWith('lxc-control-shell-'))
|
||||||
|
.map(name => caches.delete(name)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupLegacyPwa() {
|
export function cleanupLegacyPwa() {
|
||||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||||
void removeLegacyRootWorker().catch(error => {
|
void removeLegacyRootWorker().catch(error => {
|
||||||
console.warn('旧版 PWA 清理失败', error)
|
console.warn('Legacy PWA cleanup failed', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +123,7 @@ async function registerWorker() {
|
|||||||
observeRegistration(registration)
|
observeRegistration(registration)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A failed PWA registration must never stop the online control console.
|
// A failed PWA registration must never stop the online control console.
|
||||||
console.warn('控制台 PWA 注册失败', error)
|
console.warn('Console PWA registration failed', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,18 +137,25 @@ export function initializePwa() {
|
|||||||
const updateDisplayMode = () => emit({ standalone: isStandalone() })
|
const updateDisplayMode = () => emit({ standalone: isStandalone() })
|
||||||
window.addEventListener('online', updateConnection)
|
window.addEventListener('online', updateConnection)
|
||||||
window.addEventListener('offline', updateConnection)
|
window.addEventListener('offline', updateConnection)
|
||||||
const modernListener = (displayMode as unknown as {
|
const modernListener = (
|
||||||
addEventListener?: (type: 'change', listener: () => void) => void
|
displayMode as unknown as {
|
||||||
}).addEventListener
|
addEventListener?: (type: 'change', listener: () => void) => void
|
||||||
|
}
|
||||||
|
).addEventListener
|
||||||
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
|
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
|
||||||
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
|
else
|
||||||
.addListener?.call(displayMode, updateDisplayMode)
|
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
|
||||||
|
displayMode,
|
||||||
|
updateDisplayMode,
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', event => {
|
window.addEventListener('beforeinstallprompt', event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
emit({ installPrompt: event as BeforeInstallPromptEvent })
|
emit({ installPrompt: event as BeforeInstallPromptEvent })
|
||||||
})
|
})
|
||||||
window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true }))
|
window.addEventListener('appinstalled', () =>
|
||||||
|
emit({ installPrompt: undefined, standalone: true }),
|
||||||
|
)
|
||||||
|
|
||||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||||
|
|
||||||
@@ -151,13 +174,17 @@ export function initializePwa() {
|
|||||||
void snapshot.registration?.update()
|
void snapshot.registration?.update()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.setInterval(() => {
|
window.setInterval(
|
||||||
if (navigator.onLine) void snapshot.registration?.update()
|
() => {
|
||||||
}, 60 * 60 * 1000)
|
if (navigator.onLine) void snapshot.registration?.update()
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authRoute(name: 'login' | 'register' | 'setup'): string {
|
export function authRoute(name: 'login' | 'register' | 'setup'): string {
|
||||||
const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/')
|
const inControlScope =
|
||||||
|
location.pathname === '/control' || location.pathname.startsWith('/control/')
|
||||||
return inControlScope ? `/control/${name}` : `/${name}`
|
return inControlScope ? `/control/${name}` : `/${name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +192,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) updateBlockers.set(key, reason)
|
if (active) updateBlockers.set(key, reason)
|
||||||
else updateBlockers.delete(key)
|
else updateBlockers.delete(key)
|
||||||
return () => { updateBlockers.delete(key) }
|
return () => {
|
||||||
|
updateBlockers.delete(key)
|
||||||
|
}
|
||||||
}, [active, key, reason])
|
}, [active, key, reason])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,31 +211,39 @@ function applyUpdate() {
|
|||||||
if (!worker) return
|
if (!worker) return
|
||||||
const reasons = [...new Set(updateBlockers.values())]
|
const reasons = [...new Set(updateBlockers.values())]
|
||||||
if (reasons.length > 0) {
|
if (reasons.length > 0) {
|
||||||
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
|
window.alert(
|
||||||
|
translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
const confirmed = window.confirm(translate('pwa.confirm_update'))
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
reloadForUpdate = true
|
reloadForUpdate = true
|
||||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PwaControls() {
|
export function PwaControls() {
|
||||||
|
const { t } = useI18n()
|
||||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||||
const canInstall = Boolean(state.installPrompt) && !state.standalone
|
const canInstall = Boolean(state.installPrompt) && !state.standalone
|
||||||
if (state.online && !state.waitingWorker && !canInstall) return null
|
if (state.online && !state.waitingWorker && !canInstall) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pwa-controls" aria-live="polite">
|
<div className="pwa-controls" aria-live="polite">
|
||||||
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
|
{!state.online && (
|
||||||
|
<span className="pwa-state offline">
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
{t('pwa.offline')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{state.waitingWorker && (
|
{state.waitingWorker && (
|
||||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||||
更新可用
|
{t('pwa.update')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canInstall && (
|
{canInstall && (
|
||||||
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
||||||
安装到设备
|
{t('pwa.install')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,703 @@
|
|||||||
|
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: 1px solid var(--theme-card-border);
|
||||||
|
border-radius: clamp(8px, 1.5vmin, 15px);
|
||||||
|
background: var(--theme-card-background);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px rgba(231, 255, 249, 0.12),
|
||||||
|
0 10px 32px rgba(0, 12, 24, 0.24);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current::after,
|
||||||
|
.song-queue::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.1;
|
||||||
|
background: var(--theme-pattern-vine) left bottom / min(70%, 480px) auto no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(6px, 1.2vmin, 12px);
|
||||||
|
min-height: var(--song-current-min, 56px);
|
||||||
|
padding: clamp(6px, 1.2vmin, 12px);
|
||||||
|
animation: song-current-arrive 0.58s cubic-bezier(0.18, 0.86, 0.22, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: clamp(30px, 3em, 46px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid rgba(133, 255, 232, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
background: rgba(6, 48, 57, 0.72);
|
||||||
|
box-shadow: 0 0 24px rgba(95, 255, 226, 0.22);
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current-mark-note,
|
||||||
|
.song-waveform {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current-copy,
|
||||||
|
.song-score {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current-copy,
|
||||||
|
.song-score,
|
||||||
|
.song-queue > header,
|
||||||
|
.song-row,
|
||||||
|
.song-queue-empty {
|
||||||
|
filter: brightness(var(--component-font-brightness, 1.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-current-copy small,
|
||||||
|
.song-score 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-score {
|
||||||
|
justify-items: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-score b {
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: var(--theme-price);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue > header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.38em 0.72em;
|
||||||
|
border-bottom: 1px solid rgba(133, 255, 232, 0.16);
|
||||||
|
color: var(--theme-compact-user);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue > header b {
|
||||||
|
min-width: 2em;
|
||||||
|
padding: 0.06em 0.45em;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: rgba(109, 235, 211, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue-viewport {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue-track {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28em;
|
||||||
|
padding: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 2.5em minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55em;
|
||||||
|
padding: 0.4em 0.62em;
|
||||||
|
border: 1px solid rgba(128, 238, 218, 0.13);
|
||||||
|
border-radius: 0.62em;
|
||||||
|
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
animation: song-row-insert 0.62s cubic-bezier(0.16, 0.86, 0.24, 1.08) both;
|
||||||
|
animation-delay: var(--song-row-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every queue bar gets a small theme-matched star and floret. Keeping these as
|
||||||
|
pseudo-elements avoids multiplying DOM nodes for a very long queue. */
|
||||||
|
.song-row::before,
|
||||||
|
.song-row::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
width: 0.72em;
|
||||||
|
height: 0.72em;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: var(--theme-motion-sparkle, song-sparkle) 3.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row::before {
|
||||||
|
top: 12%;
|
||||||
|
right: 1.5%;
|
||||||
|
background: linear-gradient(135deg, #fff5bc, #aaffed 60%, #efd4ff);
|
||||||
|
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(179, 255, 237, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row::after {
|
||||||
|
right: 8%;
|
||||||
|
bottom: 3%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 17%, #ffd6e5 0 19%, transparent 22%),
|
||||||
|
radial-gradient(circle at 82% 50%, #ffd6e5 0 19%, transparent 22%),
|
||||||
|
radial-gradient(circle at 50% 83%, #ffd6e5 0 19%, transparent 22%),
|
||||||
|
radial-gradient(circle at 18% 50%, #ffd6e5 0 19%, transparent 22%),
|
||||||
|
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
|
||||||
|
animation-delay: -1.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row-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-narrow .song-score {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-overlay.song-short .song-current {
|
||||||
|
min-height: 42px;
|
||||||
|
padding-block: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-overlay.song-short .song-current-mark {
|
||||||
|
width: clamp(28px, 2.5em, 42px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-overlay.song-short .song-queue > header {
|
||||||
|
padding-block: 0.24em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.song-current,
|
||||||
|
.song-row,
|
||||||
|
.song-particle,
|
||||||
|
.song-row::before,
|
||||||
|
.song-row::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: 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: 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: 1px solid rgba(116, 166, 157, 0.52);
|
||||||
|
border-width: 1px 0 0 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,
|
||||||
|
.theme-moonlit-water .song-score {
|
||||||
|
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-score 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-score b,
|
||||||
|
.theme-moonlit-water .song-index {
|
||||||
|
color: #c9b06f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-moonlit-water .song-score {
|
||||||
|
min-width: clamp(72px, 6.8em, 116px);
|
||||||
|
justify-items: end;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-moonlit-water .song-score b {
|
||||||
|
font-size: 0.68em;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-moonlit-water .song-score small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 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-narrow .song-score {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-width: 4.8em;
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,395 @@
|
|||||||
|
/** 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 { 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,
|
||||||
|
averageScore: 4.8,
|
||||||
|
ratingCount: 26,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
ratingCount: 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordered(items: SongRequestItem[]): SongRequestItem[] {
|
||||||
|
return [...items]
|
||||||
|
.filter(item => item.status === 'queued')
|
||||||
|
.sort((left, right) => left.queuePosition - right.queuePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSongQueue(preview: boolean, 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(item?: SongRequestItem) {
|
||||||
|
if (!item?.ratingCount || item.averageScore == null) return translate('song.no_rating')
|
||||||
|
return translate('song.overlay.score', {
|
||||||
|
score: item.averageScore.toFixed(1),
|
||||||
|
count: item.ratingCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Theme-owned stars and florets used inside the compact current-song card. */
|
||||||
|
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="song-particle-layer" aria-hidden="true">
|
||||||
|
{theme.ornaments.particles.slice(0, count).map((kind, index) => (
|
||||||
|
<i className={`song-particle ${kind}`} key={`${kind}-${index}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBounceScroll(
|
||||||
|
viewport: RefObject<HTMLDivElement | null>,
|
||||||
|
track: RefObject<HTMLDivElement | null>,
|
||||||
|
speed: number,
|
||||||
|
pauseSeconds: number,
|
||||||
|
dependency: unknown,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
const container = viewport.current
|
||||||
|
const content = track.current
|
||||||
|
if (!container || !content) return
|
||||||
|
let frame = 0
|
||||||
|
let direction = 1
|
||||||
|
let last = performance.now()
|
||||||
|
let pausedUntil = last + pauseSeconds * 1000
|
||||||
|
// 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,
|
||||||
|
['--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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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-score">
|
||||||
|
<b>{score(queue.current)}</b>
|
||||||
|
<small>{translate('song.overlay.rate_help')}</small>
|
||||||
|
<div className="song-waveform" aria-hidden="true">
|
||||||
|
{Array.from({ length: 8 }, (_, index) => (
|
||||||
|
<i key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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 }
|
||||||
|
}
|
||||||
+280
-1
@@ -1 +1,280 @@
|
|||||||
*{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: 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,103 @@
|
|||||||
|
/* Shared upper/lower ornament for the three text-led Moonlit Water widgets.
|
||||||
|
The SVG remains monochrome and transparent; this gradient owns its tint so
|
||||||
|
future themes can reuse the mask with a different palette. */
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
) {
|
||||||
|
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px);
|
||||||
|
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
|
||||||
|
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px);
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::before,
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
left: clamp(8px, 1.4vmin, 18px);
|
||||||
|
right: clamp(8px, 1.4vmin, 18px);
|
||||||
|
height: var(--moonlit-edge-height);
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(138, 174, 158, 0.7) 13%,
|
||||||
|
rgba(211, 188, 127, 0.82) 39%,
|
||||||
|
#ead79f 50%,
|
||||||
|
rgba(211, 188, 127, 0.82) 61%,
|
||||||
|
rgba(138, 174, 158, 0.7) 87%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
|
||||||
|
mask: var(--theme-pattern-edge) center / 100% 100% no-repeat;
|
||||||
|
opacity: 0.72;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(226, 204, 146, 0.2));
|
||||||
|
animation: moonlit-edge-breathe 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::before {
|
||||||
|
top: var(--moonlit-edge-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::after {
|
||||||
|
bottom: var(--moonlit-edge-offset);
|
||||||
|
transform: rotate(180deg);
|
||||||
|
animation-delay: -3.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
padding-block: calc(clamp(8px, 1.3vmin, 16px) + var(--moonlit-edge-space));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moonlit-edge-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::before,
|
||||||
|
:is(
|
||||||
|
.overlay.theme-moonlit-water,
|
||||||
|
.song-overlay.theme-moonlit-water,
|
||||||
|
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||||
|
)::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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-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,52 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
arrive: 'arrive',
|
||||||
|
sheen: 'sheen',
|
||||||
|
featured: 'featured',
|
||||||
|
float: 'float',
|
||||||
|
sparkle: 'card-sparkle',
|
||||||
|
unfurl: 'scroll-unfurl',
|
||||||
|
railsOpen: 'scroll-rails-open',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
},
|
||||||
|
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,49 @@
|
|||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
motion: {
|
||||||
|
arrive: string
|
||||||
|
sheen: string
|
||||||
|
featured: string
|
||||||
|
float: string
|
||||||
|
sparkle: string
|
||||||
|
unfurl: string
|
||||||
|
railsOpen: string
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
-1
@@ -1,4 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Shared control-console view models.
|
||||||
|
*
|
||||||
|
* These types describe sanitized API responses and editable settings, not raw
|
||||||
|
* database rows or Bilibili packets. Secret fields are optional because the
|
||||||
|
* server normally returns only `*Configured` flags after initial submission.
|
||||||
|
*/
|
||||||
|
import type { OverlayThemeId } from './themes'
|
||||||
|
import type { FontFamilyId } from './typography'
|
||||||
|
|
||||||
export type OverlaySettings = {
|
export type OverlaySettings = {
|
||||||
|
themeId: OverlayThemeId
|
||||||
|
fontFamily: FontFamilyId
|
||||||
|
fontBrightness: number
|
||||||
|
viewerColor: string | null
|
||||||
|
danmakuColor: string | null
|
||||||
fontScale: number
|
fontScale: number
|
||||||
showDanmaku: boolean
|
showDanmaku: boolean
|
||||||
showEnter: boolean
|
showEnter: boolean
|
||||||
@@ -19,6 +34,11 @@ export type OverlaySettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const defaultOverlaySettings: OverlaySettings = {
|
export const defaultOverlaySettings: OverlaySettings = {
|
||||||
|
themeId: 'jade-scroll',
|
||||||
|
fontFamily: 'fang-song',
|
||||||
|
fontBrightness: 130,
|
||||||
|
viewerColor: null,
|
||||||
|
danmakuColor: null,
|
||||||
fontScale: 140,
|
fontScale: 140,
|
||||||
showDanmaku: true,
|
showDanmaku: true,
|
||||||
showEnter: true,
|
showEnter: true,
|
||||||
@@ -38,6 +58,170 @@ export const defaultOverlaySettings: OverlaySettings = {
|
|||||||
featuredValueThreshold: 100_000,
|
featuredValueThreshold: 100_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renderer and anti-spam settings for the built-in song request component. */
|
||||||
|
export type SongRequestSettings = {
|
||||||
|
themeId: OverlayThemeId
|
||||||
|
fontFamily: FontFamilyId
|
||||||
|
fontBrightness: number
|
||||||
|
requesterColor: string | null
|
||||||
|
songTitleColor: string | null
|
||||||
|
fontScale: number
|
||||||
|
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,
|
||||||
|
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 gift meteors and guard celebrations. */
|
||||||
|
export type GiftEffectSettings = {
|
||||||
|
themeId: GiftEffectThemeId
|
||||||
|
fontFamily: FontFamilyId
|
||||||
|
fontBrightness: number
|
||||||
|
highValueThreshold: number
|
||||||
|
featuredValueThreshold: number
|
||||||
|
normal: MeteorTierSettings
|
||||||
|
high: MeteorTierSettings
|
||||||
|
featured: MeteorTierSettings
|
||||||
|
trailIntensity: number
|
||||||
|
guardStarCount: number
|
||||||
|
guardEffectDurationMs: number
|
||||||
|
maxConcurrentEffects: 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,
|
||||||
|
guardStarCount: 48,
|
||||||
|
guardEffectDurationMs: 5_200,
|
||||||
|
maxConcurrentEffects: 8,
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
motionIntensity: 78,
|
||||||
|
lowPerformanceMode: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentSettings =
|
||||||
|
OverlaySettings | SongRequestSettings | GiftEffectSettings | 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
|
||||||
|
averageScore?: number | null
|
||||||
|
ratingCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SongQueueSummary = {
|
||||||
|
activeCount: number
|
||||||
|
queuedCount: number
|
||||||
|
completedCount: number
|
||||||
|
cancelledCount: number
|
||||||
|
ratingCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SongRequestPage = {
|
||||||
|
revision: number
|
||||||
|
current?: SongRequestItem | null
|
||||||
|
items: SongRequestItem[]
|
||||||
|
nextCursor?: number | null
|
||||||
|
summary: SongQueueSummary
|
||||||
|
}
|
||||||
|
|
||||||
export type UserRole = 'system_admin' | 'user' | string
|
export type UserRole = 'system_admin' | 'user' | string
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
@@ -60,7 +244,7 @@ export type ComponentSummary = {
|
|||||||
kind: string
|
kind: string
|
||||||
name: string
|
name: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
settings?: OverlaySettings
|
settings?: ComponentSettings
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
/** Stable IDs persisted by the backend; each maps to a fixed, injection-safe stack. */
|
||||||
|
export const fontFamilyIds = ['song', 'fang-song', 'kai'] 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fontStacks: Record<FontFamilyId, string> = {
|
||||||
|
song: '"Source Han Serif SC", "Noto Serif CJK SC", "Noto Serif SC", "Songti SC", STSong, SimSun, serif',
|
||||||
|
'fang-song': 'FangSong, STFangsong, "FZShuSong-Z01", "Noto Serif SC", "Songti SC", serif',
|
||||||
|
kai: '"Kaiti SC", STKaiti, KaiTi, "LXGW WenKai", "Noto Serif SC", 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" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __PWA_BUILD_ID__: string
|
declare const __PWA_BUILD_ID__: string
|
||||||
|
declare const __I18N_RESOURCE__: {
|
||||||
|
default_language: string
|
||||||
|
locales: Record<string, { name: string; messages: Record<string, string> }>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Vite build configuration and PWA asset emission.
|
||||||
|
*
|
||||||
|
* The same per-build identifier is compiled into the browser bundle and the
|
||||||
|
* service worker. This makes an hourly/visibility update check discover a new
|
||||||
|
* deployment while still leaving activation under explicit user control.
|
||||||
|
*/
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { parse } from 'smol-toml'
|
||||||
|
|
||||||
// A new URL makes the browser check and stage every deployed service worker.
|
// A new URL makes the browser check and stage every deployed service worker.
|
||||||
// The waiting worker is still activated explicitly by the user in the console.
|
// The waiting worker is still activated explicitly by the user in the console.
|
||||||
const pwaBuildId = Date.now().toString(36)
|
const pwaBuildId = Date.now().toString(36)
|
||||||
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
|
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
|
||||||
|
const i18nResource = parse(
|
||||||
|
readFileSync(new URL('../../resources/i18n.toml', import.meta.url), 'utf8'),
|
||||||
|
) as unknown as {
|
||||||
|
default_language: string
|
||||||
|
locales: Record<string, { messages: Record<string, string> }>
|
||||||
|
}
|
||||||
|
const defaultLanguage = i18nResource.default_language
|
||||||
|
const defaultMessages = i18nResource.locales[defaultLanguage].messages
|
||||||
|
const manifest = {
|
||||||
|
id: '/control',
|
||||||
|
name: defaultMessages['pwa.manifest.name'],
|
||||||
|
short_name: defaultMessages['pwa.manifest.short_name'],
|
||||||
|
description: defaultMessages['pwa.manifest.description'],
|
||||||
|
lang: defaultLanguage,
|
||||||
|
dir: 'auto',
|
||||||
|
start_url: '/control/?source=pwa',
|
||||||
|
scope: '/control/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
background_color: '#03121a',
|
||||||
|
theme_color: '#0a302f',
|
||||||
|
categories: ['utilities', 'productivity'],
|
||||||
|
icons: [
|
||||||
|
{ src: '/pwa/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/pwa/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{
|
||||||
|
src: '/pwa/icon-maskable-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
{
|
{
|
||||||
name: 'control-pwa-assets',
|
name: 'control-pwa-assets',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html
|
||||||
|
.replaceAll('__DEFAULT_LANGUAGE__', defaultLanguage)
|
||||||
|
.replaceAll('__APP_NAME__', defaultMessages['pwa.manifest.name'])
|
||||||
|
.replaceAll('__APP_SHORT_NAME__', defaultMessages['pwa.manifest.short_name'])
|
||||||
|
.replaceAll('__APP_DESCRIPTION__', defaultMessages['pwa.manifest.description'])
|
||||||
|
},
|
||||||
generateBundle() {
|
generateBundle() {
|
||||||
this.emitFile({
|
this.emitFile({
|
||||||
type: 'asset',
|
type: 'asset',
|
||||||
fileName: 'control/sw.js',
|
fileName: 'control/sw.js',
|
||||||
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
|
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
|
||||||
})
|
})
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: 'control/manifest.webmanifest',
|
||||||
|
source: JSON.stringify(manifest, null, 2),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
define: {
|
define: {
|
||||||
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
|
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
|
||||||
|
__I18N_RESOURCE__: JSON.stringify(i18nResource),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+146
-2409
File diff suppressed because it is too large
Load Diff
@@ -7,14 +7,12 @@ edition = "2024"
|
|||||||
axum = { version = "0.8", features = ["ws", "json"] }
|
axum = { version = "0.8", features = ["ws", "json"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# Patched local copy of the published blivedm_rs crate. The patch preserves
|
|
||||||
# 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"
|
chacha20poly1305 = "0.10"
|
||||||
deadpool-postgres = "0.14"
|
deadpool-postgres = "0.14"
|
||||||
futures-channel = "0.3"
|
# Kept as a sibling checkout during development. Docker Compose supplies the
|
||||||
|
# same directory as a named BuildKit context at `/libilibili`.
|
||||||
|
libilibili = { path = "../../../libilibili" }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 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` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||||
|
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
||||||
|
|
||||||
|
## 重要不变量
|
||||||
|
|
||||||
|
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
|
||||||
|
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
|
||||||
|
- 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,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 std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -17,9 +24,13 @@ use crate::{
|
|||||||
bilibili::BilibiliProvider,
|
bilibili::BilibiliProvider,
|
||||||
supervisor::{ProviderFactory, SourceSupervisor},
|
supervisor::{ProviderFactory, SourceSupervisor},
|
||||||
},
|
},
|
||||||
|
overlay::GiftCatalogRegistry,
|
||||||
rate_limit::AuthRateLimiter,
|
rate_limit::AuthRateLimiter,
|
||||||
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
|
realtime::{EventHub, InMemoryComponentStore, SourceEventRouter},
|
||||||
repository::TenantRepository,
|
repository::TenantRepository,
|
||||||
|
song_request::{
|
||||||
|
SONG_REQUEST_KIND, SongRequestHandler, SongRequestService, SongRequestSnapshotProvider,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -35,6 +46,8 @@ pub struct AppState {
|
|||||||
pub enrollment_limiter: AuthRateLimiter,
|
pub enrollment_limiter: AuthRateLimiter,
|
||||||
pub component_socket_slots: Arc<Semaphore>,
|
pub component_socket_slots: Arc<Semaphore>,
|
||||||
pub http: reqwest::Client,
|
pub http: reqwest::Client,
|
||||||
|
pub song_requests: SongRequestService,
|
||||||
|
pub gift_catalogs: GiftCatalogRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -53,9 +66,27 @@ impl AppState {
|
|||||||
let registry = ComponentRegistry::with_builtin_components();
|
let registry = ComponentRegistry::with_builtin_components();
|
||||||
let component_cache = Arc::new(InMemoryComponentStore::default());
|
let component_cache = Arc::new(InMemoryComponentStore::default());
|
||||||
let hub = EventHub::new(512);
|
let hub = EventHub::new(512);
|
||||||
|
let song_requests = SongRequestService::new(db.clone(), hub.clone());
|
||||||
|
let gift_catalogs = GiftCatalogRegistry::default();
|
||||||
|
registry
|
||||||
|
.register_handler(
|
||||||
|
SONG_REQUEST_KIND,
|
||||||
|
Arc::new(SongRequestHandler::new(song_requests.clone())),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
registry
|
||||||
|
.register_snapshot_provider(
|
||||||
|
SONG_REQUEST_KIND,
|
||||||
|
Arc::new(SongRequestSnapshotProvider::new(song_requests.clone())),
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
let router = SourceEventRouter::new(registry.clone(), component_cache.clone(), hub.clone());
|
let router = SourceEventRouter::new(registry.clone(), component_cache.clone(), hub.clone());
|
||||||
let repository =
|
let repository =
|
||||||
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
|
TenantRepository::new(db.clone(), registry.clone(), component_cache.clone());
|
||||||
|
repository
|
||||||
|
.ensure_builtin_components()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
repository
|
repository
|
||||||
.hydrate_all()
|
.hydrate_all()
|
||||||
.await
|
.await
|
||||||
@@ -70,6 +101,7 @@ impl AppState {
|
|||||||
auth: auth.clone(),
|
auth: auth.clone(),
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
http: http.clone(),
|
http: http.clone(),
|
||||||
|
gift_catalogs: gift_catalogs.clone(),
|
||||||
});
|
});
|
||||||
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
|
let (source_events, mut source_event_rx) = mpsc::channel::<Arc<LiveEvent>>(512);
|
||||||
let supervisor = SourceSupervisor::new(provider_factory, source_events);
|
let supervisor = SourceSupervisor::new(provider_factory, source_events);
|
||||||
@@ -106,6 +138,8 @@ impl AppState {
|
|||||||
),
|
),
|
||||||
component_socket_slots: Arc::new(Semaphore::new(128)),
|
component_socket_slots: Arc::new(Semaphore::new(128)),
|
||||||
http,
|
http,
|
||||||
|
song_requests,
|
||||||
|
gift_catalogs,
|
||||||
};
|
};
|
||||||
state.start_all_sources().await?;
|
state.start_all_sources().await?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
@@ -200,6 +234,7 @@ struct BilibiliProviderFactory {
|
|||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
gift_catalogs: GiftCatalogRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -213,12 +248,14 @@ impl ProviderFactory for BilibiliProviderFactory {
|
|||||||
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
|
.ok_or_else(|| "CookieCloud credentials have not been configured".to_string())?;
|
||||||
self.config.allowed_cookiecloud_host(&stored.host)?;
|
self.config.allowed_cookiecloud_host(&stored.host)?;
|
||||||
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
|
let cookie = fetch_bilibili_cookie(&self.http, &stored).await?;
|
||||||
Ok(Arc::new(BilibiliProvider::new(
|
let gift_catalog = self.gift_catalogs.catalog(source.owner_id).await;
|
||||||
|
Ok(Arc::new(BilibiliProvider::with_gift_catalog(
|
||||||
cookie,
|
cookie,
|
||||||
self.config.gift_refresh_seconds,
|
self.config.gift_refresh_seconds,
|
||||||
self.config.gift_request_timeout_seconds,
|
self.config.gift_request_timeout_seconds,
|
||||||
self.config.emoticon_refresh_seconds,
|
self.config.emoticon_refresh_seconds,
|
||||||
self.config.emoticon_request_timeout_seconds,
|
self.config.emoticon_request_timeout_seconds,
|
||||||
|
gift_catalog,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +286,21 @@ async fn migrate(db: &Db) -> Result<(), String> {
|
|||||||
),
|
),
|
||||||
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
|
(3_i32, include_str!("../migrations/003_multitenancy.sql")),
|
||||||
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
|
(4_i32, include_str!("../migrations/004_auth_hardening.sql")),
|
||||||
|
(5_i32, include_str!("../migrations/005_song_request.sql")),
|
||||||
|
(
|
||||||
|
6_i32,
|
||||||
|
include_str!("../migrations/006_account_live_source.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
7_i32,
|
||||||
|
include_str!("../migrations/007_switchable_account_room.sql"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8_i32,
|
||||||
|
include_str!("../migrations/008_account_language.sql"),
|
||||||
|
),
|
||||||
|
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
|
||||||
|
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
|
||||||
] {
|
] {
|
||||||
let applied = transaction
|
let applied = transaction
|
||||||
.query_one(
|
.query_one(
|
||||||
|
|||||||
+207
-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 std::{fmt, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
@@ -11,14 +19,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use tokio_postgres::Transaction;
|
use tokio_postgres::{Transaction, error::SqlState};
|
||||||
use totp_rs::{Algorithm, Secret, TOTP};
|
use totp_rs::{Algorithm, Secret, TOTP};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
|
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
|
||||||
db::{Db, DbError},
|
db::{Db, DbError},
|
||||||
|
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||||
|
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||||
|
i18n,
|
||||||
overlay::OverlaySettings,
|
overlay::OverlaySettings,
|
||||||
|
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOTP_DIGITS: usize = 6;
|
const TOTP_DIGITS: usize = 6;
|
||||||
@@ -470,7 +482,11 @@ impl AuthService {
|
|||||||
&self,
|
&self,
|
||||||
enrollment_token: &str,
|
enrollment_token: &str,
|
||||||
totp_code: &str,
|
totp_code: &str,
|
||||||
|
language: &str,
|
||||||
) -> Result<RegistrationComplete, AuthError> {
|
) -> Result<RegistrationComplete, AuthError> {
|
||||||
|
if !i18n::is_supported(language) {
|
||||||
|
return Err(AuthError::InvalidInput("language is not supported"));
|
||||||
|
}
|
||||||
let digest = token_digest(enrollment_token.trim());
|
let digest = token_digest(enrollment_token.trim());
|
||||||
let mut client = self.db.get().await?;
|
let mut client = self.db.get().await?;
|
||||||
let transaction = client.transaction().await?;
|
let transaction = client.transaction().await?;
|
||||||
@@ -589,6 +605,12 @@ impl AuthService {
|
|||||||
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
||||||
let session = insert_session(&transaction, user_id, self.session_ttl).await?;
|
let session = insert_session(&transaction, user_id, self.session_ttl).await?;
|
||||||
Db::set_tenant(&transaction, user_id).await?;
|
Db::set_tenant(&transaction, user_id).await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2)",
|
||||||
|
&[&user_id, &language],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let default_source_id = Uuid::new_v4();
|
let default_source_id = Uuid::new_v4();
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
@@ -602,13 +624,65 @@ impl AuthService {
|
|||||||
.expect("OverlaySettings is always JSON serializable");
|
.expect("OverlaySettings is always JSON serializable");
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
|
||||||
|
&[&default_component_id, &user_id, &default_settings],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let song_component_id = Uuid::new_v4();
|
||||||
|
let song_settings = serde_json::to_value(SongRequestSettings::default())
|
||||||
|
.expect("SongRequestSettings is always JSON serializable");
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO component_instances \
|
||||||
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
&[
|
&[
|
||||||
&default_component_id,
|
&song_component_id,
|
||||||
&user_id,
|
&user_id,
|
||||||
&default_source_id,
|
&SONG_REQUEST_KIND,
|
||||||
&default_settings,
|
&SONG_REQUEST_NAME,
|
||||||
|
&song_settings,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO song_request_state(owner_user_id,component_instance_id) VALUES($1,$2)",
|
||||||
|
&[&user_id, &song_component_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let gift_component_id = Uuid::new_v4();
|
||||||
|
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
|
||||||
|
.expect("GiftEffectSettings is always JSON serializable");
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO component_instances \
|
||||||
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
|
&[
|
||||||
|
&gift_component_id,
|
||||||
|
&user_id,
|
||||||
|
&GIFT_EFFECT_KIND,
|
||||||
|
&GIFT_EFFECT_NAME,
|
||||||
|
&gift_settings,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let menu_component_id = Uuid::new_v4();
|
||||||
|
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||||
|
.expect("GiftMenuSettings is always JSON serializable");
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO component_instances \
|
||||||
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
|
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||||
|
&[
|
||||||
|
&menu_component_id,
|
||||||
|
&user_id,
|
||||||
|
&GIFT_MENU_KIND,
|
||||||
|
&GIFT_MENU_NAME,
|
||||||
|
&menu_settings,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -882,6 +956,100 @@ impl AuthService {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
credentials: &CookieCloudCredentials,
|
credentials: &CookieCloudCredentials,
|
||||||
) -> Result<(), AuthError> {
|
) -> Result<(), AuthError> {
|
||||||
|
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
|
||||||
|
let mut client = self.db.get().await?;
|
||||||
|
let transaction = client.transaction().await?;
|
||||||
|
ensure_active_user(&transaction, user_id).await?;
|
||||||
|
Db::set_tenant(&transaction, user_id).await?;
|
||||||
|
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
|
||||||
|
insert_audit(
|
||||||
|
&transaction,
|
||||||
|
Some(user_id),
|
||||||
|
"cookiecloud.credentials.updated",
|
||||||
|
"user",
|
||||||
|
Some(user_id.to_string()),
|
||||||
|
json!({"host":host}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically changes the account's current room and encrypted CookieCloud
|
||||||
|
/// source. The live source keeps its stable ID, allowing the supervisor to
|
||||||
|
/// cancel the old room connection and start the replacement without
|
||||||
|
/// changing any component or OBS token.
|
||||||
|
pub async fn set_account_live_source(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
room_id: &str,
|
||||||
|
credentials: &CookieCloudCredentials,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
let room_id = room_id.trim();
|
||||||
|
validate_room_id(room_id)?;
|
||||||
|
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
|
||||||
|
let mut client = self.db.get().await?;
|
||||||
|
let transaction = client.transaction().await?;
|
||||||
|
ensure_active_user(&transaction, user_id).await?;
|
||||||
|
let old_room_id: String = transaction
|
||||||
|
.query_one("SELECT room_id FROM users WHERE id=$1", &[&user_id])
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
let room_in_use: bool = transaction
|
||||||
|
.query_one(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1 AND id<>$2)",
|
||||||
|
&[&room_id, &user_id],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
if room_in_use {
|
||||||
|
return Err(AuthError::RoomUnavailable);
|
||||||
|
}
|
||||||
|
Db::set_tenant(&transaction, user_id).await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"UPDATE users SET room_id=$1,updated_at=now() WHERE id=$2 AND status='active'",
|
||||||
|
&[&room_id, &user_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_room_write_error)?;
|
||||||
|
let changed = transaction
|
||||||
|
.execute(
|
||||||
|
"UPDATE live_sources SET room_id=$1,updated_at=now() \
|
||||||
|
WHERE owner_user_id=$2",
|
||||||
|
&[&room_id, &user_id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_room_write_error)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(AuthError::DatabaseInvariant(
|
||||||
|
"account must own exactly one live source",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
|
||||||
|
insert_audit(
|
||||||
|
&transaction,
|
||||||
|
Some(user_id),
|
||||||
|
"account.live_source.updated",
|
||||||
|
"user",
|
||||||
|
Some(user_id.to_string()),
|
||||||
|
json!({
|
||||||
|
"host": host,
|
||||||
|
"oldRoomId": old_room_id,
|
||||||
|
"newRoomId": room_id,
|
||||||
|
"roomChanged": old_room_id != room_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_cookiecloud_credentials(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
credentials: &CookieCloudCredentials,
|
||||||
|
) -> Result<(String, EncryptedSecret), AuthError> {
|
||||||
let host = normalize_cookiecloud_host(&credentials.host)
|
let host = normalize_cookiecloud_host(&credentials.host)
|
||||||
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
|
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
|
||||||
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
|
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
|
||||||
@@ -897,31 +1065,7 @@ impl AuthService {
|
|||||||
let encrypted = self
|
let encrypted = self
|
||||||
.cipher
|
.cipher
|
||||||
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
|
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
|
||||||
let mut client = self.db.get().await?;
|
Ok((host, encrypted))
|
||||||
let transaction = client.transaction().await?;
|
|
||||||
ensure_active_user(&transaction, user_id).await?;
|
|
||||||
Db::set_tenant(&transaction, user_id).await?;
|
|
||||||
transaction
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO cookiecloud_credentials \
|
|
||||||
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
|
|
||||||
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
|
|
||||||
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
|
|
||||||
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_audit(
|
|
||||||
&transaction,
|
|
||||||
Some(user_id),
|
|
||||||
"cookiecloud.credentials.updated",
|
|
||||||
"user",
|
|
||||||
Some(user_id.to_string()),
|
|
||||||
json!({"host":host}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
transaction.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_cookiecloud_credentials(
|
pub async fn get_cookiecloud_credentials(
|
||||||
@@ -1207,13 +1351,6 @@ impl AuthService {
|
|||||||
.ok_or(AuthError::InvalidCredentials)?
|
.ok_or(AuthError::InvalidCredentials)?
|
||||||
.get(0);
|
.get(0);
|
||||||
Db::set_tenant(&transaction, owner_user_id).await?;
|
Db::set_tenant(&transaction, owner_user_id).await?;
|
||||||
let source_id: Uuid = transaction
|
|
||||||
.query_one(
|
|
||||||
"SELECT id FROM live_sources WHERE owner_user_id=$1",
|
|
||||||
&[&owner_user_id],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.get(0);
|
|
||||||
let component_id = if let Some(row) = transaction
|
let component_id = if let Some(row) = transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT id FROM component_instances \
|
"SELECT id FROM component_instances \
|
||||||
@@ -1235,9 +1372,9 @@ impl AuthService {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances(id,owner_user_id,source_id,kind,name,settings) \
|
"INSERT INTO component_instances(id,owner_user_id,kind,name,settings) \
|
||||||
VALUES($1,$2,$3,'danmaku_overlay','弹幕姬',$4)",
|
VALUES($1,$2,'danmaku_overlay','弹幕姬',$3)",
|
||||||
&[&id, &owner_user_id, &source_id, &settings],
|
&[&id, &owner_user_id, &settings],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
id
|
id
|
||||||
@@ -1254,6 +1391,33 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn upsert_cookiecloud_credentials(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
user_id: Uuid,
|
||||||
|
host: &str,
|
||||||
|
encrypted: &EncryptedSecret,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO cookiecloud_credentials \
|
||||||
|
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
|
||||||
|
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
|
||||||
|
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
|
||||||
|
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_room_write_error(error: tokio_postgres::Error) -> AuthError {
|
||||||
|
if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
|
||||||
|
AuthError::RoomUnavailable
|
||||||
|
} else {
|
||||||
|
error.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SecretCipher {
|
struct SecretCipher {
|
||||||
cipher: Arc<XChaCha20Poly1305>,
|
cipher: Arc<XChaCha20Poly1305>,
|
||||||
@@ -1807,8 +1971,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn username_and_room_validation_are_canonical() {
|
fn username_and_room_validation_are_canonical() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normalize_username(" LuoXingCi ").expect("username"),
|
normalize_username(" ExampleStreamer ").expect("username"),
|
||||||
("LuoXingCi".into(), "luoxingci".into())
|
("ExampleStreamer".into(), "examplestreamer".into())
|
||||||
);
|
);
|
||||||
assert!(normalize_username("ab").is_err());
|
assert!(normalize_username("ab").is_err());
|
||||||
assert!(normalize_username("bad name").is_err());
|
assert!(normalize_username("bad name").is_err());
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Extensible component registry and event-processing contracts.
|
||||||
|
//!
|
||||||
|
//! A component kind supplies a versioned settings definition, a pure browser
|
||||||
|
//! projection and optional durable handlers. The live provider and router know
|
||||||
|
//! only these traits, so adding a gift wall or song-request component does not
|
||||||
|
//! require branching on component kinds in the ingestion pipeline.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeSet, HashMap},
|
collections::{BTreeSet, HashMap},
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -13,7 +20,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||||
|
gift_effect::GiftEffectDefinition,
|
||||||
|
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||||
overlay::OverlaySettings,
|
overlay::OverlaySettings,
|
||||||
|
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
|
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
|
||||||
@@ -76,7 +86,10 @@ pub struct ComponentInstance {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
pub source_id: Uuid,
|
/// Runtime identity of the owner's account-level source. This value is
|
||||||
|
/// derived while loading the component and is not stored on its row.
|
||||||
|
#[serde(rename = "sourceId")]
|
||||||
|
pub account_source_id: Uuid,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@@ -87,7 +100,7 @@ pub struct ComponentInstance {
|
|||||||
impl ComponentInstance {
|
impl ComponentInstance {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
source_id: Uuid,
|
account_source_id: Uuid,
|
||||||
kind: impl Into<String>,
|
kind: impl Into<String>,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
settings_version: u32,
|
settings_version: u32,
|
||||||
@@ -96,7 +109,7 @@ impl ComponentInstance {
|
|||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
owner_id,
|
owner_id,
|
||||||
source_id,
|
account_source_id,
|
||||||
kind: kind.into(),
|
kind: kind.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -173,6 +186,19 @@ pub trait EventProjection: Send + Sync {
|
|||||||
/// `async-trait` dependency.
|
/// `async-trait` dependency.
|
||||||
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
|
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
|
||||||
|
|
||||||
|
/// Future used by component-specific durable state snapshots during WebSocket
|
||||||
|
/// authentication. Snapshot messages share the normal component envelope.
|
||||||
|
pub type SnapshotFuture<'a> =
|
||||||
|
Pin<Box<dyn Future<Output = Result<Vec<ComponentMessage>, ComponentError>> + Send + 'a>>;
|
||||||
|
|
||||||
|
pub trait ComponentSnapshotProvider: Send + Sync {
|
||||||
|
fn snapshot<'a>(
|
||||||
|
&'a self,
|
||||||
|
component: &'a ComponentInstance,
|
||||||
|
room_id: &'a str,
|
||||||
|
) -> SnapshotFuture<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
/// An active handler may perform durable side effects (for example recording a
|
/// An active handler may perform durable side effects (for example recording a
|
||||||
/// song request). It runs independently of WebSocket receiver count and should
|
/// song request). It runs independently of WebSocket receiver count and should
|
||||||
/// implement idempotency in its persistence layer.
|
/// implement idempotency in its persistence layer.
|
||||||
@@ -273,6 +299,7 @@ pub struct ComponentRuntime {
|
|||||||
definition: Arc<dyn ComponentDefinition>,
|
definition: Arc<dyn ComponentDefinition>,
|
||||||
projection: Arc<dyn EventProjection>,
|
projection: Arc<dyn EventProjection>,
|
||||||
handlers: Vec<Arc<dyn EventHandler>>,
|
handlers: Vec<Arc<dyn EventHandler>>,
|
||||||
|
snapshot_provider: Option<Arc<dyn ComponentSnapshotProvider>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentRuntime {
|
impl ComponentRuntime {
|
||||||
@@ -313,6 +340,17 @@ impl ComponentRuntime {
|
|||||||
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
|
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
|
||||||
self.handlers.clone()
|
self.handlers.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn snapshot(
|
||||||
|
&self,
|
||||||
|
component: &ComponentInstance,
|
||||||
|
room_id: &str,
|
||||||
|
) -> Result<Vec<ComponentMessage>, ComponentError> {
|
||||||
|
match &self.snapshot_provider {
|
||||||
|
Some(provider) => provider.snapshot(component, room_id).await,
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -340,6 +378,21 @@ impl ComponentRegistry {
|
|||||||
)
|
)
|
||||||
.expect("built-in component kinds are unique");
|
.expect("built-in component kinds are unique");
|
||||||
registry
|
registry
|
||||||
|
.register(
|
||||||
|
Arc::new(SongRequestDefinition),
|
||||||
|
Arc::new(SongRequestProjection),
|
||||||
|
)
|
||||||
|
.expect("built-in component kinds are unique");
|
||||||
|
registry
|
||||||
|
.register(
|
||||||
|
Arc::new(GiftEffectDefinition),
|
||||||
|
Arc::new(PassthroughProjection),
|
||||||
|
)
|
||||||
|
.expect("built-in component kinds are unique");
|
||||||
|
registry
|
||||||
|
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
||||||
|
.expect("built-in component kinds are unique");
|
||||||
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register(
|
pub fn register(
|
||||||
@@ -364,6 +417,7 @@ impl ComponentRegistry {
|
|||||||
definition,
|
definition,
|
||||||
projection,
|
projection,
|
||||||
handlers: Vec::new(),
|
handlers: Vec::new(),
|
||||||
|
snapshot_provider: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -385,6 +439,22 @@ impl ComponentRegistry {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn register_snapshot_provider(
|
||||||
|
&self,
|
||||||
|
kind: &str,
|
||||||
|
provider: Arc<dyn ComponentSnapshotProvider>,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
let mut entries = self
|
||||||
|
.entries
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
let runtime = entries
|
||||||
|
.get_mut(kind)
|
||||||
|
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?;
|
||||||
|
runtime.snapshot_provider = Some(provider);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
|
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
|
||||||
self.entries
|
self.entries
|
||||||
.read()
|
.read()
|
||||||
@@ -462,6 +532,70 @@ mod tests {
|
|||||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_song_request_is_registered_with_bounded_unlimited_defaults() {
|
||||||
|
let registry = ComponentRegistry::default();
|
||||||
|
let runtime = registry.runtime("song_request").unwrap();
|
||||||
|
let mut settings = runtime.definition().default_settings();
|
||||||
|
settings["scrollSpeedPixelsPerSecond"] = Value::from(999);
|
||||||
|
let validated = registry
|
||||||
|
.validate_settings("song_request", 1, settings)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(validated["scrollSpeedPixelsPerSecond"], 200);
|
||||||
|
assert_eq!(validated["maxQueueSize"], 0);
|
||||||
|
assert_eq!(validated["maxRequestsPerViewer"], 0);
|
||||||
|
assert_eq!(validated["requestCooldownSeconds"], 0);
|
||||||
|
|
||||||
|
let instance = ComponentInstance::new(
|
||||||
|
Uuid::new_v4(),
|
||||||
|
Uuid::new_v4(),
|
||||||
|
"song_request",
|
||||||
|
"点歌姬",
|
||||||
|
1,
|
||||||
|
validated,
|
||||||
|
);
|
||||||
|
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||||
|
assert!(subscriptions.contains(LiveEventKind::Danmaku));
|
||||||
|
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
|
||||||
|
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_gift_menu_is_registered_as_a_gift_and_guard_projection() {
|
||||||
|
let registry = ComponentRegistry::default();
|
||||||
|
let runtime = registry.runtime("gift_menu").unwrap();
|
||||||
|
let instance = ComponentInstance::new(
|
||||||
|
Uuid::new_v4(),
|
||||||
|
Uuid::new_v4(),
|
||||||
|
"gift_menu",
|
||||||
|
"礼物菜单",
|
||||||
|
1,
|
||||||
|
runtime.definition().default_settings(),
|
||||||
|
);
|
||||||
|
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||||
|
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||||
|
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||||
|
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_component_kinds_are_rejected() {
|
fn duplicate_component_kinds_are_rejected() {
|
||||||
let registry = ComponentRegistry::default();
|
let registry = ComponentRegistry::default();
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
//! TOML configuration loading, validation and legacy bootstrap compatibility.
|
||||||
|
//!
|
||||||
|
//! Deployment-wide policy (bind address, encryption key, allowed CookieCloud
|
||||||
|
//! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component
|
||||||
|
//! settings become tenant-owned database records after enrollment; the legacy
|
||||||
|
//! TOML fields are import inputs and must not be treated as global live state.
|
||||||
|
|
||||||
use std::{env, fs, net::IpAddr, path::PathBuf};
|
use std::{env, fs, net::IpAddr, path::PathBuf};
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::{credentials::normalize_cookiecloud_host, overlay::OverlaySettings};
|
use crate::{
|
||||||
|
credentials::normalize_cookiecloud_host,
|
||||||
|
overlay::{OverlaySettings, OverlayThemeId},
|
||||||
|
typography::FontFamilyId,
|
||||||
|
};
|
||||||
|
|
||||||
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
/// Process-level configuration. Tenant-owned room, CookieCloud and component
|
||||||
/// settings are imported from the legacy sections once and then live in
|
/// settings are imported from the legacy sections once and then live in
|
||||||
@@ -116,6 +127,11 @@ struct EmoticonsConfig {
|
|||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
struct OverlayFileConfig {
|
struct OverlayFileConfig {
|
||||||
|
theme_id: Option<OverlayThemeId>,
|
||||||
|
font_family: Option<FontFamilyId>,
|
||||||
|
font_brightness: Option<u16>,
|
||||||
|
viewer_color: Option<String>,
|
||||||
|
danmaku_color: Option<String>,
|
||||||
font_scale: Option<u16>,
|
font_scale: Option<u16>,
|
||||||
max_visible: Option<u8>,
|
max_visible: Option<u8>,
|
||||||
collapse_after_seconds: Option<u16>,
|
collapse_after_seconds: Option<u16>,
|
||||||
@@ -207,7 +223,7 @@ impl Config {
|
|||||||
legacy_obs_access_token: file.obs.access_token,
|
legacy_obs_access_token: file.obs.access_token,
|
||||||
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
legacy_overlay_defaults: overlay_defaults(file.overlay),
|
||||||
log_filter: file.logging.filter.unwrap_or_else(|| {
|
log_filter: file.logging.filter.unwrap_or_else(|| {
|
||||||
"lxc_stream_server=info,blivedm=warn,tokio_postgres=warn".into()
|
"lxc_stream_server=info,libilibili=warn,tokio_postgres=warn".into()
|
||||||
}),
|
}),
|
||||||
gift_refresh_seconds: file
|
gift_refresh_seconds: file
|
||||||
.gifts
|
.gifts
|
||||||
@@ -311,6 +327,11 @@ fn derive_key(secret: &str, domain: &[u8]) -> [u8; 32] {
|
|||||||
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||||
let default = OverlaySettings::default();
|
let default = OverlaySettings::default();
|
||||||
OverlaySettings {
|
OverlaySettings {
|
||||||
|
theme_id: file.theme_id.unwrap_or(default.theme_id),
|
||||||
|
font_family: file.font_family.unwrap_or(default.font_family),
|
||||||
|
font_brightness: file.font_brightness.unwrap_or(default.font_brightness),
|
||||||
|
viewer_color: file.viewer_color.or(default.viewer_color),
|
||||||
|
danmaku_color: file.danmaku_color.or(default.danmaku_color),
|
||||||
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
||||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! CookieCloud boundary and Bilibili cookie extraction.
|
||||||
|
//!
|
||||||
|
//! Tenant input reaches an outbound HTTP client only after canonical URL
|
||||||
|
//! validation and exact allow-list matching in the caller. Redirects are
|
||||||
|
//! disabled by the shared client, the synchronization key is encoded as one
|
||||||
|
//! path segment, and only the minimum Bilibili cookie fields leave this module.
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
Url,
|
Url,
|
||||||
header::{REFERER, USER_AGENT},
|
header::{REFERER, USER_AGENT},
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! PostgreSQL pool, migrations and low-level tenant-scoped queries.
|
||||||
|
//!
|
||||||
|
//! Multi-tenant tables use PostgreSQL row-level security in addition to owner
|
||||||
|
//! columns and composite foreign keys. Every tenant query must execute inside a
|
||||||
|
//! transaction after [`Db::set_tenant`], which uses `SET LOCAL` so pooled
|
||||||
|
//! connections cannot retain the previous request's identity.
|
||||||
|
|
||||||
use std::{fmt, str::FromStr};
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
||||||
@@ -101,8 +108,11 @@ impl Db {
|
|||||||
Self::set_tenant(&transaction, user_id).await?;
|
Self::set_tenant(&transaction, user_id).await?;
|
||||||
let rows = transaction
|
let rows = transaction
|
||||||
.query(
|
.query(
|
||||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||||
FROM component_instances WHERE owner_user_id=$1 ORDER BY created_at",
|
component.settings_version,component.enabled \
|
||||||
|
FROM component_instances AS component \
|
||||||
|
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
|
||||||
|
WHERE component.owner_user_id=$1 ORDER BY component.created_at",
|
||||||
&[&user_id],
|
&[&user_id],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -112,7 +122,7 @@ impl Db {
|
|||||||
.map(|row| ComponentRecord {
|
.map(|row| ComponentRecord {
|
||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
owner_user_id: user_id,
|
owner_user_id: user_id,
|
||||||
source_id: row.get(1),
|
account_source_id: row.get(1),
|
||||||
kind: row.get(2),
|
kind: row.get(2),
|
||||||
name: row.get(3),
|
name: row.get(3),
|
||||||
settings: row.get(4),
|
settings: row.get(4),
|
||||||
@@ -134,7 +144,9 @@ pub struct ActiveTenant {
|
|||||||
pub struct ComponentRecord {
|
pub struct ComponentRecord {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub owner_user_id: Uuid,
|
pub owner_user_id: Uuid,
|
||||||
pub source_id: Uuid,
|
/// Derived from the account's singleton live source; it is not component
|
||||||
|
/// configuration and is never persisted on `component_instances`.
|
||||||
|
pub account_source_id: Uuid,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub settings: Value,
|
pub settings: Value,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Provider-independent live events and the versioned component wire envelope.
|
||||||
|
//!
|
||||||
|
//! Provider adapters normalize platform packets into [`LiveEvent`]. Components
|
||||||
|
//! subscribe to stable [`LiveEventKind`] values and project them to
|
||||||
|
//! [`ComponentMessage`], keeping Bilibili command names and raw payloads out of
|
||||||
|
//! browser contracts and future provider implementations.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -44,6 +51,10 @@ impl LiveEventKind {
|
|||||||
pub struct PlatformViewer {
|
pub struct PlatformViewer {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Optional public avatar supplied by the live platform. Older event
|
||||||
|
/// formats omit it, so renderers must keep a local fallback.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
@@ -88,6 +99,9 @@ pub struct GiftDetails {
|
|||||||
pub id: Option<i64>,
|
pub id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub coin_type: String,
|
pub coin_type: String,
|
||||||
|
/// Display value in Bilibili batteries. Upstream gift prices are gold
|
||||||
|
/// coin values, where 100 gold coins equal one battery.
|
||||||
|
pub battery_value: i64,
|
||||||
pub unit_price: i64,
|
pub unit_price: i64,
|
||||||
pub total_price: i64,
|
pub total_price: i64,
|
||||||
pub price_cny: f64,
|
pub price_cny: f64,
|
||||||
@@ -270,6 +284,27 @@ pub struct ComponentMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentMessage {
|
impl ComponentMessage {
|
||||||
|
/// Build a component-owned message that does not originate directly from a
|
||||||
|
/// provider packet, such as a durable queue snapshot or settings update.
|
||||||
|
pub fn new(
|
||||||
|
component: &crate::components::ComponentInstance,
|
||||||
|
room_id: impl Into<String>,
|
||||||
|
event_type: impl Into<String>,
|
||||||
|
payload: Value,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
owner_id: component.owner_id,
|
||||||
|
component_id: component.id,
|
||||||
|
source_id: component.account_source_id,
|
||||||
|
version: COMPONENT_PROTOCOL_VERSION,
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||||
|
room_id: room_id.into(),
|
||||||
|
event_type: event_type.into(),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_live_event(
|
pub fn from_live_event(
|
||||||
component_id: Uuid,
|
component_id: Uuid,
|
||||||
event: &LiveEvent,
|
event: &LiveEvent,
|
||||||
@@ -300,6 +335,7 @@ mod tests {
|
|||||||
PlatformViewer {
|
PlatformViewer {
|
||||||
uid: "42".into(),
|
uid: "42".into(),
|
||||||
name: "观众".into(),
|
name: "观众".into(),
|
||||||
|
avatar_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Full-screen gift and membership effect component.
|
||||||
|
//!
|
||||||
|
//! The component is deliberately passive: it subscribes to canonical gift and
|
||||||
|
//! guard-purchase 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,
|
||||||
|
pub guard_star_count: u8,
|
||||||
|
pub guard_effect_duration_ms: u16,
|
||||||
|
pub max_concurrent_effects: u8,
|
||||||
|
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,
|
||||||
|
guard_star_count: 48,
|
||||||
|
guard_effect_duration_ms: 5_200,
|
||||||
|
max_concurrent_effects: 8,
|
||||||
|
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.guard_star_count = self.guard_star_count.clamp(8, 96);
|
||||||
|
self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000);
|
||||||
|
self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
LiveEventKind::GuardPurchase,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_only_subscribes_to_durable_gifts_and_guards() {
|
||||||
|
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 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,554 @@
|
|||||||
|
//! 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,
|
||||||
|
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,
|
||||||
|
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.motion_intensity = self.motion_intensity.min(100);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
..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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
|
||||||
|
let sanitized = definition.validate_settings(settings).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
//! Axum HTTP/WebSocket adapter and public trust boundary.
|
||||||
|
//!
|
||||||
|
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
|
||||||
|
//! request bodies are never trusted. This module also enforces same-origin
|
||||||
|
//! mutation checks, response cache policy, component-token WebSocket
|
||||||
|
//! authentication, bounded request bodies and security headers for the static
|
||||||
|
//! control console and OBS entry point.
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{
|
extract::{
|
||||||
DefaultBodyLimit, Path, Request, State,
|
DefaultBodyLimit, Path, Query, Request, State,
|
||||||
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
||||||
},
|
},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
@@ -26,9 +34,11 @@ use crate::{
|
|||||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
|
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
|
||||||
domain::{
|
domain::{
|
||||||
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
|
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
|
||||||
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer,
|
GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer,
|
||||||
},
|
},
|
||||||
|
gift_menu::GIFT_MENU_KIND,
|
||||||
repository::{ComponentView, RepositoryError},
|
repository::{ComponentView, RepositoryError},
|
||||||
|
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session";
|
const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session";
|
||||||
@@ -51,6 +61,18 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/v1/invitations/{id}", delete(revoke_invitation))
|
.route("/api/v1/invitations/{id}", delete(revoke_invitation))
|
||||||
.route("/api/v1/source", get(get_source).put(put_source))
|
.route("/api/v1/source", get(get_source).put(put_source))
|
||||||
.route("/api/v1/source/reconnect", post(reconnect_source))
|
.route("/api/v1/source/reconnect", post(reconnect_source))
|
||||||
|
.route(
|
||||||
|
"/api/v1/account/live-source",
|
||||||
|
get(get_source).put(put_source),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/account/live-source/reconnect",
|
||||||
|
post(reconnect_source),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/account/preferences",
|
||||||
|
get(get_account_preferences).put(put_account_preferences),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/components",
|
"/api/v1/components",
|
||||||
get(list_components).post(create_component),
|
get(list_components).post(create_component),
|
||||||
@@ -68,10 +90,38 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/v1/components/{id}/test-events",
|
"/api/v1/components/{id}/test-events",
|
||||||
post(component_test_event),
|
post(component_test_event),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/gift-catalog",
|
||||||
|
get(component_gift_catalog),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/gift-catalog/refresh",
|
||||||
|
post(refresh_component_gift_catalog),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/components/{public_id}/stream",
|
"/api/v1/components/{public_id}/stream",
|
||||||
get(component_stream),
|
get(component_stream),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/song-requests",
|
||||||
|
get(list_song_requests),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/song-requests/clear",
|
||||||
|
post(clear_song_requests),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/song-requests/{request_id}/promote",
|
||||||
|
post(promote_song_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/song-requests/{request_id}/complete",
|
||||||
|
post(complete_song_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/components/{id}/song-requests/{request_id}/cancel",
|
||||||
|
post(cancel_song_request),
|
||||||
|
)
|
||||||
.route("/api/{*path}", any(api_not_found))
|
.route("/api/{*path}", any(api_not_found))
|
||||||
.route("/", get(spa_page))
|
.route("/", get(spa_page))
|
||||||
.route("/login", get(spa_page))
|
.route("/login", get(spa_page))
|
||||||
@@ -83,6 +133,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/control/register", get(spa_page))
|
.route("/control/register", get(spa_page))
|
||||||
.route("/control/setup", get(spa_page))
|
.route("/control/setup", get(spa_page))
|
||||||
.route("/control/invitations", get(spa_page))
|
.route("/control/invitations", get(spa_page))
|
||||||
|
.route("/control/account", get(spa_page))
|
||||||
.route("/obs/{public_id}", get(spa_page))
|
.route("/obs/{public_id}", get(spa_page))
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
||||||
@@ -241,6 +292,7 @@ async fn register_start(
|
|||||||
struct EnrollmentConfirmRequest {
|
struct EnrollmentConfirmRequest {
|
||||||
enrollment_token: String,
|
enrollment_token: String,
|
||||||
code: String,
|
code: String,
|
||||||
|
language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn enrollment_confirm(
|
async fn enrollment_confirm(
|
||||||
@@ -250,9 +302,20 @@ async fn enrollment_confirm(
|
|||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
same_origin(&state, &headers)?;
|
same_origin(&state, &headers)?;
|
||||||
consume_enrollment_budget(&state, &headers).await?;
|
consume_enrollment_budget(&state, &headers).await?;
|
||||||
|
let requested_language = body
|
||||||
|
.language
|
||||||
|
.unwrap_or_else(|| crate::i18n::default_language().to_owned());
|
||||||
|
let language = requested_language.trim();
|
||||||
|
if !crate::i18n::is_supported(language) {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_input",
|
||||||
|
"Language is not supported",
|
||||||
|
));
|
||||||
|
}
|
||||||
let result = state
|
let result = state
|
||||||
.auth
|
.auth
|
||||||
.registration_confirm(&body.enrollment_token, &body.code)
|
.registration_confirm(&body.enrollment_token, &body.code, language)
|
||||||
.await?;
|
.await?;
|
||||||
if result.user.role == UserRole::SystemAdmin {
|
if result.user.role == UserRole::SystemAdmin {
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
@@ -326,7 +389,7 @@ async fn login(
|
|||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
state.login_limiter.failure(&body.username, &ip).await;
|
state.login_limiter.failure(&body.username, &ip).await;
|
||||||
return Err(ApiError::from(error).as_generic_login());
|
return Err(ApiError::from(error).into_generic_login());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
state.login_limiter.success(&body.username, &ip).await;
|
state.login_limiter.success(&body.username, &ip).await;
|
||||||
@@ -486,13 +549,7 @@ async fn put_source(
|
|||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
same_origin(&state, &headers)?;
|
same_origin(&state, &headers)?;
|
||||||
let session = require_session(&state, &headers).await?;
|
let session = require_session(&state, &headers).await?;
|
||||||
if body.room_id.trim() != session.user.room_id {
|
let room_id = body.room_id.trim().to_owned();
|
||||||
return Err(ApiError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"room_is_immutable",
|
|
||||||
"The room bound by the invitation cannot be changed",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let existing = state
|
let existing = state
|
||||||
.auth
|
.auth
|
||||||
.get_cookiecloud_credentials(session.user.id)
|
.get_cookiecloud_credentials(session.user.id)
|
||||||
@@ -543,7 +600,7 @@ async fn put_source(
|
|||||||
})?;
|
})?;
|
||||||
state
|
state
|
||||||
.auth
|
.auth
|
||||||
.set_cookiecloud_credentials(session.user.id, &credentials)
|
.set_account_live_source(session.user.id, &room_id, &credentials)
|
||||||
.await?;
|
.await?;
|
||||||
state
|
state
|
||||||
.restart_user_source(session.user.id)
|
.restart_user_source(session.user.id)
|
||||||
@@ -565,6 +622,50 @@ async fn reconnect_source(
|
|||||||
Ok(Json(json!({"ok":true})))
|
Ok(Json(json!({"ok":true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_account_preferences(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let language = state.repository.account_language(session.user.id).await?;
|
||||||
|
Ok(Json(json!({"language":language})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PutAccountPreferences {
|
||||||
|
language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_account_preferences(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<PutAccountPreferences>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
same_origin(&state, &headers)?;
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let language = state
|
||||||
|
.repository
|
||||||
|
.set_account_language(session.user.id, body.language.trim())
|
||||||
|
.await?;
|
||||||
|
let room_id = state.repository.room_id(session.user.id).await?;
|
||||||
|
for view in state.repository.list_components(session.user.id).await? {
|
||||||
|
let component = state
|
||||||
|
.repository
|
||||||
|
.get_component(session.user.id, view.id)
|
||||||
|
.await?;
|
||||||
|
state.hub.publish(
|
||||||
|
component.id,
|
||||||
|
Arc::new(ComponentMessage::new(
|
||||||
|
&component,
|
||||||
|
&room_id,
|
||||||
|
"component.language.updated",
|
||||||
|
json!({"language":language}),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Json(json!({"language":language})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_components(
|
async fn list_components(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -637,12 +738,199 @@ async fn put_component_settings(
|
|||||||
let message = settings_message(
|
let message = settings_message(
|
||||||
&component,
|
&component,
|
||||||
&session.user.room_id,
|
&session.user.room_id,
|
||||||
"overlay.settings.updated",
|
"component.settings.updated",
|
||||||
);
|
);
|
||||||
state.hub.publish(component.id, Arc::new(message));
|
state.hub.publish(component.id, Arc::new(message));
|
||||||
|
if component.kind == "danmaku_overlay" {
|
||||||
|
state.hub.publish(
|
||||||
|
component.id,
|
||||||
|
Arc::new(settings_message(
|
||||||
|
&component,
|
||||||
|
&session.user.room_id,
|
||||||
|
"overlay.settings.updated",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Json(json!({"settings":component.settings})))
|
Ok(Json(json!({"settings":component.settings})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn component_gift_catalog(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
|
if component.kind != GIFT_MENU_KIND {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"Gift catalog is unavailable for this component",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let catalog = state.gift_catalogs.catalog(session.user.id).await;
|
||||||
|
if catalog.is_empty().await {
|
||||||
|
catalog
|
||||||
|
.refresh(
|
||||||
|
&session.user.room_id,
|
||||||
|
state.config.gift_request_timeout_seconds,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
}
|
||||||
|
Ok(Json(json!({"gifts":catalog.list().await})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_component_gift_catalog(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
same_origin(&state, &headers)?;
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
|
if component.kind != GIFT_MENU_KIND {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"Gift catalog is unavailable for this component",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let catalog = state.gift_catalogs.catalog(session.user.id).await;
|
||||||
|
catalog
|
||||||
|
.refresh(
|
||||||
|
&session.user.room_id,
|
||||||
|
state.config.gift_request_timeout_seconds,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
Ok(Json(json!({"gifts":catalog.list().await})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SongRequestsQuery {
|
||||||
|
scope: Option<String>,
|
||||||
|
cursor: Option<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_song_requests(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<SongRequestsQuery>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
|
require_song_component(&component)?;
|
||||||
|
let scope = match query.scope.as_deref().unwrap_or("active") {
|
||||||
|
"active" => SongListScope::Active,
|
||||||
|
"history" => SongListScope::History,
|
||||||
|
_ => {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_scope",
|
||||||
|
"scope must be active or history",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let page = state
|
||||||
|
.song_requests
|
||||||
|
.list_page(
|
||||||
|
&component,
|
||||||
|
scope,
|
||||||
|
query.cursor.unwrap_or(0),
|
||||||
|
query.limit.unwrap_or(50),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::to_value(page).map_err(internal)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn promote_song_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
same_origin(&state, &headers)?;
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
|
require_song_component(&component)?;
|
||||||
|
state
|
||||||
|
.song_requests
|
||||||
|
.promote(&component, request_id, &session.user.room_id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_song_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
change_song_request(state, headers, id, request_id, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_song_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
change_song_request(state, headers, id, request_id, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_song_requests(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
same_origin(&state, &headers)?;
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
|
require_song_component(&component)?;
|
||||||
|
let cancelled_count = state
|
||||||
|
.song_requests
|
||||||
|
.clear_active(&component, &session.user.room_id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"cancelledCount": cancelled_count,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn change_song_request(
|
||||||
|
state: AppState,
|
||||||
|
headers: HeaderMap,
|
||||||
|
component_id: Uuid,
|
||||||
|
request_id: Uuid,
|
||||||
|
cancelled: bool,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
same_origin(&state, &headers)?;
|
||||||
|
let session = require_session(&state, &headers).await?;
|
||||||
|
let component = state
|
||||||
|
.repository
|
||||||
|
.get_component(session.user.id, component_id)
|
||||||
|
.await?;
|
||||||
|
require_song_component(&component)?;
|
||||||
|
state
|
||||||
|
.song_requests
|
||||||
|
.finish(&component, request_id, cancelled, &session.user.room_id)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_song_component(component: &ComponentInstance) -> Result<(), ApiError> {
|
||||||
|
if component.kind == SONG_REQUEST_KIND {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApiError::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"song_component_not_found",
|
||||||
|
"Song request component not found",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_component_token(
|
async fn get_component_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -704,9 +992,19 @@ enum TestEventRequest {
|
|||||||
name: String,
|
name: String,
|
||||||
#[serde(rename = "giftName")]
|
#[serde(rename = "giftName")]
|
||||||
gift_name: String,
|
gift_name: String,
|
||||||
|
#[serde(rename = "giftId")]
|
||||||
|
gift_id: Option<i64>,
|
||||||
battery: i32,
|
battery: i32,
|
||||||
quantity: i32,
|
quantity: i32,
|
||||||
},
|
},
|
||||||
|
Guard {
|
||||||
|
uid: String,
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "guardName")]
|
||||||
|
guard_name: String,
|
||||||
|
quantity: i32,
|
||||||
|
price: i64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn component_test_event(
|
async fn component_test_event(
|
||||||
@@ -718,7 +1016,11 @@ async fn component_test_event(
|
|||||||
same_origin(&state, &headers)?;
|
same_origin(&state, &headers)?;
|
||||||
let session = require_session(&state, &headers).await?;
|
let session = require_session(&state, &headers).await?;
|
||||||
let component = state.repository.get_component(session.user.id, id).await?;
|
let component = state.repository.get_component(session.user.id, id).await?;
|
||||||
let viewer = |uid: String, name: String| PlatformViewer { uid, name };
|
let viewer = |uid: String, name: String| PlatformViewer {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
avatar_url: None,
|
||||||
|
};
|
||||||
let payload = match body {
|
let payload = match body {
|
||||||
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
||||||
viewer: viewer(uid, name),
|
viewer: viewer(uid, name),
|
||||||
@@ -732,18 +1034,23 @@ async fn component_test_event(
|
|||||||
uid,
|
uid,
|
||||||
name,
|
name,
|
||||||
gift_name,
|
gift_name,
|
||||||
|
gift_id,
|
||||||
battery,
|
battery,
|
||||||
quantity,
|
quantity,
|
||||||
} => {
|
} => {
|
||||||
let quantity = quantity.max(1);
|
let quantity = quantity.max(1);
|
||||||
let unit_price = i64::from(battery.max(0));
|
// The test API accepts the user-facing battery value while the
|
||||||
|
// canonical event retains Bilibili's raw gold-coin price.
|
||||||
|
let battery_value = i64::from(battery.max(0));
|
||||||
|
let unit_price = battery_value.saturating_mul(100);
|
||||||
let total_price = unit_price.saturating_mul(i64::from(quantity));
|
let total_price = unit_price.saturating_mul(i64::from(quantity));
|
||||||
LiveEventPayload::Gift(GiftEvent {
|
LiveEventPayload::Gift(GiftEvent {
|
||||||
viewer: viewer(uid, name),
|
viewer: viewer(uid, name),
|
||||||
gift: GiftDetails {
|
gift: GiftDetails {
|
||||||
id: None,
|
id: gift_id,
|
||||||
name: gift_name,
|
name: gift_name,
|
||||||
coin_type: "gold".into(),
|
coin_type: "gold".into(),
|
||||||
|
battery_value,
|
||||||
unit_price,
|
unit_price,
|
||||||
total_price,
|
total_price,
|
||||||
price_cny: total_price as f64 / 1000.0,
|
price_cny: total_price as f64 / 1000.0,
|
||||||
@@ -756,10 +1063,22 @@ async fn component_test_event(
|
|||||||
source_event_id: format!("test-{}", Uuid::new_v4()),
|
source_event_id: format!("test-{}", Uuid::new_v4()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
TestEventRequest::Guard {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
guard_name,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
} => LiveEventPayload::GuardPurchase(GuardPurchaseEvent {
|
||||||
|
viewer: viewer(uid, name),
|
||||||
|
guard_name,
|
||||||
|
quantity: quantity.max(1),
|
||||||
|
price: price.max(0),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let mut event = LiveEvent::new(
|
let mut event = LiveEvent::new(
|
||||||
component.owner_id,
|
component.owner_id,
|
||||||
component.source_id,
|
component.account_source_id,
|
||||||
"test",
|
"test",
|
||||||
session.user.room_id,
|
session.user.room_id,
|
||||||
payload,
|
payload,
|
||||||
@@ -867,9 +1186,20 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut receiver = state.hub.subscribe(component_id);
|
let mut receiver = state.hub.subscribe(component_id);
|
||||||
|
let language = match state
|
||||||
|
.repository
|
||||||
|
.account_language(identity.owner_user_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(language) => language,
|
||||||
|
Err(_) => {
|
||||||
|
close_ws(&mut socket, "Component is unavailable").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if socket
|
if socket
|
||||||
.send(Message::Text(
|
.send(Message::Text(
|
||||||
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id})
|
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id,"componentKind":component.kind,"language":language})
|
||||||
.to_string()
|
.to_string()
|
||||||
.into(),
|
.into(),
|
||||||
))
|
))
|
||||||
@@ -885,19 +1215,54 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let snapshot = settings_message(&component, &room_id, "overlay.settings.snapshot");
|
let snapshot = settings_message(&component, &room_id, "component.settings.snapshot");
|
||||||
if send_component_message(&mut socket, &snapshot)
|
if send_component_message(&mut socket, &snapshot)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if component.kind == "danmaku_overlay"
|
||||||
|
&& send_component_message(
|
||||||
|
&mut socket,
|
||||||
|
&settings_message(&component, &room_id, "overlay.settings.snapshot"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let runtime = match state.registry.runtime(&component.kind) {
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(_) => {
|
||||||
|
close_ws(&mut socket, "Component is unavailable").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let component_snapshots = match runtime.snapshot(&component, &room_id).await {
|
||||||
|
Ok(messages) => messages,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(component_id = %component.id, %error, "component snapshot failed");
|
||||||
|
close_ws(&mut socket, "Component snapshot is unavailable").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for message in component_snapshots {
|
||||||
|
if send_component_message(&mut socket, &message).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
event = receiver.recv() => match event {
|
event = receiver.recv() => match event {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
if send_component_message(&mut socket, &message).await.is_err() { break; }
|
if send_component_message(&mut socket, &message).await.is_err() { break; }
|
||||||
}
|
}
|
||||||
|
// A durable component cannot safely guess dropped deltas. A
|
||||||
|
// disconnect makes the browser obtain a fresh paged snapshot;
|
||||||
|
// passive visual components can keep their legacy best-effort
|
||||||
|
// behavior.
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) if component.kind == SONG_REQUEST_KIND => break,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
},
|
},
|
||||||
@@ -937,17 +1302,12 @@ fn settings_message(
|
|||||||
room_id: &str,
|
room_id: &str,
|
||||||
event_type: &str,
|
event_type: &str,
|
||||||
) -> ComponentMessage {
|
) -> ComponentMessage {
|
||||||
ComponentMessage {
|
ComponentMessage::new(
|
||||||
owner_id: component.owner_id,
|
component,
|
||||||
component_id: component.id,
|
room_id,
|
||||||
source_id: component.source_id,
|
event_type,
|
||||||
version: COMPONENT_PROTOCOL_VERSION,
|
json!({"settings":component.settings}),
|
||||||
id: Uuid::new_v4(),
|
)
|
||||||
occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
|
||||||
room_id: room_id.to_owned(),
|
|
||||||
event_type: event_type.into(),
|
|
||||||
payload: json!({"settings":component.settings}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_session(
|
async fn require_session(
|
||||||
@@ -1117,9 +1477,11 @@ fn constant_time_secret_eq(provided: &str, expected: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ErrorBody {
|
struct ErrorBody {
|
||||||
error: &'static str,
|
error: &'static str,
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
|
message_key: String,
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,7 +1508,7 @@ impl ApiError {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn as_generic_login(mut self) -> Self {
|
fn into_generic_login(mut self) -> Self {
|
||||||
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
||||||
self.status = StatusCode::UNAUTHORIZED;
|
self.status = StatusCode::UNAUTHORIZED;
|
||||||
self.code = "invalid_credentials";
|
self.code = "invalid_credentials";
|
||||||
@@ -1166,14 +1528,15 @@ impl IntoResponse for ApiError {
|
|||||||
Json(ErrorBody {
|
Json(ErrorBody {
|
||||||
error: self.code,
|
error: self.code,
|
||||||
code: self.code,
|
code: self.code,
|
||||||
|
message_key: format!("api.error.{}", self.code),
|
||||||
message: self.message,
|
message: self.message,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
if let Some(retry_after) = self.retry_after {
|
if let Some(retry_after) = self.retry_after
|
||||||
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) {
|
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
|
||||||
response.headers_mut().insert(header::RETRY_AFTER, value);
|
{
|
||||||
}
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
@@ -1201,11 +1564,14 @@ impl From<AuthError> for ApiError {
|
|||||||
"enrollment_unavailable",
|
"enrollment_unavailable",
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
),
|
),
|
||||||
AuthError::AccountUnavailable | AuthError::RoomUnavailable => Self::new(
|
AuthError::AccountUnavailable => Self::new(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
"account_unavailable",
|
"account_unavailable",
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
),
|
),
|
||||||
|
AuthError::RoomUnavailable => {
|
||||||
|
Self::new(StatusCode::CONFLICT, "room_unavailable", error.to_string())
|
||||||
|
}
|
||||||
AuthError::InvalidTotp
|
AuthError::InvalidTotp
|
||||||
| AuthError::TotpReplay
|
| AuthError::TotpReplay
|
||||||
| AuthError::InvalidCredentials
|
| AuthError::InvalidCredentials
|
||||||
@@ -1248,6 +1614,25 @@ impl From<RepositoryError> for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SongRequestError> for ApiError {
|
||||||
|
fn from(error: SongRequestError) -> Self {
|
||||||
|
match error {
|
||||||
|
SongRequestError::NotFound => Self::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"song_request_not_found",
|
||||||
|
"Song request was not found",
|
||||||
|
),
|
||||||
|
SongRequestError::Conflict(message) => {
|
||||||
|
Self::new(StatusCode::CONFLICT, "song_request_conflict", message)
|
||||||
|
}
|
||||||
|
SongRequestError::Invalid(message) => {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, "invalid_song_request", message)
|
||||||
|
}
|
||||||
|
other => internal(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn internal(error: impl std::fmt::Display) -> ApiError {
|
fn internal(error: impl std::fmt::Display) -> ApiError {
|
||||||
error!(%error, "internal operation failed");
|
error!(%error, "internal operation failed");
|
||||||
ApiError::new(
|
ApiError::new(
|
||||||
|
|||||||
@@ -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,14 @@ pub mod config;
|
|||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
|
pub mod gift_effect;
|
||||||
|
pub mod gift_menu;
|
||||||
pub mod http_api;
|
pub mod http_api;
|
||||||
|
pub mod i18n;
|
||||||
pub mod live;
|
pub mod live;
|
||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
pub mod realtime;
|
pub mod realtime;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub mod song_request;
|
||||||
|
pub mod typography;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
|||||||
|
//! Live-provider abstraction and source lifecycle types.
|
||||||
|
//!
|
||||||
|
//! A provider receives trusted tenant/source context and emits canonical
|
||||||
|
//! [`LiveEvent`] values plus observable connection status. Cancellation and
|
||||||
|
//! restart ownership live in [`supervisor::SourceSupervisor`], keeping provider
|
||||||
|
//! implementations focused on one upstream connection.
|
||||||
|
|
||||||
pub mod bilibili;
|
pub mod bilibili;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|
||||||
@@ -35,9 +42,9 @@ impl SourceStatus {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SourceContext {
|
pub struct SourceContext {
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
/// Stable database identity for the account's single fixed live source.
|
/// Stable identity for the account's single fixed listener. Components do
|
||||||
/// It remains distinct from the owner id so future provider/source models
|
/// not persist or select this ID; it remains in event envelopes so clients
|
||||||
/// do not leak the current one-room product rule into event contracts.
|
/// can identify the shared upstream connection.
|
||||||
pub source_id: Uuid,
|
pub source_id: Uuid,
|
||||||
pub room_id: String,
|
pub room_id: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Per-account source task ownership, restart and cancellation.
|
||||||
|
//!
|
||||||
|
//! The supervisor guarantees at most one provider generation for a source ID.
|
||||||
|
//! Reconfiguration cancels the old task before a replacement starts, preventing
|
||||||
|
//! duplicate Bilibili listeners and duplicate downstream events.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -21,7 +27,7 @@ struct RunningSource {
|
|||||||
status: watch::Receiver<SourceStatus>,
|
status: watch::Receiver<SourceStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns exactly one provider task per account/source. Restart always cancels
|
/// Owns exactly one provider task per account. Restart always cancels
|
||||||
/// the prior generation before starting another, which prevents the duplicate
|
/// the prior generation before starting another, which prevents the duplicate
|
||||||
/// listeners produced by the legacy `/reconnect` handler.
|
/// listeners produced by the legacy `/reconnect` handler.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Executable entry point for the livestream component host.
|
||||||
|
//!
|
||||||
|
//! Keep this file limited to process concerns: configuration, logging, socket
|
||||||
|
//! binding and graceful shutdown. All application behavior belongs in the
|
||||||
|
//! library crate so it can be exercised without starting a real HTTP server.
|
||||||
|
|
||||||
use lxc_stream_server::{app::AppState, config::Config, http_api};
|
use lxc_stream_server::{app::AppState, config::Config, http_api};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
|||||||
+191
-22
@@ -1,12 +1,49 @@
|
|||||||
|
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
|
||||||
|
//!
|
||||||
|
//! Settings are sanitized before persistence or projection. Catalog refreshes
|
||||||
|
//! replace the in-memory snapshot only after a complete valid response, so a
|
||||||
|
//! transient upstream failure preserves the last known gift images, prices and
|
||||||
|
//! emoticon URLs instead of breaking live rendering.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness};
|
||||||
|
|
||||||
|
/// Stable identifier for a renderer theme.
|
||||||
|
///
|
||||||
|
/// This enum is deliberately shared by defaults, validation and serialization:
|
||||||
|
/// persisted component settings can therefore never reference a theme that the
|
||||||
|
/// deployed frontend does not know how to render. Add a variant only together
|
||||||
|
/// with its frontend theme definition and stylesheet.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum OverlayThemeId {
|
||||||
|
#[default]
|
||||||
|
JadeScroll,
|
||||||
|
MoonlitWater,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OverlaySettings {
|
pub struct OverlaySettings {
|
||||||
|
/// Visual theme selected for this tenant-owned component instance.
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme_id: OverlayThemeId,
|
||||||
|
#[serde(default)]
|
||||||
|
pub font_family: FontFamilyId,
|
||||||
|
#[serde(default = "default_font_brightness")]
|
||||||
|
pub font_brightness: u16,
|
||||||
|
/// Optional per-component overrides. `None` keeps the selected theme's
|
||||||
|
/// palette, which makes future theme changes immediately visible.
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewer_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub danmaku_color: Option<String>,
|
||||||
#[serde(default = "default_font_scale")]
|
#[serde(default = "default_font_scale")]
|
||||||
pub font_scale: u16,
|
pub font_scale: u16,
|
||||||
pub show_danmaku: bool,
|
pub show_danmaku: bool,
|
||||||
@@ -34,6 +71,11 @@ pub struct OverlaySettings {
|
|||||||
impl Default for OverlaySettings {
|
impl Default for OverlaySettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
theme_id: OverlayThemeId::default(),
|
||||||
|
font_family: FontFamilyId::default(),
|
||||||
|
font_brightness: default_font_brightness(),
|
||||||
|
viewer_color: None,
|
||||||
|
danmaku_color: None,
|
||||||
font_scale: default_font_scale(),
|
font_scale: default_font_scale(),
|
||||||
show_danmaku: true,
|
show_danmaku: true,
|
||||||
show_enter: true,
|
show_enter: true,
|
||||||
@@ -58,6 +100,9 @@ impl Default for OverlaySettings {
|
|||||||
impl OverlaySettings {
|
impl OverlaySettings {
|
||||||
pub fn sanitize(mut self) -> Self {
|
pub fn sanitize(mut self) -> Self {
|
||||||
self.max_visible = self.max_visible.clamp(1, 12);
|
self.max_visible = self.max_visible.clamp(1, 12);
|
||||||
|
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||||
|
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
|
||||||
|
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
|
||||||
self.font_scale = self.font_scale.clamp(50, 300);
|
self.font_scale = self.font_scale.clamp(50, 300);
|
||||||
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||||
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
||||||
@@ -71,6 +116,14 @@ impl OverlaySettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_optional_hex_color(value: Option<String>) -> Option<String> {
|
||||||
|
let value = value?.trim().to_ascii_uppercase();
|
||||||
|
(value.len() == 7
|
||||||
|
&& value.starts_with('#')
|
||||||
|
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||||
|
.then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn default_font_scale() -> u16 {
|
fn default_font_scale() -> u16 {
|
||||||
140
|
140
|
||||||
}
|
}
|
||||||
@@ -93,6 +146,7 @@ pub struct GiftMeta {
|
|||||||
pub id: Option<i64>,
|
pub id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub coin_type: String,
|
pub coin_type: String,
|
||||||
|
pub battery_value: i64,
|
||||||
pub unit_price: i64,
|
pub unit_price: i64,
|
||||||
pub image_url: Option<String>,
|
pub image_url: Option<String>,
|
||||||
pub animation_url: Option<String>,
|
pub animation_url: Option<String>,
|
||||||
@@ -106,6 +160,24 @@ pub struct GiftCatalog {
|
|||||||
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
|
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Account-keyed catalog handles shared by the live provider and control API.
|
||||||
|
/// Each account receives a distinct [`GiftCatalog`], so switching or refreshing
|
||||||
|
/// one room can never replace another tenant's gift metadata.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct GiftCatalogRegistry {
|
||||||
|
catalogs: Arc<RwLock<HashMap<Uuid, GiftCatalog>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiftCatalogRegistry {
|
||||||
|
pub async fn catalog(&self, owner_id: Uuid) -> GiftCatalog {
|
||||||
|
if let Some(catalog) = self.catalogs.read().await.get(&owner_id).cloned() {
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
let mut catalogs = self.catalogs.write().await;
|
||||||
|
catalogs.entry(owner_id).or_default().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct EmoticonMeta {
|
pub struct EmoticonMeta {
|
||||||
pub emoji: String,
|
pub emoji: String,
|
||||||
@@ -123,16 +195,30 @@ pub struct EmoticonCatalog {
|
|||||||
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parser return aliases keep the atomic cache-replacement contract visible:
|
||||||
|
// both lookup maps and their source count are produced before either lock is
|
||||||
|
// updated, so readers never observe a half-refreshed catalog.
|
||||||
|
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
|
||||||
|
type EmoticonCatalogSnapshot = (
|
||||||
|
HashMap<String, EmoticonMeta>,
|
||||||
|
HashMap<String, EmoticonMeta>,
|
||||||
|
usize,
|
||||||
|
);
|
||||||
|
|
||||||
impl EmoticonCatalog {
|
impl EmoticonCatalog {
|
||||||
pub async fn len(&self) -> usize {
|
pub async fn len(&self) -> usize {
|
||||||
self.by_unique.read().await.len()
|
self.by_unique.read().await.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_empty(&self) -> bool {
|
||||||
|
self.len().await == 0
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
||||||
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
|
if let Some(unique) = unique.filter(|value| !value.is_empty())
|
||||||
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() {
|
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
|
||||||
return Some(emoticon);
|
{
|
||||||
}
|
return Some(emoticon);
|
||||||
}
|
}
|
||||||
self.by_emoji.read().await.get(emoji).cloned()
|
self.by_emoji.read().await.get(emoji).cloned()
|
||||||
}
|
}
|
||||||
@@ -184,11 +270,15 @@ impl GiftCatalog {
|
|||||||
self.by_id.read().await.len()
|
self.by_id.read().await.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_empty(&self) -> bool {
|
||||||
|
self.len().await == 0
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
||||||
if let Some(id) = id {
|
if let Some(id) = id
|
||||||
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
|
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
|
||||||
return Some(gift);
|
{
|
||||||
}
|
return Some(gift);
|
||||||
}
|
}
|
||||||
self.by_name
|
self.by_name
|
||||||
.read()
|
.read()
|
||||||
@@ -197,6 +287,21 @@ impl GiftCatalog {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return a stable, deduplicated control-console view ordered by price and
|
||||||
|
/// name. ID-backed entries are preferred because names are not unique.
|
||||||
|
pub async fn list(&self) -> Vec<GiftMeta> {
|
||||||
|
let mut gifts: Vec<_> = self.by_id.read().await.values().cloned().collect();
|
||||||
|
if gifts.is_empty() {
|
||||||
|
gifts.extend(self.by_name.read().await.values().cloned());
|
||||||
|
}
|
||||||
|
gifts.sort_by(|left, right| {
|
||||||
|
left.unit_price
|
||||||
|
.cmp(&right.unit_price)
|
||||||
|
.then_with(|| left.name.cmp(&right.name))
|
||||||
|
});
|
||||||
|
gifts
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
|
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
.timeout(std::time::Duration::from_secs(timeout_seconds))
|
||||||
@@ -231,9 +336,7 @@ impl GiftCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_catalog(
|
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||||
payload: &Value,
|
|
||||||
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
|
|
||||||
let list = payload
|
let list = payload
|
||||||
.pointer("/data/gift_config/base_config/list")
|
.pointer("/data/gift_config/base_config/list")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -245,6 +348,7 @@ fn parse_catalog(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let id = item.get("id").and_then(Value::as_i64);
|
let id = item.get("id").and_then(Value::as_i64);
|
||||||
|
let unit_price = item.get("price").and_then(Value::as_i64).unwrap_or(0);
|
||||||
let gift = GiftMeta {
|
let gift = GiftMeta {
|
||||||
id,
|
id,
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
@@ -253,7 +357,8 @@ fn parse_catalog(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("gold")
|
.unwrap_or("gold")
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0),
|
battery_value: gift_price_to_batteries(unit_price),
|
||||||
|
unit_price,
|
||||||
image_url: string_field(item, "img_basic"),
|
image_url: string_field(item, "img_basic"),
|
||||||
animation_url: string_field(item, "gif"),
|
animation_url: string_field(item, "gif"),
|
||||||
effect_type: item.get("effect").and_then(|value| match value {
|
effect_type: item.get("effect").and_then(|value| match value {
|
||||||
@@ -274,6 +379,12 @@ fn parse_catalog(
|
|||||||
Ok((ids, names, list.len()))
|
Ok((ids, names, list.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bilibili's gift panel and live messages express paid gift prices in gold
|
||||||
|
/// coins rather than batteries. One battery is 100 gold coins (and ¥0.1).
|
||||||
|
pub fn gift_price_to_batteries(raw_price: i64) -> i64 {
|
||||||
|
raw_price.max(0) / 100
|
||||||
|
}
|
||||||
|
|
||||||
fn string_field(value: &Value, name: &str) -> Option<String> {
|
fn string_field(value: &Value, name: &str) -> Option<String> {
|
||||||
value
|
value
|
||||||
.get(name)
|
.get(name)
|
||||||
@@ -282,16 +393,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_emoticon_catalog(
|
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
|
||||||
payload: &Value,
|
|
||||||
) -> Result<
|
|
||||||
(
|
|
||||||
HashMap<String, EmoticonMeta>,
|
|
||||||
HashMap<String, EmoticonMeta>,
|
|
||||||
usize,
|
|
||||||
),
|
|
||||||
String,
|
|
||||||
> {
|
|
||||||
let packages = payload
|
let packages = payload
|
||||||
.pointer("/data/data")
|
.pointer("/data/data")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -383,6 +485,29 @@ fn normalize_name(name: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gift_catalog_registry_isolates_account_snapshots() {
|
||||||
|
let registry = GiftCatalogRegistry::default();
|
||||||
|
let first = registry.catalog(Uuid::new_v4()).await;
|
||||||
|
let second = registry.catalog(Uuid::new_v4()).await;
|
||||||
|
first.by_id.write().await.insert(
|
||||||
|
1,
|
||||||
|
GiftMeta {
|
||||||
|
id: Some(1),
|
||||||
|
name: "小花花".into(),
|
||||||
|
coin_type: "gold".into(),
|
||||||
|
battery_value: 1,
|
||||||
|
unit_price: 100,
|
||||||
|
image_url: None,
|
||||||
|
animation_url: None,
|
||||||
|
effect_type: None,
|
||||||
|
stay_time: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(first.list().await.len(), 1);
|
||||||
|
assert!(second.list().await.is_empty());
|
||||||
|
}
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -394,6 +519,7 @@ mod tests {
|
|||||||
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
|
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
|
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
|
||||||
|
assert_eq!(ids.get(&42).expect("id index").battery_value, 300);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names
|
names
|
||||||
.get("青玉灯")
|
.get("青玉灯")
|
||||||
@@ -404,6 +530,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_raw_gold_coin_prices_to_batteries() {
|
||||||
|
assert_eq!(gift_price_to_batteries(100), 1);
|
||||||
|
assert_eq!(gift_price_to_batteries(15_000), 150);
|
||||||
|
assert_eq!(gift_price_to_batteries(-1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_an_empty_catalog_without_replacing_the_cache() {
|
fn rejects_an_empty_catalog_without_replacing_the_cache() {
|
||||||
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
|
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
|
||||||
@@ -441,6 +574,9 @@ mod tests {
|
|||||||
fn settings_are_bounded_before_persistence() {
|
fn settings_are_bounded_before_persistence() {
|
||||||
let settings = OverlaySettings {
|
let settings = OverlaySettings {
|
||||||
font_scale: 999,
|
font_scale: 999,
|
||||||
|
font_brightness: 1,
|
||||||
|
viewer_color: Some(" #a1b2c3 ".into()),
|
||||||
|
danmaku_color: Some("not-css".into()),
|
||||||
max_visible: 99,
|
max_visible: 99,
|
||||||
collapse_after_seconds: 1,
|
collapse_after_seconds: 1,
|
||||||
unfold_duration_ms: 9_000,
|
unfold_duration_ms: 9_000,
|
||||||
@@ -453,6 +589,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.sanitize();
|
.sanitize();
|
||||||
assert_eq!(settings.font_scale, 300);
|
assert_eq!(settings.font_scale, 300);
|
||||||
|
assert_eq!(settings.font_brightness, 70);
|
||||||
|
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
|
||||||
|
assert_eq!(settings.danmaku_color, None);
|
||||||
assert_eq!(settings.max_visible, 12);
|
assert_eq!(settings.max_visible, 12);
|
||||||
assert_eq!(settings.collapse_after_seconds, 2);
|
assert_eq!(settings.collapse_after_seconds, 2);
|
||||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||||
@@ -475,13 +614,43 @@ mod tests {
|
|||||||
let object = value.as_object_mut().expect("settings object");
|
let object = value.as_object_mut().expect("settings object");
|
||||||
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
object.insert("title".into(), json!("旧版弹幕栏标题"));
|
||||||
object.remove("fontScale");
|
object.remove("fontScale");
|
||||||
|
object.remove("fontFamily");
|
||||||
|
object.remove("fontBrightness");
|
||||||
|
object.remove("viewerColor");
|
||||||
|
object.remove("danmakuColor");
|
||||||
object.remove("unfoldDurationMs");
|
object.remove("unfoldDurationMs");
|
||||||
object.remove("particleCount");
|
object.remove("particleCount");
|
||||||
object.remove("particleSpeed");
|
object.remove("particleSpeed");
|
||||||
|
object.remove("themeId");
|
||||||
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
|
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
|
||||||
|
assert_eq!(settings.theme_id, OverlayThemeId::JadeScroll);
|
||||||
|
assert_eq!(settings.font_family, FontFamilyId::FangSong);
|
||||||
|
assert_eq!(settings.font_brightness, 130);
|
||||||
|
assert_eq!(settings.viewer_color, None);
|
||||||
|
assert_eq!(settings.danmaku_color, None);
|
||||||
assert_eq!(settings.font_scale, 140);
|
assert_eq!(settings.font_scale, 140);
|
||||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||||
assert_eq!(settings.particle_count, 8);
|
assert_eq!(settings.particle_count, 8);
|
||||||
assert_eq!(settings.particle_speed, 100);
|
assert_eq!(settings.particle_speed, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_theme_ids_are_rejected() {
|
||||||
|
let mut value = json!(OverlaySettings::default());
|
||||||
|
value["themeId"] = json!("theme-that-is-not-installed");
|
||||||
|
let error = serde_json::from_value::<OverlaySettings>(value).expect_err("unknown theme");
|
||||||
|
assert!(error.to_string().contains("unknown variant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moonlit_water_theme_is_a_valid_persisted_identifier() {
|
||||||
|
let mut value = json!(OverlaySettings::default());
|
||||||
|
value["themeId"] = json!("moonlit-water");
|
||||||
|
let settings: OverlaySettings = serde_json::from_value(value).expect("moonlit theme");
|
||||||
|
assert_eq!(settings.theme_id, OverlayThemeId::MoonlitWater);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(settings).unwrap()["themeId"],
|
||||||
|
"moonlit-water"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! In-memory abuse limits for anonymous authentication and enrollment routes.
|
||||||
|
//!
|
||||||
|
//! Limits are evaluated across both account and network-derived keys while
|
||||||
|
//! returning one generic result to callers. This reduces brute-force attempts
|
||||||
|
//! without turning timing or error messages into a username-enumeration API.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Tenant-aware event routing and component-scoped realtime fanout.
|
||||||
|
//!
|
||||||
|
//! The router resolves every enabled instance owned by the account, validates its
|
||||||
|
//! settings/subscriptions, runs durable handlers, then publishes passive
|
||||||
|
//! projections. Each component owns a separate broadcast channel; there is no
|
||||||
|
//! global receiver that could accidentally observe another tenant's events.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -119,15 +126,11 @@ impl Error for ComponentStoreError {}
|
|||||||
pub type ComponentStoreFuture<'a> =
|
pub type ComponentStoreFuture<'a> =
|
||||||
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
Pin<Box<dyn Future<Output = Result<Vec<ComponentInstance>, ComponentStoreError>> + Send + 'a>>;
|
||||||
|
|
||||||
/// Persistence port used by source routing. A PostgreSQL implementation should
|
/// Persistence port used by account-stream routing. A provider event is offered
|
||||||
/// always scope its query by both owner and source; the router repeats that
|
/// to every enabled component owned by that account; each component definition
|
||||||
/// check as defense in depth.
|
/// then filters it through its event subscription.
|
||||||
pub trait ComponentInstanceStore: Send + Sync {
|
pub trait ComponentInstanceStore: Send + Sync {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a>;
|
||||||
&'a self,
|
|
||||||
owner_id: Uuid,
|
|
||||||
source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -166,22 +169,14 @@ impl InMemoryComponentStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentInstanceStore for InMemoryComponentStore {
|
impl ComponentInstanceStore for InMemoryComponentStore {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||||
&'a self,
|
|
||||||
owner_id: Uuid,
|
|
||||||
source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a> {
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Ok(self
|
Ok(self
|
||||||
.instances
|
.instances
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|instance| {
|
.filter(|instance| instance.enabled && instance.owner_id == owner_id)
|
||||||
instance.enabled
|
|
||||||
&& instance.owner_id == owner_id
|
|
||||||
&& instance.source_id == source_id
|
|
||||||
})
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect())
|
.collect())
|
||||||
})
|
})
|
||||||
@@ -222,7 +217,7 @@ pub enum RouteError {
|
|||||||
impl fmt::Display for RouteError {
|
impl fmt::Display for RouteError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Store(error) => write!(formatter, "cannot resolve source components: {error}"),
|
Self::Store(error) => write!(formatter, "cannot resolve account components: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +270,7 @@ impl SourceEventRouter {
|
|||||||
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
|
pub async fn route(&self, event: Arc<LiveEvent>) -> Result<RouteReport, RouteError> {
|
||||||
let components = self
|
let components = self
|
||||||
.components
|
.components
|
||||||
.list_enabled_for_source(event.owner_id, event.source_id)
|
.list_enabled_for_owner(event.owner_id)
|
||||||
.await?;
|
.await?;
|
||||||
let mut report = RouteReport {
|
let mut report = RouteReport {
|
||||||
considered: components.len(),
|
considered: components.len(),
|
||||||
@@ -283,14 +278,11 @@ impl SourceEventRouter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for component in components {
|
for component in components {
|
||||||
if !component.enabled
|
if !component.enabled || component.owner_id != event.owner_id {
|
||||||
|| component.owner_id != event.owner_id
|
|
||||||
|| component.source_id != event.source_id
|
|
||||||
{
|
|
||||||
report.failures.push(RouteFailure {
|
report.failures.push(RouteFailure {
|
||||||
component_id: component.id,
|
component_id: component.id,
|
||||||
stage: RouteStage::Scope,
|
stage: RouteStage::Scope,
|
||||||
detail: "component owner/source does not match the source event".into(),
|
detail: "component owner does not match the account event stream".into(),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -342,7 +334,7 @@ impl SourceEventRouter {
|
|||||||
match runtime.project(&component, &event) {
|
match runtime.project(&component, &event) {
|
||||||
Ok(Some(message)) => {
|
Ok(Some(message)) => {
|
||||||
if message.owner_id != component.owner_id
|
if message.owner_id != component.owner_id
|
||||||
|| message.source_id != component.source_id
|
|| message.source_id != event.source_id
|
||||||
|| message.component_id != component.id
|
|| message.component_id != component.id
|
||||||
{
|
{
|
||||||
report.failures.push(RouteFailure {
|
report.failures.push(RouteFailure {
|
||||||
@@ -410,6 +402,7 @@ mod tests {
|
|||||||
viewer: PlatformViewer {
|
viewer: PlatformViewer {
|
||||||
uid: "42".into(),
|
uid: "42".into(),
|
||||||
name: "观众".into(),
|
name: "观众".into(),
|
||||||
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
text: "晚上好".into(),
|
text: "晚上好".into(),
|
||||||
segments: Vec::new(),
|
segments: Vec::new(),
|
||||||
@@ -510,16 +503,38 @@ mod tests {
|
|||||||
assert!(report.failures.is_empty());
|
assert!(report.failures.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_account_event_is_offered_to_all_owned_components() {
|
||||||
|
let owner_id = Uuid::new_v4();
|
||||||
|
let account_source_id = Uuid::new_v4();
|
||||||
|
let first = overlay(owner_id, account_source_id);
|
||||||
|
let second = overlay(owner_id, account_source_id);
|
||||||
|
let first_id = first.id;
|
||||||
|
let second_id = second.id;
|
||||||
|
let hub = EventHub::new(8);
|
||||||
|
let mut first_rx = hub.subscribe(first_id);
|
||||||
|
let mut second_rx = hub.subscribe(second_id);
|
||||||
|
let store = Arc::new(InMemoryComponentStore::new(vec![first, second]));
|
||||||
|
let router = SourceEventRouter::new(ComponentRegistry::default(), store, hub);
|
||||||
|
|
||||||
|
let report = router
|
||||||
|
.route(danmaku(owner_id, account_source_id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.considered, 2);
|
||||||
|
assert_eq!(report.projected, 2);
|
||||||
|
assert_eq!(first_rx.try_recv().unwrap().component_id, first_id);
|
||||||
|
assert_eq!(second_rx.try_recv().unwrap().component_id, second_id);
|
||||||
|
assert!(report.failures.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
struct LeakyStore {
|
struct LeakyStore {
|
||||||
instances: Vec<ComponentInstance>,
|
instances: Vec<ComponentInstance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentInstanceStore for LeakyStore {
|
impl ComponentInstanceStore for LeakyStore {
|
||||||
fn list_enabled_for_source<'a>(
|
fn list_enabled_for_owner<'a>(&'a self, _owner_id: Uuid) -> ComponentStoreFuture<'a> {
|
||||||
&'a self,
|
|
||||||
_owner_id: Uuid,
|
|
||||||
_source_id: Uuid,
|
|
||||||
) -> ComponentStoreFuture<'a> {
|
|
||||||
Box::pin(async move { Ok(self.instances.clone()) })
|
Box::pin(async move { Ok(self.instances.clone()) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Repository facade for tenant components, settings and the routing cache.
|
||||||
|
//!
|
||||||
|
//! PostgreSQL is authoritative. Successful writes are reflected into the
|
||||||
|
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
|
||||||
|
//! account-source restarts hydrate that cache from tenant-scoped rows before events are
|
||||||
|
//! routed.
|
||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -8,7 +15,11 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{ComponentInstance, ComponentRegistry},
|
components::{ComponentInstance, ComponentRegistry},
|
||||||
db::{ComponentRecord, Db, DbError},
|
db::{ComponentRecord, Db, DbError},
|
||||||
|
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||||
|
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||||
|
i18n,
|
||||||
realtime::InMemoryComponentStore,
|
realtime::InMemoryComponentStore,
|
||||||
|
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -38,6 +49,92 @@ impl TenantRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure every active tenant has every required singleton component.
|
||||||
|
/// Partial unique indexes make concurrent starts idempotent. The song
|
||||||
|
/// component additionally owns a relational revision state row.
|
||||||
|
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?;
|
||||||
|
let existing = transaction
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||||
|
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let component_id: Uuid = if let Some(row) = existing {
|
||||||
|
row.get(0)
|
||||||
|
} else {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let settings = serde_json::to_value(SongRequestSettings::default())
|
||||||
|
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO component_instances \
|
||||||
|
(id,owner_user_id,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_one(
|
||||||
|
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||||
|
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.get(0)
|
||||||
|
};
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
|
||||||
|
VALUES($1,$2) ON CONFLICT(component_instance_id) DO NOTHING",
|
||||||
|
&[&tenant.user_id, &component_id],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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) ON CONFLICT DO NOTHING",
|
||||||
|
&[
|
||||||
|
&Uuid::new_v4(),
|
||||||
|
&tenant.user_id,
|
||||||
|
&GIFT_EFFECT_KIND,
|
||||||
|
&GIFT_EFFECT_NAME,
|
||||||
|
&gift_settings,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
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) ON CONFLICT DO NOTHING",
|
||||||
|
&[
|
||||||
|
&Uuid::new_v4(),
|
||||||
|
&tenant.user_id,
|
||||||
|
&GIFT_MENU_KIND,
|
||||||
|
&GIFT_MENU_NAME,
|
||||||
|
&menu_settings,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn hydrate_tenant(
|
pub async fn hydrate_tenant(
|
||||||
&self,
|
&self,
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
@@ -65,6 +162,12 @@ impl TenantRepository {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<ComponentInstance, RepositoryError> {
|
) -> Result<ComponentInstance, RepositoryError> {
|
||||||
|
// Every tenant receives this singleton during registration/startup.
|
||||||
|
// Keeping creation internal prevents a second instance from racing the
|
||||||
|
// partial unique index and turning a domain conflict into a DB error.
|
||||||
|
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) {
|
||||||
|
return Err(RepositoryError::Forbidden);
|
||||||
|
}
|
||||||
let runtime = self
|
let runtime = self
|
||||||
.registry
|
.registry
|
||||||
.runtime(kind)
|
.runtime(kind)
|
||||||
@@ -90,12 +193,11 @@ impl TenantRepository {
|
|||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO component_instances \
|
"INSERT INTO component_instances \
|
||||||
(id,owner_user_id,source_id,kind,name,settings,settings_version,enabled) \
|
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,true)",
|
VALUES($1,$2,$3,$4,$5,$6,true)",
|
||||||
&[
|
&[
|
||||||
&component.id,
|
&component.id,
|
||||||
&owner_id,
|
&owner_id,
|
||||||
&source_id,
|
|
||||||
&component.kind,
|
&component.kind,
|
||||||
&component.name,
|
&component.name,
|
||||||
&component.settings,
|
&component.settings,
|
||||||
@@ -116,6 +218,20 @@ impl TenantRepository {
|
|||||||
let mut client = self.db.get().await?;
|
let mut client = self.db.get().await?;
|
||||||
let transaction = client.transaction().await?;
|
let transaction = client.transaction().await?;
|
||||||
Db::set_tenant(&transaction, owner_id).await?;
|
Db::set_tenant(&transaction, owner_id).await?;
|
||||||
|
let kind: String = transaction
|
||||||
|
.query_opt(
|
||||||
|
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||||
|
&[&owner_id, &component_id],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or(RepositoryError::NotFound)?
|
||||||
|
.get(0);
|
||||||
|
if matches!(
|
||||||
|
kind.as_str(),
|
||||||
|
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
|
||||||
|
) {
|
||||||
|
return Err(RepositoryError::Forbidden);
|
||||||
|
}
|
||||||
let changed = transaction
|
let changed = transaction
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
"DELETE FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||||
@@ -140,8 +256,11 @@ impl TenantRepository {
|
|||||||
Db::set_tenant(&transaction, owner_id).await?;
|
Db::set_tenant(&transaction, owner_id).await?;
|
||||||
let row = transaction
|
let row = transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT id,source_id,kind,name,settings,settings_version,enabled \
|
"SELECT component.id,source.id,component.kind,component.name,component.settings,\
|
||||||
FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
component.settings_version,component.enabled \
|
||||||
|
FROM component_instances AS component \
|
||||||
|
JOIN live_sources AS source ON source.owner_user_id=component.owner_user_id \
|
||||||
|
WHERE component.owner_user_id=$1 AND component.id=$2",
|
||||||
&[&owner_id, &component_id],
|
&[&owner_id, &component_id],
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -150,7 +269,7 @@ impl TenantRepository {
|
|||||||
self.validate_loaded_component(component_from_record(ComponentRecord {
|
self.validate_loaded_component(component_from_record(ComponentRecord {
|
||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
owner_user_id: owner_id,
|
owner_user_id: owner_id,
|
||||||
source_id: row.get(1),
|
account_source_id: row.get(1),
|
||||||
kind: row.get(2),
|
kind: row.get(2),
|
||||||
name: row.get(3),
|
name: row.get(3),
|
||||||
settings: row.get(4),
|
settings: row.get(4),
|
||||||
@@ -219,6 +338,44 @@ impl TenantRepository {
|
|||||||
Ok(row.get(0))
|
Ok(row.get(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn account_language(&self, owner_id: Uuid) -> Result<String, RepositoryError> {
|
||||||
|
let mut client = self.db.get().await?;
|
||||||
|
let transaction = client.transaction().await?;
|
||||||
|
Db::set_tenant(&transaction, owner_id).await?;
|
||||||
|
let language = transaction
|
||||||
|
.query_opt(
|
||||||
|
"SELECT language FROM account_preferences WHERE user_id=$1",
|
||||||
|
&[&owner_id],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.map(|row| row.get(0))
|
||||||
|
.unwrap_or_else(|| i18n::default_language().to_owned());
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_account_language(
|
||||||
|
&self,
|
||||||
|
owner_id: Uuid,
|
||||||
|
language: &str,
|
||||||
|
) -> Result<String, RepositoryError> {
|
||||||
|
if !i18n::is_supported(language) {
|
||||||
|
return Err(RepositoryError::Invalid("language is not supported".into()));
|
||||||
|
}
|
||||||
|
let mut client = self.db.get().await?;
|
||||||
|
let transaction = client.transaction().await?;
|
||||||
|
Db::set_tenant(&transaction, owner_id).await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2) \
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language,updated_at=now()",
|
||||||
|
&[&owner_id, &language],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(language.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn token_summary(
|
pub async fn token_summary(
|
||||||
&self,
|
&self,
|
||||||
owner_id: Uuid,
|
owner_id: Uuid,
|
||||||
@@ -343,7 +500,7 @@ fn component_from_record(record: ComponentRecord) -> Result<ComponentInstance, R
|
|||||||
Ok(ComponentInstance {
|
Ok(ComponentInstance {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
owner_id: record.owner_user_id,
|
owner_id: record.owner_user_id,
|
||||||
source_id: record.source_id,
|
account_source_id: record.account_source_id,
|
||||||
kind: record.kind,
|
kind: record.kind,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
//! Shared, validated typography settings for all built-in OBS components.
|
||||||
|
//!
|
||||||
|
//! Font IDs map to fixed frontend font stacks 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -1,9 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
additional_contexts:
|
||||||
|
# Cargo resolves apps/server-rust/../../../libilibili to this checkout.
|
||||||
|
libilibili: ../libilibili
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=16m,noexec,nosuid,nodev
|
- /tmp:size=16m,noexec,nosuid,nodev
|
||||||
@@ -15,7 +19,7 @@ services:
|
|||||||
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
||||||
- ./config.toml:/app/config.toml:ro
|
- ./config.toml:/app/config.toml:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9719/health"]
|
test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1:9719/health']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
+17
-6
@@ -1,4 +1,4 @@
|
|||||||
# 洛星瓷多用户直播组件服务配置。
|
# 多用户直播组件服务配置。
|
||||||
#
|
#
|
||||||
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件
|
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项。这个文件
|
||||||
# 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要
|
# 包含首次迁移所需的 CookieCloud、bootstrap proof 和旧 OBS 令牌,请不要
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
|
# Docker Compose 会将它以只读方式挂载到容器的 /app/config.toml。
|
||||||
|
|
||||||
# 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次
|
# 旧单用户配置兼容段:room_id 只在数据库没有任何账户时,绑定给首次
|
||||||
# 创建的 system_admin,并且此后不可修改。后续账号的 room_id 由系统
|
# 创建的 system_admin,作为该账户的初始房间。后续账号的初始 room_id
|
||||||
# 管理员创建邀请码时指定,不读取此字段。
|
# 由系统管理员创建邀请码时指定;注册后用户可在控制台自行切换。
|
||||||
# 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。
|
# 实际 Bilibili Cookie 不直接写在配置中,而是通过 CookieCloud 获取。
|
||||||
[connection]
|
[connection]
|
||||||
room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。
|
room_id = "123456" # 替换为首个系统管理员的真实直播间 ID;不能以 0 开头。
|
||||||
@@ -87,6 +87,17 @@ request_timeout_seconds = 10
|
|||||||
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
|
# 迁移后修改这里不会覆盖任何已有组件。价格单位为千分之一元,因此
|
||||||
# 10000 = 10 元、100000 = 100 元。
|
# 10000 = 10 元、100000 = 100 元。
|
||||||
[overlay]
|
[overlay]
|
||||||
|
# 主题 ID。弹幕姬与点歌姬支持 jade-scroll(青玉花卷)和 moonlit-water(静夜曲水)。
|
||||||
|
# 未来主题会继续使用稳定 ID,避免改名。
|
||||||
|
# 影响已经保存到 PostgreSQL 的组件设置。
|
||||||
|
theme_id = "jade-scroll"
|
||||||
|
# 中文字体:song(宋体)、fang-song(仿宋)或 kai(楷体)。
|
||||||
|
font_family = "fang-song"
|
||||||
|
# 文字亮度百分比,范围 70-180;深色直播背景建议从 130 开始。
|
||||||
|
font_brightness = 130
|
||||||
|
# 可选的弹幕昵称与正文颜色,只接受 #RRGGBB;不填写则跟随所选主题。
|
||||||
|
# viewer_color = "#B7AD83"
|
||||||
|
# danmaku_color = "#C6AE7A"
|
||||||
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
||||||
font_scale = 140
|
font_scale = 140
|
||||||
max_visible = 5
|
max_visible = 5
|
||||||
@@ -112,7 +123,7 @@ guard = true
|
|||||||
like = false
|
like = false
|
||||||
share = false
|
share = false
|
||||||
|
|
||||||
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
|
# 默认记录本服务生命周期信息,并保持依赖库仅输出警告。
|
||||||
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
|
# libilibili 的凭据 Debug 已脱敏,但仍不要记录 Cookie 或原始认证载荷。
|
||||||
[logging]
|
[logging]
|
||||||
filter = "lxc_stream_server=info,blivedm=warn,tokio_postgres=warn"
|
filter = "lxc_stream_server=info,libilibili=warn,tokio_postgres=warn"
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 系统架构
|
||||||
|
|
||||||
|
本文描述直播组件服务的运行边界、数据流和扩展点。实现代码分别位于 `apps/server-rust` 与
|
||||||
|
`apps/overlay`。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
|
||||||
|
- 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
|
||||||
|
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。
|
||||||
|
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 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。
|
||||||
|
|
||||||
|
## 状态所有权
|
||||||
|
|
||||||
|
| 状态 | 权威来源 | 内存副本 | 说明 |
|
||||||
|
| ---------------- | ------------ | ------------------------ | ----------------------------- |
|
||||||
|
| 用户、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)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 组件开发指南
|
||||||
|
|
||||||
|
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
||||||
|
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||||
|
|
||||||
|
## 一个组件由什么组成
|
||||||
|
|
||||||
|
| 部分 | Rust 契约 | 职责 |
|
||||||
|
| -------- | --------------------- | ------------------------------------------------- |
|
||||||
|
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
|
||||||
|
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
|
||||||
|
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
|
||||||
|
| 实例 | `ComponentInstance` | owner、kind、名称、设置和启用状态 |
|
||||||
|
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
|
||||||
|
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
|
||||||
|
|
||||||
|
## 新增组件步骤
|
||||||
|
|
||||||
|
1. 选择稳定、全小写的 kind,例如 `gift_wall` 或 `song_request`。
|
||||||
|
2. 为设置定义可序列化结构,提供安全默认值和 `sanitize`/校验逻辑。
|
||||||
|
3. 实现 `ComponentDefinition`:
|
||||||
|
- `settings_version` 从 `1` 开始。
|
||||||
|
- `migrate_settings` 必须能把已保存的旧版本升级到当前版本。
|
||||||
|
- `subscriptions` 只返回当前设置需要的 `LiveEventKind`。
|
||||||
|
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
|
||||||
|
5. 若需要可靠业务动作,实现 `EventHandler`:
|
||||||
|
- 即使没有 OBS 客户端也会执行。
|
||||||
|
- 数据库操作必须包含 owner/component 条件。
|
||||||
|
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
||||||
|
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
||||||
|
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
||||||
|
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
||||||
|
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
||||||
|
|
||||||
|
## Projection 与 Handler 的边界
|
||||||
|
|
||||||
|
Projection 面向“现在打开的浏览器”。消息丢失或没有接收者都属于正常情况。它必须快速、确定、无副作用。
|
||||||
|
|
||||||
|
Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写入必须在这里完成,而不是等待前端收到 WebSocket。一个 handler 失败会记录在
|
||||||
|
`RouteReport`,但不会阻止其他 handler 或 OBS 投影。
|
||||||
|
|
||||||
|
## 设置版本规则
|
||||||
|
|
||||||
|
- 数据库同时保存 `settings` 与 `settings_version`。
|
||||||
|
- 每次读取或路由前,通过 registry 迁移并校验设置。
|
||||||
|
- 新字段应提供默认值,删除/改义字段必须增加版本并编写迁移。
|
||||||
|
- 客户端输入只能作为待校验 JSON;后端返回的 sanitized 设置才是权威值。
|
||||||
|
- UI slider 的范围不能代替后端范围检查。
|
||||||
|
|
||||||
|
## 租户与 token 规则
|
||||||
|
|
||||||
|
- 组件必须属于经过认证的 owner,不能选择或覆盖账户直播源。
|
||||||
|
- 路由和数据库查询都要重复检查 owner;账户事件可被其所有启用组件订阅。
|
||||||
|
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
|
||||||
|
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
||||||
|
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||||
|
|
||||||
|
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
||||||
|
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md) 与
|
||||||
|
[`gift-menu.md`](gift-menu.md)。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# `danmaku_overlay` 弹幕姬
|
||||||
|
|
||||||
|
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
|
||||||
|
|
||||||
|
## 订阅事件
|
||||||
|
|
||||||
|
组件根据设置动态订阅:
|
||||||
|
|
||||||
|
- 弹幕、进房、礼物与礼物连击、醒目留言、舰长、点赞、分享。
|
||||||
|
- 关闭类别后,后端不为该组件投影对应事件,前端也会进行一次兼容性过滤。
|
||||||
|
- 未归一化的 `live.unknown` 默认不进入弹幕姬。
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
| 字段 | 范围/单位 | 行为 |
|
||||||
|
| ------------------------ | ----------- | -------------------------- |
|
||||||
|
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
|
||||||
|
| `fontFamily` | 字体 ID | `song`、`fang-song`、`kai` |
|
||||||
|
| `fontBrightness` | 70–180% | 只调整文字亮度 |
|
||||||
|
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
|
||||||
|
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
|
||||||
|
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||||
|
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||||
|
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||||
|
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
||||||
|
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
|
||||||
|
| `particleCount` | 0–12 | 每卡星花粒子数量 |
|
||||||
|
| `particleSpeed` | 25–300% | 粒子动画速度 |
|
||||||
|
| `lowPerformanceMode` | boolean | 关闭高成本动态效果 |
|
||||||
|
| `highValueThreshold` | 千分之一元 | 高价值礼物起点 |
|
||||||
|
| `featuredValueThreshold` | 千分之一元 | 焦点礼物起点 |
|
||||||
|
| `show*` | boolean | 控制各事件类别订阅 |
|
||||||
|
|
||||||
|
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range
|
||||||
|
input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册的安全字体栈,不接受任意 CSS;OBS 设备缺少首选字体时会依次回退到可用衬线字体。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
|
||||||
|
|
||||||
|
## 主题
|
||||||
|
|
||||||
|
主题同时封装配色、花纹素材、装饰变体、粒子组合与动画名称。当前内置主题为
|
||||||
|
`jade-scroll`(“青玉花卷”)与
|
||||||
|
`moonlit-water`(“静夜曲水”)。后者不绘制卡片底色、外框或消息分隔线,只保留青黛、米金衬线文字、少量花星微光以及组件上下共享的透明古风细边;控制台仍以选择框呈现,新增主题后无需改变设置交互或卡片队列。
|
||||||
|
|
||||||
|
增加主题时需要同步完成三处注册:
|
||||||
|
|
||||||
|
1. 在后端 `OverlayThemeId` 中增加稳定 ID,使未知主题无法进入数据库。
|
||||||
|
2. 在 `apps/overlay/src/themes/` 增加主题定义,并注册到 `overlayThemes`。
|
||||||
|
3. 提供主题使用的 CSS 动画和素材;不要在 `Card` 中加入按主题 ID 分支。
|
||||||
|
|
||||||
|
旧组件没有 `themeId` 时会自动使用
|
||||||
|
`jade-scroll`。WebSocket 收到未知 ID 时前端也会安全降级到该主题,以支持前后端滚动部署。
|
||||||
|
|
||||||
|
## 卡片生命周期
|
||||||
|
|
||||||
|
1. 新事件插入队首,以卷轴动画横向展开。
|
||||||
|
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||||
|
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||||
|
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||||
|
5. 超过 `maxVisible` 的最旧卡才会离开队列。
|
||||||
|
|
||||||
|
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
|
||||||
|
|
||||||
|
## 礼物展示
|
||||||
|
|
||||||
|
- `live.gift` 创建礼物卡;`live.gift.combo` 使用 `comboId` 更新同一张卡。
|
||||||
|
- 元数据优先使用礼物目录中的静态图、GIF、币种和价格。
|
||||||
|
- GIF 加载失败时降级为静态图片;静态图失败时隐藏图片但保留名称、数量和用户。
|
||||||
|
- 普通、高价值和焦点礼物按 `totalPrice` 与两个阈值分级。
|
||||||
|
- `priceCny` 只用于人类可读展示;整数 `totalPrice` 用于精确分级。
|
||||||
|
|
||||||
|
## 弹幕表情
|
||||||
|
|
||||||
|
文字和表情按 `segments` 顺序混排。表情 URL 来自消息本身或用户级表情目录补全;图片使用
|
||||||
|
`no-referrer`,失败后显示原始表情文字。整条大表情可通过 `standalone` 使用更合适的尺寸。
|
||||||
|
|
||||||
|
## OBS 自适应
|
||||||
|
|
||||||
|
- 根背景完全透明,不存在 1920×1080 固定画布。
|
||||||
|
- `ResizeObserver` 根据浏览器源实际宽高选择 `narrow`、`short` 或 `standard`。
|
||||||
|
- 字号和间距使用 CSS custom properties 与 `clamp()`,OBS 自由缩放时不拉伸素材比例。
|
||||||
|
- 建议从 `360×600`、`440×760`、`600×1080` 或 `720×320` 开始测试。
|
||||||
|
- 低高度会减少视觉密度,但不会把仍在队列中的文字裁成省略号。
|
||||||
|
|
||||||
|
## 测试页面
|
||||||
|
|
||||||
|
控制台的事件测试直接构造 canonical
|
||||||
|
`LiveEvent`,使用当前账户、source 和 component 走同一套订阅、handler、projection 与 EventHub。测试事件标记为
|
||||||
|
`simulated=true`,不会发送到 Bilibili,也不会跨组件广播。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 全屏礼物特效组件
|
||||||
|
|
||||||
|
`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和
|
||||||
|
`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅
|
||||||
|
`live.gift.combo`,避免一次连击重复触发完整特效。
|
||||||
|
|
||||||
|
## 展示行为
|
||||||
|
|
||||||
|
`jade-starfall`
|
||||||
|
主题中,普通礼物从视口左侧生成一组带青玉、金色星尘拖尾的流星,礼物图片或 GIF 是流星主体,并从右侧完全飞出。原始价值按
|
||||||
|
`highValueThreshold` 和 `featuredValueThreshold`
|
||||||
|
分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。
|
||||||
|
|
||||||
|
舰长、提督或总督使用同一个 `live.guard.buy`
|
||||||
|
路径,临时显示不透明星河、闪耀粒子、身份和用户昵称。多个普通礼物可并发;大航海展示选择最新事件。
|
||||||
|
|
||||||
|
`moonlit-water`(“静夜曲水”)则采用“月下清供”:价值低于 50 元(原始
|
||||||
|
`totalPrice < 50000`)的礼物以右侧水纹短笺展示图标、用户、名称和价格;50 元及以上礼物在整张浏览器源上展开月轮、水纹、礼物图标和文字。舰长、提督、总督也使用全屏月夜祝贺。该主题的全屏动效仍不绘制不透明底色,OBS 场景始终可见。
|
||||||
|
|
||||||
|
## 尺寸与性能
|
||||||
|
|
||||||
|
OBS 建议从 `1920×1080` 开始,但渲染器没有固定画布。`ResizeObserver`
|
||||||
|
按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整:
|
||||||
|
|
||||||
|
- 三档数量、尺寸和速度;
|
||||||
|
- 拖尾强度和最大并发特效数;
|
||||||
|
- 大航海星数和全屏持续时间;
|
||||||
|
- 中文字体与文字亮度;
|
||||||
|
- 低性能模式(限制粒子和流星数量)。
|
||||||
|
|
||||||
|
## 扩展主题
|
||||||
|
|
||||||
|
后端设置的 `themeId` 使用稳定 kebab-case ID。前端主题统一在 `apps/overlay/src/giftThemes.ts`
|
||||||
|
注册颜色、动效标识和本地化资源键;主题 CSS 只作用于
|
||||||
|
`.gift-effect-overlay`,不得改变页面根背景或控制台。新增主题时同时扩展 Rust/TypeScript 类型、设置迁移、两种语言资源和降级测试。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 礼物菜单组件
|
||||||
|
|
||||||
|
`gift_menu`
|
||||||
|
是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。
|
||||||
|
|
||||||
|
## 目录与触发器
|
||||||
|
|
||||||
|
账户直播监听器调用 Bilibili `giftPanel/roomGiftList`
|
||||||
|
并定时刷新按账户隔离的内存目录;控制台读取同一份缓存,也可以主动刷新。接口无需 Cookie,返回礼物 ID、名称、价格、静态图和 GIF。字段说明见
|
||||||
|
[礼物 API 文档](https://github.com/pskdje/bilibili-API-collect/blob/main/docs/live/gift.md)。上游失败时保留最后一次成功目录。
|
||||||
|
|
||||||
|
菜单项按顺序保存在经过后端校验的组件 settings 中,最多 100 项,支持:
|
||||||
|
|
||||||
|
- 指定礼物 ID;事件缺少 ID 时以保存的名称降级匹配;
|
||||||
|
- 舰长、提督或总督;
|
||||||
|
- 指定礼物单价,单位为电池,例如 `150`。
|
||||||
|
|
||||||
|
礼物事件先匹配指定礼物 ID(事件缺少 ID 时匹配名称);命中后只高亮具体礼物。如果没有具体礼物命中,才按单价电池数回退匹配。组件只消费
|
||||||
|
`live.gift`,不消费连击更新,避免重复触发。Bilibili 接口中的 `price` 是金瓜子而不是电池;后端统一按
|
||||||
|
`price / 100` 换算(例如 `100` 金瓜子为 `1` 电池)。盲盒事件优先使用 `blind_gift.original_*`
|
||||||
|
的盲盒 ID、名称与原价,不使用开出的奖品价值。对于 protobuf
|
||||||
|
`SEND_GIFT_V2`,后端复用同一礼物管线,并优先使用非零 `discount_price`
|
||||||
|
作为实际支付单价;`transaction_id` 用作稳定事件 ID。当前 V2
|
||||||
|
schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电池价值回退匹配,但不能保证命中具体盲盒条目。
|
||||||
|
|
||||||
|
## OBS 行为
|
||||||
|
|
||||||
|
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
|
||||||
|
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
|
||||||
|
|
||||||
|
命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
|
||||||
|
|
||||||
|
`jade-banquet`
|
||||||
|
复用仓库内已有且记录许可的花枝 SVG。`moonlit-water`(“静夜曲水”)提供另一种完全透明的分页排版,并与弹幕姬、点歌姬共享透明古风上下细边:每页行数和换页间隔可调,不进行纵向滚动;换页时旧内容从左向右渐隐,同时新内容沿相同方向淡入。礼物触发会以该条目为第一页首行,并以水纹扫光高亮。新增主题必须在
|
||||||
|
`giftMenuThemes.ts` 注册稳定 ID、资源键和局部 CSS 变量。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# `song_request` 点歌姬
|
||||||
|
|
||||||
|
每个账户自动拥有一个不可删除、不可重复创建的 `song_request`
|
||||||
|
实例,并与该账户的固定直播源绑定。组件只订阅规范化的
|
||||||
|
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||||
|
|
||||||
|
## 弹幕命令
|
||||||
|
|
||||||
|
- `点歌 <歌名>`:合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||||
|
- `打分 <1-5>`:对事务执行时的当前歌曲评分。同一 Bilibili UID 只有一票,重复评分会覆盖旧分数。
|
||||||
|
|
||||||
|
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond`
|
||||||
|
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
|
||||||
|
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
|
||||||
|
|
||||||
|
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名、紧凑评分和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
|
||||||
|
|
||||||
|
## OBS 与管理
|
||||||
|
|
||||||
|
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
||||||
|
|
||||||
|
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史与评分,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
||||||
|
RLS 再次限制组件归属。
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# 组件实时协议
|
||||||
|
|
||||||
|
当前协议版本为 `1`。后端的权威定义在 `domain.rs` 的
|
||||||
|
`ComponentMessage`,浏览器渲染器只依赖本文列出的稳定字段。
|
||||||
|
|
||||||
|
## 连接地址与认证
|
||||||
|
|
||||||
|
组件流地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wss://danmaku.luoxingci.com/api/v1/components/<publicId>/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
OBS 页面地址中的 token 位于 fragment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
fragment 不会进入首次 HTTP 请求或 Nginx access
|
||||||
|
log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "authenticate",
|
||||||
|
"token": "component-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端验证 token 摘要、组件归属和 `events:subscribe` scope。认证成功后返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"type": "authenticated",
|
||||||
|
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
|
||||||
|
"componentKind": "song_request",
|
||||||
|
"language": "zh-CN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
之后立即发送通用的 `component.settings.snapshot`,再发送该 kind 的状态快照和实时事件。弹幕姬还会发送
|
||||||
|
`overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||||
|
close 结束连接。
|
||||||
|
|
||||||
|
`language` 是组件所属账户的 locale。用户在控制台修改语言后,所有已连接组件会立即收到
|
||||||
|
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
||||||
|
|
||||||
|
设置快照中的 `themeId` 是稳定的主题标识。弹幕姬与点歌姬支持 `jade-scroll` 和
|
||||||
|
`moonlit-water`;礼物特效支持 `jade-starfall` 和 `moonlit-water`;礼物菜单支持 `jade-banquet` 和
|
||||||
|
`moonlit-water`。消费者应把未知主题降级为自身默认主题,不能因主题发布顺序不同而中断实时消息。
|
||||||
|
|
||||||
|
## 版本化事件信封
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "d56c1a28-a6f1-4ad9-b02f-4c22dc4d3c27",
|
||||||
|
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
|
||||||
|
"sourceId": "c8087bb0-0dde-40eb-a43d-a9a2574c2717",
|
||||||
|
"occurredAt": "2026-07-15T20:10:30.125Z",
|
||||||
|
"roomId": "123456",
|
||||||
|
"type": "live.danmaku",
|
||||||
|
"payload": {
|
||||||
|
"viewer": { "uid": "42", "name": "观众" },
|
||||||
|
"text": "晚上好",
|
||||||
|
"segments": [{ "type": "text", "text": "晚上好" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
|
||||||
|
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。 `sourceId`
|
||||||
|
标识所属账户唯一的直播监听连接,不表示组件单独绑定了一个直播源;同一账户不同组件收到的实时事件具有相同的
|
||||||
|
`sourceId`。
|
||||||
|
|
||||||
|
## 事件类型
|
||||||
|
|
||||||
|
| `type` | 主要 payload | 说明 |
|
||||||
|
| ----------------------------- | --------------------------------------------- | ---------------------- |
|
||||||
|
| `component.settings.snapshot` | `settings` | 所有组件的设置快照 |
|
||||||
|
| `component.settings.updated` | `settings` | 所有组件的设置更新 |
|
||||||
|
| `component.language.updated` | `language` | 账户语言实时更新 |
|
||||||
|
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
|
||||||
|
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
|
||||||
|
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
|
||||||
|
| `live.enter` | `viewer` | 进房事件 |
|
||||||
|
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
|
||||||
|
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
|
||||||
|
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
|
||||||
|
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
|
||||||
|
| `live.like` | `viewer` | 点赞 |
|
||||||
|
| `live.share` | `viewer` | 分享 |
|
||||||
|
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
|
||||||
|
|
||||||
|
## 点歌姬状态
|
||||||
|
|
||||||
|
`song_request` 认证后按顺序接收:
|
||||||
|
|
||||||
|
1. `song.queue.snapshot.begin`:包含 `snapshotId`、`revision`、当前歌曲和待唱总数。
|
||||||
|
2. 零到多个 `song.queue.snapshot.page`:每页最多 100 个待唱项。
|
||||||
|
3. `song.queue.snapshot.end`:提交该快照。
|
||||||
|
|
||||||
|
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
|
||||||
|
`completed`、`cancelled`、`cleared` 或
|
||||||
|
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
|
||||||
|
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
|
||||||
|
|
||||||
|
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||||
|
是供展示使用的人民币数值。
|
||||||
|
|
||||||
|
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
||||||
|
`live.gift`;连击事件用于更新同一张视觉卡片。
|
||||||
|
|
||||||
|
`gift_effect` 只订阅 `live.gift` 与 `live.guard.buy`。礼物档位由 sanitized settings 中的
|
||||||
|
`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或
|
||||||
|
`gift.animationUrl`
|
||||||
|
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
||||||
|
|
||||||
|
`gift_menu` 同样只消费一次性 `live.gift` 与 `live.guard.buy`。命中配置后投影为
|
||||||
|
`gift-menu.triggered`,payload 包含 `itemIds`、`viewer` 和
|
||||||
|
`sourceEventId`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
||||||
|
|
||||||
|
## 表情分段
|
||||||
|
|
||||||
|
`live.danmaku.payload.segments` 是判别联合:
|
||||||
|
|
||||||
|
- `text`:包含可直接显示的文字。
|
||||||
|
- `emoticon`:包含回退文字、图片 URL、可选尺寸、动态标记和整条大表情标记。
|
||||||
|
|
||||||
|
图片失败时客户端必须回退到 `text`,不能让一张失效图片破坏整条弹幕。
|
||||||
|
|
||||||
|
## 背压与重连
|
||||||
|
|
||||||
|
- 每个组件使用有界 `tokio::broadcast` channel。
|
||||||
|
- 慢客户端发生 `Lagged` 时跳过已经丢失的旧帧,继续接收新事件;实时展示不保证历史重放。
|
||||||
|
- 浏览器对非鉴权关闭使用指数退避重连,上限 12 秒。
|
||||||
|
- 鉴权失败不会自动重试,避免对已撤销 token 形成无限请求。
|
||||||
|
- 需要可靠业务处理的功能必须实现 `EventHandler` 并写入数据库,不能把 WebSocket 当作消息队列。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 安全模型
|
||||||
|
|
||||||
|
本服务同时处理 Bilibili 登录 Cookie、TOTP Secret、一次性恢复码、管理员邀请码和 OBS
|
||||||
|
token。以下规则是实现约束,而不是可选部署建议。
|
||||||
|
|
||||||
|
## 租户隔离
|
||||||
|
|
||||||
|
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
|
||||||
|
- 组件查询始终限定 `owner_user_id`;组件表不保存直播源外键。
|
||||||
|
- 直播间、直播源和 CookieCloud 凭据分别以账户 ID 建立唯一约束与 RLS 边界。
|
||||||
|
- 用户切换直播间时,账户房间、直播源房间与加密 CookieCloud 凭据在同一事务中更新;目标房间仍保持账户间唯一。
|
||||||
|
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
|
||||||
|
- 语言偏好使用强制 RLS 的账户行;修改时只向该 owner 的组件广播 locale。
|
||||||
|
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||||
|
`SET`。
|
||||||
|
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||||
|
- 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。
|
||||||
|
|
||||||
|
## Secret 生命周期
|
||||||
|
|
||||||
|
| Secret | 浏览器可见性 | 数据库存储 | 轮换/消费 |
|
||||||
|
| -------------------- | ------------------- | ----------------------- | ---------------- |
|
||||||
|
| TOTP Secret | 注册时显示一次 | XChaCha20-Poly1305 密文 | 账户绑定 |
|
||||||
|
| 恢复码 | 注册完成时显示一次 | SHA-256 摘要 | 单次消费 |
|
||||||
|
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
||||||
|
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
||||||
|
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
||||||
|
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
|
||||||
|
|
||||||
|
`security.data_encryption_key`
|
||||||
|
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
||||||
|
|
||||||
|
## Passwordless 认证
|
||||||
|
|
||||||
|
- 第一个系统管理员只能通过数据库为空时的 bootstrap 流程创建。
|
||||||
|
- bootstrap proof 不成为账户密码,也不能用于日常登录。
|
||||||
|
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
|
||||||
|
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
|
||||||
|
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
|
||||||
|
|
||||||
|
## CookieCloud 与 SSRF
|
||||||
|
|
||||||
|
- 部署者通过 `cookiecloud_allowed_hosts` 指定精确的基础地址白名单。
|
||||||
|
- URL 会规范化,并拒绝 embedded credentials、query 和 fragment。
|
||||||
|
- Key 编码成单一路径段,不能注入额外路径。
|
||||||
|
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
||||||
|
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
||||||
|
- `libilibili::Credentials` 的 `Debug` 已脱敏,但应用仍不得记录 Cookie、原始认证响应或弹幕 token。
|
||||||
|
|
||||||
|
## HTTP、WebSocket 与 OBS
|
||||||
|
|
||||||
|
- 公开部署必须在受信任反代终止 HTTPS,并保持 `secure_cookies = true`。
|
||||||
|
- 生产 session 使用 `__Host-` Cookie、HttpOnly、Secure 与 SameSite 策略。
|
||||||
|
- 写 API 执行 same-origin 检查并限制 body 大小。
|
||||||
|
- 所有 `/api/*` 响应使用 `Cache-Control: no-store`。
|
||||||
|
- OBS token 放在 URL fragment,并作为 WebSocket 第一帧发送。
|
||||||
|
- token 只具有 `events:subscribe` scope,且只能订阅其绑定的组件。
|
||||||
|
- WebSocket 首帧、frame size、认证时间和全局连接数均有上限。
|
||||||
|
|
||||||
|
## PWA 边界
|
||||||
|
|
||||||
|
- manifest、Service Worker 和 start URL 都限定在 `/control/`。
|
||||||
|
- `/obs/*` 不在 Service Worker scope 内。
|
||||||
|
- worker 只缓存静态应用壳层、图标和构建资源;不缓存 API、WebSocket 或直播事件。
|
||||||
|
- 离线 mutation 直接失败,不使用 Background Sync。
|
||||||
|
- 新 worker 仅在用户确认后激活。
|
||||||
|
- TOTP、恢复码、邀请码、新 OBS 地址或未保存设置可见时,更新会被阻止。
|
||||||
|
|
||||||
|
## 上线检查表
|
||||||
|
|
||||||
|
- [ ] 使用独立随机 `data_encryption_key`,并在安全位置备份。
|
||||||
|
- [ ] `config.toml` 权限为 `0600`,且已被 Git 和 Docker build context 排除。
|
||||||
|
- [ ] PostgreSQL runtime role 可以执行迁移,但 PUBLIC 无权执行 SECURITY DEFINER helper。
|
||||||
|
- [ ] `secure_cookies = true`,Nginx 正确传递 `X-Forwarded-Proto https`。
|
||||||
|
- [ ] 应用仅监听 loopback,9719 未直接暴露公网。
|
||||||
|
- [ ] CookieCloud 白名单只包含管理员批准的实例。
|
||||||
|
- [ ] 日志中没有 Cookie、TOTP、token、邀请码或上游认证响应。
|
||||||
|
- [ ] 轮换 OBS token 后,旧浏览器源立即断开且无法重连。
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
# Single source of truth for every user-facing control-console and OBS string.
|
||||||
|
# Keys are stable API; add a value to every locale before using a new key.
|
||||||
|
default_language = "zh-CN"
|
||||||
|
|
||||||
|
[locales."zh-CN"]
|
||||||
|
name = "简体中文"
|
||||||
|
|
||||||
|
[locales."zh-CN".messages]
|
||||||
|
"language.label" = "语言"
|
||||||
|
"language.save_failed" = "语言偏好保存失败,请稍后重试。"
|
||||||
|
"brand.name" = "直播组件控制台"
|
||||||
|
"brand.tagline" = "OBS 组件工作室"
|
||||||
|
"common.star" = "星"
|
||||||
|
"common.cloud" = "云"
|
||||||
|
"common.copy" = "复制"
|
||||||
|
"common.copied" = "已复制"
|
||||||
|
"common.cancel" = "取消"
|
||||||
|
"common.revoke" = "撤销"
|
||||||
|
"common.save" = "保存"
|
||||||
|
"common.loading" = "正在加载…"
|
||||||
|
"common.user" = "用户"
|
||||||
|
"common.system_admin" = "系统管理员"
|
||||||
|
"common.enabled" = "运行中"
|
||||||
|
"common.disabled" = "已停用"
|
||||||
|
"common.connected" = "已连接"
|
||||||
|
"common.disconnected" = "未连接"
|
||||||
|
"common.permanent" = "永久"
|
||||||
|
"common.viewer" = "直播间观众"
|
||||||
|
"common.not_provided" = "未提供"
|
||||||
|
"common.gift" = "礼物"
|
||||||
|
"common.guard" = "舰长"
|
||||||
|
"common.failed" = "操作失败,请稍后再试"
|
||||||
|
"api.offline" = "当前处于离线状态,操作没有提交;联网后请重试。"
|
||||||
|
"api.http_error" = "请求失败(HTTP {status})"
|
||||||
|
"api.error.invalid_input" = "输入内容无效,请检查后重试。"
|
||||||
|
"api.error.invalid_credentials" = "用户名、动态验证码或会话无效。"
|
||||||
|
"api.error.forbidden" = "当前账户没有执行此操作的权限。"
|
||||||
|
"api.error.not_found" = "请求的资源不存在。"
|
||||||
|
"api.error.room_unavailable" = "该直播间已经被其他账户使用。"
|
||||||
|
"api.error.cookiecloud_invalid" = "CookieCloud 凭据无效或无法取得 Bilibili Cookie。"
|
||||||
|
"api.error.cookiecloud_host_not_allowed" = "该 CookieCloud 地址不在部署白名单中。"
|
||||||
|
"api.error.invalid_component_token" = "OBS 访问令牌无效或已经轮换。"
|
||||||
|
"api.error.component_not_found" = "组件不存在或已经停用。"
|
||||||
|
"api.error.offline" = "当前离线,操作尚未提交。"
|
||||||
|
"api.error.setup_completed" = "系统初始化已经完成。"
|
||||||
|
"api.error.rate_limited" = "尝试次数过多,请稍后重试。"
|
||||||
|
"api.error.invalid_bootstrap_proof" = "一次性初始化口令无效。"
|
||||||
|
"api.error.invalid_scope" = "请求的列表范围无效。"
|
||||||
|
"api.error.song_component_not_found" = "点歌姬不存在或已停用。"
|
||||||
|
"api.error.component_invalid" = "组件配置或类型无效。"
|
||||||
|
"api.error.component_connection_limit" = "OBS 组件连接数已达上限。"
|
||||||
|
"api.error.authentication_required" = "请先登录后再继续。"
|
||||||
|
"api.error.system_admin_required" = "该操作需要系统管理员权限。"
|
||||||
|
"api.error.cross_site_request" = "已拒绝跨站请求。"
|
||||||
|
"api.error.origin_mismatch" = "请求来源与本站不匹配。"
|
||||||
|
"api.error.enrollment_rate_limited" = "注册尝试过于频繁,请稍后重试。"
|
||||||
|
"api.error.invitation_unavailable" = "邀请码无效、已过期或已使用。"
|
||||||
|
"api.error.enrollment_unavailable" = "注册流程无效或已过期。"
|
||||||
|
"api.error.account_unavailable" = "账户不可用或用户名已被使用。"
|
||||||
|
"api.error.song_request_not_found" = "点歌记录不存在。"
|
||||||
|
"api.error.song_request_conflict" = "点歌队列已变化,请刷新后重试。"
|
||||||
|
"api.error.invalid_song_request" = "点歌操作无效。"
|
||||||
|
"api.error.internal_error" = "服务器暂时无法完成操作。"
|
||||||
|
"main.obs_missing_credentials" = "OBS 地址缺少组件标识或访问令牌"
|
||||||
|
"main.obs_invalid_token" = "OBS 访问令牌无效或已经轮换"
|
||||||
|
"main.obs_connecting" = "正在连接组件…"
|
||||||
|
"main.redirecting" = "正在前往控制台…"
|
||||||
|
"main.not_found_title" = "这里没有组件"
|
||||||
|
"main.not_found_description" = "地址可能已经失效,或者组件已被所属用户删除。"
|
||||||
|
"main.back_to_console" = "返回控制台"
|
||||||
|
"main.auth_unavailable" = "无法连接认证服务"
|
||||||
|
"main.offline_title" = "控制台目前处于离线状态"
|
||||||
|
"main.unavailable_title" = "控制台暂时无法连接"
|
||||||
|
"main.offline_description" = "应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。"
|
||||||
|
"main.retry" = "重新连接"
|
||||||
|
"main.verifying_session" = "正在验证安全会话…"
|
||||||
|
"main.not_found_eyebrow" = "404 · 迷失云间"
|
||||||
|
"main.offline_eyebrow" = "离线应用"
|
||||||
|
"main.connection_error_eyebrow" = "连接错误"
|
||||||
|
"auth.totp_qr_alt" = "TOTP 验证器绑定二维码"
|
||||||
|
"auth.qr_unavailable" = "二维码暂不可用,请使用右侧密钥手工添加。"
|
||||||
|
"auth.bind_authenticator" = "绑定动态验证器"
|
||||||
|
"auth.totp_step_scan" = "使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。"
|
||||||
|
"auth.totp_step_manual" = "若无法扫码,选择“输入设置密钥”。"
|
||||||
|
"auth.totp_step_confirm" = "输入应用中出现的 6 位动态码完成绑定。"
|
||||||
|
"auth.manual_key" = "手工设置密钥"
|
||||||
|
"auth.recovery_file_title" = "直播组件控制台恢复码"
|
||||||
|
"auth.recovery_file_warning" = "每个恢复码只能使用一次,请离线妥善保存。"
|
||||||
|
"auth.security_complete" = "安全设置完成"
|
||||||
|
"auth.save_recovery_codes" = "保存账户恢复码"
|
||||||
|
"auth.recovery_description" = "手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。"
|
||||||
|
"auth.no_recovery_codes" = "服务端没有返回恢复码,请先联系管理员确认恢复策略。"
|
||||||
|
"auth.copy_all" = "复制全部"
|
||||||
|
"auth.download_text" = "下载文本"
|
||||||
|
"auth.recovery_saved" = "我已妥善保存"
|
||||||
|
"auth.login_blocker" = "完成或清空正在填写的登录表单"
|
||||||
|
"auth.login_failed" = "用户名或动态验证码不正确"
|
||||||
|
"auth.product_eyebrow" = "直播组件"
|
||||||
|
"auth.login_title" = "回到你的控制台"
|
||||||
|
"auth.first_deployment" = "首次部署?"
|
||||||
|
"auth.create_admin" = "创建系统管理员"
|
||||||
|
"auth.have_invitation" = "持有邀请码?"
|
||||||
|
"auth.register_account" = "注册新账户"
|
||||||
|
"auth.passwordless_description" = "这是无密码账户。输入用户名与验证器中的动态验证码即可登录。"
|
||||||
|
"auth.username" = "用户名"
|
||||||
|
"auth.recovery_code" = "账户恢复码"
|
||||||
|
"auth.totp_code" = "6 位动态验证码"
|
||||||
|
"auth.recovery_placeholder" = "输入一个尚未使用的恢复码"
|
||||||
|
"auth.use_totp" = "改用动态验证码"
|
||||||
|
"auth.use_recovery" = "验证器不可用?改用恢复码"
|
||||||
|
"auth.verifying" = "正在验证…"
|
||||||
|
"auth.login" = "安全登录"
|
||||||
|
"auth.enrollment_blocker" = "完成 TOTP 绑定并保存一次性恢复码"
|
||||||
|
"auth.registration_blocker" = "完成或清空正在填写的注册表单"
|
||||||
|
"auth.registration_missing_id" = "服务端未返回注册流程标识"
|
||||||
|
"auth.registration_start_failed" = "无法开始安全注册流程"
|
||||||
|
"auth.registration_confirm_failed" = "动态验证码无效或注册流程已过期"
|
||||||
|
"auth.setup_step_two" = "系统初始化 · 第二步"
|
||||||
|
"auth.register_step_two" = "邀请码注册 · 第二步"
|
||||||
|
"auth.require_totp" = "强制绑定 TOTP"
|
||||||
|
"auth.authenticator_code" = "验证器中的 6 位动态码"
|
||||||
|
"auth.confirming" = "正在确认…"
|
||||||
|
"auth.confirm_create" = "确认绑定并创建账户"
|
||||||
|
"auth.setup_only" = "仅首次部署可用"
|
||||||
|
"auth.invited_only" = "仅限受邀用户"
|
||||||
|
"auth.create_your_account" = "创建你的账户"
|
||||||
|
"auth.already_account" = "已有账户?"
|
||||||
|
"auth.back_to_login" = "返回登录"
|
||||||
|
"auth.setup_description" = "首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。"
|
||||||
|
"auth.register_description" = "邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。"
|
||||||
|
"auth.invitation" = "邀请码"
|
||||||
|
"auth.bootstrap_password" = "一次性初始化口令"
|
||||||
|
"auth.bootstrap_help" = "填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。"
|
||||||
|
"auth.preparing_totp" = "正在准备 TOTP…"
|
||||||
|
"auth.next_bind" = "下一步:绑定验证器"
|
||||||
|
"nav.back_components" = "返回组件控制台"
|
||||||
|
"nav.console" = "控制台导航"
|
||||||
|
"nav.components" = "我的组件"
|
||||||
|
"nav.account" = "直播账户"
|
||||||
|
"nav.invitations" = "邀请码"
|
||||||
|
"nav.logout" = "退出"
|
||||||
|
"settings.theme" = "主题"
|
||||||
|
"settings.font_family" = "中文字体"
|
||||||
|
"settings.font_family_description" = "按 OBS 所在设备已安装字体依次匹配;缺失时自动回退到衬线字体。"
|
||||||
|
"settings.font_family.song" = "宋体 · 端正"
|
||||||
|
"settings.font_family.fang_song" = "仿宋 · 清雅"
|
||||||
|
"settings.font_family.kai" = "楷体 · 书卷"
|
||||||
|
"settings.font_brightness" = "文字亮度"
|
||||||
|
"settings.font_brightness_description" = "只提亮文字,不改变透明背景、礼物图片或直播画面。"
|
||||||
|
"settings.viewer_color" = "用户昵称颜色"
|
||||||
|
"settings.danmaku_color" = "弹幕内容颜色"
|
||||||
|
"settings.use_theme_color" = "跟随主题"
|
||||||
|
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
|
||||||
|
"settings.font_size" = "字号"
|
||||||
|
"settings.max_visible" = "最大可见条数"
|
||||||
|
"settings.auto_collapse" = "自动收缩"
|
||||||
|
"settings.unfold_duration" = "卷轴展开时长"
|
||||||
|
"settings.motion_intensity" = "动效强度"
|
||||||
|
"settings.particle_count" = "每卡粒子数量"
|
||||||
|
"settings.particle_speed" = "粒子动画速度"
|
||||||
|
"settings.show_events" = "显示事件"
|
||||||
|
"settings.event.danmaku" = "弹幕"
|
||||||
|
"settings.event.enter" = "进房"
|
||||||
|
"settings.event.gift" = "礼物"
|
||||||
|
"settings.event.superchat" = "醒目留言"
|
||||||
|
"settings.event.guard" = "舰长"
|
||||||
|
"settings.event.like" = "点赞"
|
||||||
|
"settings.event.share" = "分享"
|
||||||
|
"settings.low_performance" = "低性能模式"
|
||||||
|
"settings.high_gift" = "高价值礼物阈值(厘)"
|
||||||
|
"settings.featured_gift" = "特别高价值阈值(厘)"
|
||||||
|
"settings.saving" = "正在保存…"
|
||||||
|
"settings.save_sync" = "保存并实时同步"
|
||||||
|
"preview.adaptive" = "自适应预览"
|
||||||
|
"preview.adaptive_description" = "预设只改变预览尺寸;OBS 浏览器源仍可使用任意宽高。"
|
||||||
|
"preview.narrow" = "窄侧栏"
|
||||||
|
"preview.portrait" = "竖屏"
|
||||||
|
"preview.hd_portrait" = "高清竖栏"
|
||||||
|
"preview.horizontal" = "横向条"
|
||||||
|
"song.settings.scroll_speed" = "滚动速度"
|
||||||
|
"song.settings.edge_pause" = "两端暂停"
|
||||||
|
"song.settings.requester_color" = "点歌用户颜色"
|
||||||
|
"song.settings.title_color" = "歌名颜色"
|
||||||
|
"song.settings.limits" = "可选防刷限制(0 表示不限制)"
|
||||||
|
"song.settings.queue_limit" = "队列上限"
|
||||||
|
"song.settings.viewer_limit" = "每位观众上限"
|
||||||
|
"song.settings.cooldown" = "点歌冷却(秒)"
|
||||||
|
"song.preview.title" = "点歌姬预览"
|
||||||
|
"song.preview.description" = "当前歌曲固定在顶部;待唱列表溢出后会往返滚动。"
|
||||||
|
"source.blocker" = "保存或还原直播源与 CookieCloud 设置"
|
||||||
|
"source.saved" = "账户直播源已保存,所有组件会共享这条事件流。"
|
||||||
|
"source.save_failed" = "直播源保存失败"
|
||||||
|
"source.title" = "Bilibili 直播源"
|
||||||
|
"source.description" = "每个账户只有一条监听连接和一份 CookieCloud 凭据;监听事件会提供给本账户的所有组件。"
|
||||||
|
"source.current_room" = "当前直播间 ID"
|
||||||
|
"source.room_help" = "保存后会断开原直播间并立即切换监听;已配置的组件和 OBS 地址不变。"
|
||||||
|
"source.cookiecloud_host" = "CookieCloud 地址"
|
||||||
|
"source.host_placeholder" = "https://cookie.example.com"
|
||||||
|
"source.cookiecloud_key" = "CookieCloud UUID / Key"
|
||||||
|
"source.cookiecloud_password" = "CookieCloud 密码"
|
||||||
|
"source.secret_keep" = "已设置;留空保持不变"
|
||||||
|
"source.key_placeholder" = "请输入同步 UUID / Key"
|
||||||
|
"source.password_placeholder" = "请输入同步密码"
|
||||||
|
"source.validating" = "正在验证并保存…"
|
||||||
|
"source.save" = "保存直播源"
|
||||||
|
"obs.blocker" = "复制并妥善保存本次生成的 OBS 地址"
|
||||||
|
"obs.status_failed" = "无法读取当前组件的 OBS 令牌状态"
|
||||||
|
"obs.rotate_confirm" = "轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?"
|
||||||
|
"obs.token_created" = "新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。"
|
||||||
|
"obs.rotate_failed" = "无法轮换 OBS 令牌"
|
||||||
|
"obs.address_copied" = "OBS 浏览器源地址已复制。"
|
||||||
|
"obs.copy_blocked" = "浏览器阻止了复制,请手工选择下方地址。"
|
||||||
|
"obs.access_title" = "OBS 只读访问"
|
||||||
|
"obs.access_description" = "令牌仅能订阅这一组件;轮换不会影响账户登录或其他组件。"
|
||||||
|
"obs.access_status" = "访问状态"
|
||||||
|
"obs.token_ready" = "已生成令牌"
|
||||||
|
"obs.token_missing" = "尚未生成"
|
||||||
|
"obs.public_id" = "组件公开标识"
|
||||||
|
"obs.last_rotated" = "最近轮换"
|
||||||
|
"obs.generated_address" = "本次生成的 OBS 地址"
|
||||||
|
"obs.generating" = "正在生成…"
|
||||||
|
"obs.rotate" = "轮换令牌"
|
||||||
|
"obs.generate" = "生成 OBS 地址"
|
||||||
|
"obs.copy_address" = "复制地址"
|
||||||
|
"test.default_viewer" = "测试观众"
|
||||||
|
"test.default_text" = "今天也要闪闪发光!"
|
||||||
|
"test.default_gift" = "小花花"
|
||||||
|
"test.default_guard" = "舰长"
|
||||||
|
"test.sent" = "测试事件已发送到当前组件。"
|
||||||
|
"test.failed" = "测试事件发送失败"
|
||||||
|
"test.title" = "事件测试"
|
||||||
|
"test.description" = "模拟事件只进入当前用户、当前组件,不会向 Bilibili 发送消息。"
|
||||||
|
"test.event_type" = "事件类型"
|
||||||
|
"test.enter" = "进入直播间"
|
||||||
|
"test.uid" = "测试 UID"
|
||||||
|
"test.nickname" = "测试昵称"
|
||||||
|
"test.danmaku_text" = "弹幕内容"
|
||||||
|
"test.gift_name" = "礼物名称"
|
||||||
|
"test.gift_id" = "礼物 ID(可选)"
|
||||||
|
"test.gift_id_placeholder" = "用于测试指定礼物菜单项"
|
||||||
|
"test.quantity" = "数量"
|
||||||
|
"test.battery" = "电池数"
|
||||||
|
"test.guard" = "上舰"
|
||||||
|
"test.guard_name" = "舰队身份"
|
||||||
|
"test.price" = "价值(电池)"
|
||||||
|
"test.sending" = "正在发送…"
|
||||||
|
"test.trigger" = "触发测试事件"
|
||||||
|
"components.title" = "我的组件"
|
||||||
|
"components.eyebrow" = "直播组件"
|
||||||
|
"components.empty_title" = "还没有组件"
|
||||||
|
"components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。"
|
||||||
|
"components.danmaku_mark" = "弹"
|
||||||
|
"components.song_mark" = "歌"
|
||||||
|
"components.gift_mark" = "礼"
|
||||||
|
"components.gift_menu_mark" = "单"
|
||||||
|
"components.generic_mark" = "件"
|
||||||
|
"components.danmaku_type" = "直播弹幕姬"
|
||||||
|
"components.song_type" = "直播点歌姬"
|
||||||
|
"components.gift_type" = "全屏礼物星雨"
|
||||||
|
"components.gift_menu_type" = "直播礼物菜单"
|
||||||
|
"components.coming_soon" = "即将支持"
|
||||||
|
"components.future" = "更多主题 · 互动组件"
|
||||||
|
"components.settings_blocker" = "保存或还原当前组件设置"
|
||||||
|
"components.settings_load_failed" = "无法读取组件设置"
|
||||||
|
"components.load_failed" = "控制台数据加载失败"
|
||||||
|
"components.settings_saved" = "组件设置已保存,并实时同步到已连接的 OBS。"
|
||||||
|
"components.settings_save_failed" = "组件设置保存失败"
|
||||||
|
"components.loading" = "正在打开控制台…"
|
||||||
|
"components.danmaku_settings" = "弹幕姬设置"
|
||||||
|
"components.danmaku_description" = "每一项都独立保存在当前用户的组件下。"
|
||||||
|
"components.song_settings" = "点歌姬设置"
|
||||||
|
"components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
|
||||||
|
"components.gift_settings" = "全屏礼物特效设置"
|
||||||
|
"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。"
|
||||||
|
"components.gift_menu_settings" = "礼物菜单设置"
|
||||||
|
"components.gift_menu_description" = "把当前直播间的礼物、大航海身份或指定电池单价映射为主播提供的直播内容。"
|
||||||
|
"components.open_song_stats" = "打开点歌统计"
|
||||||
|
"components.generic_settings" = "组件设置"
|
||||||
|
"components.no_editor" = "该组件类型的设置编辑器尚未安装。"
|
||||||
|
"components.welcome" = "欢迎来到直播控制台"
|
||||||
|
"components.none_configurable" = "当前账户还没有可配置的组件。"
|
||||||
|
"account.load_failed" = "无法读取账户直播源"
|
||||||
|
"account.title" = "直播账户"
|
||||||
|
"account.eyebrow" = "账户直播源"
|
||||||
|
"account.description" = "配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。"
|
||||||
|
"account.loading" = "正在读取账户直播源…"
|
||||||
|
"song.queue_changing" = "点歌队列正在频繁变化,请稍后重试"
|
||||||
|
"song.queue_load_failed" = "点歌队列读取失败"
|
||||||
|
"song.cancel_confirm" = "确定取消「{title}」吗?"
|
||||||
|
"song.promoted" = "已移到下一首。"
|
||||||
|
"song.completed" = "已完成并自动切换下一首。"
|
||||||
|
"song.cancelled" = "已取消点歌。"
|
||||||
|
"song.action_failed" = "队列操作失败"
|
||||||
|
"song.clear_queue" = "一键清空队列"
|
||||||
|
"song.clearing" = "正在清空…"
|
||||||
|
"song.clear_confirm" = "确定清空当前歌曲和全部待唱歌曲吗?它们会被标记为已取消并保留在历史记录中。"
|
||||||
|
"song.cleared" = "已清空点歌队列,共取消 {count} 首歌曲。"
|
||||||
|
"song.clear_failed" = "清空点歌队列失败"
|
||||||
|
"song.stats_title" = "点歌统计与队列"
|
||||||
|
"song.stats_eyebrow" = "点歌队列管理"
|
||||||
|
"song.back_settings" = "返回组件设置"
|
||||||
|
"song.stat.active" = "活动点歌"
|
||||||
|
"song.stat.completed" = "已经完成"
|
||||||
|
"song.stat.cancelled" = "已经取消"
|
||||||
|
"song.stat.ratings" = "累计评分"
|
||||||
|
"song.current" = "当前歌曲"
|
||||||
|
"song.current_description" = "完成或取消当前歌曲后,队首会自动接替。"
|
||||||
|
"song.average_score" = "平均 {score} 分 · {count} 人评分"
|
||||||
|
"song.no_rating" = "尚无评分"
|
||||||
|
"song.complete_current" = "完成当前歌曲"
|
||||||
|
"song.no_current" = "当前没有正在演唱的歌曲。"
|
||||||
|
"song.queue_title" = "待唱队列({count})"
|
||||||
|
"song.queue_description" = "置顶会把歌曲移动为下一首,不会打断当前歌曲。"
|
||||||
|
"song.promote" = "移到下一首"
|
||||||
|
"song.queue_empty" = "待唱队列为空。"
|
||||||
|
"song.queue_loading" = "正在读取完整队列…"
|
||||||
|
"song.history" = "近期历史"
|
||||||
|
"song.history_description" = "长期历史保存在数据库中;此处显示最近 50 条。"
|
||||||
|
"song.column.title" = "歌曲"
|
||||||
|
"song.column.requester" = "点歌用户"
|
||||||
|
"song.column.result" = "结果"
|
||||||
|
"song.column.rating" = "评分"
|
||||||
|
"song.column.finished" = "结束时间"
|
||||||
|
"song.status.completed" = "已完成"
|
||||||
|
"song.status.cancelled" = "已取消"
|
||||||
|
"song.no_history" = "尚无历史记录。"
|
||||||
|
"invitation.blocker" = "复制并妥善保存本次生成的一次性邀请码"
|
||||||
|
"invitation.load_failed" = "邀请码读取失败"
|
||||||
|
"invitation.created" = "邀请码已创建。明文只显示这一次。"
|
||||||
|
"invitation.create_failed" = "邀请码创建失败"
|
||||||
|
"invitation.revoke_confirm" = "确定撤销这个邀请码吗?尚未完成的注册会立即失效。"
|
||||||
|
"invitation.revoked" = "邀请码已撤销。"
|
||||||
|
"invitation.revoke_failed" = "邀请码撤销失败"
|
||||||
|
"invitation.title" = "邀请码管理"
|
||||||
|
"invitation.eyebrow" = "系统管理"
|
||||||
|
"invitation.admin_only" = "仅系统管理员"
|
||||||
|
"invitation.create" = "创建邀请码"
|
||||||
|
"invitation.create_description" = "默认单次使用、24 小时有效;邀请码只用于注册,不能用于日常登录。"
|
||||||
|
"invitation.initial_room" = "初始 Bilibili 直播间 ID"
|
||||||
|
"invitation.valid_hours" = "有效小时数"
|
||||||
|
"invitation.creating" = "正在创建…"
|
||||||
|
"invitation.one_time" = "仅显示一次"
|
||||||
|
"invitation.copy_code" = "复制邀请码"
|
||||||
|
"invitation.copy_link" = "复制注册链接"
|
||||||
|
"invitation.history" = "历史邀请码"
|
||||||
|
"invitation.history_description" = "列表不包含邀请码明文,只显示可审计的状态和使用次数。"
|
||||||
|
"invitation.column.room" = "直播间"
|
||||||
|
"invitation.column.prefix" = "邀请码前缀"
|
||||||
|
"invitation.column.created" = "创建时间"
|
||||||
|
"invitation.column.expires" = "有效期"
|
||||||
|
"invitation.column.status" = "状态"
|
||||||
|
"invitation.status.revoked" = "已撤销"
|
||||||
|
"invitation.status.expired" = "已过期"
|
||||||
|
"invitation.status.used" = "已使用"
|
||||||
|
"invitation.status.available" = "可使用"
|
||||||
|
"invitation.empty" = "尚未创建邀请码。"
|
||||||
|
"forbidden.title" = "没有访问权限"
|
||||||
|
"forbidden.description" = "邀请码管理仅对系统管理员开放。"
|
||||||
|
"forbidden.back" = "返回我的组件"
|
||||||
|
"overlay.enter" = "进入了直播间"
|
||||||
|
"overlay.guard" = "开通 {guard}"
|
||||||
|
"overlay.like" = "点亮了一颗星"
|
||||||
|
"overlay.share" = "分享了直播间"
|
||||||
|
"overlay.gift" = "献上 {gift} ×{quantity}"
|
||||||
|
"overlay.interaction" = "送来了一份互动"
|
||||||
|
"overlay.preview_viewer" = "青玉观众"
|
||||||
|
"overlay.preview_message" = "今天也要闪闪发光!"
|
||||||
|
"overlay.preview_gift_viewer" = "星光旅人"
|
||||||
|
"overlay.preview_gift" = "甜蜜告白"
|
||||||
|
"song.overlay.preview_title" = "星河入梦"
|
||||||
|
"song.overlay.preview_viewer" = "青玉观众"
|
||||||
|
"song.overlay.preview_titles" = "晚风告白|月下花笺|云海来信|落星成诗"
|
||||||
|
"song.overlay.preview_viewers" = "星光旅人|花间客|小瓷片|月桂"
|
||||||
|
"song.overlay.score" = "{score} ★ · {count} 人"
|
||||||
|
"song.overlay.current_aria" = "当前歌曲"
|
||||||
|
"song.overlay.sing_mark" = "唱"
|
||||||
|
"song.overlay.now_singing" = "正在演唱 · {viewer} 点歌"
|
||||||
|
"song.overlay.waiting" = "等待弹幕点歌"
|
||||||
|
"song.overlay.request_help" = "发送「点歌 歌名」加入队列"
|
||||||
|
"song.overlay.rate_help" = "发送「打分 1-5」"
|
||||||
|
"song.overlay.queue_aria" = "待唱队列"
|
||||||
|
"song.overlay.queue_title" = "待唱列表"
|
||||||
|
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
|
||||||
|
"theme.jade_scroll.name" = "青玉花卷"
|
||||||
|
"theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。"
|
||||||
|
"theme.moonlit_water.name" = "静夜曲水"
|
||||||
|
"theme.moonlit_water.description" = "完全透明的月夜水色主题:青黛与米金衬线文字、细金分隔线和克制的花星微光。"
|
||||||
|
"gift.theme.jade_starfall.name" = "青玉星落"
|
||||||
|
"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。"
|
||||||
|
"gift.theme.moonlit_water.name" = "静夜曲水"
|
||||||
|
"gift.theme.moonlit_water.description" = "50 元以下礼物以水纹短笺轻声出现;50 元及以上礼物与大航海触发透明全屏月下清供。"
|
||||||
|
"gift.settings.thresholds" = "礼物价值分档(原始价格,1000 = 1 元)"
|
||||||
|
"gift.settings.high_threshold" = "高价值阈值"
|
||||||
|
"gift.settings.featured_threshold" = "特别高价值阈值"
|
||||||
|
"gift.settings.tiers" = "分档流星参数"
|
||||||
|
"gift.settings.tiers_description" = "流星大小以 1920×1080 为参考,其他 OBS 尺寸会按实际视口自适应。"
|
||||||
|
"gift.settings.tier.normal" = "普通礼物"
|
||||||
|
"gift.settings.tier.high" = "高价礼物"
|
||||||
|
"gift.settings.tier.featured" = "特别高价礼物"
|
||||||
|
"gift.settings.count" = "流星数量"
|
||||||
|
"gift.settings.size" = "礼物主体大小"
|
||||||
|
"gift.settings.speed" = "飞行速度"
|
||||||
|
"gift.settings.trail" = "拖尾强度"
|
||||||
|
"gift.settings.guard_stars" = "大航海星光数量"
|
||||||
|
"gift.settings.guard_duration" = "大航海全屏时长"
|
||||||
|
"gift.settings.concurrent" = "最大同时特效数"
|
||||||
|
"gift.preview.title" = "全屏礼物特效预览"
|
||||||
|
"gift.preview.description" = "预览按 16:9 缩放显示;OBS 浏览器源默认可用 1920×1080,也可使用任意分辨率。"
|
||||||
|
"gift.preview.normal_button" = "预览普通礼物"
|
||||||
|
"gift.preview.high_button" = "预览高价礼物"
|
||||||
|
"gift.preview.featured_button" = "预览特别礼物"
|
||||||
|
"gift.preview.guard_button" = "预览舰长特效"
|
||||||
|
"gift.preview.viewer" = "星光观众"
|
||||||
|
"gift.preview.normal" = "小花花"
|
||||||
|
"gift.preview.high" = "青玉献礼"
|
||||||
|
"gift.preview.featured" = "星河之梦"
|
||||||
|
"gift.preview.guard" = "舰长"
|
||||||
|
"gift.guard_aria" = "大航海全屏庆祝特效"
|
||||||
|
"gift.guard_salute" = "星河为你闪耀"
|
||||||
|
"gift.guard_title" = "{guard}·星光献礼"
|
||||||
|
"gift.guard_viewer" = "感谢 {viewer} 的守护"
|
||||||
|
"gift.moonlit.ceremony_label" = "月下清供"
|
||||||
|
"gift_menu.theme.jade_banquet.name" = "青玉华宴"
|
||||||
|
"gift_menu.theme.jade_banquet.description" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
||||||
|
"gift_menu.theme.moonlit_water.name" = "静夜曲水"
|
||||||
|
"gift_menu.theme.moonlit_water.description" = "全透明的月夜礼物清单,以青黛、米金文字和细水纹高亮呈现。"
|
||||||
|
"gift_menu.catalog.title" = "当前直播间礼物目录"
|
||||||
|
"gift_menu.catalog.loading" = "正在读取礼物与图标…"
|
||||||
|
"gift_menu.catalog.count" = "已加载 {count} 种礼物"
|
||||||
|
"gift_menu.catalog.refresh" = "立即刷新礼物目录"
|
||||||
|
"gift_menu.catalog.failed" = "礼物目录读取失败"
|
||||||
|
"gift_menu.editor.add_title" = "添加菜单项"
|
||||||
|
"gift_menu.editor.trigger_type" = "触发类型"
|
||||||
|
"gift_menu.trigger.gift" = "指定普通礼物"
|
||||||
|
"gift_menu.trigger.guard" = "指定大航海身份"
|
||||||
|
"gift_menu.trigger.battery" = "指定礼物单价"
|
||||||
|
"gift_menu.editor.gift" = "直播间礼物"
|
||||||
|
"gift_menu.editor.gift_option" = "{name} · {battery} 电池"
|
||||||
|
"gift_menu.editor.guard_level" = "大航海身份"
|
||||||
|
"gift_menu.editor.battery_amount" = "单价电池数"
|
||||||
|
"gift_menu.editor.description" = "对应直播内容"
|
||||||
|
"gift_menu.editor.description_placeholder" = "例如:点歌一首 / 学唱一首歌"
|
||||||
|
"gift_menu.editor.add" = "加入礼物菜单"
|
||||||
|
"gift_menu.editor.description_required" = "请填写该礼物对应的直播内容。"
|
||||||
|
"gift_menu.editor.gift_required" = "请先从当前直播间目录选择礼物。"
|
||||||
|
"gift_menu.editor.duplicate" = "这个触发条件已经存在于菜单中。"
|
||||||
|
"gift_menu.editor.empty" = "还没有菜单项,请从上方添加。"
|
||||||
|
"gift_menu.editor.move_up" = "上移菜单项"
|
||||||
|
"gift_menu.editor.move_down" = "下移菜单项"
|
||||||
|
"gift_menu.editor.remove" = "移除"
|
||||||
|
"gift_menu.guard.captain" = "舰长"
|
||||||
|
"gift_menu.guard.admiral" = "提督"
|
||||||
|
"gift_menu.guard.governor" = "总督"
|
||||||
|
"gift_menu.guard_mark.captain" = "舰"
|
||||||
|
"gift_menu.guard_mark.admiral" = "提"
|
||||||
|
"gift_menu.guard_mark.governor" = "总"
|
||||||
|
"gift_menu.battery_mark" = "电"
|
||||||
|
"gift_menu.settings.visible_rows" = "最多展示行数"
|
||||||
|
"gift_menu.settings.rows_per_page" = "每页展示行数"
|
||||||
|
"gift_menu.settings.row_height" = "每行高度"
|
||||||
|
"gift_menu.settings.scroll_speed" = "循环滚动速度"
|
||||||
|
"gift_menu.settings.page_interval" = "换页间隔"
|
||||||
|
"gift_menu.settings.highlight_duration" = "触发高亮时长"
|
||||||
|
"gift_menu.settings.font_scale" = "文字缩放"
|
||||||
|
"gift_menu.settings.motion" = "动效强度"
|
||||||
|
"gift_menu.unit.pixels" = "{value} 像素"
|
||||||
|
"gift_menu.unit.speed" = "{value} 像素/秒"
|
||||||
|
"gift_menu.unit.seconds" = "{value} 秒"
|
||||||
|
"gift_menu.preview.title" = "礼物菜单预览"
|
||||||
|
"gift_menu.preview.description" = "点击任意菜单行可预览定位与渐变高亮动画。"
|
||||||
|
"gift_menu.aria" = "直播间礼物菜单"
|
||||||
|
"gift_menu.overlay.battery" = "电池 ×{amount}"
|
||||||
|
"gift_menu.overlay.triggered_by" = "{viewer} 已触发"
|
||||||
|
"gift_menu.overlay.empty" = "请先在控制台配置礼物菜单"
|
||||||
|
"pwa.blocked" = "暂时不能更新,请先处理以下内容:\n\n{reasons}"
|
||||||
|
"pwa.confirm_update" = "更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?"
|
||||||
|
"pwa.offline" = "离线"
|
||||||
|
"pwa.update" = "更新可用"
|
||||||
|
"pwa.install" = "安装到设备"
|
||||||
|
"pwa.manifest.name" = "直播组件控制台"
|
||||||
|
"pwa.manifest.short_name" = "直播控制台"
|
||||||
|
"pwa.manifest.description" = "直播组件与 OBS 浏览器源控制台"
|
||||||
|
|
||||||
|
[locales."en-US"]
|
||||||
|
name = "English"
|
||||||
|
|
||||||
|
[locales."en-US".messages]
|
||||||
|
"language.label" = "Language"
|
||||||
|
"language.save_failed" = "Could not save the language preference. Try again later."
|
||||||
|
"brand.name" = "Live Component Console"
|
||||||
|
"brand.tagline" = "OBS COMPONENT STUDIO"
|
||||||
|
"common.star" = "Star"
|
||||||
|
"common.cloud" = "Cloud"
|
||||||
|
"common.copy" = "Copy"
|
||||||
|
"common.copied" = "Copied"
|
||||||
|
"common.cancel" = "Cancel"
|
||||||
|
"common.revoke" = "Revoke"
|
||||||
|
"common.save" = "Save"
|
||||||
|
"common.loading" = "Loading…"
|
||||||
|
"common.user" = "User"
|
||||||
|
"common.system_admin" = "System administrator"
|
||||||
|
"common.enabled" = "Running"
|
||||||
|
"common.disabled" = "Disabled"
|
||||||
|
"common.connected" = "Connected"
|
||||||
|
"common.disconnected" = "Disconnected"
|
||||||
|
"common.permanent" = "Never"
|
||||||
|
"common.viewer" = "Viewer"
|
||||||
|
"common.not_provided" = "Not provided"
|
||||||
|
"common.gift" = "Gift"
|
||||||
|
"common.guard" = "Guard"
|
||||||
|
"common.failed" = "The operation failed. Please try again later."
|
||||||
|
"api.offline" = "You are offline. Nothing was submitted; reconnect and try again."
|
||||||
|
"api.http_error" = "Request failed (HTTP {status})"
|
||||||
|
"api.error.invalid_input" = "Check the entered values and try again."
|
||||||
|
"api.error.invalid_credentials" = "The username, verification code, or session is invalid."
|
||||||
|
"api.error.forbidden" = "This account is not allowed to perform that operation."
|
||||||
|
"api.error.not_found" = "The requested resource does not exist."
|
||||||
|
"api.error.room_unavailable" = "That live room is already used by another account."
|
||||||
|
"api.error.cookiecloud_invalid" = "CookieCloud credentials are invalid or no Bilibili cookie could be fetched."
|
||||||
|
"api.error.cookiecloud_host_not_allowed" = "That CookieCloud host is not allowed by this deployment."
|
||||||
|
"api.error.invalid_component_token" = "The OBS access token is invalid or has been rotated."
|
||||||
|
"api.error.component_not_found" = "The component does not exist or is disabled."
|
||||||
|
"api.error.offline" = "You are offline. The operation was not submitted."
|
||||||
|
"api.error.setup_completed" = "Initial system setup is already complete."
|
||||||
|
"api.error.rate_limited" = "Too many attempts. Try again later."
|
||||||
|
"api.error.invalid_bootstrap_proof" = "The one-time setup secret is invalid."
|
||||||
|
"api.error.invalid_scope" = "The requested list scope is invalid."
|
||||||
|
"api.error.song_component_not_found" = "The song request component does not exist or is disabled."
|
||||||
|
"api.error.component_invalid" = "The component configuration or kind is invalid."
|
||||||
|
"api.error.component_connection_limit" = "The OBS component connection limit has been reached."
|
||||||
|
"api.error.authentication_required" = "Log in before continuing."
|
||||||
|
"api.error.system_admin_required" = "This operation requires a system administrator."
|
||||||
|
"api.error.cross_site_request" = "The cross-site request was rejected."
|
||||||
|
"api.error.origin_mismatch" = "The request origin does not match this site."
|
||||||
|
"api.error.enrollment_rate_limited" = "Too many registration attempts. Try again later."
|
||||||
|
"api.error.invitation_unavailable" = "The invitation is invalid, expired, or already used."
|
||||||
|
"api.error.enrollment_unavailable" = "The registration flow is invalid or expired."
|
||||||
|
"api.error.account_unavailable" = "The account is unavailable or the username is already used."
|
||||||
|
"api.error.song_request_not_found" = "The song request does not exist."
|
||||||
|
"api.error.song_request_conflict" = "The song queue changed. Refresh and try again."
|
||||||
|
"api.error.invalid_song_request" = "The song request operation is invalid."
|
||||||
|
"api.error.internal_error" = "The server could not complete the operation."
|
||||||
|
"main.obs_missing_credentials" = "The OBS URL is missing a component ID or access token"
|
||||||
|
"main.obs_invalid_token" = "The OBS access token is invalid or has been rotated"
|
||||||
|
"main.obs_connecting" = "Connecting to component…"
|
||||||
|
"main.redirecting" = "Opening the studio…"
|
||||||
|
"main.not_found_title" = "Component not found"
|
||||||
|
"main.not_found_description" = "The address may have expired, or the owner deleted the component."
|
||||||
|
"main.back_to_console" = "Back to console"
|
||||||
|
"main.auth_unavailable" = "Could not connect to the authentication service"
|
||||||
|
"main.offline_title" = "The console is offline"
|
||||||
|
"main.unavailable_title" = "The studio is temporarily unavailable"
|
||||||
|
"main.offline_description" = "The app shell opened offline, but account, live-source, and component data is never cached. Reconnect to verify your session."
|
||||||
|
"main.retry" = "Reconnect"
|
||||||
|
"main.verifying_session" = "Verifying secure session…"
|
||||||
|
"main.not_found_eyebrow" = "404 · LOST IN THE CLOUDS"
|
||||||
|
"main.offline_eyebrow" = "OFFLINE APP"
|
||||||
|
"main.connection_error_eyebrow" = "CONNECTION ERROR"
|
||||||
|
"auth.totp_qr_alt" = "TOTP authenticator enrollment QR code"
|
||||||
|
"auth.qr_unavailable" = "The QR code is unavailable. Add the key manually instead."
|
||||||
|
"auth.bind_authenticator" = "Connect an authenticator"
|
||||||
|
"auth.totp_step_scan" = "Scan the QR code with 1Password, Aegis, Microsoft Authenticator, or another TOTP app."
|
||||||
|
"auth.totp_step_manual" = "If scanning fails, choose the option to enter a setup key."
|
||||||
|
"auth.totp_step_confirm" = "Enter the six-digit code from the app to finish enrollment."
|
||||||
|
"auth.manual_key" = "Manual setup key"
|
||||||
|
"auth.recovery_file_title" = "Live Component Console recovery codes"
|
||||||
|
"auth.recovery_file_warning" = "Each recovery code works once. Store this file securely offline."
|
||||||
|
"auth.security_complete" = "Security setup complete"
|
||||||
|
"auth.save_recovery_codes" = "Save account recovery codes"
|
||||||
|
"auth.recovery_description" = "Use a recovery code if your phone or authenticator is unavailable. The server will never show these plaintext codes again."
|
||||||
|
"auth.no_recovery_codes" = "The server returned no recovery codes. Contact the administrator before continuing."
|
||||||
|
"auth.copy_all" = "Copy all"
|
||||||
|
"auth.download_text" = "Download text"
|
||||||
|
"auth.recovery_saved" = "I saved them securely"
|
||||||
|
"auth.login_blocker" = "finish or clear the login form"
|
||||||
|
"auth.login_failed" = "The username or verification code is incorrect"
|
||||||
|
"auth.product_eyebrow" = "Live Components"
|
||||||
|
"auth.login_title" = "Return to your studio"
|
||||||
|
"auth.first_deployment" = "First deployment?"
|
||||||
|
"auth.create_admin" = "Create system administrator"
|
||||||
|
"auth.have_invitation" = "Have an invitation?"
|
||||||
|
"auth.register_account" = "Register an account"
|
||||||
|
"auth.passwordless_description" = "This account has no password. Enter your username and the current authenticator code."
|
||||||
|
"auth.username" = "Username"
|
||||||
|
"auth.recovery_code" = "Account recovery code"
|
||||||
|
"auth.totp_code" = "Six-digit verification code"
|
||||||
|
"auth.recovery_placeholder" = "Enter an unused recovery code"
|
||||||
|
"auth.use_totp" = "Use a verification code"
|
||||||
|
"auth.use_recovery" = "Authenticator unavailable? Use a recovery code"
|
||||||
|
"auth.verifying" = "Verifying…"
|
||||||
|
"auth.login" = "Secure login"
|
||||||
|
"auth.enrollment_blocker" = "finish TOTP enrollment and save the one-time recovery codes"
|
||||||
|
"auth.registration_blocker" = "finish or clear the registration form"
|
||||||
|
"auth.registration_missing_id" = "The server did not return a registration flow ID"
|
||||||
|
"auth.registration_start_failed" = "Could not start secure registration"
|
||||||
|
"auth.registration_confirm_failed" = "The verification code is invalid or registration expired"
|
||||||
|
"auth.setup_step_two" = "System setup · Step two"
|
||||||
|
"auth.register_step_two" = "Invited registration · Step two"
|
||||||
|
"auth.require_totp" = "TOTP is required"
|
||||||
|
"auth.authenticator_code" = "Six-digit code from your authenticator"
|
||||||
|
"auth.confirming" = "Confirming…"
|
||||||
|
"auth.confirm_create" = "Confirm and create account"
|
||||||
|
"auth.setup_only" = "Available only during first setup"
|
||||||
|
"auth.invited_only" = "Invited users only"
|
||||||
|
"auth.create_your_account" = "Create your account"
|
||||||
|
"auth.already_account" = "Already have an account?"
|
||||||
|
"auth.back_to_login" = "Back to login"
|
||||||
|
"auth.setup_description" = "The first account can manage invitations. The legacy administrator secret authorizes this setup once and never becomes an account password."
|
||||||
|
"auth.register_description" = "The invitation is only for registration. This passwordless account will require TOTP for every login."
|
||||||
|
"auth.invitation" = "Invitation code"
|
||||||
|
"auth.bootstrap_password" = "One-time setup secret"
|
||||||
|
"auth.bootstrap_help" = "Enter the legacy administrator secret from deployment configuration. It only authorizes setup and is not stored as a user password."
|
||||||
|
"auth.preparing_totp" = "Preparing TOTP…"
|
||||||
|
"auth.next_bind" = "Next: connect authenticator"
|
||||||
|
"nav.back_components" = "Back to component console"
|
||||||
|
"nav.console" = "Console navigation"
|
||||||
|
"nav.components" = "My components"
|
||||||
|
"nav.account" = "Live account"
|
||||||
|
"nav.invitations" = "Invitations"
|
||||||
|
"nav.logout" = "Log out"
|
||||||
|
"settings.theme" = "Theme"
|
||||||
|
"settings.font_family" = "Chinese typeface"
|
||||||
|
"settings.font_family_description" = "Uses the first installed font on the OBS device and falls back safely to a serif face."
|
||||||
|
"settings.font_family.song" = "Song · Formal"
|
||||||
|
"settings.font_family.fang_song" = "FangSong · Refined"
|
||||||
|
"settings.font_family.kai" = "Kai · Calligraphic"
|
||||||
|
"settings.font_brightness" = "Text brightness"
|
||||||
|
"settings.font_brightness_description" = "Brightens text only; transparency, gift artwork, and the underlying scene are unchanged."
|
||||||
|
"settings.viewer_color" = "Viewer name color"
|
||||||
|
"settings.danmaku_color" = "Chat message color"
|
||||||
|
"settings.use_theme_color" = "Use theme color"
|
||||||
|
"settings.text_color_description" = "Overrides the theme when selected; reset to follow future theme changes."
|
||||||
|
"settings.font_size" = "Font size"
|
||||||
|
"settings.max_visible" = "Maximum visible items"
|
||||||
|
"settings.auto_collapse" = "Auto-collapse"
|
||||||
|
"settings.unfold_duration" = "Scroll-open duration"
|
||||||
|
"settings.motion_intensity" = "Motion intensity"
|
||||||
|
"settings.particle_count" = "Particles per card"
|
||||||
|
"settings.particle_speed" = "Particle speed"
|
||||||
|
"settings.show_events" = "Visible events"
|
||||||
|
"settings.event.danmaku" = "Chat"
|
||||||
|
"settings.event.enter" = "Room entry"
|
||||||
|
"settings.event.gift" = "Gift"
|
||||||
|
"settings.event.superchat" = "Super Chat"
|
||||||
|
"settings.event.guard" = "Guard"
|
||||||
|
"settings.event.like" = "Like"
|
||||||
|
"settings.event.share" = "Share"
|
||||||
|
"settings.low_performance" = "Low-performance mode"
|
||||||
|
"settings.high_gift" = "High-value gift threshold (milli-CNY)"
|
||||||
|
"settings.featured_gift" = "Featured gift threshold (milli-CNY)"
|
||||||
|
"settings.saving" = "Saving…"
|
||||||
|
"settings.save_sync" = "Save and sync live"
|
||||||
|
"preview.adaptive" = "Responsive preview"
|
||||||
|
"preview.adaptive_description" = "Presets only resize the preview. OBS browser sources may use any dimensions."
|
||||||
|
"preview.narrow" = "Narrow sidebar"
|
||||||
|
"preview.portrait" = "Portrait"
|
||||||
|
"preview.hd_portrait" = "HD portrait"
|
||||||
|
"preview.horizontal" = "Horizontal bar"
|
||||||
|
"song.settings.scroll_speed" = "Scroll speed"
|
||||||
|
"song.settings.edge_pause" = "Edge pause"
|
||||||
|
"song.settings.requester_color" = "Requester color"
|
||||||
|
"song.settings.title_color" = "Song title color"
|
||||||
|
"song.settings.limits" = "Optional anti-spam limits (0 means unlimited)"
|
||||||
|
"song.settings.queue_limit" = "Queue limit"
|
||||||
|
"song.settings.viewer_limit" = "Per-viewer limit"
|
||||||
|
"song.settings.cooldown" = "Request cooldown (seconds)"
|
||||||
|
"song.preview.title" = "Song request preview"
|
||||||
|
"song.preview.description" = "The current song stays pinned while an overflowing queue scrolls back and forth."
|
||||||
|
"source.blocker" = "save or revert live-source and CookieCloud settings"
|
||||||
|
"source.saved" = "Account live source saved. Every component shares this event stream."
|
||||||
|
"source.save_failed" = "Could not save the live source"
|
||||||
|
"source.title" = "Bilibili live source"
|
||||||
|
"source.description" = "Each account has one listener and one CookieCloud credential set. Every account component can consume its events."
|
||||||
|
"source.current_room" = "Current live room ID"
|
||||||
|
"source.room_help" = "Saving disconnects the old room and immediately starts the new listener. Components and OBS URLs do not change."
|
||||||
|
"source.cookiecloud_host" = "CookieCloud host"
|
||||||
|
"source.host_placeholder" = "https://cookie.example.com"
|
||||||
|
"source.cookiecloud_key" = "CookieCloud UUID / key"
|
||||||
|
"source.cookiecloud_password" = "CookieCloud password"
|
||||||
|
"source.secret_keep" = "Already set; leave blank to keep it"
|
||||||
|
"source.key_placeholder" = "Enter sync UUID / key"
|
||||||
|
"source.password_placeholder" = "Enter sync password"
|
||||||
|
"source.validating" = "Validating and saving…"
|
||||||
|
"source.save" = "Save live source"
|
||||||
|
"obs.blocker" = "copy and securely save the newly generated OBS URL"
|
||||||
|
"obs.status_failed" = "Could not read this component's OBS token status"
|
||||||
|
"obs.rotate_confirm" = "Rotating immediately invalidates every OBS browser source using the old URL. Continue?"
|
||||||
|
"obs.token_created" = "A new OBS token was created. Copy it now; the plaintext token will not be shown again after leaving this page."
|
||||||
|
"obs.rotate_failed" = "Could not rotate the OBS token"
|
||||||
|
"obs.address_copied" = "OBS browser source URL copied."
|
||||||
|
"obs.copy_blocked" = "The browser blocked copying. Select the URL below manually."
|
||||||
|
"obs.access_title" = "Read-only OBS access"
|
||||||
|
"obs.access_description" = "This token can only subscribe to this component. Rotation does not affect account login or other components."
|
||||||
|
"obs.access_status" = "Access status"
|
||||||
|
"obs.token_ready" = "Token generated"
|
||||||
|
"obs.token_missing" = "Not generated"
|
||||||
|
"obs.public_id" = "Public component ID"
|
||||||
|
"obs.last_rotated" = "Last rotated"
|
||||||
|
"obs.generated_address" = "New OBS URL"
|
||||||
|
"obs.generating" = "Generating…"
|
||||||
|
"obs.rotate" = "Rotate token"
|
||||||
|
"obs.generate" = "Generate OBS URL"
|
||||||
|
"obs.copy_address" = "Copy URL"
|
||||||
|
"test.default_viewer" = "Test viewer"
|
||||||
|
"test.default_text" = "Shine brightly today!"
|
||||||
|
"test.default_gift" = "Little flower"
|
||||||
|
"test.default_guard" = "Captain"
|
||||||
|
"test.sent" = "The test event was sent to this component."
|
||||||
|
"test.failed" = "Could not send the test event"
|
||||||
|
"test.title" = "Event testing"
|
||||||
|
"test.description" = "Simulated events only enter this user's selected component and never send anything to Bilibili."
|
||||||
|
"test.event_type" = "Event type"
|
||||||
|
"test.enter" = "Enter room"
|
||||||
|
"test.uid" = "Test UID"
|
||||||
|
"test.nickname" = "Test nickname"
|
||||||
|
"test.danmaku_text" = "Chat message"
|
||||||
|
"test.gift_name" = "Gift name"
|
||||||
|
"test.gift_id" = "Gift ID (optional)"
|
||||||
|
"test.gift_id_placeholder" = "Tests a specific gift-menu entry"
|
||||||
|
"test.quantity" = "Quantity"
|
||||||
|
"test.battery" = "Battery value"
|
||||||
|
"test.guard" = "Guard purchase"
|
||||||
|
"test.guard_name" = "Guard tier"
|
||||||
|
"test.price" = "Value (battery)"
|
||||||
|
"test.sending" = "Sending…"
|
||||||
|
"test.trigger" = "Trigger test event"
|
||||||
|
"components.title" = "My components"
|
||||||
|
"components.eyebrow" = "LIVE COMPONENTS"
|
||||||
|
"components.empty_title" = "No components yet"
|
||||||
|
"components.empty_description" = "The service creates a default chat overlay after account initialization."
|
||||||
|
"components.danmaku_mark" = "Chat"
|
||||||
|
"components.song_mark" = "Song"
|
||||||
|
"components.gift_mark" = "Gift"
|
||||||
|
"components.gift_menu_mark" = "Menu"
|
||||||
|
"components.generic_mark" = "App"
|
||||||
|
"components.danmaku_type" = "Live chat overlay"
|
||||||
|
"components.song_type" = "Song request overlay"
|
||||||
|
"components.gift_type" = "Full-screen gift starfall"
|
||||||
|
"components.gift_menu_type" = "Live gift menu"
|
||||||
|
"components.coming_soon" = "Coming soon"
|
||||||
|
"components.future" = "More themes · Interactive components"
|
||||||
|
"components.settings_blocker" = "save or revert the current component settings"
|
||||||
|
"components.settings_load_failed" = "Could not load component settings"
|
||||||
|
"components.load_failed" = "Could not load console data"
|
||||||
|
"components.settings_saved" = "Component settings saved and synced to connected OBS sources."
|
||||||
|
"components.settings_save_failed" = "Could not save component settings"
|
||||||
|
"components.loading" = "Opening the studio…"
|
||||||
|
"components.danmaku_settings" = "Chat overlay settings"
|
||||||
|
"components.danmaku_description" = "Every setting is stored independently on this user's component."
|
||||||
|
"components.song_settings" = "Song request settings"
|
||||||
|
"components.song_description" = "Viewers send “点歌 Song name” to queue a song and “打分 1-5” to rate the current song."
|
||||||
|
"components.gift_settings" = "Full-screen gift effect settings"
|
||||||
|
"components.gift_description" = "Gifts become meteors crossing a transparent canvas; Guard, Admiral, and Governor purchases trigger a full-screen starlight celebration."
|
||||||
|
"components.gift_menu_settings" = "Gift menu settings"
|
||||||
|
"components.gift_menu_description" = "Map gifts available in the current room, membership tiers, or a gift unit price to content the streamer will perform."
|
||||||
|
"components.open_song_stats" = "Open song statistics"
|
||||||
|
"components.generic_settings" = "Component settings"
|
||||||
|
"components.no_editor" = "No settings editor is installed for this component kind."
|
||||||
|
"components.welcome" = "Welcome to Live Studio"
|
||||||
|
"components.none_configurable" = "This account has no configurable components."
|
||||||
|
"account.load_failed" = "Could not load the account live source"
|
||||||
|
"account.title" = "Live account"
|
||||||
|
"account.eyebrow" = "ACCOUNT LIVE SOURCE"
|
||||||
|
"account.description" = "Configure the room and CookieCloud once for every current and future component in this account."
|
||||||
|
"account.loading" = "Loading account live source…"
|
||||||
|
"song.queue_changing" = "The song queue is changing rapidly. Try again shortly."
|
||||||
|
"song.queue_load_failed" = "Could not load the song queue"
|
||||||
|
"song.cancel_confirm" = "Cancel “{title}”?"
|
||||||
|
"song.promoted" = "Moved to next."
|
||||||
|
"song.completed" = "Completed and advanced to the next song."
|
||||||
|
"song.cancelled" = "Song request cancelled."
|
||||||
|
"song.action_failed" = "Queue operation failed"
|
||||||
|
"song.clear_queue" = "Clear entire queue"
|
||||||
|
"song.clearing" = "Clearing…"
|
||||||
|
"song.clear_confirm" = "Clear the current song and every upcoming request? They will be marked cancelled and retained in history."
|
||||||
|
"song.cleared" = "Queue cleared. {count} requests were cancelled."
|
||||||
|
"song.clear_failed" = "Could not clear the song queue"
|
||||||
|
"song.stats_title" = "Song statistics and queue"
|
||||||
|
"song.stats_eyebrow" = "SONG QUEUE MANAGER"
|
||||||
|
"song.back_settings" = "Back to component settings"
|
||||||
|
"song.stat.active" = "Active requests"
|
||||||
|
"song.stat.completed" = "Completed"
|
||||||
|
"song.stat.cancelled" = "Cancelled"
|
||||||
|
"song.stat.ratings" = "Ratings"
|
||||||
|
"song.current" = "Current song"
|
||||||
|
"song.current_description" = "Completing or cancelling the current song automatically advances the queue."
|
||||||
|
"song.average_score" = "Average {score} · {count} ratings"
|
||||||
|
"song.no_rating" = "No ratings yet"
|
||||||
|
"song.complete_current" = "Complete current song"
|
||||||
|
"song.no_current" = "No song is currently being performed."
|
||||||
|
"song.queue_title" = "Upcoming queue ({count})"
|
||||||
|
"song.queue_description" = "Promoting makes a song next without interrupting the current song."
|
||||||
|
"song.promote" = "Move to next"
|
||||||
|
"song.queue_empty" = "The upcoming queue is empty."
|
||||||
|
"song.queue_loading" = "Loading the full queue…"
|
||||||
|
"song.history" = "Recent history"
|
||||||
|
"song.history_description" = "Full history remains in the database; the latest 50 entries are shown here."
|
||||||
|
"song.column.title" = "Song"
|
||||||
|
"song.column.requester" = "Requested by"
|
||||||
|
"song.column.result" = "Result"
|
||||||
|
"song.column.rating" = "Rating"
|
||||||
|
"song.column.finished" = "Finished"
|
||||||
|
"song.status.completed" = "Completed"
|
||||||
|
"song.status.cancelled" = "Cancelled"
|
||||||
|
"song.no_history" = "No history yet."
|
||||||
|
"invitation.blocker" = "copy and securely save the new one-time invitation"
|
||||||
|
"invitation.load_failed" = "Could not load invitations"
|
||||||
|
"invitation.created" = "Invitation created. Its plaintext is shown only once."
|
||||||
|
"invitation.create_failed" = "Could not create invitation"
|
||||||
|
"invitation.revoke_confirm" = "Revoke this invitation? Any unfinished registration will immediately fail."
|
||||||
|
"invitation.revoked" = "Invitation revoked."
|
||||||
|
"invitation.revoke_failed" = "Could not revoke invitation"
|
||||||
|
"invitation.title" = "Invitation management"
|
||||||
|
"invitation.eyebrow" = "SYSTEM ADMINISTRATION"
|
||||||
|
"invitation.admin_only" = "System administrators only"
|
||||||
|
"invitation.create" = "Create invitation"
|
||||||
|
"invitation.create_description" = "Single-use and valid for 24 hours by default. Invitations are only for registration, never daily login."
|
||||||
|
"invitation.initial_room" = "Initial Bilibili live room ID"
|
||||||
|
"invitation.valid_hours" = "Valid hours"
|
||||||
|
"invitation.creating" = "Creating…"
|
||||||
|
"invitation.one_time" = "Shown once"
|
||||||
|
"invitation.copy_code" = "Copy invitation"
|
||||||
|
"invitation.copy_link" = "Copy registration link"
|
||||||
|
"invitation.history" = "Invitation history"
|
||||||
|
"invitation.history_description" = "Plaintext codes are omitted; only auditable state and usage are shown."
|
||||||
|
"invitation.column.room" = "Live room"
|
||||||
|
"invitation.column.prefix" = "Code prefix"
|
||||||
|
"invitation.column.created" = "Created"
|
||||||
|
"invitation.column.expires" = "Expires"
|
||||||
|
"invitation.column.status" = "Status"
|
||||||
|
"invitation.status.revoked" = "Revoked"
|
||||||
|
"invitation.status.expired" = "Expired"
|
||||||
|
"invitation.status.used" = "Used"
|
||||||
|
"invitation.status.available" = "Available"
|
||||||
|
"invitation.empty" = "No invitations have been created."
|
||||||
|
"forbidden.title" = "Access denied"
|
||||||
|
"forbidden.description" = "Only system administrators can manage invitations."
|
||||||
|
"forbidden.back" = "Back to my components"
|
||||||
|
"overlay.enter" = "entered the studio"
|
||||||
|
"overlay.guard" = "activated {guard}"
|
||||||
|
"overlay.like" = "lit up a star"
|
||||||
|
"overlay.share" = "shared the live room"
|
||||||
|
"overlay.gift" = "sent {gift} ×{quantity}"
|
||||||
|
"overlay.interaction" = "sent an interaction"
|
||||||
|
"overlay.preview_viewer" = "Jade Viewer"
|
||||||
|
"overlay.preview_message" = "Shine brightly today!"
|
||||||
|
"overlay.preview_gift_viewer" = "Starlight Traveler"
|
||||||
|
"overlay.preview_gift" = "Sweet Confession"
|
||||||
|
"song.overlay.preview_title" = "Dreaming in Starlight"
|
||||||
|
"song.overlay.preview_viewer" = "Jade Viewer"
|
||||||
|
"song.overlay.preview_titles" = "Evening Breeze|Moonlit Letter|Message from the Clouds|Poem of Falling Stars"
|
||||||
|
"song.overlay.preview_viewers" = "Starlight Traveler|Garden Guest|Little Porcelain|Laurel"
|
||||||
|
"song.overlay.score" = "{score} ★ · {count} viewers"
|
||||||
|
"song.overlay.current_aria" = "Current song"
|
||||||
|
"song.overlay.sing_mark" = "Sing"
|
||||||
|
"song.overlay.now_singing" = "Now singing · requested by {viewer}"
|
||||||
|
"song.overlay.waiting" = "Waiting for a chat request"
|
||||||
|
"song.overlay.request_help" = "Send “点歌 Song name” to join the queue"
|
||||||
|
"song.overlay.rate_help" = "Send “打分 1-5”"
|
||||||
|
"song.overlay.queue_aria" = "Upcoming queue"
|
||||||
|
"song.overlay.queue_title" = "Upcoming songs"
|
||||||
|
"song.overlay.queue_empty" = "Who will request the next song?"
|
||||||
|
"theme.jade_scroll.name" = "Jade Blossom Scroll"
|
||||||
|
"theme.jade_scroll.description" = "Dark jade glass, classical floral patterns, starlight particles, and a horizontal scroll reveal."
|
||||||
|
"theme.moonlit_water.name" = "Moonlit Stillwater"
|
||||||
|
"theme.moonlit_water.description" = "A fully transparent moonlit theme with deep-jade and ivory-gold serif type, fine rules, and restrained floral starlight."
|
||||||
|
"gift.theme.jade_starfall.name" = "Jade Starfall"
|
||||||
|
"gift.theme.jade_starfall.description" = "Traditional jade, luminous floral scrollwork, and golden stardust shape each gift meteor."
|
||||||
|
"gift.theme.moonlit_water.name" = "Moonlit Stillwater"
|
||||||
|
"gift.theme.moonlit_water.description" = "Gifts below CNY 50 arrive as a quiet waterline note; CNY 50+ gifts and membership purchases receive a transparent full-scene moonlit offering."
|
||||||
|
"gift.settings.thresholds" = "Gift value tiers (raw price, 1000 = CNY 1)"
|
||||||
|
"gift.settings.high_threshold" = "High-value threshold"
|
||||||
|
"gift.settings.featured_threshold" = "Featured-value threshold"
|
||||||
|
"gift.settings.tiers" = "Meteor settings by tier"
|
||||||
|
"gift.settings.tiers_description" = "Sizes use 1920×1080 as a reference; other OBS dimensions scale responsively to their actual viewport."
|
||||||
|
"gift.settings.tier.normal" = "Normal gift"
|
||||||
|
"gift.settings.tier.high" = "High-value gift"
|
||||||
|
"gift.settings.tier.featured" = "Featured gift"
|
||||||
|
"gift.settings.count" = "Meteor count"
|
||||||
|
"gift.settings.size" = "Gift core size"
|
||||||
|
"gift.settings.speed" = "Flight speed"
|
||||||
|
"gift.settings.trail" = "Trail intensity"
|
||||||
|
"gift.settings.guard_stars" = "Membership star count"
|
||||||
|
"gift.settings.guard_duration" = "Membership full-screen duration"
|
||||||
|
"gift.settings.concurrent" = "Maximum concurrent effects"
|
||||||
|
"gift.preview.title" = "Full-screen gift effect preview"
|
||||||
|
"gift.preview.description" = "The preview is scaled to 16:9. OBS browser sources may use the 1920×1080 default or any other resolution."
|
||||||
|
"gift.preview.normal_button" = "Preview normal gift"
|
||||||
|
"gift.preview.high_button" = "Preview high-value gift"
|
||||||
|
"gift.preview.featured_button" = "Preview featured gift"
|
||||||
|
"gift.preview.guard_button" = "Preview Guard effect"
|
||||||
|
"gift.preview.viewer" = "Starlight viewer"
|
||||||
|
"gift.preview.normal" = "Little flower"
|
||||||
|
"gift.preview.high" = "Jade offering"
|
||||||
|
"gift.preview.featured" = "Dream of the Stars"
|
||||||
|
"gift.preview.guard" = "Guard"
|
||||||
|
"gift.guard_aria" = "Full-screen membership celebration"
|
||||||
|
"gift.guard_salute" = "THE STARS SHINE FOR YOU"
|
||||||
|
"gift.guard_title" = "{guard} · STARLIGHT TRIBUTE"
|
||||||
|
"gift.guard_viewer" = "Thank you, {viewer}, for your support"
|
||||||
|
"gift.moonlit.ceremony_label" = "MOONLIT OFFERING"
|
||||||
|
"gift_menu.theme.jade_banquet.name" = "Jade Banquet"
|
||||||
|
"gift_menu.theme.jade_banquet.description" = "An ornate gift menu of jade glass, gilded blossoms, and starlight highlights."
|
||||||
|
"gift_menu.theme.moonlit_water.name" = "Moonlit Stillwater"
|
||||||
|
"gift_menu.theme.moonlit_water.description" = "A fully transparent moonlit gift list with deep-jade and ivory-gold type, fine rules, and waterline highlights."
|
||||||
|
"gift_menu.catalog.title" = "Current room gift catalog"
|
||||||
|
"gift_menu.catalog.loading" = "Loading gifts and icons…"
|
||||||
|
"gift_menu.catalog.count" = "Loaded {count} gifts"
|
||||||
|
"gift_menu.catalog.refresh" = "Refresh gift catalog"
|
||||||
|
"gift_menu.catalog.failed" = "Could not load the gift catalog"
|
||||||
|
"gift_menu.editor.add_title" = "Add menu item"
|
||||||
|
"gift_menu.editor.trigger_type" = "Trigger type"
|
||||||
|
"gift_menu.trigger.gift" = "Specific regular gift"
|
||||||
|
"gift_menu.trigger.guard" = "Specific membership tier"
|
||||||
|
"gift_menu.trigger.battery" = "Specific gift unit price"
|
||||||
|
"gift_menu.editor.gift" = "Room gift"
|
||||||
|
"gift_menu.editor.gift_option" = "{name} · {battery} battery"
|
||||||
|
"gift_menu.editor.guard_level" = "Membership tier"
|
||||||
|
"gift_menu.editor.battery_amount" = "Unit price in battery"
|
||||||
|
"gift_menu.editor.description" = "Stream content"
|
||||||
|
"gift_menu.editor.description_placeholder" = "For example: request a song / learn a song"
|
||||||
|
"gift_menu.editor.add" = "Add to gift menu"
|
||||||
|
"gift_menu.editor.description_required" = "Describe the content triggered by this gift."
|
||||||
|
"gift_menu.editor.gift_required" = "Select a gift from the current room catalog first."
|
||||||
|
"gift_menu.editor.duplicate" = "This trigger is already present in the menu."
|
||||||
|
"gift_menu.editor.empty" = "No menu items yet. Add one above."
|
||||||
|
"gift_menu.editor.move_up" = "Move menu item up"
|
||||||
|
"gift_menu.editor.move_down" = "Move menu item down"
|
||||||
|
"gift_menu.editor.remove" = "Remove"
|
||||||
|
"gift_menu.guard.captain" = "Guard"
|
||||||
|
"gift_menu.guard.admiral" = "Admiral"
|
||||||
|
"gift_menu.guard.governor" = "Governor"
|
||||||
|
"gift_menu.guard_mark.captain" = "G"
|
||||||
|
"gift_menu.guard_mark.admiral" = "A"
|
||||||
|
"gift_menu.guard_mark.governor" = "V"
|
||||||
|
"gift_menu.battery_mark" = "B"
|
||||||
|
"gift_menu.settings.visible_rows" = "Maximum visible rows"
|
||||||
|
"gift_menu.settings.rows_per_page" = "Rows per page"
|
||||||
|
"gift_menu.settings.row_height" = "Row height"
|
||||||
|
"gift_menu.settings.scroll_speed" = "Loop scroll speed"
|
||||||
|
"gift_menu.settings.page_interval" = "Page interval"
|
||||||
|
"gift_menu.settings.highlight_duration" = "Trigger highlight duration"
|
||||||
|
"gift_menu.settings.font_scale" = "Text scale"
|
||||||
|
"gift_menu.settings.motion" = "Motion intensity"
|
||||||
|
"gift_menu.unit.pixels" = "{value} px"
|
||||||
|
"gift_menu.unit.speed" = "{value} px/s"
|
||||||
|
"gift_menu.unit.seconds" = "{value} s"
|
||||||
|
"gift_menu.preview.title" = "Gift menu preview"
|
||||||
|
"gift_menu.preview.description" = "Click any row to preview targeting and the gradient highlight animation."
|
||||||
|
"gift_menu.aria" = "Live room gift menu"
|
||||||
|
"gift_menu.overlay.battery" = "Battery ×{amount}"
|
||||||
|
"gift_menu.overlay.triggered_by" = "Triggered by {viewer}"
|
||||||
|
"gift_menu.overlay.empty" = "Configure the gift menu in the control console"
|
||||||
|
"pwa.blocked" = "The update is blocked. Finish these items first:\n\n{reasons}"
|
||||||
|
"pwa.confirm_update" = "Updating refreshes the console. Save settings, invitations, recovery codes, and newly rotated OBS tokens first. Update now?"
|
||||||
|
"pwa.offline" = "Offline"
|
||||||
|
"pwa.update" = "Update available"
|
||||||
|
"pwa.install" = "Install app"
|
||||||
|
"pwa.manifest.name" = "Live Component Console"
|
||||||
|
"pwa.manifest.short_name" = "Live Studio"
|
||||||
|
"pwa.manifest.description" = "Live components and OBS browser-source console"
|
||||||
-3934
File diff suppressed because it is too large
Load Diff
Vendored
-172
@@ -1,172 +0,0 @@
|
|||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2024"
|
|
||||||
name = "blivedm"
|
|
||||||
version = "0.5.6"
|
|
||||||
authors = ["isomo <jiahaoxing2000@gmail.com>"]
|
|
||||||
build = false
|
|
||||||
publish = true
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = "Bilibili live room danmaku WebSocket client with TTS and plugin support"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"bilibili",
|
|
||||||
"danmaku",
|
|
||||||
"live",
|
|
||||||
"websocket",
|
|
||||||
"tts",
|
|
||||||
]
|
|
||||||
categories = [
|
|
||||||
"command-line-utilities",
|
|
||||||
"network-programming",
|
|
||||||
]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/isomoes/blivedm_rs"
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
rustdoc-args = [
|
|
||||||
"--cfg",
|
|
||||||
"docsrs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[features]
|
|
||||||
browser_cookies = [
|
|
||||||
"dep:sqlite",
|
|
||||||
"dep:directories",
|
|
||||||
"dep:chrono",
|
|
||||||
]
|
|
||||||
default = ["browser_cookies"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "blivedm"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "blivedm"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "integration_bili_live_client"
|
|
||||||
path = "examples/integration_bili_live_client.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "simple_client"
|
|
||||||
path = "examples/simple_client.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "tts_example"
|
|
||||||
path = "examples/tts_example.rs"
|
|
||||||
|
|
||||||
[dependencies.arboard]
|
|
||||||
version = "3.4"
|
|
||||||
features = ["wayland-data-control"]
|
|
||||||
|
|
||||||
[dependencies.base64]
|
|
||||||
version = "0.21"
|
|
||||||
|
|
||||||
[dependencies.brotlic]
|
|
||||||
version = "0.8.1"
|
|
||||||
|
|
||||||
[dependencies.chrono]
|
|
||||||
version = "0.4"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.clap]
|
|
||||||
version = "4.0"
|
|
||||||
features = ["derive"]
|
|
||||||
|
|
||||||
[dependencies.clap_complete]
|
|
||||||
version = "4.0"
|
|
||||||
|
|
||||||
[dependencies.crossterm]
|
|
||||||
version = "0.28"
|
|
||||||
|
|
||||||
[dependencies.directories]
|
|
||||||
version = "5.0"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.dirs]
|
|
||||||
version = "5.0"
|
|
||||||
|
|
||||||
[dependencies.env_logger]
|
|
||||||
version = "0.11.8"
|
|
||||||
|
|
||||||
[dependencies.futures]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dependencies.futures-channel]
|
|
||||||
version = "0.3.28"
|
|
||||||
|
|
||||||
[dependencies.http]
|
|
||||||
version = "0.2.11"
|
|
||||||
|
|
||||||
[dependencies.log]
|
|
||||||
version = "0.4"
|
|
||||||
|
|
||||||
[dependencies.md5]
|
|
||||||
version = "0.7"
|
|
||||||
|
|
||||||
[dependencies.native-tls]
|
|
||||||
version = "0.2.0"
|
|
||||||
|
|
||||||
[dependencies.ratatui]
|
|
||||||
version = "0.29"
|
|
||||||
|
|
||||||
[dependencies.reqwest]
|
|
||||||
version = "0.11.17"
|
|
||||||
features = [
|
|
||||||
"blocking",
|
|
||||||
"cookies",
|
|
||||||
"rustls-tls",
|
|
||||||
"json",
|
|
||||||
"stream",
|
|
||||||
]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.rodio]
|
|
||||||
version = "0.17"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
|
||||||
version = "1.0"
|
|
||||||
features = ["derive"]
|
|
||||||
|
|
||||||
[dependencies.serde_json]
|
|
||||||
version = "1.0"
|
|
||||||
|
|
||||||
[dependencies.sqlite]
|
|
||||||
version = "0.36"
|
|
||||||
optional = true
|
|
||||||
|
|
||||||
[dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"rt-multi-thread",
|
|
||||||
"macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.toml]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.tungstenite]
|
|
||||||
version = "0.20.1"
|
|
||||||
|
|
||||||
[dependencies.unicode-width]
|
|
||||||
version = "0.2.0"
|
|
||||||
|
|
||||||
[dependencies.url]
|
|
||||||
version = "2.3.1"
|
|
||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 isomo
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
Vendored
-537
@@ -1,537 +0,0 @@
|
|||||||
// src/client/auth.rs
|
|
||||||
//! Authentication helpers for Bilibili live danmaku WebSocket client
|
|
||||||
|
|
||||||
use md5;
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
use reqwest::header::HeaderMap;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
// Add browser cookie support
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
use crate::browser_cookies;
|
|
||||||
|
|
||||||
/// Get Bilibili cookies from browser (preferred, newest), then fallback to provided cookie string
|
|
||||||
pub fn get_cookies_or_browser(provided_cookie: Option<&str>) -> Option<String> {
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
{
|
|
||||||
// First try browser cookies as they are the newest
|
|
||||||
log::info!("Searching for Bilibili cookies in browser (newest)...");
|
|
||||||
if let Some(browser_cookie) = browser_cookies::find_bilibili_cookies_as_string() {
|
|
||||||
log::info!("Found Bilibili cookies in browser (using newest)");
|
|
||||||
return Some(browser_cookie);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("No Bilibili cookies found in browser, checking provided cookie...");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "browser_cookies"))]
|
|
||||||
{
|
|
||||||
log::debug!("Browser cookie feature not enabled. Skip to find cookies in browser...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cookie) = provided_cookie {
|
|
||||||
if !cookie.is_empty() && cookie != "dummy_sessdata" && cookie.len() > 20 {
|
|
||||||
log::info!("Using provided cookie as fallback");
|
|
||||||
return Some(cookie.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::warn!("No valid Bilibili cookies found in browser or provided input");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_uid(headers: HeaderMap) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client.get(UID_INIT_URL).headers(request_headers).send();
|
|
||||||
log::debug!("init uid response: {:?}", response);
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init uid response: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init uid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the buvid by sending a request and extracting the 'buvid3' cookie.
|
|
||||||
///
|
|
||||||
/// Note: This function is not used for document creation.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if the request fails.
|
|
||||||
pub fn init_buvid(headers: HeaderMap) -> (StatusCode, String) {
|
|
||||||
// Not used for document creation.
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client.get(BUVID_INIT_URL).headers(request_headers).send();
|
|
||||||
let stat: StatusCode;
|
|
||||||
let mut buvid: String = "".to_string();
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
let cookies = resp.cookies();
|
|
||||||
for i in cookies {
|
|
||||||
log::debug!("init buvid response cookie : {:?}", i);
|
|
||||||
if "buvid3".eq(i.name()) {
|
|
||||||
buvid = i.value().to_string();
|
|
||||||
log::info!("init buvid response: {:?}", buvid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init buvid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, buvid)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the room by sending a request with the given room ID.
|
|
||||||
///
|
|
||||||
/// Note: This function should NOT be used for document creation.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if the request fails.
|
|
||||||
pub fn init_room(headers: HeaderMap, temp_room_id: &str) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let url = format!("{}?room_id={}", ROOM_INIT_URL, temp_room_id);
|
|
||||||
let response = client.get(url).headers(request_headers).send();
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init room response: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init buvid failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_host_server(headers: HeaderMap, room_id: u64) -> (StatusCode, String) {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers.clone();
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
// Get WBI keys for signing
|
|
||||||
let wbi_keys = match get_wbi_keys(request_headers.clone()) {
|
|
||||||
Ok(keys) => keys,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to get WBI keys: {:?}", e);
|
|
||||||
panic!("Failed to get WBI keys");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare parameters for signing
|
|
||||||
let params = vec![
|
|
||||||
("id", room_id.to_string()),
|
|
||||||
("type", "0".to_string()),
|
|
||||||
("web_location", "444.8".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Generate signed query string
|
|
||||||
let signed_query = encode_wbi(params, wbi_keys);
|
|
||||||
|
|
||||||
// Construct final URL
|
|
||||||
let url = format!("{}?{}", DANMAKU_SERVER_CONF_URL, signed_query);
|
|
||||||
|
|
||||||
// debug log the total request
|
|
||||||
let response = client.get(url).headers(request_headers).send();
|
|
||||||
log::debug!("init host server response: {:?}", response);
|
|
||||||
let stat: StatusCode;
|
|
||||||
let body: String;
|
|
||||||
match response {
|
|
||||||
Ok(resp) => {
|
|
||||||
stat = resp.status();
|
|
||||||
body = resp.text().unwrap();
|
|
||||||
log::info!("init host server response body: {:?}", body);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
panic!("init host server failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(stat, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WBI signing constants and functions
|
|
||||||
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
|
|
||||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29,
|
|
||||||
28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25,
|
|
||||||
54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WbiImg {
|
|
||||||
img_url: String,
|
|
||||||
sub_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Data {
|
|
||||||
wbi_img: WbiImg,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ResWbi {
|
|
||||||
data: Data,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对 imgKey 和 subKey 进行字符顺序打乱编码
|
|
||||||
fn get_mixin_key(orig: &[u8]) -> String {
|
|
||||||
MIXIN_KEY_ENC_TAB
|
|
||||||
.iter()
|
|
||||||
.take(32)
|
|
||||||
.map(|&i| orig[i] as char)
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_url_encoded(s: &str) -> String {
|
|
||||||
s.chars()
|
|
||||||
.filter_map(|c| match c.is_ascii_alphanumeric() || "-_.~".contains(c) {
|
|
||||||
true => Some(c.to_string()),
|
|
||||||
false => {
|
|
||||||
// 过滤 value 中的 "!'()*" 字符
|
|
||||||
if "!'()*".contains(c) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let encoded = c
|
|
||||||
.encode_utf8(&mut [0; 4])
|
|
||||||
.bytes()
|
|
||||||
.fold("".to_string(), |acc, b| acc + &format!("%{:02X}", b));
|
|
||||||
Some(encoded)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为请求参数进行 wbi 签名
|
|
||||||
fn encode_wbi(params: Vec<(&str, String)>, (img_key, sub_key): (String, String)) -> String {
|
|
||||||
let cur_time = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
|
||||||
Ok(t) => t.as_secs(),
|
|
||||||
Err(_) => panic!("SystemTime before UNIX EPOCH!"),
|
|
||||||
};
|
|
||||||
_encode_wbi(params, (img_key, sub_key), cur_time)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _encode_wbi(
|
|
||||||
mut params: Vec<(&str, String)>,
|
|
||||||
(img_key, sub_key): (String, String),
|
|
||||||
timestamp: u64,
|
|
||||||
) -> String {
|
|
||||||
let mixin_key = get_mixin_key((img_key + &sub_key).as_bytes());
|
|
||||||
// 添加当前时间戳
|
|
||||||
params.push(("wts", timestamp.to_string()));
|
|
||||||
// 重新排序
|
|
||||||
params.sort_by(|a, b| a.0.cmp(b.0));
|
|
||||||
// 拼接参数
|
|
||||||
let query = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", get_url_encoded(k), get_url_encoded(v)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
// 计算签名
|
|
||||||
let web_sign = format!("{:x}", md5::compute(query.clone() + &mixin_key));
|
|
||||||
// 返回最终的 query
|
|
||||||
query + &format!("&w_rid={}", web_sign)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_wbi_keys(headers: HeaderMap) -> Result<(String, String), reqwest::Error> {
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.https_only(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut request_headers = headers;
|
|
||||||
request_headers.insert("user-agent", USER_AGENT.parse().unwrap());
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get("https://api.bilibili.com/x/web-interface/nav")
|
|
||||||
.headers(request_headers)
|
|
||||||
.send()?;
|
|
||||||
|
|
||||||
let res_wbi: ResWbi = response.json()?;
|
|
||||||
Ok((
|
|
||||||
take_filename(res_wbi.data.wbi_img.img_url).unwrap(),
|
|
||||||
take_filename(res_wbi.data.wbi_img.sub_url).unwrap(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_filename(url: String) -> Option<String> {
|
|
||||||
url.rsplit_once('/')
|
|
||||||
.and_then(|(_, s)| s.rsplit_once('.'))
|
|
||||||
.map(|(s, _)| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const UID_INIT_URL: &str = "https://api.bilibili.com/x/web-interface/nav";
|
|
||||||
pub const BUVID_INIT_URL: &str = "https://data.bilibili.com/v/";
|
|
||||||
pub const ROOM_INIT_URL: &str =
|
|
||||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom";
|
|
||||||
pub const DANMAKU_SERVER_CONF_URL: &str =
|
|
||||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo";
|
|
||||||
pub const USER_AGENT: &str =
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0";
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_uid_url_constant() {
|
|
||||||
assert!(UID_INIT_URL.contains("bilibili.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_take_filename() {
|
|
||||||
assert_eq!(
|
|
||||||
take_filename(
|
|
||||||
"https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png".to_string()
|
|
||||||
),
|
|
||||||
Some("7cd084941338484aae1ad9425b84077c".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
take_filename(
|
|
||||||
"https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png".to_string()
|
|
||||||
),
|
|
||||||
Some("4932caff0ff746eab6f01bf08b70ac45".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test edge case with no extension
|
|
||||||
assert_eq!(
|
|
||||||
take_filename("https://example.com/path/file".to_string()),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encode_wbi_with_known_values() {
|
|
||||||
let params = vec![
|
|
||||||
("foo", String::from("114")),
|
|
||||||
("bar", String::from("514")),
|
|
||||||
("zab", String::from("1919810")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1702204169,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
"bar=514&foo=114&wts=1702204169&zab=1919810&w_rid=8f6f2b5b3d485fe1886cec6a0be8c5d4"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encode_wbi_bilibili_danmu_params() {
|
|
||||||
// Test with the actual Bilibili danmu parameters from the example
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Using the timestamp from the example URL (1748308267)
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748308267,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The result should contain the correct parameters and w_rid
|
|
||||||
assert!(result.contains("id=24779526"));
|
|
||||||
assert!(result.contains("type=0"));
|
|
||||||
assert!(result.contains("web_location=444.8"));
|
|
||||||
assert!(result.contains("wts=1748308267"));
|
|
||||||
assert!(result.contains("w_rid="));
|
|
||||||
|
|
||||||
// Check the parameter order (should be alphabetical)
|
|
||||||
let expected_order = "id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=";
|
|
||||||
assert!(result.starts_with(expected_order));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wbi_signature_consistency() {
|
|
||||||
// Test that the same parameters always generate the same signature
|
|
||||||
let params1 = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let params2 = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let keys = (
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let timestamp = 1748308267;
|
|
||||||
|
|
||||||
let result1 = _encode_wbi(params1, keys.clone(), timestamp);
|
|
||||||
let result2 = _encode_wbi(params2, keys, timestamp);
|
|
||||||
|
|
||||||
assert_eq!(result1, result2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_wbi_parameter_sorting() {
|
|
||||||
// Test that parameters are properly sorted alphabetically
|
|
||||||
let params = vec![
|
|
||||||
("z_param", String::from("last")),
|
|
||||||
("a_param", String::from("first")),
|
|
||||||
("m_param", String::from("middle")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748308267,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that parameters appear in alphabetical order
|
|
||||||
let parts: Vec<&str> = result.split('&').collect();
|
|
||||||
assert!(parts[0].starts_with("a_param="));
|
|
||||||
assert!(parts[1].starts_with("m_param="));
|
|
||||||
assert!(parts[2].starts_with("wts="));
|
|
||||||
assert!(parts[3].starts_with("z_param="));
|
|
||||||
assert!(parts[4].starts_with("w_rid="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_correct_bilibili_url_signature() {
|
|
||||||
// Test the exact URL from the working example:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748308267&w_rid=884cf361b8ad4e239b4a9dbbb7134679"
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=d1e619744b4977f88ed67524a1f567cc&wts=1751072897"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1751072897,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1751072897&w_rid=d1e619744b4977f88ed67524a1f567cc";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "d1e619744b4977f88ed67524a1f567cc");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_second_bilibili_url_signature() {
|
|
||||||
// Test the second URL example:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&w_rid=fa20533eb27334ba6f2ec7263721319a&wts=1748311635"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748311635,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1748311635&w_rid=fa20533eb27334ba6f2ec7263721319a";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "fa20533eb27334ba6f2ec7263721319a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_third_bilibili_url_signature() {
|
|
||||||
// Test the third URL example from README:
|
|
||||||
// "https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507"
|
|
||||||
|
|
||||||
let params = vec![
|
|
||||||
("id", String::from("24779526")),
|
|
||||||
("type", String::from("0")),
|
|
||||||
("web_location", String::from("444.8")),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = _encode_wbi(
|
|
||||||
params,
|
|
||||||
(
|
|
||||||
"7cd084941338484aae1ad9425b84077c".to_string(),
|
|
||||||
"4932caff0ff746eab6f01bf08b70ac45".to_string(),
|
|
||||||
),
|
|
||||||
1748312554,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Expected complete query string from working URL
|
|
||||||
let expected = "id=24779526&type=0&web_location=444.8&wts=1748312554&w_rid=30f250e8abd9effea1bcb88aab416507";
|
|
||||||
assert_eq!(result, expected);
|
|
||||||
|
|
||||||
// Extract and verify the w_rid specifically
|
|
||||||
let w_rid = result.split("w_rid=").nth(1).unwrap();
|
|
||||||
assert_eq!(w_rid, "30f250e8abd9effea1bcb88aab416507");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-416
@@ -1,416 +0,0 @@
|
|||||||
// src/client/browser_cookies.rs
|
|
||||||
//! Browser cookie reading functionality for automatic SESSDATA detection
|
|
||||||
|
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
|
||||||
use directories::UserDirs;
|
|
||||||
use log::{debug, info, warn};
|
|
||||||
use sqlite::Connection;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Cookie {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
pub domain: String,
|
|
||||||
pub path: String,
|
|
||||||
pub expires: Option<DateTime<Utc>>,
|
|
||||||
pub secure: bool,
|
|
||||||
pub http_only: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Browser {
|
|
||||||
Chrome,
|
|
||||||
Firefox,
|
|
||||||
Edge,
|
|
||||||
Chromium,
|
|
||||||
Opera,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Browser {
|
|
||||||
pub fn get_cookie_db_path(&self) -> Option<PathBuf> {
|
|
||||||
let user_dirs = UserDirs::new()?;
|
|
||||||
let home_dir = user_dirs.home_dir();
|
|
||||||
|
|
||||||
match self {
|
|
||||||
Browser::Chrome => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/google-chrome/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("Library/Application Support/Google/Chrome/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Local/Google/Chrome/User Data/Default/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Firefox => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let firefox_dir = vec![
|
|
||||||
home_dir.join(".mozilla/firefox"),
|
|
||||||
home_dir.join("snap/firefox/common/.mozilla/firefox"),
|
|
||||||
home_dir.join(".var/app/org.mozilla.firefox/.mozilla/firefox"),
|
|
||||||
home_dir.join("snap/firefox/current/.mozilla/firefox"),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.find(|p| p.exists())?;
|
|
||||||
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let firefox_dir = home_dir.join("Library/Application Support/Firefox/Profiles");
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let firefox_dir = home_dir.join("AppData/Roaming/Mozilla/Firefox/Profiles");
|
|
||||||
Self::find_firefox_profile_cookies(&firefox_dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Edge => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/microsoft-edge/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir.join("Library/Application Support/Microsoft Edge/Default/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Local/Microsoft/Edge/User Data/Default/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Chromium => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/chromium/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("Library/Application Support/Chromium/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join("AppData/Local/Chromium/User Data/Default/Network/Cookies"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Browser::Opera => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(".config/opera/Default/Cookies"))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
Some(home_dir.join(
|
|
||||||
"Library/Application Support/com.operasoftware.Opera/Default/Cookies",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Some(
|
|
||||||
home_dir
|
|
||||||
.join("AppData/Roaming/Opera Software/Opera Stable/Network/Cookies"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_firefox_profile_cookies(firefox_dir: &Path) -> Option<PathBuf> {
|
|
||||||
if !firefox_dir.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for the default profile directory
|
|
||||||
let entries = fs::read_dir(firefox_dir).ok()?;
|
|
||||||
for entry in entries {
|
|
||||||
if let Ok(entry) = entry {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
let dir_name = path.file_name()?.to_str()?;
|
|
||||||
if dir_name.contains(".default") || dir_name.contains(".default-release") {
|
|
||||||
let cookies_path = path.join("cookies.sqlite");
|
|
||||||
if cookies_path.exists() {
|
|
||||||
return Some(cookies_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_all_supported() -> Vec<Browser> {
|
|
||||||
vec![
|
|
||||||
Browser::Chrome,
|
|
||||||
Browser::Firefox,
|
|
||||||
Browser::Edge,
|
|
||||||
Browser::Chromium,
|
|
||||||
Browser::Opera,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read cookies from a browser's cookie database
|
|
||||||
pub fn read_cookies_from_browser(
|
|
||||||
browser: &Browser,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let db_path = browser
|
|
||||||
.get_cookie_db_path()
|
|
||||||
.ok_or_else(|| "Could not determine cookie database path".to_string())?;
|
|
||||||
|
|
||||||
if !db_path.exists() {
|
|
||||||
return Err(format!("Cookie database not found at: {:?}", db_path));
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Reading cookies from: {:?}", db_path);
|
|
||||||
|
|
||||||
// Create a temporary copy of the database since browsers might have it locked
|
|
||||||
let temp_path = std::env::temp_dir().join(format!("temp_cookies_{}.db", std::process::id()));
|
|
||||||
if let Err(e) = fs::copy(&db_path, &temp_path) {
|
|
||||||
return Err(format!("Failed to copy cookie database: {}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = match browser {
|
|
||||||
Browser::Firefox => read_firefox_cookies(&temp_path, domain_filter),
|
|
||||||
_ => read_chromium_cookies(&temp_path, domain_filter),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
let _ = fs::remove_file(&temp_path);
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_chromium_cookies(
|
|
||||||
db_path: &Path,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let connection =
|
|
||||||
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
|
|
||||||
|
|
||||||
let mut query =
|
|
||||||
"SELECT name, value, host_key, path, expires_utc, is_secure, is_httponly FROM cookies"
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if let Some(domain) = domain_filter {
|
|
||||||
query.push_str(&format!(" WHERE host_key LIKE '%{}'", domain));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookies = Vec::new();
|
|
||||||
|
|
||||||
connection
|
|
||||||
.iterate(query, |pairs| {
|
|
||||||
let mut cookie_data = HashMap::new();
|
|
||||||
for &(column, value) in pairs.iter() {
|
|
||||||
cookie_data.insert(column, value.unwrap_or(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expires = if let Some(expires_str) = cookie_data.get("expires_utc") {
|
|
||||||
if let Ok(expires_microseconds) = expires_str.parse::<i64>() {
|
|
||||||
// Chrome stores time as microseconds since Windows epoch (1601-01-01)
|
|
||||||
// Convert to Unix timestamp (seconds since 1970-01-01)
|
|
||||||
let windows_epoch_offset = 11644473600_i64; // seconds between 1601 and 1970
|
|
||||||
let unix_timestamp = (expires_microseconds / 1_000_000) - windows_epoch_offset;
|
|
||||||
Utc.timestamp_opt(unix_timestamp, 0).single()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let cookie = Cookie {
|
|
||||||
name: cookie_data.get("name").unwrap_or(&"").to_string(),
|
|
||||||
value: cookie_data.get("value").unwrap_or(&"").to_string(),
|
|
||||||
domain: cookie_data.get("host_key").unwrap_or(&"").to_string(),
|
|
||||||
path: cookie_data.get("path").unwrap_or(&"").to_string(),
|
|
||||||
expires,
|
|
||||||
secure: cookie_data.get("is_secure").unwrap_or(&"0") == &"1",
|
|
||||||
http_only: cookie_data.get("is_httponly").unwrap_or(&"0") == &"1",
|
|
||||||
};
|
|
||||||
|
|
||||||
cookies.push(cookie);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to query cookies: {}", e))?;
|
|
||||||
|
|
||||||
Ok(cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_firefox_cookies(
|
|
||||||
db_path: &Path,
|
|
||||||
domain_filter: Option<&str>,
|
|
||||||
) -> Result<Vec<Cookie>, String> {
|
|
||||||
let connection =
|
|
||||||
Connection::open(db_path).map_err(|e| format!("Failed to open cookie database: {}", e))?;
|
|
||||||
|
|
||||||
let mut query =
|
|
||||||
"SELECT name, value, host, path, expiry, isSecure, isHttpOnly FROM moz_cookies".to_string();
|
|
||||||
|
|
||||||
if let Some(domain) = domain_filter {
|
|
||||||
query.push_str(&format!(" WHERE host LIKE '%{}'", domain));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cookies = Vec::new();
|
|
||||||
|
|
||||||
connection
|
|
||||||
.iterate(query, |pairs| {
|
|
||||||
let mut cookie_data = HashMap::new();
|
|
||||||
for &(column, value) in pairs.iter() {
|
|
||||||
cookie_data.insert(column, value.unwrap_or(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
let expires = if let Some(expires_str) = cookie_data.get("expiry") {
|
|
||||||
if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
|
|
||||||
Utc.timestamp_opt(expires_timestamp, 0).single()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let cookie = Cookie {
|
|
||||||
name: cookie_data.get("name").unwrap_or(&"").to_string(),
|
|
||||||
value: cookie_data.get("value").unwrap_or(&"").to_string(),
|
|
||||||
domain: cookie_data.get("host").unwrap_or(&"").to_string(),
|
|
||||||
path: cookie_data.get("path").unwrap_or(&"").to_string(),
|
|
||||||
expires,
|
|
||||||
secure: cookie_data.get("isSecure").unwrap_or(&"0") == &"1",
|
|
||||||
http_only: cookie_data.get("isHttpOnly").unwrap_or(&"0") == &"1",
|
|
||||||
};
|
|
||||||
|
|
||||||
cookies.push(cookie);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to query cookies: {}", e))?;
|
|
||||||
|
|
||||||
Ok(cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find SESSDATA cookie from all supported browsers
|
|
||||||
pub fn find_bilibili_cookies_as_string() -> Option<String> {
|
|
||||||
let browsers = Browser::get_all_supported();
|
|
||||||
let mut all_cookies = vec![];
|
|
||||||
|
|
||||||
for browser in browsers {
|
|
||||||
info!("Checking browser: {:?}", browser);
|
|
||||||
|
|
||||||
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
|
|
||||||
all_cookies.extend(cookies);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut valid_cookies = all_cookies
|
|
||||||
.into_iter()
|
|
||||||
.filter(|cookie| {
|
|
||||||
if let Some(expires) = cookie.expires {
|
|
||||||
if Utc::now() > expires {
|
|
||||||
warn!(
|
|
||||||
"Found expired {} cookie, expires: {:?}",
|
|
||||||
cookie.name, expires
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// Deduplicate cookies, keeping the one with the latest expiry
|
|
||||||
valid_cookies.sort_by(|a, b| {
|
|
||||||
if a.name != b.name {
|
|
||||||
a.name.cmp(&b.name)
|
|
||||||
} else {
|
|
||||||
b.expires.cmp(&a.expires) // None is smaller
|
|
||||||
}
|
|
||||||
});
|
|
||||||
valid_cookies.dedup_by(|a, b| a.name == b.name);
|
|
||||||
|
|
||||||
if valid_cookies.is_empty() {
|
|
||||||
warn!("No valid bilibili cookies found in any browser");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Found {} valid bilibili cookies", valid_cookies.len());
|
|
||||||
|
|
||||||
let cookie_string = valid_cookies
|
|
||||||
.iter()
|
|
||||||
.map(|c| format!("{}={}", c.name, c.value))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("; ");
|
|
||||||
|
|
||||||
if cookie_string.contains("SESSDATA") {
|
|
||||||
Some(cookie_string)
|
|
||||||
} else {
|
|
||||||
warn!("No SESSDATA cookie found among the valid cookies");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all bilibili cookies from browsers for debugging
|
|
||||||
pub fn get_all_bilibili_cookies() -> HashMap<String, String> {
|
|
||||||
let mut all_cookies = HashMap::new();
|
|
||||||
let browsers = Browser::get_all_supported();
|
|
||||||
|
|
||||||
for browser in browsers {
|
|
||||||
if let Ok(cookies) = read_cookies_from_browser(&browser, Some("bilibili.com")) {
|
|
||||||
for cookie in cookies {
|
|
||||||
// Only include non-expired cookies
|
|
||||||
if let Some(expires) = cookie.expires {
|
|
||||||
if Utc::now() > expires {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the most recent cookie if duplicates exist
|
|
||||||
let key = format!("{}_{}", cookie.name, cookie.domain);
|
|
||||||
all_cookies.insert(key, cookie.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
all_cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_browser_path_detection() {
|
|
||||||
for browser in Browser::get_all_supported() {
|
|
||||||
let path = browser.get_cookie_db_path();
|
|
||||||
println!("{:?} cookie path: {:?}", browser, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_sessdata() {
|
|
||||||
// This test will only work if you have bilibili cookies in your browser
|
|
||||||
if let Some(sessdata) = find_bilibili_cookies_as_string() {
|
|
||||||
println!("Found SESSDATA: {}", &sessdata[..20.min(sessdata.len())]);
|
|
||||||
assert!(!sessdata.is_empty());
|
|
||||||
} else {
|
|
||||||
println!("No SESSDATA found - this is normal if you're not logged into bilibili");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-12
@@ -1,12 +0,0 @@
|
|||||||
// src/client/lib.rs
|
|
||||||
//! Library entry for the client package
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
#[cfg(feature = "browser_cookies")]
|
|
||||||
pub mod browser_cookies;
|
|
||||||
pub mod models;
|
|
||||||
pub mod scheduler;
|
|
||||||
pub mod websocket;
|
|
||||||
|
|
||||||
// Re-export commonly used functions
|
|
||||||
pub use auth::get_cookies_or_browser;
|
|
||||||
Vendored
-97
@@ -1,97 +0,0 @@
|
|||||||
// src/client/models.rs
|
|
||||||
//! Data models for Bilibili live danmaku WebSocket client
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DanmuServer {
|
|
||||||
pub host: String,
|
|
||||||
pub port: i32,
|
|
||||||
pub wss_port: i32,
|
|
||||||
pub ws_port: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DanmuServer {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
host: String::from("broadcastlv.chat.bilibili.com"),
|
|
||||||
port: 2243,
|
|
||||||
wss_port: 443,
|
|
||||||
ws_port: 2244,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub struct MsgHead {
|
|
||||||
pub pack_len: u32,
|
|
||||||
pub raw_header_size: u16,
|
|
||||||
pub ver: u16,
|
|
||||||
pub operation: u32,
|
|
||||||
pub seq_id: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct AuthMessage {
|
|
||||||
pub uid: u64,
|
|
||||||
pub roomid: u64,
|
|
||||||
pub protover: i32,
|
|
||||||
pub platform: String,
|
|
||||||
pub type_: i32,
|
|
||||||
pub key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthMessage {
|
|
||||||
pub fn from(map: &HashMap<String, String>) -> AuthMessage {
|
|
||||||
AuthMessage {
|
|
||||||
uid: map.get("uid").unwrap().parse::<u64>().unwrap(),
|
|
||||||
roomid: map.get("room_id").unwrap().parse::<u64>().unwrap(),
|
|
||||||
protover: 3,
|
|
||||||
platform: "web".to_string(),
|
|
||||||
type_: 2,
|
|
||||||
key: map.get("token").unwrap().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum BiliMessage {
|
|
||||||
Danmu {
|
|
||||||
user: String,
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
Gift {
|
|
||||||
user: String,
|
|
||||||
gift: String,
|
|
||||||
num: String,
|
|
||||||
},
|
|
||||||
/// Online rank count message (ONLINE_RANK_COUNT)
|
|
||||||
OnlineRankCount {
|
|
||||||
/// Number of high-energy users in the live room
|
|
||||||
count: u64,
|
|
||||||
/// Number of online users in the live room
|
|
||||||
online_count: u64,
|
|
||||||
},
|
|
||||||
// Add more variants as needed
|
|
||||||
Raw(serde_json::Value),
|
|
||||||
#[deprecated(note = "Use Raw variant instead")]
|
|
||||||
Unsupported,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_auth_message_from_map() {
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
map.insert("uid".to_string(), "12345".to_string());
|
|
||||||
map.insert("room_id".to_string(), "67890".to_string());
|
|
||||||
map.insert("token".to_string(), "test_token".to_string());
|
|
||||||
let auth = AuthMessage::from(&map);
|
|
||||||
assert_eq!(auth.uid, 12345);
|
|
||||||
assert_eq!(auth.roomid, 67890);
|
|
||||||
assert_eq!(auth.key, "test_token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-192
@@ -1,192 +0,0 @@
|
|||||||
// In Cargo.toml, ensure you have: client = { path = "../client" }
|
|
||||||
use models::BiliMessage;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::models;
|
|
||||||
|
|
||||||
/// Context information passed to event handlers
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct EventContext {
|
|
||||||
/// Bilibili cookies for authentication
|
|
||||||
pub cookies: Option<String>,
|
|
||||||
/// Room ID where the event occurred
|
|
||||||
pub room_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventContext {
|
|
||||||
/// Create a new EventContext with automatic cookie detection
|
|
||||||
pub fn new_with_auto_cookies(room_id: u64) -> Self {
|
|
||||||
let cookies = crate::auth::get_cookies_or_browser(None);
|
|
||||||
Self { cookies, room_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new EventContext with provided cookies
|
|
||||||
pub fn new(cookies: Option<String>, room_id: u64) -> Self {
|
|
||||||
Self { cookies, room_id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for event handlers (plugins) that process BiliMessage.
|
|
||||||
pub trait EventHandler: Send + Sync {
|
|
||||||
fn handle(&self, msg: &BiliMessage, context: &EventContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scheduling mode: Parallel or Sequential.
|
|
||||||
pub enum ScheduleMode {
|
|
||||||
Parallel,
|
|
||||||
Sequential,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scheduler struct: manages event handlers and dispatches messages.
|
|
||||||
pub struct Scheduler {
|
|
||||||
/// Each stage is a Vec of handlers to run in parallel; stages run sequentially.
|
|
||||||
stages: Vec<Vec<Arc<dyn EventHandler>>>,
|
|
||||||
/// Context information for event handlers
|
|
||||||
context: EventContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Scheduler {
|
|
||||||
pub fn new(context: EventContext) -> Self {
|
|
||||||
Scheduler {
|
|
||||||
stages: Vec::new(),
|
|
||||||
context,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a new stage (group of handlers to run in parallel)
|
|
||||||
pub fn add_stage(&mut self, handlers: Vec<Arc<dyn EventHandler>>) {
|
|
||||||
self.stages.push(handlers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a single handler as a new sequential stage
|
|
||||||
pub fn add_sequential_handler(&mut self, handler: Arc<dyn EventHandler>) {
|
|
||||||
self.stages.push(vec![handler]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trigger all stages with the given BiliMessage.
|
|
||||||
pub fn trigger(&self, msg: BiliMessage) {
|
|
||||||
for stage in &self.stages {
|
|
||||||
let mut handles = vec![];
|
|
||||||
for handler in stage {
|
|
||||||
let msg = msg.clone();
|
|
||||||
let context = self.context.clone();
|
|
||||||
let handler = Arc::clone(handler);
|
|
||||||
handles.push(std::thread::spawn(move || {
|
|
||||||
handler.handle(&msg, &context);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Wait for all handlers in this stage to finish before next stage
|
|
||||||
for handle in handles {
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(left: u64, right: u64) -> u64 {
|
|
||||||
left + right
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::models::BiliMessage;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
|
||||||
|
|
||||||
struct AssertHandler {
|
|
||||||
called: Arc<AtomicBool>,
|
|
||||||
last_msg: Arc<Mutex<Option<BiliMessage>>>,
|
|
||||||
}
|
|
||||||
impl super::EventHandler for AssertHandler {
|
|
||||||
fn handle(&self, msg: &BiliMessage, _context: &super::EventContext) {
|
|
||||||
self.called.store(true, Ordering::SeqCst);
|
|
||||||
let mut lock = self.last_msg.lock().unwrap();
|
|
||||||
*lock = Some(msg.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scheduler_with_mpsc_channel() {
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let called = Arc::new(AtomicBool::new(false));
|
|
||||||
let last_msg = Arc::new(Mutex::new(None));
|
|
||||||
let handler = AssertHandler {
|
|
||||||
called: Arc::clone(&called),
|
|
||||||
last_msg: Arc::clone(&last_msg),
|
|
||||||
};
|
|
||||||
let context = super::EventContext {
|
|
||||||
cookies: Some("test_cookies".to_string()),
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
let mut scheduler = super::Scheduler::new(context);
|
|
||||||
scheduler.add_sequential_handler(Arc::new(handler));
|
|
||||||
|
|
||||||
// Send a test message
|
|
||||||
let test_msg = BiliMessage::Danmu {
|
|
||||||
user: "user1".to_string(),
|
|
||||||
text: "hello".to_string(),
|
|
||||||
};
|
|
||||||
tx.send(test_msg.clone()).unwrap();
|
|
||||||
|
|
||||||
// Simulate receiving and triggering
|
|
||||||
if let Ok(msg) = rx.recv() {
|
|
||||||
scheduler.trigger(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert handler was called and message matches
|
|
||||||
assert!(called.load(Ordering::SeqCst), "Handler was not called");
|
|
||||||
let lock = last_msg.lock().unwrap();
|
|
||||||
assert!(lock.is_some(), "No message stored in handler");
|
|
||||||
assert_eq!(lock.as_ref().unwrap(), &test_msg, "Message does not match");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scheduler_add_stage_and_sequential_handler() {
|
|
||||||
use crate::models::BiliMessage;
|
|
||||||
|
|
||||||
struct CounterHandler {
|
|
||||||
counter: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
impl super::EventHandler for CounterHandler {
|
|
||||||
fn handle(&self, _msg: &BiliMessage, _context: &super::EventContext) {
|
|
||||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let counter1 = Arc::new(AtomicUsize::new(0));
|
|
||||||
let counter2 = Arc::new(AtomicUsize::new(0));
|
|
||||||
let counter3 = Arc::new(AtomicUsize::new(0));
|
|
||||||
|
|
||||||
let handler1 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter1),
|
|
||||||
});
|
|
||||||
let handler2 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter2),
|
|
||||||
});
|
|
||||||
let handler3 = Arc::new(CounterHandler {
|
|
||||||
counter: Arc::clone(&counter3),
|
|
||||||
});
|
|
||||||
|
|
||||||
let context = super::EventContext {
|
|
||||||
cookies: Some("test_cookies".to_string()),
|
|
||||||
room_id: 12345,
|
|
||||||
};
|
|
||||||
let mut scheduler = super::Scheduler::new(context);
|
|
||||||
// Add a parallel stage (handler1 and handler2)
|
|
||||||
scheduler.add_stage(vec![handler1, handler2]);
|
|
||||||
// Add a sequential stage (handler3)
|
|
||||||
scheduler.add_sequential_handler(handler3);
|
|
||||||
|
|
||||||
let test_msg = BiliMessage::Danmu {
|
|
||||||
user: "user2".to_string(),
|
|
||||||
text: "test".to_string(),
|
|
||||||
};
|
|
||||||
scheduler.trigger(test_msg);
|
|
||||||
|
|
||||||
// Both handler1 and handler2 should be called once (parallel stage)
|
|
||||||
assert_eq!(counter1.load(Ordering::SeqCst), 1, "Handler1 not called");
|
|
||||||
assert_eq!(counter2.load(Ordering::SeqCst), 1, "Handler2 not called");
|
|
||||||
// handler3 should be called once (sequential stage)
|
|
||||||
assert_eq!(counter3.load(Ordering::SeqCst), 1, "Handler3 not called");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-530
@@ -1,530 +0,0 @@
|
|||||||
// src/client/websocket.rs
|
|
||||||
//! WebSocket client for Bilibili live danmaku messages (refactored from bili_live_dm)
|
|
||||||
|
|
||||||
use native_tls::TlsStream;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::net::TcpStream;
|
|
||||||
use std::panic;
|
|
||||||
use tungstenite::{client, Message, WebSocket};
|
|
||||||
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use futures_channel::mpsc::Sender;
|
|
||||||
use http::Response;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::auth::*;
|
|
||||||
use crate::models::{AuthMessage, BiliMessage, DanmuServer, MsgHead};
|
|
||||||
|
|
||||||
pub struct BiliLiveClient {
|
|
||||||
ws: WebSocket<TlsStream<TcpStream>>,
|
|
||||||
cookies: String,
|
|
||||||
room_id: String,
|
|
||||||
auth_msg: String,
|
|
||||||
ss: Sender<BiliMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BiliLiveClient {
|
|
||||||
pub fn new(cookies: &str, room_id: &str, r: Sender<BiliMessage>) -> Self {
|
|
||||||
let (ws, auth_msg) = Self::connect_with_auth(cookies, room_id)
|
|
||||||
.unwrap_or_else(|e| panic!("Failed to create websocket client: {}", e));
|
|
||||||
BiliLiveClient {
|
|
||||||
ws,
|
|
||||||
cookies: cookies.to_string(),
|
|
||||||
room_id: room_id.to_string(),
|
|
||||||
auth_msg,
|
|
||||||
ss: r,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new client with automatic browser cookie detection
|
|
||||||
/// If cookies is None or empty, it will try to find cookies from browser
|
|
||||||
pub fn new_auto(
|
|
||||||
cookies: Option<&str>,
|
|
||||||
room_id: &str,
|
|
||||||
r: Sender<BiliMessage>,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
let resolved_cookies = get_cookies_or_browser(cookies)
|
|
||||||
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
|
|
||||||
let (ws, auth_msg) = Self::connect_with_auth(&resolved_cookies, room_id)?;
|
|
||||||
Ok(BiliLiveClient {
|
|
||||||
ws,
|
|
||||||
cookies: resolved_cookies,
|
|
||||||
room_id: room_id.to_string(),
|
|
||||||
auth_msg,
|
|
||||||
ss: r,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_auth(&mut self) {
|
|
||||||
if let Err(e) = self.send_auth_internal() {
|
|
||||||
log::error!("failed to send auth packet: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_heart_beat(&mut self) {
|
|
||||||
if let Err(e) = self.send_heart_beat_internal() {
|
|
||||||
log::error!("failed to send heartbeat: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_ws_message(&mut self, resv: Vec<u8>) {
|
|
||||||
let mut offset = 0;
|
|
||||||
let header = &resv[0..16];
|
|
||||||
let mut head_1 = get_msg_header(header);
|
|
||||||
if head_1.operation == 5 || head_1.operation == 8 {
|
|
||||||
loop {
|
|
||||||
let body: &[u8] = &resv[offset + 16..offset + (head_1.pack_len as usize)];
|
|
||||||
self.parse_business_message(head_1, body);
|
|
||||||
offset += head_1.pack_len as usize;
|
|
||||||
if offset >= resv.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let temp_head = &resv[offset..(offset + 16)];
|
|
||||||
head_1 = get_msg_header(temp_head);
|
|
||||||
}
|
|
||||||
} else if head_1.operation == 3 {
|
|
||||||
let mut body: [u8; 4] = [0, 0, 0, 0];
|
|
||||||
body[0] = resv[16];
|
|
||||||
body[1] = resv[17];
|
|
||||||
body[2] = resv[18];
|
|
||||||
body[3] = resv[19];
|
|
||||||
let popularity = i32::from_be_bytes(body);
|
|
||||||
log::info!("popularity:{}", popularity);
|
|
||||||
} else {
|
|
||||||
log::error!(
|
|
||||||
"unknown message operation={:?}, header={:?}}}",
|
|
||||||
head_1.operation,
|
|
||||||
head_1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_business_message(&mut self, h: MsgHead, b: &[u8]) {
|
|
||||||
if h.operation == 5 {
|
|
||||||
if h.ver == 3 {
|
|
||||||
let res: Vec<u8> = decompress(b).unwrap();
|
|
||||||
self.parse_ws_message(res);
|
|
||||||
} else if h.ver == 0 {
|
|
||||||
let s = String::from_utf8(b.to_vec()).unwrap();
|
|
||||||
let res_json: Value = serde_json::from_str(s.as_str()).unwrap();
|
|
||||||
if let Some(msg) = handle(res_json) {
|
|
||||||
let _ = self.ss.try_send(msg);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::error!("Unknown compression format");
|
|
||||||
}
|
|
||||||
} else if h.operation == 8 {
|
|
||||||
self.send_heart_beat();
|
|
||||||
} else {
|
|
||||||
log::error!("Unknown message format {}", h.operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receive(&mut self) -> Result<(), String> {
|
|
||||||
if self.ws.can_read() {
|
|
||||||
let msg = self.ws.read();
|
|
||||||
match msg {
|
|
||||||
Ok(m) => {
|
|
||||||
let res = m.into_data();
|
|
||||||
if res.len() >= 16 {
|
|
||||||
self.parse_ws_message(res);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(tungstenite::Error::Io(error))
|
|
||||||
if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) =>
|
|
||||||
{
|
|
||||||
// A short read timeout lets an embedding application check
|
|
||||||
// its cancellation token without treating idle rooms as a
|
|
||||||
// disconnected WebSocket.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("read msg error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect().map_err(|reconnect_err| {
|
|
||||||
format!("{}; reconnect failed: {}", msg, reconnect_err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bound a blocking read so supervisors can stop/restart one tenant's
|
|
||||||
/// listener without leaking the previous connection.
|
|
||||||
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<(), String> {
|
|
||||||
self.ws
|
|
||||||
.get_mut()
|
|
||||||
.get_mut()
|
|
||||||
.set_read_timeout(timeout)
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self) {
|
|
||||||
let _ = self.ws.close(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect_with_auth(
|
|
||||||
cookies: &str,
|
|
||||||
room_id: &str,
|
|
||||||
) -> Result<(WebSocket<TlsStream<TcpStream>>, String), String> {
|
|
||||||
panic::catch_unwind(|| {
|
|
||||||
let (v, auth) = init_server(cookies, room_id);
|
|
||||||
let (ws, _res) = connect_result(v["host_list"].clone())?;
|
|
||||||
let auth_msg = serde_json::to_string(&auth)
|
|
||||||
.map_err(|e| format!("serialize auth payload failed: {}", e))?;
|
|
||||||
Ok((ws, auth_msg))
|
|
||||||
})
|
|
||||||
.map_err(|_| format!("websocket setup panicked for room {}", room_id))?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_auth_internal(&mut self) -> Result<(), String> {
|
|
||||||
match self.ws.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
))) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("send auth error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect()?;
|
|
||||||
self.ws
|
|
||||||
.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
)))
|
|
||||||
.map_err(|retry_err| format!("{}; resend auth failed: {}", msg, retry_err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_heart_beat_internal(&mut self) -> Result<(), String> {
|
|
||||||
match self
|
|
||||||
.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
|
|
||||||
{
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("send heartbeat error: {}", e);
|
|
||||||
log::warn!("{}", msg);
|
|
||||||
self.reconnect()?;
|
|
||||||
self.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)))
|
|
||||||
.map_err(|retry_err| format!("{}; resend heartbeat failed: {}", msg, retry_err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconnect(&mut self) -> Result<(), String> {
|
|
||||||
let backoff = [1_u64, 2, 5];
|
|
||||||
let mut last_err = None;
|
|
||||||
|
|
||||||
for (idx, delay_secs) in backoff.iter().enumerate() {
|
|
||||||
if idx > 0 {
|
|
||||||
thread::sleep(Duration::from_secs(*delay_secs));
|
|
||||||
}
|
|
||||||
|
|
||||||
match Self::connect_with_auth(&self.cookies, &self.room_id) {
|
|
||||||
Ok((ws, auth_msg)) => {
|
|
||||||
self.ws = ws;
|
|
||||||
self.auth_msg = auth_msg;
|
|
||||||
let auth_resend = self.ws.send(Message::Binary(make_packet(
|
|
||||||
self.auth_msg.as_str(),
|
|
||||||
Operation::AUTH,
|
|
||||||
)));
|
|
||||||
let heartbeat_resend = self
|
|
||||||
.ws
|
|
||||||
.send(Message::Binary(make_packet("{}", Operation::HEARTBEAT)));
|
|
||||||
|
|
||||||
match (auth_resend, heartbeat_resend) {
|
|
||||||
(Ok(()), Ok(())) => {
|
|
||||||
log::info!(
|
|
||||||
"websocket reconnected on attempt {} for room {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
(auth_result, heartbeat_result) => {
|
|
||||||
let auth_err = auth_result.err().map(|e| e.to_string());
|
|
||||||
let heartbeat_err = heartbeat_result.err().map(|e| e.to_string());
|
|
||||||
let reconnect_err = match (auth_err, heartbeat_err) {
|
|
||||||
(Some(auth_err), Some(heartbeat_err)) => format!(
|
|
||||||
"reconnected socket but auth resend failed: {}; heartbeat resend failed: {}",
|
|
||||||
auth_err, heartbeat_err
|
|
||||||
),
|
|
||||||
(Some(auth_err), None) => {
|
|
||||||
format!("reconnected socket but auth resend failed: {}", auth_err)
|
|
||||||
}
|
|
||||||
(None, Some(heartbeat_err)) => format!(
|
|
||||||
"reconnected socket but heartbeat resend failed: {}",
|
|
||||||
heartbeat_err
|
|
||||||
),
|
|
||||||
(None, None) => unreachable!(),
|
|
||||||
};
|
|
||||||
log::warn!(
|
|
||||||
"websocket reconnect attempt {} did not fully recover for room {}: {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id,
|
|
||||||
reconnect_err
|
|
||||||
);
|
|
||||||
last_err = Some(reconnect_err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"websocket reconnect attempt {} failed for room {}: {}",
|
|
||||||
idx + 1,
|
|
||||||
self.room_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
last_err = Some(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_err.unwrap_or_else(|| "unknown reconnect failure".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn gen_damu_list(list: &Value) -> Vec<DanmuServer> {
|
|
||||||
let server_list = list.as_array().unwrap();
|
|
||||||
let mut res: Vec<DanmuServer> = Vec::new();
|
|
||||||
if server_list.len() == 0 {
|
|
||||||
let d = DanmuServer::default();
|
|
||||||
res.push(d);
|
|
||||||
}
|
|
||||||
for s in server_list {
|
|
||||||
res.push(DanmuServer {
|
|
||||||
host: s["host"].as_str().unwrap().to_string(),
|
|
||||||
port: s["port"].as_u64().unwrap() as i32,
|
|
||||||
wss_port: s["wss_port"].as_u64().unwrap() as i32,
|
|
||||||
ws_port: s["ws_port"].as_u64().unwrap() as i32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_server(vd: Vec<DanmuServer>) -> (String, String, String) {
|
|
||||||
let (host, wss_port) = (vd.get(0).unwrap().host.clone(), vd.get(0).unwrap().wss_port);
|
|
||||||
(
|
|
||||||
host.clone(),
|
|
||||||
format!("{}:{}", host.clone(), wss_port),
|
|
||||||
format!("wss://{}:{}/sub", host, wss_port),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_server(cookies: &str, room_id: &str) -> (Value, AuthMessage) {
|
|
||||||
let mut auth_map = HashMap::new();
|
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::COOKIE,
|
|
||||||
reqwest::header::HeaderValue::from_str(cookies).unwrap(),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::USER_AGENT,
|
|
||||||
reqwest::header::HeaderValue::from_static(crate::auth::USER_AGENT),
|
|
||||||
);
|
|
||||||
log::debug!("headers: {:?}", headers);
|
|
||||||
|
|
||||||
// Extract SESSDATA from cookies for authentication
|
|
||||||
let sessdata = cookies
|
|
||||||
.split(';')
|
|
||||||
.find_map(|kv| {
|
|
||||||
let mut parts = kv.trim().splitn(2, '=');
|
|
||||||
let key = parts.next()?.trim();
|
|
||||||
let value = parts.next()?.trim();
|
|
||||||
if key == "SESSDATA" {
|
|
||||||
Some(value.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "".to_string());
|
|
||||||
|
|
||||||
if !sessdata.is_empty() {
|
|
||||||
let (_, body1) = init_uid(headers.clone());
|
|
||||||
let body1_v: Value = serde_json::from_str(body1.as_str()).unwrap();
|
|
||||||
|
|
||||||
// Check if the authentication was successful
|
|
||||||
if let Some(mid) = body1_v["data"]["mid"].as_i64() {
|
|
||||||
auth_map.insert("uid".to_string(), mid.to_string());
|
|
||||||
log::info!("Successfully authenticated with uid: {}", mid);
|
|
||||||
} else {
|
|
||||||
log::warn!("Authentication failed - SESSDATA may be invalid or expired");
|
|
||||||
log::debug!("Auth response: {}", body1);
|
|
||||||
auth_map.insert("uid".to_string(), "0".to_string());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
auth_map.insert("uid".to_string(), "0".to_string());
|
|
||||||
}
|
|
||||||
// here the live room id is easily obtained, so we not get it by url.
|
|
||||||
auth_map.insert("room_id".to_string(), room_id.to_string());
|
|
||||||
|
|
||||||
let room_id_num = room_id.parse::<u64>().expect("room_id must be a valid u64");
|
|
||||||
let (_, body4) = init_host_server(headers.clone(), room_id_num);
|
|
||||||
let body4_res: Value = serde_json::from_str(body4.as_str()).unwrap();
|
|
||||||
let server_info = &body4_res["data"];
|
|
||||||
let token = &body4_res["data"]["token"].as_str().unwrap();
|
|
||||||
auth_map.insert("token".to_string(), token.to_string());
|
|
||||||
|
|
||||||
let auth_msg = AuthMessage::from(&auth_map);
|
|
||||||
(server_info.clone(), auth_msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect(v: Value) -> (WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>) {
|
|
||||||
connect_result(v).expect("Can't connect")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn connect_result(
|
|
||||||
v: Value,
|
|
||||||
) -> Result<(WebSocket<TlsStream<TcpStream>>, Response<Option<Vec<u8>>>), String> {
|
|
||||||
let danmu_server = gen_damu_list(&v);
|
|
||||||
let (host, url, ws_url) = find_server(danmu_server);
|
|
||||||
let connector: native_tls::TlsConnector =
|
|
||||||
native_tls::TlsConnector::new().map_err(|e| format!("tls init failed: {}", e))?;
|
|
||||||
let stream: TcpStream = TcpStream::connect(url.as_str())
|
|
||||||
.map_err(|e| format!("tcp connect to {} failed: {}", url, e))?;
|
|
||||||
let stream: native_tls::TlsStream<TcpStream> = connector
|
|
||||||
.connect(host.as_str(), stream)
|
|
||||||
.map_err(|e| format!("tls connect to {} failed: {}", host, e))?;
|
|
||||||
let parsed_url =
|
|
||||||
Url::parse(ws_url.as_str()).map_err(|e| format!("invalid websocket url: {}", e))?;
|
|
||||||
client(parsed_url, stream).map_err(|e| format!("websocket handshake failed: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Operation {
|
|
||||||
AUTH,
|
|
||||||
HEARTBEAT,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_packet(body: &str, ops: Operation) -> Vec<u8> {
|
|
||||||
let json: Value = serde_json::from_str(body).unwrap();
|
|
||||||
let temp = json.to_string();
|
|
||||||
let body_content: &[u8] = temp.as_bytes();
|
|
||||||
let pack_len: [u8; 4] = ((16 + body.len()) as u32).to_be_bytes();
|
|
||||||
let raw_header_size: [u8; 2] = (16 as u16).to_be_bytes();
|
|
||||||
let ver: [u8; 2] = (1 as u16).to_be_bytes();
|
|
||||||
let operation: [u8; 4] = match ops {
|
|
||||||
Operation::AUTH => (7 as u32).to_be_bytes(),
|
|
||||||
Operation::HEARTBEAT => (2 as u32).to_be_bytes(),
|
|
||||||
};
|
|
||||||
let seq_id: [u8; 4] = (1 as u32).to_be_bytes();
|
|
||||||
let mut res = pack_len.to_vec();
|
|
||||||
res.append(&mut raw_header_size.to_vec());
|
|
||||||
res.append(&mut ver.to_vec());
|
|
||||||
res.append(&mut operation.to_vec());
|
|
||||||
res.append(&mut seq_id.to_vec());
|
|
||||||
res.append(&mut body_content.to_vec());
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_msg_header(v_s: &[u8]) -> MsgHead {
|
|
||||||
let mut pack_len: [u8; 4] = [0; 4];
|
|
||||||
let mut raw_header_size: [u8; 2] = [0; 2];
|
|
||||||
let mut ver: [u8; 2] = [0; 2];
|
|
||||||
let mut operation: [u8; 4] = [0; 4];
|
|
||||||
let mut seq_id: [u8; 4] = [0; 4];
|
|
||||||
for (i, v) in v_s.iter().enumerate() {
|
|
||||||
if i < 4 {
|
|
||||||
pack_len[i] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 6 {
|
|
||||||
raw_header_size[i - 4] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 8 {
|
|
||||||
ver[i - 6] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 12 {
|
|
||||||
operation[i - 8] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if i < 16 {
|
|
||||||
seq_id[i - 12] = *v;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MsgHead {
|
|
||||||
pack_len: u32::from_be_bytes(pack_len),
|
|
||||||
raw_header_size: u16::from_be_bytes(raw_header_size),
|
|
||||||
ver: u16::from_be_bytes(ver),
|
|
||||||
operation: u32::from_be_bytes(operation),
|
|
||||||
seq_id: u32::from_be_bytes(seq_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decompress(body: &[u8]) -> std::io::Result<Vec<u8>> {
|
|
||||||
use brotlic::DecompressorReader;
|
|
||||||
use std::io::Read;
|
|
||||||
let mut decompressed_reader: DecompressorReader<&[u8]> = DecompressorReader::new(body);
|
|
||||||
let mut decoded_input = Vec::new();
|
|
||||||
let _ = decompressed_reader.read_to_end(&mut decoded_input)?;
|
|
||||||
Ok(decoded_input)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// here we detail [info format is online](https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/live/message_stream.md)
|
|
||||||
/// .
|
|
||||||
pub fn handle(json: Value) -> Option<BiliMessage> {
|
|
||||||
let category = json["cmd"].as_str().unwrap_or("");
|
|
||||||
match category {
|
|
||||||
// Preserve all fields for callers that need a stable UID, gift price
|
|
||||||
// and upstream event identifier. The previous typed variants discard
|
|
||||||
// those values, which makes reliable accounting impossible.
|
|
||||||
"DANMU_MSG" | "SEND_GIFT" => Some(BiliMessage::Raw(json)),
|
|
||||||
"ONLINE_RANK_COUNT" => Some(BiliMessage::OnlineRankCount {
|
|
||||||
count: json["data"]["count"].as_u64().unwrap_or(0),
|
|
||||||
online_count: json["data"]["online_count"].as_u64().unwrap_or(0),
|
|
||||||
}),
|
|
||||||
// Add more cases for other types as needed
|
|
||||||
_ => Some(BiliMessage::Raw(json)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced init_server that can automatically detect cookies from browser
|
|
||||||
pub fn init_server_auto(
|
|
||||||
provided_cookies: Option<&str>,
|
|
||||||
room_id: &str,
|
|
||||||
) -> Result<(Value, AuthMessage), String> {
|
|
||||||
// Try to get cookies from provided value or browser cookies
|
|
||||||
let cookies = get_cookies_or_browser(provided_cookies)
|
|
||||||
.ok_or_else(|| "No cookies found in provided value or browser cookies. Please log into bilibili.com in your browser or provide cookies manually.".to_string())?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Using cookies for authentication: {}...",
|
|
||||||
&cookies[..10.min(cookies.len())]
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = init_server(&cookies, room_id);
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use futures_channel::mpsc::channel;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_bili_live_client_connect() {
|
|
||||||
// Always enable debug log output for test
|
|
||||||
let _ = env_logger::builder()
|
|
||||||
.is_test(true)
|
|
||||||
.filter_level(log::LevelFilter::Debug)
|
|
||||||
.try_init();
|
|
||||||
// Get cookies from environment variable for real test
|
|
||||||
let cookies =
|
|
||||||
std::env::var("Cookie").unwrap_or_else(|_| "SESSDATA=dummy_sessdata".to_string());
|
|
||||||
let room_id = "24779526";
|
|
||||||
let (tx, _rx) = channel(10);
|
|
||||||
let _client = BiliLiveClient::new(&cookies, room_id, tx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-276
@@ -1,276 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(default)]
|
|
||||||
pub connection: Option<ConnectionConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tts: Option<TtsConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auto_reply: Option<AutoReplyConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub debug: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ConnectionConfig {
|
|
||||||
pub cookies: Option<String>,
|
|
||||||
pub room_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct TtsConfig {
|
|
||||||
pub server: Option<String>,
|
|
||||||
pub voice: Option<String>,
|
|
||||||
pub backend: Option<String>,
|
|
||||||
pub quality: Option<String>,
|
|
||||||
pub format: Option<String>,
|
|
||||||
pub sample_rate: Option<u32>,
|
|
||||||
pub volume: Option<f32>,
|
|
||||||
pub command: Option<String>,
|
|
||||||
pub args: Option<String>,
|
|
||||||
/// Alibaba DashScope TTS configuration
|
|
||||||
pub ali_api_key: Option<String>,
|
|
||||||
pub ali_model: Option<String>,
|
|
||||||
pub ali_voice: Option<String>,
|
|
||||||
pub ali_language_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TriggerConfig {
|
|
||||||
pub keywords: Vec<String>,
|
|
||||||
pub response: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AutoReplyConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(default = "default_cooldown")]
|
|
||||||
pub cooldown_seconds: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers: Vec<TriggerConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoReplyConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
cooldown_seconds: default_cooldown(),
|
|
||||||
triggers: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cooldown() -> u64 {
|
|
||||||
5
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoReplyConfig {
|
|
||||||
/// Convert to blivedm::plugins::auto_reply::AutoReplyConfig
|
|
||||||
pub fn to_plugin_config(&self) -> blivedm::plugins::auto_reply::AutoReplyConfig {
|
|
||||||
blivedm::plugins::auto_reply::AutoReplyConfig {
|
|
||||||
enabled: self.enabled,
|
|
||||||
cooldown_seconds: self.cooldown_seconds,
|
|
||||||
triggers: self
|
|
||||||
.triggers
|
|
||||||
.iter()
|
|
||||||
.map(|t| t.to_plugin_trigger())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TriggerConfig {
|
|
||||||
/// Convert to blivedm::plugins::auto_reply::TriggerConfig
|
|
||||||
pub fn to_plugin_trigger(&self) -> blivedm::plugins::auto_reply::TriggerConfig {
|
|
||||||
blivedm::plugins::auto_reply::TriggerConfig {
|
|
||||||
keywords: self.keywords.clone(),
|
|
||||||
response: self.response.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Load configuration from file with fallback locations
|
|
||||||
pub fn load_from_file(config_path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let config_file = if let Some(path) = config_path {
|
|
||||||
// Use provided path
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
// Try current directory first
|
|
||||||
let current_dir_config = PathBuf::from("config.toml");
|
|
||||||
if current_dir_config.exists() {
|
|
||||||
current_dir_config
|
|
||||||
} else {
|
|
||||||
// Try XDG config directory
|
|
||||||
Self::get_default_config_path()?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !config_file.exists() {
|
|
||||||
log::debug!("Config file {:?} not found", config_file);
|
|
||||||
|
|
||||||
// Create config file if it doesn't exist and we're using default locations
|
|
||||||
if config_path.is_none() {
|
|
||||||
match Self::create_example_config(&config_file) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("Created configuration file: {:?}", config_file);
|
|
||||||
println!("You can customize it as needed.");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to create config file: {}", e);
|
|
||||||
return Ok(Config::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Ok(Config::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Loading configuration from {:?}", config_file);
|
|
||||||
let content = fs::read_to_string(&config_file)
|
|
||||||
.map_err(|e| format!("Failed to read config file {:?}: {}", config_file, e))?;
|
|
||||||
|
|
||||||
let config: Config = toml::from_str(&content)
|
|
||||||
.map_err(|e| format!("Failed to parse config file {:?}: {}", config_file, e))?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default configuration file path (~/.config/blivedm_rs/config.toml)
|
|
||||||
fn get_default_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.ok_or("Unable to determine config directory")?
|
|
||||||
.join("blivedm_rs");
|
|
||||||
|
|
||||||
// Create config directory if it doesn't exist
|
|
||||||
if !config_dir.exists() {
|
|
||||||
fs::create_dir_all(&config_dir).map_err(|e| {
|
|
||||||
format!("Failed to create config directory {:?}: {}", config_dir, e)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config_dir.join("config.toml"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an example configuration file
|
|
||||||
pub fn create_example_config(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let example_config = Config {
|
|
||||||
connection: None,
|
|
||||||
tts: Some(TtsConfig {
|
|
||||||
server: Some("http://localhost:8000".to_string()),
|
|
||||||
voice: None,
|
|
||||||
backend: None,
|
|
||||||
quality: None,
|
|
||||||
format: None,
|
|
||||||
sample_rate: None,
|
|
||||||
volume: None,
|
|
||||||
command: None,
|
|
||||||
args: None,
|
|
||||||
ali_api_key: None,
|
|
||||||
ali_model: None,
|
|
||||||
ali_voice: None,
|
|
||||||
ali_language_type: None,
|
|
||||||
}),
|
|
||||||
auto_reply: Some(AutoReplyConfig {
|
|
||||||
enabled: false,
|
|
||||||
cooldown_seconds: 5,
|
|
||||||
triggers: vec![
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["你好".to_string(), "hello".to_string()],
|
|
||||||
response: "欢迎来到直播间!".to_string(),
|
|
||||||
},
|
|
||||||
TriggerConfig {
|
|
||||||
keywords: vec!["谢谢".to_string(), "thanks".to_string()],
|
|
||||||
response: "不客气~".to_string(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
debug: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(&example_config)
|
|
||||||
.map_err(|e| format!("Failed to serialize example config: {}", e))?;
|
|
||||||
|
|
||||||
fs::write(path, toml_string)
|
|
||||||
.map_err(|e| format!("Failed to write example config to {:?}: {}", path, e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the effective configuration (for debugging)
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn print_effective_config(
|
|
||||||
cookies: &Option<String>,
|
|
||||||
room_id: &str,
|
|
||||||
tts_server: &Option<String>,
|
|
||||||
tts_voice: &Option<String>,
|
|
||||||
tts_backend: &Option<String>,
|
|
||||||
tts_quality: &Option<String>,
|
|
||||||
tts_format: &Option<String>,
|
|
||||||
tts_sample_rate: &Option<u32>,
|
|
||||||
tts_volume: &Option<f32>,
|
|
||||||
tts_command: &Option<String>,
|
|
||||||
tts_args: &Option<String>,
|
|
||||||
ali_api_key: &Option<String>,
|
|
||||||
ali_model: &Option<String>,
|
|
||||||
ali_voice: &Option<String>,
|
|
||||||
ali_language_type: &Option<String>,
|
|
||||||
auto_reply: &Option<AutoReplyConfig>,
|
|
||||||
debug: bool,
|
|
||||||
) {
|
|
||||||
println!("=== Effective Configuration ===");
|
|
||||||
println!("Connection:");
|
|
||||||
println!(" room_id: {}", room_id);
|
|
||||||
if let Some(cookies_val) = cookies {
|
|
||||||
println!(
|
|
||||||
" cookies: {}...",
|
|
||||||
&cookies_val.chars().take(20).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" cookies: None (will auto-detect)");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("TTS (REST API):");
|
|
||||||
println!(" server: {:?}", tts_server);
|
|
||||||
println!(" voice: {:?}", tts_voice);
|
|
||||||
println!(" backend: {:?}", tts_backend);
|
|
||||||
println!(" quality: {:?}", tts_quality);
|
|
||||||
println!(" format: {:?}", tts_format);
|
|
||||||
println!(" sample_rate: {:?}", tts_sample_rate);
|
|
||||||
println!(" volume: {:?}", tts_volume);
|
|
||||||
println!(" command: {:?}", tts_command);
|
|
||||||
println!(" args: {:?}", tts_args);
|
|
||||||
|
|
||||||
println!("TTS (Alibaba DashScope):");
|
|
||||||
if let Some(key) = ali_api_key {
|
|
||||||
println!(
|
|
||||||
" api_key: {}...",
|
|
||||||
&key.chars().take(10).collect::<String>()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" api_key: None");
|
|
||||||
}
|
|
||||||
println!(" model: {:?}", ali_model);
|
|
||||||
println!(" voice: {:?}", ali_voice);
|
|
||||||
println!(" language_type: {:?}", ali_language_type);
|
|
||||||
|
|
||||||
println!("Auto Reply:");
|
|
||||||
if let Some(auto_reply_config) = auto_reply {
|
|
||||||
println!(" enabled: {}", auto_reply_config.enabled);
|
|
||||||
println!(" cooldown_seconds: {}", auto_reply_config.cooldown_seconds);
|
|
||||||
println!(
|
|
||||||
" triggers: {} configured",
|
|
||||||
auto_reply_config.triggers.len()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(" enabled: false (not configured)");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Debug: {}", debug);
|
|
||||||
println!("===============================");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user