Compare commits
6
Commits
97a3f1be48
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1020c75e37 | ||
|
|
e5186f62bd | ||
|
|
845b0f5900 | ||
|
|
a36d511d39 | ||
|
|
acc4c117f7 | ||
|
|
4eda93cf24 |
@@ -1,7 +1,7 @@
|
||||
# 多用户直播组件服务
|
||||
|
||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
|
||||
`gift_effect` 全屏礼物特效与 `gift_menu`
|
||||
`gift_effect` 全屏礼物特效、`guard_effect` 大航海特效与 `gift_menu`
|
||||
礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||
|
||||
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
|
||||
@@ -21,6 +21,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr
|
||||
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
|
||||
- [`song_request` 点歌姬](docs/components/song-request.md)
|
||||
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
|
||||
- [`guard_effect` 大航海特效](docs/components/guard-effect.md)
|
||||
- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
|
||||
- [WebSocket 实时协议](docs/protocol.md)
|
||||
- [租户、Secret 与部署安全](docs/security.md)
|
||||
@@ -72,7 +73,7 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
|
||||
`[database].url`
|
||||
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
|
||||
RLS 约束,HTTP
|
||||
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
|
||||
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
|
||||
`account_preferences` 表中。
|
||||
|
||||
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
|
||||
@@ -211,12 +212,16 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通
|
||||
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
|
||||
权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
||||
|
||||
每个账户会自动拥有不可删除的点歌姬、全屏礼物特效和礼物菜单组件。观众发送 `点歌 歌名` 加入队列,发送
|
||||
`打分 1-5`
|
||||
为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
|
||||
每个账户会自动拥有不可删除的点歌姬、全屏礼物特效、大航海特效和礼物菜单组件。观众发送 `点歌歌名` 或
|
||||
`点歌 歌名`
|
||||
加入队列;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态与历史持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。
|
||||
|
||||
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性
|
||||
`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。
|
||||
礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度。该组件只订阅一次性
|
||||
`live.gift`,不会接收大航海事件,也不会把连击更新重复播放为新礼物。密集礼物在每个 OBS 礼物组件中按有界 FIFO 逐个播放,当前动画结束前不会叠加下一笔特效。
|
||||
|
||||
大航海特效是独立的 `guard_effect` 组件,只订阅
|
||||
`live.guard.buy`。舰长、提督和总督分别播放独立视频,或按主题展示月下星光庆祝;它拥有自己的实例、设置、OBS
|
||||
token 和 WebSocket 通道。连续上舰事件同样按有界 FIFO 逐个完整播放,不会覆盖正在播放的视频。
|
||||
|
||||
礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
||||
|
||||
|
||||
+23
-11
@@ -4,17 +4,18 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
||||
|
||||
## 路由
|
||||
|
||||
| 路由 | 权限 | 作用 |
|
||||
| --------------------------------------- | --------------- | ------------------------------------ |
|
||||
| `/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 浏览器源 |
|
||||
| 路由 | 权限 | 作用 |
|
||||
| --------------------------------------- | --------------- | -------------------------------- |
|
||||
| `/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。
|
||||
`main.tsx`
|
||||
在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。控制台允许同一组件类型创建多个命名实例;列表必须显示实例名称而不是只显示类型,以便不同 OBS 场景的样式和地址可被区分。
|
||||
|
||||
## 文件职责
|
||||
|
||||
@@ -26,8 +27,10 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
||||
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
|
||||
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
|
||||
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
|
||||
| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 |
|
||||
| `src/giftEffect.tsx` | 礼物流星与视口自适应渲染 |
|
||||
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
|
||||
| `src/guardEffect.tsx` | 独立大航海视频、感谢卷轴与月夜庆祝渲染 |
|
||||
| `src/guardThemes.ts` | 可扩展大航海特效主题注册表与 CSS 变量 |
|
||||
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
|
||||
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
|
||||
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
|
||||
@@ -56,6 +59,14 @@ build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后
|
||||
metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则嵌入同一文件来验证允许保存的 locale。增加文案键时必须为每个 locale 提供值;增加语言时同时增加完整
|
||||
`[locales."<code>".messages]` 段。业务命令如“点歌”是 Bilibili 输入协议,不属于 UI 翻译。
|
||||
|
||||
## 字体资源
|
||||
|
||||
Noto Serif SC、ZCOOL XiaoWei 与 LXGW
|
||||
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书与鸿雷行书简体则以固定 WOFF2 文件保存在
|
||||
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。前三套 Fontsource 字体与漓雨手书的 OFL-1.1 许可证输出到
|
||||
`/fonts/licenses/`;鸿雷行书的随附说明不构成开放授权,公开或商业部署前必须确认 Web 嵌入与再分发权利,具体摘要见
|
||||
`public/fonts/NOTICE.md`。
|
||||
|
||||
## 格式化与构建
|
||||
|
||||
```bash
|
||||
@@ -75,4 +86,5 @@ YAML 和项目文档。
|
||||
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
|
||||
- [点歌姬组件](../../docs/components/song-request.md)
|
||||
- [全屏礼物特效](../../docs/components/gift-effect.md)
|
||||
- [大航海特效](../../docs/components/guard-effect.md)
|
||||
- [礼物菜单组件](../../docs/components/gift-menu.md)
|
||||
|
||||
Generated
+30
@@ -8,6 +8,9 @@
|
||||
"name": "lxc-stream-overlay",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/lxgw-wenkai": "5.3.0",
|
||||
"@fontsource/noto-serif-sc": "5.3.0",
|
||||
"@fontsource/zcool-xiaowei": "5.3.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
},
|
||||
@@ -745,6 +748,33 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/lxgw-wenkai": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/lxgw-wenkai/-/lxgw-wenkai-5.3.0.tgz",
|
||||
"integrity": "sha512-+U5AAOXeaB7o+3G0Qm7I7Djz2Jd1hqNY+kClOZH8bTunyyGa2WxY12LZftt2AqUsxlfxwdQpd75Js1HQIt4H4g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-serif-sc": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
|
||||
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/zcool-xiaowei": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/zcool-xiaowei/-/zcool-xiaowei-5.3.0.tgz",
|
||||
"integrity": "sha512-iRuUPl9ONFbYzi6JcEshpE4VedINyzQ4hv0Z5XEt0cB0pMPiQeqc2Qvv1NwJCNzkPwG02VCdcO+cF2K8ylYn2A==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && vite build && node scripts/check-font-assets.mjs",
|
||||
"dev": "vite",
|
||||
"docs:check": "node scripts/check-doc-links.mjs ../..",
|
||||
"format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml",
|
||||
"format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/lxgw-wenkai": "5.3.0",
|
||||
"@fontsource/noto-serif-sc": "5.3.0",
|
||||
"@fontsource/zcool-xiaowei": "5.3.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
},
|
||||
|
||||
@@ -28,3 +28,10 @@ so OBS never needs to load the source websites.
|
||||
|
||||
`moonlit-edge.svg` is an original repository asset. It is a transparent, monochrome mask shared by
|
||||
the Moonlit Water danmaku, song-request, and gift-menu components.
|
||||
|
||||
# Membership voyage videos
|
||||
|
||||
`captain.webm`, `admiral.webm`, and `general.webm` are project-owner-supplied 1280×720 VP9/Opus
|
||||
videos used by the independent Jade Starfall Guard, Admiral, and Governor component respectively.
|
||||
Each video runs for approximately 7.988 seconds. Their redistribution rights remain the
|
||||
responsibility of the deployment owner.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 72" aria-hidden="true">
|
||||
<!-- Repository-original transparent lotus silhouette used as a CSS mask. -->
|
||||
<g fill="#000">
|
||||
<path d="M48 39C36 28 35 15 48 3c13 12 12 25 0 36Z" />
|
||||
<path d="M43 43C27 38 19 26 24 10c16 5 23 17 19 33Z" opacity=".9" />
|
||||
<path d="M53 43c16-5 24-17 19-33-16 5-23 17-19 33Z" opacity=".9" />
|
||||
<path d="M39 48C21 49 9 40 7 24c17-1 28 8 32 24Z" opacity=".75" />
|
||||
<path d="M57 48c18 1 30-8 32-24-17-1-28 8-32 24Z" opacity=".75" />
|
||||
<path d="M48 62C29 62 16 56 10 45c13 4 25 4 38-1 13 5 25 5 38 1-6 11-19 17-38 17Z" />
|
||||
<path d="M17 67c20-5 42-5 62 0-21 3-41 3-62 0Z" opacity=".62" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 697 B |
@@ -0,0 +1,33 @@
|
||||
# Bundled font notice
|
||||
|
||||
## Liyu Shoushu
|
||||
|
||||
`liyu-shoushu-v0.107.woff2` is a WOFF2 conversion of `LiyuShoushu.ttf` from the official
|
||||
[Liyu Shoushu v0.107 release](https://github.com/InkPantherType/LiyuShoushu/releases/tag/v0.107).
|
||||
|
||||
- Release tag commit: `219a0f42cb7b3f0d00be6e5bf9f8b5298a1f3060`
|
||||
- Original TTF SHA-256: `16554439b7e95da1fc354ed7b2c1551e83e942c3c5afb2a2e883761c6037dc22`
|
||||
- Bundled WOFF2 SHA-256: `b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3`
|
||||
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
|
||||
- Coverage retained: 18,828 glyphs and 18,221 Unicode mappings
|
||||
- License: SIL Open Font License 1.1; see
|
||||
[`licenses/liyu-shoushu-OFL.txt`](licenses/liyu-shoushu-OFL.txt)
|
||||
|
||||
The original font credits Yuji Kataoka and the Yuji Project Authors for the base brush glyphs, LXGW
|
||||
and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's
|
||||
[zi2zi-JiT](https://github.com/kaonashi-tyc/zi2zi-JiT) for generated Chinese glyphs. This notice
|
||||
preserves the zi2zi-JiT attribution required by the upstream font documentation.
|
||||
|
||||
## HongLei XingShu Jian
|
||||
|
||||
`honglei-xingshu-jian.woff2` is a full-font WOFF2 conversion of the project owner's
|
||||
`鸿雷行书简体.otf`; it is used only by the independent Jade Starfall membership component.
|
||||
|
||||
- Embedded family: `hongleixingshu` / `鸿雷行书简体`
|
||||
- Original OTF SHA-256: `5081c025bc350339885297550b33d6742cd6467c7db5b340c537d0eb1e357234`
|
||||
- Bundled WOFF2 SHA-256: `6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d`
|
||||
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
|
||||
- Coverage retained: 7,012 glyphs
|
||||
- License: the supplied download notice does not grant an open-source or redistribution license.
|
||||
Confirm that the deployment owner holds Web embedding and redistribution rights before public or
|
||||
commercial deployment.
|
||||
Binary file not shown.
@@ -0,0 +1,94 @@
|
||||
Copyright 2026 Alexander Tseng.
|
||||
Portions Copyright 2021 The Yuji Project Authors (https://github.com/Kinutafontfactory/Yuji).
|
||||
Portions Copyright LXGW and LXGWZhenKai Project Authors (https://github.com/lxgw/LxgwZhenKai).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -66,6 +66,7 @@ function isControlShell(pathname) {
|
||||
function isStaticAsset(pathname) {
|
||||
return (
|
||||
pathname.startsWith('/assets/') ||
|
||||
pathname.startsWith('/fonts/') ||
|
||||
pathname.startsWith('/pwa/') ||
|
||||
pathname === '/control/manifest.webmanifest'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
|
||||
const dist = new URL('../dist/', import.meta.url)
|
||||
|
||||
async function filesBelow(directory, prefix = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = []
|
||||
for (const entry of entries) {
|
||||
const relative = `${prefix}${entry.name}`
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await filesBelow(new URL(`${entry.name}/`, directory), `${relative}/`)))
|
||||
} else {
|
||||
files.push(relative)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const files = await filesBelow(dist)
|
||||
const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai']
|
||||
const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2'
|
||||
const hongleiFont = 'fonts/honglei-xingshu-jian.woff2'
|
||||
|
||||
for (const font of expectedFonts) {
|
||||
if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) {
|
||||
throw new Error(`Missing bundled WOFF2 assets for ${font}`)
|
||||
}
|
||||
if (!files.includes(`fonts/licenses/${font}-OFL.txt`)) {
|
||||
throw new Error(`Missing bundled license for ${font}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!files.includes(liyuFont)) throw new Error('Missing bundled WOFF2 asset for liyu-shoushu')
|
||||
if (!files.includes('fonts/licenses/liyu-shoushu-OFL.txt')) {
|
||||
throw new Error('Missing bundled license for liyu-shoushu')
|
||||
}
|
||||
const liyuDigest = createHash('sha256')
|
||||
.update(await readFile(new URL(liyuFont, dist)))
|
||||
.digest('hex')
|
||||
if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') {
|
||||
throw new Error(`Unexpected liyu-shoushu WOFF2 digest: ${liyuDigest}`)
|
||||
}
|
||||
if (!files.includes(hongleiFont)) throw new Error('Missing bundled WOFF2 asset for HongLei XingShu')
|
||||
const hongleiDigest = createHash('sha256')
|
||||
.update(await readFile(new URL(hongleiFont, dist)))
|
||||
.digest('hex')
|
||||
if (hongleiDigest !== '6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d') {
|
||||
throw new Error(`Unexpected HongLei XingShu WOFF2 digest: ${hongleiDigest}`)
|
||||
}
|
||||
|
||||
const guardVideos = new Map([
|
||||
['assets/captain.webm', 'e9d2b416a582c4606cec1d613027e941bfc422f3ad12de24e88980908496d759'],
|
||||
['assets/admiral.webm', '7c504469f638f321768edecb3b5ba25d6826737349f3479c89fb1776d35a8672'],
|
||||
['assets/general.webm', '869b54a5104e4dfbcc97a6338e8805444e0f0034c6d8075530b2242a2c3436e1'],
|
||||
])
|
||||
for (const [video, expectedDigest] of guardVideos) {
|
||||
if (!files.includes(video)) throw new Error(`Missing bundled membership video: ${video}`)
|
||||
const digest = createHash('sha256')
|
||||
.update(await readFile(new URL(video, dist)))
|
||||
.digest('hex')
|
||||
if (digest !== expectedDigest) throw new Error(`Unexpected membership video digest: ${video}`)
|
||||
}
|
||||
|
||||
const legacyWoff = files.find(file => file.endsWith('.woff'))
|
||||
if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`)
|
||||
|
||||
const css = (
|
||||
await Promise.all(
|
||||
files.filter(file => file.endsWith('.css')).map(file => readFile(new URL(file, dist), 'utf8')),
|
||||
)
|
||||
).join('\n')
|
||||
|
||||
for (const family of [
|
||||
'Noto Serif SC',
|
||||
'ZCOOL XiaoWei',
|
||||
'LXGW WenKai',
|
||||
'Liyu Shoushu',
|
||||
'HongLei XingShu',
|
||||
]) {
|
||||
const declarations = [
|
||||
`font-family:${family}`,
|
||||
`font-family:${JSON.stringify(family)}`,
|
||||
`font-family:'${family}'`,
|
||||
]
|
||||
if (!declarations.some(declaration => css.includes(declaration))) {
|
||||
throw new Error(`Missing @font-face declaration for ${family}`)
|
||||
}
|
||||
}
|
||||
if (!css.includes('font-display:block') || css.includes('font-display:swap')) {
|
||||
throw new Error('Bundled fonts must block local fallback while loading')
|
||||
}
|
||||
|
||||
console.log('Verified same-origin component fonts, licenses, and membership videos.')
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared Moonlit Water edge ornament.
|
||||
*
|
||||
* Keeping the artwork inline lets the persisted decoration setting control
|
||||
* real SVG stroke width. A raster image or CSS-scaled mask would only distort
|
||||
* the motif and could not make its calligraphic lines genuinely heavier.
|
||||
*/
|
||||
function EdgeArtwork({ position }: { position: 'top' | 'bottom' }) {
|
||||
return (
|
||||
<svg
|
||||
className={`theme-edge-art theme-edge-${position}`}
|
||||
viewBox="0 0 1200 56"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="theme-edge-rule">
|
||||
<path d="M12 30h338c52 0 86-8 116-24-13 13-13 29 0 42-30-16-64-24-116-24H12" />
|
||||
<path d="M1188 30H850c-52 0-86-8-116-24 13 13 13 29 0 42 30-16 64-24 116-24h338" />
|
||||
</g>
|
||||
<g className="theme-edge-water">
|
||||
<path d="M38 38c35-14 70-14 105 0s70 14 105 0" />
|
||||
<path d="M1162 38c-35-14-70-14-105 0s-70 14-105 0" />
|
||||
</g>
|
||||
<g className="theme-edge-cloud">
|
||||
<path d="M192 27c10-14 31-14 40 0 8-10 25-8 29 4-22 8-53 8-76 0 1-2 4-3 7-4Z" />
|
||||
<path d="M1008 27c-10-14-31-14-40 0-8-10-25-8-29 4 22 8 53 8 76 0-1-2-4-3-7-4Z" />
|
||||
</g>
|
||||
<g className="theme-edge-leaves">
|
||||
<path d="M485 28c13-14 27-17 42-9-10 13-24 16-42 9Z" />
|
||||
<path d="M715 28c-13-14-27-17-42-9 10 13 24 16 42 9Z" />
|
||||
</g>
|
||||
<g className="theme-edge-lotus">
|
||||
<path d="M600 34c-9-10-9-22 0-32 9 10 9 22 0 32Z" />
|
||||
<path d="M595 36c-15-5-22-16-19-29 15 5 22 15 19 29Z" />
|
||||
<path d="M605 36c15-5 22-16 19-29-15 5-22 15-19 29Z" />
|
||||
<path d="M590 40c-17 0-28-7-32-20 16 0 27 7 32 20Z" />
|
||||
<path d="M610 40c17 0 28-7 32-20-16 0-27 7-32 20Z" />
|
||||
<path d="M600 49c-18 0-31-5-39-15 14 4 27 3 39-3 12 6 25 7 39 3-8 10-21 15-39 15Z" />
|
||||
</g>
|
||||
<g className="theme-edge-dots">
|
||||
<circle cx="92" cy="27" r="3.2" />
|
||||
<circle cx="1108" cy="27" r="3.2" />
|
||||
<path d="m536 27 4-7 4 7-4 7Z" />
|
||||
<path d="m664 27-4-7-4 7 4 7Z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Decorative chrome is ignored by assistive technology and never captures input. */
|
||||
export function ThemeEdges() {
|
||||
return (
|
||||
<div className="theme-edge-set" aria-hidden="true">
|
||||
<EdgeArtwork position="top" />
|
||||
<EdgeArtwork position="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+21
-7
@@ -11,6 +11,7 @@ import type {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
GuardEffectSettings,
|
||||
GiftEffectSettings,
|
||||
GiftCatalogItem,
|
||||
GiftMenuItem,
|
||||
@@ -27,8 +28,10 @@ import { normalizeThemeId } from './themes'
|
||||
import { normalizeFontFamilyId } from './typography'
|
||||
import { normalizeGiftEffectThemeId } from './giftThemes'
|
||||
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
|
||||
import { normalizeGuardEffectThemeId } from './guardThemes'
|
||||
import {
|
||||
defaultGiftEffectSettings,
|
||||
defaultGuardEffectSettings,
|
||||
defaultGiftMenuSettings,
|
||||
defaultOverlaySettings,
|
||||
defaultSongRequestSettings,
|
||||
@@ -189,7 +192,13 @@ export function normalizeRecoveryCodes(value: unknown): string[] {
|
||||
|
||||
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
||||
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
|
||||
: root.component && typeof root.component === 'object'
|
||||
? [root.component]
|
||||
: []
|
||||
return list
|
||||
.map(entry => {
|
||||
const item = object(entry)
|
||||
@@ -255,6 +264,17 @@ export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGuardEffectSettings(value: unknown): GuardEffectSettings {
|
||||
const root = object(value)
|
||||
const settings = object(root.settings ?? value)
|
||||
return {
|
||||
...defaultGuardEffectSettings,
|
||||
...(settings as Partial<GuardEffectSettings>),
|
||||
themeId: normalizeGuardEffectThemeId(settings.themeId),
|
||||
fontFamily: normalizeFontFamilyId(settings.fontFamily),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
|
||||
const item = object(value)
|
||||
const trigger = object(item.trigger)
|
||||
@@ -360,11 +380,6 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +399,6 @@ export function normalizeSongRequestPage(value: unknown): SongRequestPage {
|
||||
queuedCount: Number(summary.queuedCount ?? 0),
|
||||
completedCount: Number(summary.completedCount ?? 0),
|
||||
cancelledCount: Number(summary.cancelledCount ?? 0),
|
||||
ratingCount: Number(summary.ratingCount ?? 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function AuthShell({
|
||||
)
|
||||
}
|
||||
|
||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
export function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||
const { t } = useI18n()
|
||||
const source = useMemo(() => {
|
||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
.song-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -374,7 +374,17 @@ body,
|
||||
}
|
||||
|
||||
.wall {
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wall > .cards {
|
||||
/* Keep the chronological stack pinned to the bottom. Once it is taller
|
||||
than the OBS viewport, its oldest rows overflow above the clipping edge
|
||||
while each newly appended row remains visible at the bottom. */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -804,6 +814,22 @@ body,
|
||||
background 0.24s ease;
|
||||
}
|
||||
|
||||
.danmaku-lotus {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
right: clamp(8px, 1.6vw, 18px);
|
||||
width: clamp(26px, 7%, 44px);
|
||||
aspect-ratio: 4 / 3;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(145deg, var(--theme-accent), var(--theme-user));
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--theme-accent) 48%, transparent));
|
||||
opacity: 0.62;
|
||||
transform: translateY(-50%);
|
||||
-webkit-mask: var(--theme-pattern-lotus) center / contain no-repeat;
|
||||
mask: var(--theme-pattern-lotus) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.card.danmaku.expanded {
|
||||
min-height: 92px;
|
||||
padding: 14px;
|
||||
@@ -826,7 +852,7 @@ body,
|
||||
inset-inline: -1px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border-inline: 4px solid rgba(123, 238, 214, 0.72);
|
||||
border-inline: var(--component-decoration-rail, 4px) solid rgba(123, 238, 214, 0.72);
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -901,6 +927,18 @@ body,
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* In compact-only mode the whole bottom-anchored stack is moved with FLIP in
|
||||
overlay.tsx. Suppress the per-card side entrance so the new row appears to
|
||||
rise from below the component while the existing rows are pushed upward. */
|
||||
.overlay.stack-arrival .card.danmaku.compact {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card.danmaku.expanded .copy,
|
||||
.card.danmaku.compact .copy {
|
||||
padding-inline-end: clamp(38px, 8%, 58px);
|
||||
}
|
||||
|
||||
.short .cards .card:nth-child(n + 4) {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1071,6 +1109,13 @@ body,
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .danmaku-lotus {
|
||||
width: clamp(30px, 8%, 52px);
|
||||
background: linear-gradient(145deg, #e8d59a, #9cb7a5 72%);
|
||||
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.34));
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .card.danmaku .copy > b {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
@@ -1099,7 +1144,7 @@ body,
|
||||
height: clamp(38px, 2.3em, 56px);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(231, 201, 130, 0.58);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.58);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 36% 28%, rgba(224, 233, 205, 0.32), rgba(22, 72, 76, 0.5));
|
||||
color: #d2b976;
|
||||
@@ -1190,6 +1235,11 @@ body,
|
||||
height: clamp(32px, 1.9em, 44px);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .card.danmaku.expanded .copy,
|
||||
.theme-moonlit-water .card.danmaku.compact .copy {
|
||||
padding-inline-end: clamp(42px, 9%, 66px);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .danmaku-emoticon {
|
||||
filter: drop-shadow(0 1px 3px rgba(10, 34, 38, 0.56));
|
||||
}
|
||||
@@ -1951,13 +2001,37 @@ select {
|
||||
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
|
||||
}
|
||||
|
||||
.future-components {
|
||||
.component-create {
|
||||
margin: 18px 8px 0;
|
||||
padding-top: 15px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
border-top: 1px solid rgba(94, 187, 173, 0.12);
|
||||
color: #567d78;
|
||||
}
|
||||
|
||||
.component-create > b {
|
||||
color: #bcebe2;
|
||||
}
|
||||
|
||||
.component-create > small {
|
||||
color: #719e98;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.component-create select,
|
||||
.component-create input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(75, 157, 151, 0.45);
|
||||
border-radius: 9px;
|
||||
color: #edfffb;
|
||||
background: rgba(3, 18, 26, 0.82);
|
||||
}
|
||||
|
||||
.component-create button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
@@ -1974,6 +2048,14 @@ select {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-heading-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
margin: 0;
|
||||
color: #e2fff9;
|
||||
@@ -2309,10 +2391,6 @@ td:last-child {
|
||||
.component-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
}
|
||||
|
||||
.future-components {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
|
||||
+685
-134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
type QueueItem = { id: string }
|
||||
|
||||
type EffectQueueState<T extends QueueItem> = {
|
||||
active?: T
|
||||
pending: T[]
|
||||
}
|
||||
|
||||
export function enqueueEffect<T extends QueueItem>(
|
||||
state: EffectQueueState<T>,
|
||||
effect: T,
|
||||
capacity: number,
|
||||
): EffectQueueState<T> {
|
||||
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
|
||||
return state
|
||||
}
|
||||
if (!state.active) return { active: effect, pending: state.pending }
|
||||
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
|
||||
if (state.pending.length >= boundedCapacity) return state
|
||||
return { ...state, pending: [...state.pending, effect] }
|
||||
}
|
||||
|
||||
export function completeEffect<T extends QueueItem>(
|
||||
state: EffectQueueState<T>,
|
||||
activeId: string,
|
||||
): EffectQueueState<T> {
|
||||
if (state.active?.id !== activeId) return state
|
||||
const [active, ...pending] = state.pending
|
||||
return { active, pending }
|
||||
}
|
||||
|
||||
export function useEffectQueue<T extends QueueItem>() {
|
||||
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
|
||||
const enqueue = useCallback((effect: T, capacity: number) => {
|
||||
setState(current => enqueueEffect(current, effect, capacity))
|
||||
}, [])
|
||||
const complete = useCallback((activeId: string) => {
|
||||
setState(current => completeEffect(current, activeId))
|
||||
}, [])
|
||||
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@font-face {
|
||||
font-family: 'Liyu Shoushu';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'HongLei XingShu';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
|
||||
}
|
||||
+14
-237
@@ -18,6 +18,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meteor-burst {
|
||||
animation: gift-burst-lifetime var(--burst-duration) linear both;
|
||||
}
|
||||
|
||||
.gift-meteor {
|
||||
position: absolute;
|
||||
top: var(--meteor-y);
|
||||
@@ -156,122 +160,7 @@
|
||||
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 {
|
||||
.gift-low-motion .meteor-spark {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -293,6 +182,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-burst-lifetime {
|
||||
from,
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gift-meteor-sparkle {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
@@ -304,53 +200,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
.meteor-spark {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -369,8 +224,7 @@
|
||||
|
||||
.moonlit-whisper-copy,
|
||||
.moonlit-gift-whisper > small,
|
||||
.moonlit-ceremony-copy,
|
||||
.guard-copy {
|
||||
.moonlit-ceremony-copy {
|
||||
filter: brightness(var(--component-font-brightness, 1.3));
|
||||
}
|
||||
|
||||
@@ -617,76 +471,6 @@
|
||||
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,
|
||||
@@ -784,10 +568,3 @@
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-water {
|
||||
50% {
|
||||
opacity: 0.68;
|
||||
transform: scale(1.04) translateY(-1.4%);
|
||||
}
|
||||
}
|
||||
|
||||
+108
-142
@@ -1,7 +1,8 @@
|
||||
/** Full-viewport gift meteor and guard celebration renderer. */
|
||||
/** Full-viewport gift renderer. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { normalizeGiftEffectSettings } from './api'
|
||||
import { useEffectQueue } from './effectQueue'
|
||||
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
|
||||
import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
@@ -21,22 +22,17 @@ 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'
|
||||
export type GiftEffectPreviewMode = GiftTier
|
||||
|
||||
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
|
||||
const MOONLIT_CEREMONY_THRESHOLD = 50_000
|
||||
@@ -59,14 +55,7 @@ function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
|
||||
}
|
||||
|
||||
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 viewer = { uid: '10001', name: translate('gift.preview.viewer') }
|
||||
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
|
||||
return {
|
||||
id: `preview-${mode}-${nonce}`,
|
||||
@@ -86,23 +75,12 @@ function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnve
|
||||
|
||||
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()
|
||||
if (envelope.type !== 'live.gift') return undefined
|
||||
return {
|
||||
id: envelope.id,
|
||||
kind,
|
||||
payload: envelope.payload ?? {},
|
||||
receivedAt: now,
|
||||
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
|
||||
previewTier,
|
||||
}
|
||||
}
|
||||
@@ -116,7 +94,7 @@ function useGiftEffects(
|
||||
language: string,
|
||||
) {
|
||||
const [settings, setSettings] = useState(defaultGiftEffectSettings)
|
||||
const [effects, setEffects] = useState<VisualEffect[]>([])
|
||||
const queue = useEffectQueue<VisualEffect>()
|
||||
const settingsRef = useRef(settings)
|
||||
const lastSequenceRef = useRef(0)
|
||||
|
||||
@@ -139,36 +117,24 @@ function useGiftEffects(
|
||||
setSettings(next)
|
||||
continue
|
||||
}
|
||||
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
|
||||
const effect = toVisualEffect(envelope)
|
||||
if (!effect) continue
|
||||
setEffects(current =>
|
||||
[effect, ...current.filter(item => item.id !== effect.id)].slice(
|
||||
0,
|
||||
settingsRef.current.maxConcurrentEffects,
|
||||
),
|
||||
)
|
||||
queue.enqueue(effect, settingsRef.current.queueCapacity)
|
||||
}
|
||||
}, [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])
|
||||
const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode)
|
||||
if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity)
|
||||
}, [language, preview, previewMode, previewNonce])
|
||||
|
||||
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 }
|
||||
return {
|
||||
settings,
|
||||
effect: queue.active,
|
||||
pendingCount: queue.pendingCount,
|
||||
completeEffect: queue.complete,
|
||||
}
|
||||
}
|
||||
|
||||
function GiftImage({ gift }: { gift: Gift }) {
|
||||
@@ -200,6 +166,7 @@ function MeteorBurst({
|
||||
viewportWidth,
|
||||
trailIntensity,
|
||||
lowPerformance,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: VisualEffect
|
||||
tier: GiftTier
|
||||
@@ -208,19 +175,39 @@ function MeteorBurst({
|
||||
viewportWidth: number
|
||||
trailIntensity: number
|
||||
lowPerformance: boolean
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const gift = effect.payload.gift ?? {}
|
||||
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
|
||||
const size = Math.max(20, tierSettings.size * scale)
|
||||
const speed = Math.max(80, tierSettings.speed * scale)
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
const meteors = Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:${index}`)
|
||||
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
|
||||
return {
|
||||
index,
|
||||
startY: 7 + (seed % 78),
|
||||
drift: ((seed >>> 8) % 37) - 18,
|
||||
delay: index * 95 + ((seed >>> 16) % 260),
|
||||
duration,
|
||||
lifetime:
|
||||
(reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260),
|
||||
}
|
||||
})
|
||||
const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600)
|
||||
return (
|
||||
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
|
||||
{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)
|
||||
<div
|
||||
className={`meteor-burst tier-${tier}`}
|
||||
aria-label={gift.name || translate('common.gift')}
|
||||
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
|
||||
onComplete()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{meteors.map(({ index, startY, drift, delay, duration }) => {
|
||||
return (
|
||||
<div
|
||||
className="gift-meteor"
|
||||
@@ -254,7 +241,13 @@ function MeteorBurst({
|
||||
* 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 }) {
|
||||
function MoonlitGiftWhisper({
|
||||
effect,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: VisualEffect
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const gift = effect.payload.gift ?? {}
|
||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||
const quantity = effect.payload.quantity || 1
|
||||
@@ -271,6 +264,13 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
|
||||
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
|
||||
} as CSSProperties
|
||||
}
|
||||
onAnimationEnd={event => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
event.animationName === 'moonlit-whisper-appear'
|
||||
)
|
||||
onComplete()
|
||||
}}
|
||||
>
|
||||
<i className="moonlit-whisper-ripple" aria-hidden="true" />
|
||||
<span className="moonlit-whisper-image">
|
||||
@@ -292,13 +292,31 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
|
||||
* 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 }) {
|
||||
function MoonlitGiftCeremony({
|
||||
effect,
|
||||
tier,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: VisualEffect
|
||||
tier: GiftTier
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const gift = effect.payload.gift ?? {}
|
||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||
const quantity = effect.payload.quantity || 1
|
||||
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
|
||||
return (
|
||||
<section className={`moonlit-gift-ceremony tier-${tier}`} aria-live="polite">
|
||||
<section
|
||||
className={`moonlit-gift-ceremony tier-${tier}`}
|
||||
aria-live="polite"
|
||||
onAnimationEnd={event => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
event.animationName === 'moonlit-offering-sweep'
|
||||
)
|
||||
onComplete()
|
||||
}}
|
||||
>
|
||||
<div className="moonlit-ceremony-moon" aria-hidden="true" />
|
||||
<div className="moonlit-ceremony-water" aria-hidden="true">
|
||||
<i />
|
||||
@@ -338,59 +356,6 @@ function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: Gif
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -428,48 +393,49 @@ export function GiftEffectOverlay({
|
||||
}, [])
|
||||
|
||||
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'
|
||||
const effect = remote.effect
|
||||
let activeVisual = null
|
||||
if (effect) {
|
||||
const tier = tierFor(effect, settings)
|
||||
const gift = effect.payload.gift
|
||||
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
|
||||
const onComplete = () => remote.completeEffect(effect.id)
|
||||
activeVisual = moonlit ? (
|
||||
totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
|
||||
<MoonlitGiftCeremony effect={effect} tier={tier} onComplete={onComplete} key={effect.id} />
|
||||
) : (
|
||||
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
|
||||
)
|
||||
) : (
|
||||
<MeteorBurst
|
||||
effect={effect}
|
||||
tier={tier}
|
||||
tierSettings={settings[tier]}
|
||||
scale={scale}
|
||||
viewportWidth={bounds.width}
|
||||
trailIntensity={settings.trailIntensity}
|
||||
lowPerformance={settings.lowPerformanceMode}
|
||||
onComplete={onComplete}
|
||||
key={effect.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
data-queue-length={remote.pendingCount}
|
||||
style={{
|
||||
...giftThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
}}
|
||||
>
|
||||
<div className="meteor-sky" aria-live="polite">
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{activeVisual}
|
||||
</div>
|
||||
{guard && <GuardCelebration effect={guard} settings={settings} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ body,
|
||||
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: var(--component-decoration-line, 1px) solid
|
||||
color-mix(in srgb, var(--menu-jade) 38%, transparent);
|
||||
border-radius: clamp(10px, 1.5vmin, 22px);
|
||||
outline: 1px solid rgba(255, 227, 142, 0.07);
|
||||
outline-offset: -4px;
|
||||
@@ -71,7 +72,8 @@ body,
|
||||
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: var(--component-decoration-line, 1px) solid
|
||||
color-mix(in srgb, var(--menu-jade) 36%, transparent);
|
||||
border-radius: calc(var(--menu-row-height) * 0.17);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(4, 29, 43, 0.91), rgba(7, 63, 66, 0.72) 58%, rgba(3, 27, 41, 0.9)),
|
||||
@@ -438,7 +440,7 @@ body,
|
||||
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-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.17);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
@@ -498,7 +500,7 @@ body,
|
||||
inset: 0;
|
||||
padding: 0.28em 1em;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(231, 201, 130, 0.82);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.82);
|
||||
border-radius: 0;
|
||||
color: #eadfc3;
|
||||
background: linear-gradient(90deg, transparent, rgba(72, 105, 98, 0.32) 50%, transparent);
|
||||
@@ -529,7 +531,7 @@ body,
|
||||
|
||||
.gift-menu-theme-moonlit-water .gift-menu-empty {
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(231, 201, 130, 0.25);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(231, 201, 130, 0.25);
|
||||
border-radius: 0;
|
||||
color: #afc2ad;
|
||||
background: transparent;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { defaultGiftMenuSettings } from './types'
|
||||
import type { GiftMenuItem, GiftMenuSettings } from './types'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
import { typographyVariables } from './typography'
|
||||
|
||||
type MenuEnvelope = {
|
||||
@@ -365,6 +366,8 @@ export function GiftMenuOverlay({
|
||||
{
|
||||
...giftMenuThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--menu-row-height' as string]: `${settings.rowHeight}px`,
|
||||
['--menu-panel-height' as string]: `${settings.rowHeight * settings.visibleRows}px`,
|
||||
['--menu-title-size' as string]: `${settings.rowHeight * 0.205 * (settings.fontScale / 100)}px`,
|
||||
@@ -379,6 +382,7 @@ export function GiftMenuOverlay({
|
||||
}
|
||||
data-connection={stream?.connection ?? 'idle'}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section
|
||||
className="gift-menu-viewport"
|
||||
ref={viewport}
|
||||
|
||||
@@ -10,13 +10,9 @@ export type GiftEffectTheme = {
|
||||
jade: string
|
||||
cyan: string
|
||||
gold: string
|
||||
rose: string
|
||||
night: string
|
||||
}
|
||||
motion: {
|
||||
meteor: string
|
||||
guardReveal: string
|
||||
starPulse: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +26,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
||||
jade: '#72f3d8',
|
||||
cyan: '#a9fff2',
|
||||
gold: '#ffe7a4',
|
||||
rose: '#ffd0e5',
|
||||
night: '#020c18',
|
||||
},
|
||||
motion: {
|
||||
meteor: 'gift-meteor-flight',
|
||||
guardReveal: 'gift-guard-reveal',
|
||||
starPulse: 'gift-star-pulse',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,13 +40,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
||||
jade: '#8daea2',
|
||||
cyan: '#c6d8c6',
|
||||
gold: '#e7c982',
|
||||
rose: '#dbc6a0',
|
||||
night: 'transparent',
|
||||
},
|
||||
motion: {
|
||||
meteor: 'moonlit-offering-sweep',
|
||||
guardReveal: 'moonlit-guard-reveal',
|
||||
starPulse: 'moonlit-star-pulse',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -72,10 +60,6 @@ export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties {
|
||||
['--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,422 @@
|
||||
.guard-effect-overlay {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--guard-cyan, #a9fff2);
|
||||
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jade-guard-voyage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.jade-guard-voyage > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
animation: jade-guard-video-fade var(--guard-duration) linear both;
|
||||
}
|
||||
|
||||
.jade-guard-scroll {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: min(86vw, 1180px);
|
||||
min-height: min(34vh, 360px);
|
||||
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: clamp(5px, 0.9vh, 12px);
|
||||
border-block: 1px solid rgba(255, 229, 153, 0.7);
|
||||
color: #fff4cf;
|
||||
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
|
||||
text-align: center;
|
||||
text-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.96),
|
||||
0 0 14px rgba(0, 0, 0, 0.92),
|
||||
0 0 22px rgba(255, 220, 130, 0.48);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(3, 15, 22, 0.34) 12%,
|
||||
rgba(3, 15, 22, 0.62) 50%,
|
||||
rgba(3, 15, 22, 0.34) 88%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
clip-path: inset(0 50% round 10px);
|
||||
transform: translate(-56%, -50%);
|
||||
will-change: clip-path, opacity, transform;
|
||||
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
|
||||
}
|
||||
|
||||
.jade-guard-scroll::before,
|
||||
.jade-guard-scroll::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7%;
|
||||
bottom: -7%;
|
||||
width: clamp(5px, 0.55vw, 10px);
|
||||
border: 1px solid rgba(255, 239, 190, 0.82);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
|
||||
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
|
||||
}
|
||||
|
||||
.jade-guard-scroll::before {
|
||||
left: clamp(13px, 2vw, 32px);
|
||||
}
|
||||
|
||||
.jade-guard-scroll::after {
|
||||
right: clamp(13px, 2vw, 32px);
|
||||
}
|
||||
|
||||
.jade-guard-scroll strong,
|
||||
.jade-guard-scroll b,
|
||||
.jade-guard-scroll span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jade-guard-scroll strong {
|
||||
color: #fff5d2;
|
||||
font-size: clamp(42px, 7vw, 126px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.jade-guard-scroll b {
|
||||
color: #d4fff3;
|
||||
font-size: clamp(24px, 3.7vw, 66px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.jade-guard-scroll span {
|
||||
color: #ffe5a6;
|
||||
font-size: clamp(34px, 5.4vw, 94px);
|
||||
letter-spacing: 0.18em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guard-celebration {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
color: #edfffa;
|
||||
background:
|
||||
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
|
||||
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
|
||||
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
|
||||
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
|
||||
}
|
||||
|
||||
.guard-nebula {
|
||||
position: absolute;
|
||||
inset: -25%;
|
||||
background: conic-gradient(
|
||||
from 90deg,
|
||||
transparent,
|
||||
rgba(93, 237, 209, 0.18),
|
||||
transparent 32%,
|
||||
rgba(255, 207, 230, 0.12),
|
||||
transparent 68%,
|
||||
rgba(255, 230, 156, 0.13),
|
||||
transparent
|
||||
);
|
||||
filter: blur(28px);
|
||||
animation: guard-nebula-turn 9s linear infinite;
|
||||
}
|
||||
|
||||
.guard-stars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.guard-stars i {
|
||||
position: absolute;
|
||||
width: var(--star-size);
|
||||
height: var(--star-size);
|
||||
opacity: 0.1;
|
||||
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
|
||||
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
||||
filter: drop-shadow(0 0 6px var(--guard-cyan));
|
||||
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
|
||||
}
|
||||
|
||||
.guard-halo {
|
||||
position: absolute;
|
||||
width: min(62vmin, 720px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(155, 255, 235, 0.4);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 60px rgba(87, 241, 212, 0.25),
|
||||
inset 0 0 70px rgba(255, 224, 166, 0.12);
|
||||
animation: guard-halo-breathe 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.guard-halo i {
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border: 1px solid rgba(255, 227, 170, 0.38);
|
||||
border-radius: 45% 55% 48% 52%;
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(2) {
|
||||
inset: 15%;
|
||||
border-color: rgba(255, 195, 224, 0.32);
|
||||
transform: rotate(76deg);
|
||||
}
|
||||
|
||||
.guard-halo i:nth-child(3) {
|
||||
inset: 23%;
|
||||
border-color: rgba(123, 246, 224, 0.45);
|
||||
transform: rotate(122deg);
|
||||
}
|
||||
|
||||
.guard-copy {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
max-width: min(82vw, 1000px);
|
||||
justify-items: center;
|
||||
gap: clamp(8px, 1.5vh, 20px);
|
||||
text-align: center;
|
||||
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
|
||||
filter: brightness(var(--component-font-brightness, 1.3));
|
||||
}
|
||||
|
||||
.guard-copy span {
|
||||
color: var(--guard-gold);
|
||||
font-size: clamp(13px, 1.6vw, 30px);
|
||||
letter-spacing: 0.45em;
|
||||
}
|
||||
|
||||
.guard-copy strong {
|
||||
color: #f3fffc;
|
||||
font-size: clamp(38px, 7vw, 132px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
|
||||
}
|
||||
|
||||
.guard-copy b {
|
||||
color: var(--guard-rose);
|
||||
font-size: clamp(16px, 2.3vw, 44px);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.guard-low-motion .guard-nebula,
|
||||
.guard-low-motion .guard-halo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water {
|
||||
color: #eadfc3;
|
||||
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-celebration {
|
||||
color: #eadfc3;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-celebration::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(62vmin, 780px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(246, 237, 207, 0.82) 0 42%,
|
||||
rgba(226, 224, 193, 0.2) 63%,
|
||||
transparent 71%
|
||||
);
|
||||
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-nebula {
|
||||
background:
|
||||
repeating-radial-gradient(
|
||||
ellipse at 50% 80%,
|
||||
transparent 0 7%,
|
||||
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
|
||||
transparent 7.7% 12%
|
||||
),
|
||||
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
|
||||
filter: blur(1px);
|
||||
animation: moonlit-guard-water 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-stars i {
|
||||
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
|
||||
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-halo {
|
||||
width: min(77vmin, 930px);
|
||||
border-color: rgba(231, 201, 130, 0.42);
|
||||
box-shadow:
|
||||
0 0 44px rgba(231, 201, 130, 0.18),
|
||||
inset 0 0 65px rgba(182, 207, 180, 0.1);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-halo i {
|
||||
border-color: rgba(203, 219, 184, 0.36);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy {
|
||||
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy span,
|
||||
.guard-theme-moonlit-water .guard-copy b {
|
||||
color: #e7c982;
|
||||
}
|
||||
|
||||
.guard-theme-moonlit-water .guard-copy strong {
|
||||
color: #eadfc3;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@keyframes guard-effect-reveal {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
8%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-video-fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
6.25%,
|
||||
93.75% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-scroll-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 50% round 10px);
|
||||
transform: translate(-56%, -50%);
|
||||
}
|
||||
12.5%,
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 round 10px);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
clip-path: inset(0 round 10px);
|
||||
transform: translate(-46%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jade-guard-copy-fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
12.5%,
|
||||
87.5% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-star-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.08;
|
||||
transform: rotate(0) scale(0.45);
|
||||
}
|
||||
48% {
|
||||
opacity: 1;
|
||||
transform: rotate(50deg) scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-nebula-turn {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes guard-halo-breathe {
|
||||
50% {
|
||||
transform: scale(1.08) rotate(3deg);
|
||||
box-shadow:
|
||||
0 0 110px rgba(87, 241, 212, 0.38),
|
||||
inset 0 0 90px rgba(255, 224, 166, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-water {
|
||||
50% {
|
||||
opacity: 0.68;
|
||||
transform: scale(1.04) translateY(-1.4%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moonlit-guard-reveal {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
8%,
|
||||
86% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guard-nebula,
|
||||
.guard-stars i,
|
||||
.guard-halo {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.jade-guard-scroll {
|
||||
clip-path: none;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-name: jade-guard-copy-fade;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/** Full-viewport renderer for the independently subscribed membership component. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { normalizeGuardEffectSettings } from './api'
|
||||
import { useEffectQueue } from './effectQueue'
|
||||
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
|
||||
import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { defaultGuardEffectSettings } from './types'
|
||||
import type { GuardEffectSettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
|
||||
type Viewer = { uid?: string; name?: string }
|
||||
type GuardPayload = {
|
||||
viewer?: Viewer
|
||||
guardName?: string
|
||||
quantity?: number
|
||||
price?: number
|
||||
settings?: Partial<GuardEffectSettings>
|
||||
}
|
||||
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
|
||||
type GuardEffect = {
|
||||
id: string
|
||||
payload: GuardPayload
|
||||
}
|
||||
type GuardLevel = 'captain' | 'admiral' | 'governor'
|
||||
export type GuardEffectPreviewMode = GuardLevel
|
||||
|
||||
const JADE_VIDEO_DURATION_MS = 8_000
|
||||
|
||||
function guardLevel(guardName: string | undefined): GuardLevel {
|
||||
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
|
||||
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
|
||||
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
|
||||
return 'captain'
|
||||
}
|
||||
|
||||
function guardVideoUrl(level: GuardLevel): string {
|
||||
return `/assets/${level === 'governor' ? 'general' : level}.webm`
|
||||
}
|
||||
|
||||
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
|
||||
return {
|
||||
id: `preview-${level}-${nonce}`,
|
||||
type: 'live.guard.buy',
|
||||
payload: {
|
||||
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
|
||||
guardName: translate(`gift_menu.guard.${level}`),
|
||||
quantity: 1,
|
||||
price: 198_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
|
||||
if (envelope.type !== 'live.guard.buy') return undefined
|
||||
return {
|
||||
id: envelope.id,
|
||||
payload: envelope.payload ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function useGuardEffect(
|
||||
preview: boolean,
|
||||
stream: ComponentStream | undefined,
|
||||
previewSettings: GuardEffectSettings | undefined,
|
||||
previewMode: GuardEffectPreviewMode,
|
||||
previewNonce: number,
|
||||
language: string,
|
||||
) {
|
||||
const [settings, setSettings] = useState(defaultGuardEffectSettings)
|
||||
const queue = useEffectQueue<GuardEffect>()
|
||||
const settingsRef = useRef(settings)
|
||||
const lastSequenceRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview || !stream) return
|
||||
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
|
||||
for (const message of pending) {
|
||||
lastSequenceRef.current = message.sequence
|
||||
const envelope = message.envelope as GuardEnvelope
|
||||
if (
|
||||
envelope.type === 'component.settings.snapshot' ||
|
||||
envelope.type === 'component.settings.updated'
|
||||
) {
|
||||
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
|
||||
settingsRef.current = next
|
||||
setSettings(next)
|
||||
continue
|
||||
}
|
||||
const next = toGuardEffect(envelope)
|
||||
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
|
||||
}
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return
|
||||
const currentSettings = previewSettings ?? settingsRef.current
|
||||
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
|
||||
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
|
||||
}, [
|
||||
language,
|
||||
preview,
|
||||
previewMode,
|
||||
previewNonce,
|
||||
previewSettings?.effectDurationMs,
|
||||
previewSettings?.themeId,
|
||||
])
|
||||
|
||||
return {
|
||||
settings,
|
||||
effect: queue.active,
|
||||
pendingCount: queue.pendingCount,
|
||||
completeEffect: queue.complete,
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let result = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result ^= value.charCodeAt(index)
|
||||
result = Math.imul(result, 16777619)
|
||||
}
|
||||
return result >>> 0
|
||||
}
|
||||
|
||||
function JadeGuardVoyage({
|
||||
effect,
|
||||
settings,
|
||||
muted,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: GuardEffect
|
||||
settings: GuardEffectSettings
|
||||
muted: boolean
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const level = guardLevel(effect.payload.guardName)
|
||||
const guard = translate(`gift_menu.guard.${level}`)
|
||||
const viewer =
|
||||
effect.payload.viewer?.name || effect.payload.viewer?.uid || translate('common.viewer')
|
||||
const title = settings.titleTemplate.replaceAll('{guard}', guard)
|
||||
return (
|
||||
<section
|
||||
className={`jade-guard-voyage guard-level-${level}`}
|
||||
aria-label={translate('guard.effect_aria')}
|
||||
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
|
||||
>
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
muted={muted}
|
||||
src={guardVideoUrl(level)}
|
||||
key={`${effect.id}:${level}`}
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={event => {
|
||||
if (event.animationName === 'jade-guard-video-fade') onComplete()
|
||||
}}
|
||||
/>
|
||||
<div className="jade-guard-scroll" aria-live="polite">
|
||||
<strong>{title}</strong>
|
||||
<b>{viewer}</b>
|
||||
<span>{settings.closingText}</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonlitGuardCelebration({
|
||||
effect,
|
||||
settings,
|
||||
onComplete,
|
||||
}: {
|
||||
effect: GuardEffect
|
||||
settings: GuardEffectSettings
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
|
||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||
const guard = effect.payload.guardName || translate('common.guard')
|
||||
return (
|
||||
<section
|
||||
className="guard-celebration"
|
||||
aria-label={translate('guard.effect_aria')}
|
||||
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
|
||||
onComplete()
|
||||
}}
|
||||
>
|
||||
<div className="guard-nebula" />
|
||||
<div className="guard-stars" aria-hidden="true">
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:guard:${index}`)
|
||||
return (
|
||||
<i
|
||||
key={index}
|
||||
style={
|
||||
{
|
||||
left: `${seed % 100}%`,
|
||||
top: `${(seed >>> 8) % 100}%`,
|
||||
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
|
||||
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="guard-halo" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<div className="guard-copy">
|
||||
<span>{translate('guard.salute')}</span>
|
||||
<strong>{translate('guard.title', { guard })}</strong>
|
||||
<b>{translate('guard.viewer', { viewer })}</b>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function GuardEffectOverlay({
|
||||
preview = false,
|
||||
previewSettings,
|
||||
previewMode = 'captain',
|
||||
previewNonce = 0,
|
||||
stream,
|
||||
}: {
|
||||
preview?: boolean
|
||||
previewSettings?: GuardEffectSettings
|
||||
previewMode?: GuardEffectPreviewMode
|
||||
previewNonce?: number
|
||||
stream?: ComponentStream
|
||||
}) {
|
||||
const { language } = useI18n()
|
||||
const remote = useGuardEffect(
|
||||
preview,
|
||||
stream,
|
||||
previewSettings,
|
||||
previewMode,
|
||||
previewNonce,
|
||||
language,
|
||||
)
|
||||
const settings = previewSettings ?? remote.settings
|
||||
const theme = getGuardEffectTheme(settings.themeId)
|
||||
const effect = remote.effect
|
||||
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
|
||||
return (
|
||||
<main
|
||||
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
data-queue-length={remote.pendingCount}
|
||||
style={{
|
||||
...guardThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
}}
|
||||
>
|
||||
{effect &&
|
||||
onComplete &&
|
||||
(theme.id === 'moonlit-water' ? (
|
||||
<MoonlitGuardCelebration
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
settings={settings}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<JadeGuardVoyage
|
||||
key={effect.id}
|
||||
effect={effect}
|
||||
settings={settings}
|
||||
muted={preview}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { GuardEffectThemeId } from './types'
|
||||
|
||||
export type GuardEffectTheme = {
|
||||
id: GuardEffectThemeId
|
||||
nameKey: string
|
||||
descriptionKey: string
|
||||
className: string
|
||||
palette: {
|
||||
cyan: string
|
||||
gold: string
|
||||
rose: string
|
||||
night: string
|
||||
}
|
||||
motion: {
|
||||
reveal: string
|
||||
starPulse: string
|
||||
}
|
||||
}
|
||||
|
||||
export const guardEffectThemes: readonly GuardEffectTheme[] = [
|
||||
{
|
||||
id: 'jade-starfall',
|
||||
nameKey: 'guard.theme.jade_starfall.name',
|
||||
descriptionKey: 'guard.theme.jade_starfall.description',
|
||||
className: 'guard-theme-jade-starfall',
|
||||
palette: {
|
||||
cyan: '#a9fff2',
|
||||
gold: '#ffe7a4',
|
||||
rose: '#ffd0e5',
|
||||
night: '#020c18',
|
||||
},
|
||||
motion: {
|
||||
reveal: 'guard-effect-reveal',
|
||||
starPulse: 'guard-star-pulse',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moonlit-water',
|
||||
nameKey: 'guard.theme.moonlit_water.name',
|
||||
descriptionKey: 'guard.theme.moonlit_water.description',
|
||||
className: 'guard-theme-moonlit-water',
|
||||
palette: {
|
||||
cyan: '#c6d8c6',
|
||||
gold: '#e7c982',
|
||||
rose: '#dbc6a0',
|
||||
night: 'transparent',
|
||||
},
|
||||
motion: {
|
||||
reveal: 'moonlit-guard-reveal',
|
||||
starPulse: 'guard-star-pulse',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
|
||||
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
|
||||
}
|
||||
|
||||
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
|
||||
return getGuardEffectTheme(id).id
|
||||
}
|
||||
|
||||
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
|
||||
return {
|
||||
['--guard-cyan' as string]: theme.palette.cyan,
|
||||
['--guard-gold' as string]: theme.palette.gold,
|
||||
['--guard-rose' as string]: theme.palette.rose,
|
||||
['--guard-night' as string]: theme.palette.night,
|
||||
['--guard-motion-reveal' as string]: theme.motion.reveal,
|
||||
['--guard-motion-star' as string]: theme.motion.starPulse,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SongRequestsPage,
|
||||
} from './control'
|
||||
import { GiftEffectOverlay } from './giftEffect'
|
||||
import { GuardEffectOverlay } from './guardEffect'
|
||||
import { GiftMenuOverlay } from './giftMenu'
|
||||
import { Overlay, tokenFromFragment } from './overlay'
|
||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||
@@ -25,9 +26,14 @@ import { SongRequestOverlay } from './songOverlay'
|
||||
import { useComponentStream } from './stream'
|
||||
import { I18nProvider, translate, useI18n } from './i18n'
|
||||
import type { Session } from './types'
|
||||
import '@fontsource/noto-serif-sc/400.css'
|
||||
import '@fontsource/zcool-xiaowei/400.css'
|
||||
import '@fontsource/lxgw-wenkai/500.css'
|
||||
import './fonts.css'
|
||||
import './style.css'
|
||||
import './control.css'
|
||||
import './giftEffect.css'
|
||||
import './guardEffect.css'
|
||||
import './giftMenu.css'
|
||||
import './song.css'
|
||||
import './themeEdges.css'
|
||||
@@ -44,6 +50,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken
|
||||
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
|
||||
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
|
||||
if (stream.componentKind === 'gift_effect') return <GiftEffectOverlay stream={stream} />
|
||||
if (stream.componentKind === 'guard_effect') return <GuardEffectOverlay stream={stream} />
|
||||
if (stream.componentKind === 'gift_menu') return <GiftMenuOverlay stream={stream} />
|
||||
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
|
||||
return <Overlay stream={stream} />
|
||||
|
||||
+114
-22
@@ -7,7 +7,7 @@
|
||||
* component settings, and malformed frames are ignored without terminating a
|
||||
* long-running browser source.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { translate, useI18n } from './i18n'
|
||||
@@ -16,6 +16,7 @@ import type { OverlayThemeDefinition } from './themes'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type { OverlaySettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
|
||||
type Envelope = {
|
||||
id: string
|
||||
@@ -170,18 +171,19 @@ function useEvents(preview: boolean, stream?: ComponentStream) {
|
||||
chooseDecorVariant(
|
||||
`${envelope.type}:${key}`,
|
||||
theme.ornaments.variantCount,
|
||||
old[0]?.decorVariant,
|
||||
old[old.length - 1]?.decorVariant,
|
||||
)
|
||||
return [
|
||||
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||
const next = [
|
||||
...old.filter(item => item.key !== key),
|
||||
].slice(0, current.maxVisible)
|
||||
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||
]
|
||||
return next.slice(Math.max(0, next.length - current.maxVisible))
|
||||
})
|
||||
}
|
||||
}, [preview, stream, stream?.messages])
|
||||
|
||||
useEffect(() => {
|
||||
setItems(current => current.slice(0, settings.maxVisible))
|
||||
setItems(current => current.slice(Math.max(0, current.length - settings.maxVisible)))
|
||||
}, [settings.maxVisible])
|
||||
|
||||
return { settings, items, setItems }
|
||||
@@ -324,6 +326,7 @@ function Card({
|
||||
<em>¥ {gift.priceCny.toFixed(2)}</em>
|
||||
)}
|
||||
</div>
|
||||
{isDanmaku && <i className="danmaku-lotus" aria-hidden="true" />}
|
||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||
</article>
|
||||
)
|
||||
@@ -332,6 +335,9 @@ function Card({
|
||||
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
|
||||
const { language } = useI18n()
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const cardsRef = useRef<HTMLDivElement>(null)
|
||||
const previousCardTopsRef = useRef(new Map<string, number>())
|
||||
const stackAnimationRef = useRef<Animation | null>(null)
|
||||
const events = useEvents(preview, stream)
|
||||
const { items, setItems } = events
|
||||
const settings = previewSettings || events.settings
|
||||
@@ -353,17 +359,6 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
useEffect(() => {
|
||||
if (preview) {
|
||||
setItems([
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: {
|
||||
viewer: { name: translate('overlay.preview_viewer') },
|
||||
text: translate('overlay.preview_message'),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gift-preview',
|
||||
key: 'gift-preview',
|
||||
@@ -382,12 +377,27 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: {
|
||||
viewer: { name: translate('overlay.preview_viewer') },
|
||||
text: translate('overlay.preview_message'),
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [items.length, language, preview, setItems])
|
||||
|
||||
useEffect(() => {
|
||||
const newest = items[0]
|
||||
if (!settings.expandNewDanmaku) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
}
|
||||
const newest = items[items.length - 1]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
@@ -399,12 +409,90 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [items, settings.collapseAfterSeconds, shape])
|
||||
}, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const cards = cardsRef.current
|
||||
if (!cards) return
|
||||
|
||||
const elements = Array.from(cards.children).filter(
|
||||
(element): element is HTMLElement => element instanceof HTMLElement,
|
||||
)
|
||||
const previousTops = previousCardTopsRef.current
|
||||
const hasNewDanmaku = items.some(
|
||||
item => item.type === 'live.danmaku' && !previousTops.has(item.key),
|
||||
)
|
||||
|
||||
// If another push is still running, carry its current offset into the
|
||||
// next FLIP animation so bursts of chat do not snap between positions.
|
||||
let runningOffset = 0
|
||||
const transform = getComputedStyle(cards).transform
|
||||
if (transform !== 'none') {
|
||||
try {
|
||||
runningOffset = new DOMMatrixReadOnly(transform).m42
|
||||
} catch {
|
||||
runningOffset = 0
|
||||
}
|
||||
}
|
||||
stackAnimationRef.current?.cancel()
|
||||
|
||||
const currentTops = new Map<string, number>()
|
||||
items.forEach((item, index) => {
|
||||
const element = elements[index]
|
||||
if (element) currentTops.set(item.key, element.getBoundingClientRect().top)
|
||||
})
|
||||
|
||||
const reducedMotion =
|
||||
settings.lowPerformanceMode ||
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
if (!settings.expandNewDanmaku && hasNewDanmaku && !reducedMotion && elements.length > 0) {
|
||||
const anchorIndex = items.findIndex(item => previousTops.has(item.key))
|
||||
const anchor = anchorIndex >= 0 ? elements[anchorIndex] : undefined
|
||||
const anchorKey = anchorIndex >= 0 ? items[anchorIndex]?.key : undefined
|
||||
const previousTop = anchorKey ? previousTops.get(anchorKey) : undefined
|
||||
const currentTop = anchor?.getBoundingClientRect().top
|
||||
const gap = Number.parseFloat(getComputedStyle(cards).rowGap) || 0
|
||||
const fallbackOffset = cards.getBoundingClientRect().height + gap
|
||||
const pushOffset = Math.max(
|
||||
0,
|
||||
previousTop !== undefined && currentTop !== undefined
|
||||
? previousTop + runningOffset - currentTop
|
||||
: fallbackOffset,
|
||||
)
|
||||
|
||||
if (pushOffset > 0.5) {
|
||||
const duration = 350 + settings.motionIntensity * 3.5
|
||||
const animation = cards.animate(
|
||||
[
|
||||
{ transform: `translate3d(0, ${pushOffset}px, 0)` },
|
||||
{ transform: 'translate3d(0, 0, 0)' },
|
||||
],
|
||||
{
|
||||
duration,
|
||||
easing: 'cubic-bezier(0.2, 0.82, 0.24, 1)',
|
||||
},
|
||||
)
|
||||
stackAnimationRef.current = animation
|
||||
animation.addEventListener('finish', () => {
|
||||
if (stackAnimationRef.current === animation) stackAnimationRef.current = null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
previousCardTopsRef.current = currentTops
|
||||
}, [items, settings.expandNewDanmaku, settings.lowPerformanceMode, settings.motionIntensity])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stackAnimationRef.current?.cancel()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`overlay ${theme.className} ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
className={`overlay ${theme.className} ${shape} ${settings.expandNewDanmaku ? '' : 'stack-arrival'} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
style={
|
||||
@@ -413,6 +501,9 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-viewer-color' as string]: settings.viewerColor || undefined,
|
||||
['--component-danmaku-color' as string]: settings.danmakuColor || undefined,
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--component-decoration-rail' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
||||
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
||||
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
||||
@@ -422,13 +513,14 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section className="wall">
|
||||
<div className="cards">
|
||||
<div className="cards" ref={cardsRef}>
|
||||
{items.map(item => (
|
||||
<Card
|
||||
item={item}
|
||||
settings={settings}
|
||||
expanded={item.key === expandedKey}
|
||||
expanded={settings.expandNewDanmaku && item.key === expandedKey}
|
||||
theme={theme}
|
||||
key={item.key}
|
||||
/>
|
||||
|
||||
+12
-58
@@ -26,7 +26,7 @@ body,
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
border: var(--component-decoration-line, 1px) solid var(--theme-card-border);
|
||||
border-radius: clamp(8px, 1.5vmin, 15px);
|
||||
background: var(--theme-card-background);
|
||||
box-shadow:
|
||||
@@ -60,7 +60,7 @@ body,
|
||||
place-items: center;
|
||||
width: clamp(30px, 3em, 46px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(133, 255, 232, 0.5);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.5);
|
||||
border-radius: 50%;
|
||||
color: var(--theme-accent);
|
||||
background: rgba(6, 48, 57, 0.72);
|
||||
@@ -73,8 +73,7 @@ body,
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song-current-copy,
|
||||
.song-score {
|
||||
.song-current-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
@@ -83,15 +82,13 @@ body,
|
||||
}
|
||||
|
||||
.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 {
|
||||
.song-current-copy small {
|
||||
color: var(--component-song-requester-color, var(--theme-compact-user));
|
||||
font-size: 0.68em;
|
||||
}
|
||||
@@ -104,16 +101,6 @@ body,
|
||||
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;
|
||||
@@ -127,7 +114,7 @@ body,
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.38em 0.72em;
|
||||
border-bottom: 1px solid rgba(133, 255, 232, 0.16);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(133, 255, 232, 0.16);
|
||||
color: var(--theme-compact-user);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
@@ -161,7 +148,7 @@ body,
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
padding: 0.4em 0.62em;
|
||||
border: 1px solid rgba(128, 238, 218, 0.13);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(128, 238, 218, 0.13);
|
||||
border-radius: 0.62em;
|
||||
background: linear-gradient(90deg, rgba(8, 47, 59, 0.74), rgba(8, 71, 72, 0.46));
|
||||
overflow: hidden;
|
||||
@@ -359,13 +346,6 @@ body,
|
||||
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;
|
||||
@@ -419,14 +399,14 @@ body,
|
||||
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);
|
||||
border-bottom: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.26);
|
||||
animation: moonlit-song-current 0.68s ease-out both;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-mark {
|
||||
position: relative;
|
||||
width: clamp(46px, 3.7em, 68px);
|
||||
border: 1px solid rgba(198, 174, 122, 0.62);
|
||||
border: var(--component-decoration-line, 1px) solid rgba(198, 174, 122, 0.62);
|
||||
border-radius: 50%;
|
||||
color: #d2b468;
|
||||
background: radial-gradient(circle, rgba(198, 174, 122, 0.08), transparent 68%);
|
||||
@@ -443,8 +423,8 @@ body,
|
||||
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: var(--component-decoration-line, 1px) solid rgba(116, 166, 157, 0.52);
|
||||
border-width: var(--component-decoration-line, 1px) 0 0 var(--component-decoration-line, 1px);
|
||||
border-radius: 100% 0;
|
||||
transform: rotate(-24deg);
|
||||
}
|
||||
@@ -458,14 +438,12 @@ body,
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-copy,
|
||||
.theme-moonlit-water .song-score {
|
||||
.theme-moonlit-water .song-current-copy {
|
||||
gap: 0.18em;
|
||||
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-current-copy small,
|
||||
.theme-moonlit-water .song-score small,
|
||||
.theme-moonlit-water .song-requester,
|
||||
.theme-moonlit-water .song-queue > header {
|
||||
color: var(--component-song-requester-color, #a9b79b);
|
||||
@@ -480,27 +458,10 @@ body,
|
||||
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;
|
||||
@@ -510,7 +471,7 @@ body,
|
||||
}
|
||||
|
||||
.theme-moonlit-water .song-waveform i {
|
||||
width: 2px;
|
||||
width: var(--component-decoration-line, 2px);
|
||||
height: 25%;
|
||||
background: #6a9f99;
|
||||
animation: moonlit-song-wave 1.8s ease-in-out infinite alternate;
|
||||
@@ -634,13 +595,6 @@ body,
|
||||
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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate, useI18n } from './i18n'
|
||||
import type { ComponentStream } from './stream'
|
||||
import { getOverlayTheme, themeCssVariables } from './themes'
|
||||
import type { OverlayThemeDefinition } from './themes'
|
||||
import { ThemeEdges } from './ThemeEdges'
|
||||
import { defaultSongRequestSettings } from './types'
|
||||
import type { SongRequestItem, SongRequestSettings } from './types'
|
||||
import { typographyVariables } from './typography'
|
||||
@@ -38,8 +39,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
||||
queuePosition: 0,
|
||||
requestedAt: now,
|
||||
startedAt: now,
|
||||
averageScore: 4.8,
|
||||
ratingCount: 26,
|
||||
},
|
||||
queued: titles.map((title, index) => ({
|
||||
id: `preview-${index}`,
|
||||
@@ -48,7 +47,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
||||
status: 'queued',
|
||||
queuePosition: index + 1,
|
||||
requestedAt: now,
|
||||
ratingCount: 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -198,14 +196,6 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
|
||||
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 (
|
||||
@@ -324,6 +314,8 @@ export function SongRequestOverlay({
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
['--component-song-requester-color' as string]: settings.requesterColor || undefined,
|
||||
['--component-song-title-color' as string]: settings.songTitleColor || undefined,
|
||||
['--component-decoration-line' as string]: `${settings.decorationLineWeight / 100}px`,
|
||||
['--component-decoration-edge-stroke' as string]: `${settings.decorationLineWeight / 50}px`,
|
||||
['--song-font-size' as string]: `${Math.min(22, Math.max(11, Math.min(bounds.width, bounds.height) * 0.024)) * (settings.fontScale / 100)}px`,
|
||||
['--song-current-min' as string]: `${Math.min(72, Math.max(48, bounds.height * 0.08))}px`,
|
||||
['--song-moonlit-font-size' as string]: `${Math.min(26, Math.max(14, Math.min(bounds.width * 0.045, bounds.height * 0.032))) * (settings.fontScale / 100)}px`,
|
||||
@@ -331,6 +323,7 @@ export function SongRequestOverlay({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThemeEdges />
|
||||
<section
|
||||
className="song-current"
|
||||
aria-label={translate('song.overlay.current_aria')}
|
||||
@@ -354,14 +347,10 @@ export function SongRequestOverlay({
|
||||
<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 className="song-waveform" aria-hidden="true">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<i key={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
|
||||
|
||||
@@ -39,7 +39,8 @@ body {
|
||||
position: relative;
|
||||
min-height: 58px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid var(--theme-card-border, rgba(101, 226, 211, 0.28));
|
||||
border: var(--component-decoration-line, 1px) solid
|
||||
var(--theme-card-border, rgba(101, 226, 211, 0.28));
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
@@ -1,70 +1,101 @@
|
||||
/* 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. */
|
||||
It uses inline SVG so the control setting changes actual stroke width rather
|
||||
than vertically stretching a thin bitmap/mask. */
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
) {
|
||||
--moonlit-edge-height: clamp(12px, 2.2vmin, 24px);
|
||||
--moonlit-edge-height: clamp(22px, 3.6vmin, 44px);
|
||||
--moonlit-edge-offset: clamp(4px, 0.65vmin, 8px);
|
||||
--moonlit-edge-space: clamp(10px, 1.8vmin, 20px);
|
||||
--moonlit-edge-space: clamp(16px, 2.5vmin, 30px);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.theme-edge-set {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::before,
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::after {
|
||||
content: '';
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)
|
||||
> .theme-edge-set {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: clamp(8px, 1.4vmin, 18px);
|
||||
right: clamp(8px, 1.4vmin, 18px);
|
||||
height: var(--moonlit-edge-height);
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
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));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.theme-edge-art {
|
||||
position: absolute;
|
||||
left: clamp(8px, 1.4vmin, 18px);
|
||||
width: calc(100% - clamp(16px, 2.8vmin, 36px));
|
||||
height: var(--moonlit-edge-height);
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 3px rgba(205, 172, 88, 0.28));
|
||||
animation: moonlit-edge-breathe 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::before {
|
||||
.theme-edge-top {
|
||||
top: var(--moonlit-edge-offset);
|
||||
}
|
||||
|
||||
:is(
|
||||
.overlay.theme-moonlit-water,
|
||||
.song-overlay.theme-moonlit-water,
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water
|
||||
)::after {
|
||||
.theme-edge-bottom {
|
||||
bottom: var(--moonlit-edge-offset);
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -3.5s;
|
||||
}
|
||||
|
||||
.theme-edge-rule,
|
||||
.theme-edge-water {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: var(--component-decoration-edge-stroke, 3.2px);
|
||||
}
|
||||
|
||||
.theme-edge-rule path,
|
||||
.theme-edge-water path {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.theme-edge-rule {
|
||||
stroke: #d2b35f;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.theme-edge-water {
|
||||
stroke: #5f978b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.theme-edge-cloud {
|
||||
fill: #568b80;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.theme-edge-leaves {
|
||||
fill: #86aa92;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.theme-edge-lotus {
|
||||
fill: #d4b45d;
|
||||
opacity: 0.96;
|
||||
filter: drop-shadow(0 0 2px rgba(221, 190, 104, 0.36));
|
||||
}
|
||||
|
||||
.theme-edge-dots {
|
||||
fill: #e4c978;
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.overlay.theme-moonlit-water {
|
||||
padding-block: calc(clamp(14px, 2.3vw, 34px) + var(--moonlit-edge-space));
|
||||
}
|
||||
@@ -74,30 +105,23 @@
|
||||
}
|
||||
|
||||
.gift-menu-overlay.gift-menu-theme-moonlit-water {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-block: calc(clamp(8px, 1.3vmin, 16px) + var(--moonlit-edge-space));
|
||||
}
|
||||
|
||||
@keyframes moonlit-edge-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.56;
|
||||
opacity: 0.78;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.78;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
.theme-edge-art {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function themeCssVariables(
|
||||
'--theme-pattern-cluster': `url("${theme.ornaments.patterns.cluster}")`,
|
||||
'--theme-pattern-vine': `url("${theme.ornaments.patterns.vine}")`,
|
||||
'--theme-pattern-edge': `url("${theme.ornaments.patterns.edge}")`,
|
||||
'--theme-pattern-lotus': `url("${theme.ornaments.patterns.lotus}")`,
|
||||
'--theme-motion-arrive': theme.motion.arrive,
|
||||
'--theme-motion-sheen': theme.motion.sheen,
|
||||
'--theme-motion-featured': theme.motion.featured,
|
||||
|
||||
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
|
||||
const cluster = '/assets/floral-cluster.svg'
|
||||
const vine = '/assets/floral-vine.svg'
|
||||
const edge = '/assets/moonlit-edge.svg'
|
||||
const lotus = '/assets/lotus-mark.svg'
|
||||
|
||||
/** The original ancient-style glass renderer, captured as the first theme. */
|
||||
export const jadeScrollTheme: OverlayThemeDefinition = {
|
||||
@@ -38,7 +39,7 @@ export const jadeScrollTheme: OverlayThemeDefinition = {
|
||||
'star',
|
||||
'floret',
|
||||
],
|
||||
patterns: { divider, cluster, vine, edge },
|
||||
patterns: { divider, cluster, vine, edge, lotus },
|
||||
},
|
||||
motion: {
|
||||
arrive: 'arrive',
|
||||
|
||||
@@ -4,6 +4,7 @@ const divider = '/assets/floral-divider.svg'
|
||||
const cluster = '/assets/floral-cluster.svg'
|
||||
const vine = '/assets/floral-vine.svg'
|
||||
const edge = '/assets/moonlit-edge.svg'
|
||||
const lotus = '/assets/lotus-mark.svg'
|
||||
|
||||
/**
|
||||
* A deliberately sparse, text-led theme inspired by moonlight on still water.
|
||||
@@ -31,7 +32,7 @@ export const moonlitWaterTheme: OverlayThemeDefinition = {
|
||||
ornaments: {
|
||||
variantCount: 4,
|
||||
particles: ['star', 'floret', 'star', 'floret', 'star', 'star'],
|
||||
patterns: { divider, cluster, vine, edge },
|
||||
patterns: { divider, cluster, vine, edge, lotus },
|
||||
},
|
||||
motion: {
|
||||
arrive: 'moonlit-line-arrive',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type OverlayThemeDefinition = {
|
||||
cluster: string
|
||||
vine: string
|
||||
edge: string
|
||||
lotus: string
|
||||
}
|
||||
}
|
||||
motion: {
|
||||
|
||||
+43
-11
@@ -15,6 +15,7 @@ export type OverlaySettings = {
|
||||
viewerColor: string | null
|
||||
danmakuColor: string | null
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
showDanmaku: boolean
|
||||
showEnter: boolean
|
||||
showGift: boolean
|
||||
@@ -23,6 +24,7 @@ export type OverlaySettings = {
|
||||
showLike: boolean
|
||||
showShare: boolean
|
||||
maxVisible: number
|
||||
expandNewDanmaku: boolean
|
||||
collapseAfterSeconds: number
|
||||
unfoldDurationMs: number
|
||||
motionIntensity: number
|
||||
@@ -40,6 +42,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
viewerColor: null,
|
||||
danmakuColor: null,
|
||||
fontScale: 140,
|
||||
decorationLineWeight: 160,
|
||||
showDanmaku: true,
|
||||
showEnter: true,
|
||||
showGift: true,
|
||||
@@ -48,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
showLike: false,
|
||||
showShare: false,
|
||||
maxVisible: 5,
|
||||
expandNewDanmaku: true,
|
||||
collapseAfterSeconds: 12,
|
||||
unfoldDurationMs: 1000,
|
||||
motionIntensity: 70,
|
||||
@@ -66,6 +70,7 @@ export type SongRequestSettings = {
|
||||
requesterColor: string | null
|
||||
songTitleColor: string | null
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
scrollSpeedPixelsPerSecond: number
|
||||
edgePauseSeconds: number
|
||||
/** Zero keeps the corresponding business limit disabled. */
|
||||
@@ -81,6 +86,7 @@ export const defaultSongRequestSettings: SongRequestSettings = {
|
||||
requesterColor: null,
|
||||
songTitleColor: null,
|
||||
fontScale: 100,
|
||||
decorationLineWeight: 160,
|
||||
scrollSpeedPixelsPerSecond: 28,
|
||||
edgePauseSeconds: 2,
|
||||
maxQueueSize: 0,
|
||||
@@ -96,7 +102,7 @@ export type MeteorTierSettings = {
|
||||
speed: number
|
||||
}
|
||||
|
||||
/** Full-viewport visual settings for gift meteors and guard celebrations. */
|
||||
/** Full-viewport visual settings for gifts. */
|
||||
export type GiftEffectSettings = {
|
||||
themeId: GiftEffectThemeId
|
||||
fontFamily: FontFamilyId
|
||||
@@ -107,9 +113,7 @@ export type GiftEffectSettings = {
|
||||
high: MeteorTierSettings
|
||||
featured: MeteorTierSettings
|
||||
trailIntensity: number
|
||||
guardStarCount: number
|
||||
guardEffectDurationMs: number
|
||||
maxConcurrentEffects: number
|
||||
queueCapacity: number
|
||||
lowPerformanceMode: boolean
|
||||
}
|
||||
|
||||
@@ -123,9 +127,34 @@ export const defaultGiftEffectSettings: GiftEffectSettings = {
|
||||
high: { count: 6, size: 126, speed: 720 },
|
||||
featured: { count: 10, size: 168, speed: 880 },
|
||||
trailIntensity: 78,
|
||||
guardStarCount: 48,
|
||||
guardEffectDurationMs: 5_200,
|
||||
maxConcurrentEffects: 8,
|
||||
queueCapacity: 256,
|
||||
lowPerformanceMode: false,
|
||||
}
|
||||
|
||||
export type GuardEffectThemeId = 'jade-starfall' | 'moonlit-water'
|
||||
|
||||
/** Full-viewport visual settings for membership-purchase celebrations. */
|
||||
export type GuardEffectSettings = {
|
||||
themeId: GuardEffectThemeId
|
||||
fontFamily: FontFamilyId
|
||||
fontBrightness: number
|
||||
starCount: number
|
||||
effectDurationMs: number
|
||||
titleTemplate: string
|
||||
closingText: string
|
||||
queueCapacity: number
|
||||
lowPerformanceMode: boolean
|
||||
}
|
||||
|
||||
export const defaultGuardEffectSettings: GuardEffectSettings = {
|
||||
themeId: 'jade-starfall',
|
||||
fontFamily: 'fang-song',
|
||||
fontBrightness: 130,
|
||||
starCount: 48,
|
||||
effectDurationMs: 5_200,
|
||||
titleTemplate: '{guard}启航',
|
||||
closingText: '相伴前行',
|
||||
queueCapacity: 256,
|
||||
lowPerformanceMode: false,
|
||||
}
|
||||
|
||||
@@ -169,6 +198,7 @@ export type GiftMenuSettings = {
|
||||
pageIntervalMs: number
|
||||
highlightDurationMs: number
|
||||
fontScale: number
|
||||
decorationLineWeight: number
|
||||
motionIntensity: number
|
||||
lowPerformanceMode: boolean
|
||||
}
|
||||
@@ -184,12 +214,17 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
|
||||
pageIntervalMs: 6_000,
|
||||
highlightDurationMs: 3_800,
|
||||
fontScale: 100,
|
||||
decorationLineWeight: 160,
|
||||
motionIntensity: 78,
|
||||
lowPerformanceMode: false,
|
||||
}
|
||||
|
||||
export type ComponentSettings =
|
||||
OverlaySettings | SongRequestSettings | GiftEffectSettings | GiftMenuSettings
|
||||
| OverlaySettings
|
||||
| SongRequestSettings
|
||||
| GiftEffectSettings
|
||||
| GuardEffectSettings
|
||||
| GiftMenuSettings
|
||||
|
||||
export type SongRequester = { uid: string; name: string }
|
||||
|
||||
@@ -202,8 +237,6 @@ export type SongRequestItem = {
|
||||
requestedAt: string
|
||||
startedAt?: string | null
|
||||
finishedAt?: string | null
|
||||
averageScore?: number | null
|
||||
ratingCount: number
|
||||
}
|
||||
|
||||
export type SongQueueSummary = {
|
||||
@@ -211,7 +244,6 @@ export type SongQueueSummary = {
|
||||
queuedCount: number
|
||||
completedCount: number
|
||||
cancelledCount: number
|
||||
ratingCount: number
|
||||
}
|
||||
|
||||
export type SongRequestPage = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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
|
||||
/** Stable IDs persisted by the backend; each maps to a bundled, same-origin webfont. */
|
||||
export const fontFamilyIds = ['song', 'fang-song', 'kai', 'liyu-shoushu'] as const
|
||||
|
||||
export type FontFamilyId = (typeof fontFamilyIds)[number]
|
||||
|
||||
@@ -9,12 +9,14 @@ export const fontFamilies: ReadonlyArray<{ id: FontFamilyId; nameKey: string }>
|
||||
{ id: 'song', nameKey: 'settings.font_family.song' },
|
||||
{ id: 'fang-song', nameKey: 'settings.font_family.fang_song' },
|
||||
{ id: 'kai', nameKey: 'settings.font_family.kai' },
|
||||
{ id: 'liyu-shoushu', nameKey: 'settings.font_family.liyu_shoushu' },
|
||||
]
|
||||
|
||||
const fontStacks: Record<FontFamilyId, string> = {
|
||||
song: '"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',
|
||||
song: '"Noto Serif SC", serif',
|
||||
'fang-song': '"ZCOOL XiaoWei", serif',
|
||||
kai: '"LXGW WenKai", serif',
|
||||
'liyu-shoushu': '"Liyu Shoushu", serif',
|
||||
}
|
||||
|
||||
export function normalizeFontFamilyId(value: unknown): FontFamilyId {
|
||||
|
||||
@@ -14,6 +14,13 @@ import { parse } from 'smol-toml'
|
||||
// The waiting worker is still activated explicitly by the user in the console.
|
||||
const pwaBuildId = Date.now().toString(36)
|
||||
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
|
||||
const bundledFontLicenses = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai'].map(font => ({
|
||||
fileName: `fonts/licenses/${font}-OFL.txt`,
|
||||
source: readFileSync(
|
||||
new URL(`./node_modules/@fontsource/${font}/LICENSE`, import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
}))
|
||||
const i18nResource = parse(
|
||||
readFileSync(new URL('../../resources/i18n.toml', import.meta.url), 'utf8'),
|
||||
) as unknown as {
|
||||
@@ -51,6 +58,16 @@ const manifest = {
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'same-origin-woff2-fonts',
|
||||
enforce: 'pre',
|
||||
transform(source, id) {
|
||||
if (!id.includes('/node_modules/@fontsource/') || !id.endsWith('.css')) return
|
||||
return source
|
||||
.replaceAll('font-display: swap', 'font-display: block')
|
||||
.replace(/,\s*url\([^)]*\.woff\) format\(['"]woff['"]\)/g, '')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'control-pwa-assets',
|
||||
transformIndexHtml(html) {
|
||||
@@ -61,6 +78,9 @@ export default defineConfig({
|
||||
.replaceAll('__APP_DESCRIPTION__', defaultMessages['pwa.manifest.description'])
|
||||
},
|
||||
generateBundle() {
|
||||
for (const license of bundledFontLicenses) {
|
||||
this.emitFile({ type: 'asset', ...license })
|
||||
}
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'control/sw.js',
|
||||
|
||||
Generated
+1
@@ -956,6 +956,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
[[package]]
|
||||
name = "libilibili"
|
||||
version = "0.1.0"
|
||||
source = "git+http://gitea.home.arpa/felis/libilibili.git#3cb2a902d4d9387c5581a1827cc19248b6000ae2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"brotli",
|
||||
|
||||
@@ -7,20 +7,30 @@ edition = "2024"
|
||||
axum = { version = "0.8", features = ["ws", "json"] }
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
chrono = { version = "0.4", default-features = false, features = [
|
||||
"clock",
|
||||
"serde",
|
||||
] }
|
||||
chacha20poly1305 = "0.10"
|
||||
deadpool-postgres = "0.14"
|
||||
# Kept as a sibling checkout during development. Docker Compose supplies the
|
||||
# same directory as a named BuildKit context at `/libilibili`.
|
||||
libilibili = { path = "../../../libilibili" }
|
||||
libilibili = { git = "http://gitea.home.arpa/felis/libilibili.git" }
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-serde_json-1", "with-uuid-1", "with-chrono-0_4"] }
|
||||
tokio-postgres = { version = "0.7", features = [
|
||||
"with-serde_json-1",
|
||||
"with-uuid-1",
|
||||
"with-chrono-0_4",
|
||||
] }
|
||||
tokio-util = "0.7"
|
||||
toml = "0.8"
|
||||
totp-rs = { version = "5.7", features = ["gen_secret", "qr", "zeroize"] }
|
||||
|
||||
@@ -21,14 +21,16 @@ crate 导出。
|
||||
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
||||
| `realtime` | account event routing 与 component-scoped fanout |
|
||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
||||
| `song_request` | 点歌命令、事务队列、快照和管理服务 |
|
||||
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||
| `guard_effect` | 独立大航海主题、感谢文案与 `live.guard.buy` 事件订阅 |
|
||||
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
||||
|
||||
## 重要不变量
|
||||
|
||||
- handler 不能信任请求体中的 owner;owner 必须来自 session 或账户 source context。
|
||||
- `component_instances` 不保存 source 绑定;账户唯一的监听事件会按 owner 提供给其全部启用组件。
|
||||
- 同一组件 kind 可以有多个实例;settings、token、持久状态和广播通道必须继续按 component ID 隔离。
|
||||
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
||||
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
||||
- `libilibili` listener 必须保持 20 秒心跳;断线后由 adapter 重新获取弹幕 host/token 并重建 socket。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Short-lived, tenant-owned TOTP replacement enrollments.
|
||||
--
|
||||
-- The current TOTP secret remains authoritative until the replacement secret
|
||||
-- has produced a valid code. Raw enrollment tokens are never persisted, and
|
||||
-- pending secrets use the same authenticated encryption boundary as account
|
||||
-- TOTP secrets.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_totp_resets (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
enrollment_token_digest BYTEA NOT NULL UNIQUE
|
||||
CHECK (octet_length(enrollment_token_digest) = 32),
|
||||
totp_secret_ciphertext BYTEA NOT NULL
|
||||
CHECK (octet_length(totp_secret_ciphertext) >= 16),
|
||||
totp_secret_nonce BYTEA NOT NULL
|
||||
CHECK (octet_length(totp_secret_nonce) = 24),
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (user_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pending_totp_resets_expiry_idx
|
||||
ON pending_totp_resets(expires_at);
|
||||
|
||||
ALTER TABLE pending_totp_resets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE pending_totp_resets FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS pending_totp_resets_owner ON pending_totp_resets;
|
||||
CREATE POLICY pending_totp_resets_owner ON pending_totp_resets
|
||||
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
|
||||
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Built-in component kinds are instance-based. An account may create multiple
|
||||
-- instances of the same kind, each with independent settings, OBS credentials,
|
||||
-- realtime fanout, and component-owned durable state.
|
||||
|
||||
DROP INDEX IF EXISTS component_instances_single_song_request;
|
||||
DROP INDEX IF EXISTS component_instances_single_gift_effect;
|
||||
DROP INDEX IF EXISTS component_instances_single_gift_menu;
|
||||
|
||||
COMMENT ON TABLE component_instances IS
|
||||
'Tenant-owned component instances. Multiple rows of the same kind may belong to one account.';
|
||||
@@ -301,6 +301,11 @@ async fn migrate(db: &Db) -> Result<(), String> {
|
||||
),
|
||||
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
|
||||
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
|
||||
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
|
||||
(
|
||||
12_i32,
|
||||
include_str!("../migrations/012_component_instances.sql"),
|
||||
),
|
||||
] {
|
||||
let applied = transaction
|
||||
.query_one(
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::{
|
||||
db::{Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
|
||||
i18n,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
@@ -669,6 +670,23 @@ impl AuthService {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let guard_component_id = Uuid::new_v4();
|
||||
let guard_settings = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable");
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&guard_component_id,
|
||||
&user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let menu_component_id = Uuid::new_v4();
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.expect("GiftMenuSettings is always JSON serializable");
|
||||
@@ -891,6 +909,251 @@ impl AuthService {
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
/// Begin replacement of the current account's TOTP secret after explicit
|
||||
/// step-up authentication. The existing secret remains valid until
|
||||
/// `totp_reset_confirm` commits the replacement.
|
||||
pub async fn totp_reset_start(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
current_code: &str,
|
||||
) -> Result<TotpResetStart, AuthError> {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT username,room_id,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \
|
||||
FROM users WHERE id=$1 AND status='active' FOR UPDATE",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
let username: String = row.get(0);
|
||||
let room_id: String = row.get(1);
|
||||
let proof_kind = if is_totp_code(current_code) {
|
||||
let secret = self.cipher.decrypt(
|
||||
&EncryptedSecret {
|
||||
ciphertext: row.get(2),
|
||||
nonce: row.get(3),
|
||||
},
|
||||
format!("user-totp:{user_id}").as_bytes(),
|
||||
)?;
|
||||
let accepted_step = accepted_totp_step(
|
||||
&secret,
|
||||
&self.issuer,
|
||||
&username,
|
||||
current_code,
|
||||
Utc::now().timestamp(),
|
||||
row.get(4),
|
||||
)?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE users SET last_totp_step=$1,updated_at=now() \
|
||||
WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)",
|
||||
&[&accepted_step, &user_id],
|
||||
)
|
||||
.await?;
|
||||
if changed != 1 {
|
||||
return Err(AuthError::TotpReplay);
|
||||
}
|
||||
"totp"
|
||||
} else {
|
||||
let recovery_digest = token_digest(current_code.trim());
|
||||
let recovery_id = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM recovery_codes \
|
||||
WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE",
|
||||
&[&user_id, &recovery_digest],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::InvalidCredentials)?
|
||||
.get::<_, Uuid>(0);
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
|
||||
&[&recovery_id],
|
||||
)
|
||||
.await?;
|
||||
"recovery"
|
||||
};
|
||||
|
||||
let reset_id = Uuid::new_v4();
|
||||
let enrollment_token = random_token("totp-reset", 32);
|
||||
let enrollment_digest = token_digest(&enrollment_token);
|
||||
let secret = Secret::generate_secret();
|
||||
let secret_bytes = secret
|
||||
.to_bytes()
|
||||
.map_err(|error| AuthError::Totp(error.to_string()))?;
|
||||
let encoded = secret.to_encoded();
|
||||
let encoded_secret = match &encoded {
|
||||
Secret::Encoded(value) => value.clone(),
|
||||
Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"),
|
||||
};
|
||||
let totp = build_totp(&secret_bytes, &self.issuer, &username)?;
|
||||
let otpauth_uri = totp.get_url();
|
||||
let qr = totp
|
||||
.get_qr_base64()
|
||||
.map_err(|error| AuthError::Totp(error.to_string()))?;
|
||||
let qr_data_url = if qr.starts_with("data:") {
|
||||
qr
|
||||
} else {
|
||||
format!("data:image/png;base64,{qr}")
|
||||
};
|
||||
let encrypted = self.cipher.encrypt(
|
||||
&secret_bytes,
|
||||
format!("pending-totp-reset:{reset_id}").as_bytes(),
|
||||
)?;
|
||||
let expires_at = future_time(self.enrollment_ttl)?;
|
||||
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"DELETE FROM pending_totp_resets WHERE user_id=$1",
|
||||
&[&user_id],
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO pending_totp_resets \
|
||||
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
|
||||
VALUES($1,$2,$3,$4,$5,$6)",
|
||||
&[
|
||||
&reset_id,
|
||||
&user_id,
|
||||
&enrollment_digest,
|
||||
&encrypted.ciphertext,
|
||||
&encrypted.nonce,
|
||||
&expires_at,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"auth.totp_reset.started",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"proof":proof_kind}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(TotpResetStart {
|
||||
enrollment_token,
|
||||
username,
|
||||
room_id,
|
||||
secret: encoded_secret,
|
||||
otpauth_uri,
|
||||
qr_data_url,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Confirm a pending replacement, rotate recovery codes, and revoke every
|
||||
/// other browser session atomically. The session performing the reset is
|
||||
/// retained so it can display the one-time recovery codes.
|
||||
pub async fn totp_reset_confirm(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
current_session_id: Uuid,
|
||||
enrollment_token: &str,
|
||||
new_totp_code: &str,
|
||||
) -> Result<Vec<String>, AuthError> {
|
||||
let digest = token_digest(enrollment_token.trim());
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
let row = transaction
|
||||
.query_opt(
|
||||
"SELECT r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
|
||||
r.failed_attempts,u.username \
|
||||
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
|
||||
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
|
||||
&[&user_id, &digest],
|
||||
)
|
||||
.await?
|
||||
.ok_or(AuthError::TotpResetUnavailable)?;
|
||||
let reset_id: Uuid = row.get(0);
|
||||
let expires_at: DateTime<Utc> = row.get(3);
|
||||
let failed_attempts: i32 = row.get(4);
|
||||
if expires_at <= Utc::now() {
|
||||
return Err(AuthError::TotpResetUnavailable);
|
||||
}
|
||||
let username: String = row.get(5);
|
||||
let secret = self.cipher.decrypt(
|
||||
&EncryptedSecret {
|
||||
ciphertext: row.get(1),
|
||||
nonce: row.get(2),
|
||||
},
|
||||
format!("pending-totp-reset:{reset_id}").as_bytes(),
|
||||
)?;
|
||||
let accepted_step = accepted_totp_step(
|
||||
&secret,
|
||||
&self.issuer,
|
||||
&username,
|
||||
new_totp_code,
|
||||
Utc::now().timestamp(),
|
||||
None,
|
||||
)?;
|
||||
let Some(accepted_step) = accepted_step else {
|
||||
if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES {
|
||||
transaction
|
||||
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
|
||||
.await?;
|
||||
} else {
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
|
||||
&[&reset_id],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
return Err(AuthError::InvalidTotp);
|
||||
};
|
||||
|
||||
let encrypted = self
|
||||
.cipher
|
||||
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
|
||||
let updated = transaction
|
||||
.execute(
|
||||
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
|
||||
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
|
||||
WHERE id=$4 AND status='active'",
|
||||
&[
|
||||
&encrypted.ciphertext,
|
||||
&encrypted.nonce,
|
||||
&accepted_step,
|
||||
&user_id,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if updated != 1 {
|
||||
return Err(AuthError::TotpResetUnavailable);
|
||||
}
|
||||
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
||||
let revoked_sessions = transaction
|
||||
.execute(
|
||||
"UPDATE user_sessions SET revoked_at=now() \
|
||||
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
|
||||
&[&user_id, ¤t_session_id],
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
|
||||
.await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"auth.totp_reset.completed",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"revokedSessions":revoked_sessions}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(recovery_codes)
|
||||
}
|
||||
|
||||
pub async fn authenticate_session(
|
||||
&self,
|
||||
raw_session_token: &str,
|
||||
@@ -1510,6 +1773,11 @@ fn accepted_totp_step(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_totp_code(code: &str) -> bool {
|
||||
let code = code.trim();
|
||||
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
async fn require_system_admin(
|
||||
transaction: &Transaction<'_>,
|
||||
user_id: Uuid,
|
||||
@@ -1790,6 +2058,18 @@ pub struct RegistrationStart {
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TotpResetStart {
|
||||
pub enrollment_token: String,
|
||||
pub username: String,
|
||||
pub room_id: String,
|
||||
pub secret: String,
|
||||
pub otpauth_uri: String,
|
||||
pub qr_data_url: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegistrationComplete {
|
||||
@@ -1847,6 +2127,7 @@ pub enum AuthError {
|
||||
Forbidden,
|
||||
InvitationUnavailable,
|
||||
EnrollmentUnavailable,
|
||||
TotpResetUnavailable,
|
||||
AccountUnavailable,
|
||||
RoomUnavailable,
|
||||
InvalidTotp,
|
||||
@@ -1879,6 +2160,9 @@ impl fmt::Display for AuthError {
|
||||
formatter,
|
||||
"registration enrollment is invalid or unavailable"
|
||||
),
|
||||
Self::TotpResetUnavailable => {
|
||||
write!(formatter, "TOTP reset is invalid or unavailable")
|
||||
}
|
||||
Self::AccountUnavailable => write!(formatter, "username or room is unavailable"),
|
||||
Self::RoomUnavailable => write!(formatter, "room is already assigned"),
|
||||
Self::InvalidTotp => write!(formatter, "invalid TOTP code"),
|
||||
@@ -1968,6 +2252,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_up_proof_only_classifies_exact_six_digit_totp_codes() {
|
||||
assert!(is_totp_code(" 012345 "));
|
||||
assert!(!is_totp_code("12345"));
|
||||
assert!(!is_totp_code("1234567"));
|
||||
assert!(!is_totp_code("12a456"));
|
||||
assert!(!is_totp_code("recovery-example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn username_and_room_validation_are_canonical() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
gift_effect::GiftEffectDefinition,
|
||||
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||
guard_effect::GuardEffectDefinition,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||
};
|
||||
@@ -389,6 +390,12 @@ impl ComponentRegistry {
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(
|
||||
Arc::new(GuardEffectDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
||||
.expect("built-in component kinds are unique");
|
||||
@@ -560,7 +567,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
|
||||
fn builtin_gift_effect_only_subscribes_to_gifts() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
|
||||
let runtime = registry.runtime("gift_effect").unwrap();
|
||||
@@ -574,7 +581,26 @@ mod tests {
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_guard_effect_only_subscribes_to_guard_purchases() {
|
||||
let registry = ComponentRegistry::default();
|
||||
assert!(registry.kinds().contains(&"guard_effect".to_owned()));
|
||||
let runtime = registry.runtime("guard_effect").unwrap();
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"guard_effect",
|
||||
"大航海特效",
|
||||
1,
|
||||
runtime.definition().default_settings(),
|
||||
);
|
||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ struct OverlayFileConfig {
|
||||
viewer_color: Option<String>,
|
||||
danmaku_color: Option<String>,
|
||||
font_scale: Option<u16>,
|
||||
decoration_line_weight: Option<u16>,
|
||||
max_visible: Option<u8>,
|
||||
expand_new_danmaku: Option<bool>,
|
||||
collapse_after_seconds: Option<u16>,
|
||||
unfold_duration_ms: Option<u16>,
|
||||
motion_intensity: Option<u8>,
|
||||
@@ -333,6 +335,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
viewer_color: file.viewer_color.or(default.viewer_color),
|
||||
danmaku_color: file.danmaku_color.or(default.danmaku_color),
|
||||
font_scale: file.font_scale.unwrap_or(default.font_scale),
|
||||
decoration_line_weight: file
|
||||
.decoration_line_weight
|
||||
.unwrap_or(default.decoration_line_weight),
|
||||
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
|
||||
show_enter: file.events.enter.unwrap_or(default.show_enter),
|
||||
show_gift: file.events.gift.unwrap_or(default.show_gift),
|
||||
@@ -341,6 +346,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
|
||||
show_like: file.events.like.unwrap_or(default.show_like),
|
||||
show_share: file.events.share.unwrap_or(default.show_share),
|
||||
max_visible: file.max_visible.unwrap_or(default.max_visible),
|
||||
expand_new_danmaku: file
|
||||
.expand_new_danmaku
|
||||
.unwrap_or(default.expand_new_danmaku),
|
||||
collapse_after_seconds: file
|
||||
.collapse_after_seconds
|
||||
.unwrap_or(default.collapse_after_seconds),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Full-screen gift and membership effect component.
|
||||
//! Full-screen gift 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.
|
||||
//! The component is deliberately passive: it subscribes only to canonical gift
|
||||
//! events and projects them to its own authenticated OBS stream.
|
||||
//! All visual differentiation is settings-driven in the browser, so receiving
|
||||
//! an effect never creates database writes or depends on an OBS connection.
|
||||
|
||||
@@ -57,9 +57,8 @@ pub struct GiftEffectSettings {
|
||||
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,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
@@ -87,9 +86,7 @@ impl Default for GiftEffectSettings {
|
||||
speed: 880,
|
||||
},
|
||||
trail_intensity: 78,
|
||||
guard_star_count: 48,
|
||||
guard_effect_duration_ms: 5_200,
|
||||
max_concurrent_effects: 8,
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
@@ -105,13 +102,15 @@ impl GiftEffectSettings {
|
||||
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.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
pub struct GiftEffectDefinition;
|
||||
|
||||
impl GiftEffectDefinition {
|
||||
@@ -148,10 +147,7 @@ impl ComponentDefinition for GiftEffectDefinition {
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([
|
||||
LiveEventKind::Gift,
|
||||
LiveEventKind::GuardPurchase,
|
||||
]))
|
||||
Ok(EventSubscription::new([LiveEventKind::Gift]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,25 +164,38 @@ mod tests {
|
||||
settings["featured"]["size"] = Value::from(9_999);
|
||||
settings["highValueThreshold"] = Value::from(50_000);
|
||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["normal"]["count"], 1);
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["featured"]["size"], 480);
|
||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_durable_gifts_and_guards() {
|
||||
fn component_only_subscribes_to_durable_gifts() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_queue_capacity_default() {
|
||||
let definition = GiftEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GiftEffectDefinition;
|
||||
|
||||
@@ -93,6 +93,8 @@ pub struct GiftMenuSettings {
|
||||
pub page_interval_ms: u16,
|
||||
pub highlight_duration_ms: u16,
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
pub motion_intensity: u8,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
@@ -110,6 +112,7 @@ impl Default for GiftMenuSettings {
|
||||
page_interval_ms: default_page_interval_ms(),
|
||||
highlight_duration_ms: 3_800,
|
||||
font_scale: 100,
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
motion_intensity: 78,
|
||||
low_performance_mode: false,
|
||||
}
|
||||
@@ -140,11 +143,16 @@ impl GiftMenuSettings {
|
||||
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
|
||||
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
|
||||
self.font_scale = self.font_scale.clamp(50, 220);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.motion_intensity = self.motion_intensity.min(100);
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let count = normalized.chars().count();
|
||||
@@ -385,6 +393,7 @@ mod tests {
|
||||
page_interval_ms: u16::MAX,
|
||||
font_scale: 1,
|
||||
font_brightness: 1,
|
||||
decoration_line_weight: 1,
|
||||
..GiftMenuSettings::default()
|
||||
};
|
||||
let sanitized = definition
|
||||
@@ -396,6 +405,7 @@ mod tests {
|
||||
assert_eq!(sanitized["pageIntervalMs"], 30_000);
|
||||
assert_eq!(sanitized["fontScale"], 50);
|
||||
assert_eq!(sanitized["fontBrightness"], 70);
|
||||
assert_eq!(sanitized["decorationLineWeight"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -403,10 +413,15 @@ mod tests {
|
||||
let definition = GiftMenuDefinition;
|
||||
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
|
||||
settings.as_object_mut().unwrap().remove("pageIntervalMs");
|
||||
settings
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("decorationLineWeight");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
|
||||
assert_eq!(sanitized["decorationLineWeight"], 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Full-screen membership-purchase effect component.
|
||||
//!
|
||||
//! This component is deliberately separate from gift effects: every instance
|
||||
//! owns its settings, read-only OBS token, event subscription, and WebSocket
|
||||
//! channel, and consumes only canonical guard-purchase events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||
domain::LiveEventKind,
|
||||
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||
};
|
||||
|
||||
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
|
||||
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GuardEffectThemeId {
|
||||
#[default]
|
||||
JadeStarfall,
|
||||
MoonlitWater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GuardEffectSettings {
|
||||
#[serde(default)]
|
||||
pub theme_id: GuardEffectThemeId,
|
||||
#[serde(default)]
|
||||
pub font_family: FontFamilyId,
|
||||
#[serde(default = "default_font_brightness")]
|
||||
pub font_brightness: u16,
|
||||
pub star_count: u8,
|
||||
pub effect_duration_ms: u16,
|
||||
#[serde(default = "default_title_template")]
|
||||
pub title_template: String,
|
||||
#[serde(default = "default_closing_text")]
|
||||
pub closing_text: String,
|
||||
#[serde(default = "default_queue_capacity")]
|
||||
pub queue_capacity: u16,
|
||||
pub low_performance_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for GuardEffectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_id: GuardEffectThemeId::default(),
|
||||
font_family: FontFamilyId::default(),
|
||||
font_brightness: default_font_brightness(),
|
||||
star_count: 48,
|
||||
effect_duration_ms: 5_200,
|
||||
title_template: default_title_template(),
|
||||
closing_text: default_closing_text(),
|
||||
queue_capacity: default_queue_capacity(),
|
||||
low_performance_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GuardEffectSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.star_count = self.star_count.clamp(8, 96);
|
||||
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
|
||||
self.title_template = sanitize_copy(self.title_template, &default_title_template());
|
||||
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
|
||||
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_queue_capacity() -> u16 {
|
||||
256
|
||||
}
|
||||
|
||||
/// Preserve the membership-related portion of the first legacy gift-effect
|
||||
/// instance when an existing account receives its initial guard component.
|
||||
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
|
||||
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable");
|
||||
for (legacy, current) in [
|
||||
("themeId", "themeId"),
|
||||
("fontFamily", "fontFamily"),
|
||||
("fontBrightness", "fontBrightness"),
|
||||
("guardStarCount", "starCount"),
|
||||
("guardEffectDurationMs", "effectDurationMs"),
|
||||
("guardTitleTemplate", "titleTemplate"),
|
||||
("guardClosingText", "closingText"),
|
||||
("lowPerformanceMode", "lowPerformanceMode"),
|
||||
] {
|
||||
if let Some(candidate) = value.get(legacy) {
|
||||
migrated[current] = candidate.clone();
|
||||
}
|
||||
}
|
||||
serde_json::from_value::<GuardEffectSettings>(migrated)
|
||||
.unwrap_or_default()
|
||||
.sanitize()
|
||||
}
|
||||
|
||||
fn default_title_template() -> String {
|
||||
"{guard}启航".into()
|
||||
}
|
||||
|
||||
fn default_closing_text() -> String {
|
||||
"相伴前行".into()
|
||||
}
|
||||
|
||||
fn sanitize_copy(value: String, fallback: &str) -> String {
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
fallback.to_owned()
|
||||
} else {
|
||||
normalized.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GuardEffectDefinition;
|
||||
|
||||
impl GuardEffectDefinition {
|
||||
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
|
||||
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentDefinition for GuardEffectDefinition {
|
||||
fn kind(&self) -> &'static str {
|
||||
GUARD_EFFECT_KIND
|
||||
}
|
||||
|
||||
fn settings_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> Value {
|
||||
serde_json::to_value(GuardEffectSettings::default())
|
||||
.expect("GuardEffectSettings is always JSON serializable")
|
||||
}
|
||||
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
|
||||
ComponentError::InvalidSettings {
|
||||
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
self.parse(settings.clone())?;
|
||||
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_are_bounded_and_copy_is_sanitized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["fontBrightness"] = Value::from(999);
|
||||
settings["starCount"] = Value::from(255);
|
||||
settings["effectDurationMs"] = Value::from(200);
|
||||
settings["queueCapacity"] = Value::from(9_999);
|
||||
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
|
||||
settings["closingText"] = Value::from(" ");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["fontBrightness"], 180);
|
||||
assert_eq!(sanitized["starCount"], 96);
|
||||
assert_eq!(sanitized["effectDurationMs"], 1_000);
|
||||
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||
assert_eq!(
|
||||
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
|
||||
80
|
||||
);
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_receive_copy_defaults() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings.as_object_mut().unwrap().remove("titleTemplate");
|
||||
settings.as_object_mut().unwrap().remove("closingText");
|
||||
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
|
||||
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
|
||||
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||
assert_eq!(sanitized["queueCapacity"], 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gift_settings_preserve_membership_configuration() {
|
||||
let legacy = serde_json::json!({
|
||||
"themeId": "moonlit-water",
|
||||
"fontFamily": "kai",
|
||||
"fontBrightness": 145,
|
||||
"guardStarCount": 72,
|
||||
"guardEffectDurationMs": 8_500,
|
||||
"guardTitleTemplate": "{guard}出发",
|
||||
"guardClosingText": "一路顺风",
|
||||
"lowPerformanceMode": true,
|
||||
"normal": { "count": 20 }
|
||||
});
|
||||
|
||||
let migrated = settings_from_legacy_gift(&legacy);
|
||||
|
||||
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
|
||||
assert_eq!(migrated.star_count, 72);
|
||||
assert_eq!(migrated.effect_duration_ms, 8_500);
|
||||
assert_eq!(migrated.title_template, "{guard}出发");
|
||||
assert_eq!(migrated.closing_text, "一路顺风");
|
||||
assert!(migrated.low_performance_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_only_subscribes_to_guard_purchases() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let subscriptions = definition
|
||||
.subscriptions(&definition.default_settings())
|
||||
.unwrap();
|
||||
|
||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||
let definition = GuardEffectDefinition;
|
||||
let mut settings = definition.default_settings();
|
||||
settings["themeId"] = Value::from("moonlit-water");
|
||||
let sanitized = definition.validate_settings(settings).unwrap();
|
||||
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/v1/auth/register/confirm", post(enrollment_confirm))
|
||||
.route("/api/v1/auth/login", post(login))
|
||||
.route("/api/v1/auth/logout", post(logout))
|
||||
.route("/api/v1/auth/totp/reset/start", post(totp_reset_start))
|
||||
.route("/api/v1/auth/totp/reset/confirm", post(totp_reset_confirm))
|
||||
.route(
|
||||
"/api/v1/invitations",
|
||||
get(list_invitations).post(create_invitation),
|
||||
@@ -433,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TotpResetStartRequest {
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn totp_reset_start(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<TotpResetStartRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
consume_enrollment_budget(&state, &headers).await?;
|
||||
let enrollment = state
|
||||
.auth
|
||||
.totp_reset_start(session.user.id, &body.code)
|
||||
.await?;
|
||||
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TotpResetConfirmRequest {
|
||||
enrollment_token: String,
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn totp_reset_confirm(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<TotpResetConfirmRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
same_origin(&state, &headers)?;
|
||||
let session = require_session(&state, &headers).await?;
|
||||
consume_enrollment_budget(&state, &headers).await?;
|
||||
let recovery_codes = state
|
||||
.auth
|
||||
.totp_reset_confirm(
|
||||
session.user.id,
|
||||
session.session_id,
|
||||
&body.enrollment_token,
|
||||
&body.code,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"recoveryCodes": recovery_codes,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateInvitationRequest {
|
||||
@@ -1564,6 +1616,11 @@ impl From<AuthError> for ApiError {
|
||||
"enrollment_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
AuthError::TotpResetUnavailable => Self::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"totp_reset_unavailable",
|
||||
error.to_string(),
|
||||
),
|
||||
AuthError::AccountUnavailable => Self::new(
|
||||
StatusCode::CONFLICT,
|
||||
"account_unavailable",
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod db;
|
||||
pub mod domain;
|
||||
pub mod gift_effect;
|
||||
pub mod gift_menu;
|
||||
pub mod guard_effect;
|
||||
pub mod http_api;
|
||||
pub mod i18n;
|
||||
pub mod live;
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
//! modeled by `libilibili` are interpreted only inside this provider boundary;
|
||||
//! unknown events expose sanitized command names, never raw packets or secrets.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use libilibili::{
|
||||
@@ -36,6 +40,7 @@ use crate::{
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20);
|
||||
const RECONNECT_BACKOFF: Duration = Duration::from_secs(3);
|
||||
const GUARD_DUPLICATE_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BilibiliProvider {
|
||||
@@ -266,6 +271,7 @@ impl LiveProvider for BilibiliProvider {
|
||||
) -> Result<(), String> {
|
||||
self.initial_catalogs(&context.room_id).await;
|
||||
self.spawn_catalog_refreshes(context.room_id.clone(), cancel.clone());
|
||||
let mut guard_deduplicator = GuardEventDeduplicator::default();
|
||||
|
||||
let room_id = context
|
||||
.room_id
|
||||
@@ -346,6 +352,9 @@ impl LiveProvider for BilibiliProvider {
|
||||
let Some(raw) = normalize_upstream(upstream) else {
|
||||
continue;
|
||||
};
|
||||
if !guard_deduplicator.should_emit(&raw, Instant::now()) {
|
||||
continue;
|
||||
}
|
||||
if !received_event {
|
||||
received_event = true;
|
||||
info!(room_id = %context.room_id, "libilibili listener received its first live event");
|
||||
@@ -472,6 +481,49 @@ enum ProviderEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct GuardEventFingerprint {
|
||||
viewer_uid: String,
|
||||
level: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GuardEventDeduplicator {
|
||||
recent: HashMap<GuardEventFingerprint, Instant>,
|
||||
}
|
||||
|
||||
impl GuardEventDeduplicator {
|
||||
fn should_emit(&mut self, event: &ProviderEvent, now: Instant) -> bool {
|
||||
let ProviderEvent::Guard { viewer, name, .. } = event else {
|
||||
return true;
|
||||
};
|
||||
self.recent
|
||||
.retain(|_, seen| now.saturating_duration_since(*seen) <= GUARD_DUPLICATE_WINDOW);
|
||||
let fingerprint = GuardEventFingerprint {
|
||||
viewer_uid: viewer.uid.clone(),
|
||||
level: guard_level_key(name),
|
||||
};
|
||||
if self.recent.contains_key(&fingerprint) {
|
||||
return false;
|
||||
}
|
||||
self.recent.insert(fingerprint, now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_level_key(name: &str) -> String {
|
||||
let normalized = name.trim().to_lowercase();
|
||||
if normalized.contains("总督") || normalized.contains("governor") {
|
||||
"governor".to_owned()
|
||||
} else if normalized.contains("提督") || normalized.contains("admiral") {
|
||||
"admiral".to_owned()
|
||||
} else if normalized.contains("舰长") || normalized.contains("captain") {
|
||||
"captain".to_owned()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_upstream(event: UpstreamLiveEvent) -> Option<ProviderEvent> {
|
||||
let UpstreamLiveEvent::Command(command) = event else {
|
||||
return None;
|
||||
@@ -507,11 +559,17 @@ fn normalize_command(command: LiveCommand) -> Option<ProviderEvent> {
|
||||
command,
|
||||
raw,
|
||||
error: _,
|
||||
} => normalize_raw(&raw).or_else(|| {
|
||||
Some(ProviderEvent::Unknown {
|
||||
command: sanitized_command(command.as_deref()),
|
||||
})
|
||||
}),
|
||||
} => {
|
||||
if ignored_raw_command(&raw) {
|
||||
None
|
||||
} else {
|
||||
normalize_raw(&raw).or_else(|| {
|
||||
Some(ProviderEvent::Unknown {
|
||||
command: sanitized_command(command.as_deref()),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
LiveCommand::OnlineRankCount(message) => unknown(message.cmd),
|
||||
LiveCommand::WatchedChange(message) => unknown(message.cmd),
|
||||
LiveCommand::StopLiveRoomList(message) => unknown(message.cmd),
|
||||
@@ -839,9 +897,13 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
};
|
||||
let data_viewer = |value: &Value| {
|
||||
viewer(
|
||||
value.get("uid")?,
|
||||
value
|
||||
.get("uid")
|
||||
.or_else(|| value.pointer("/sender_uinfo/uid"))
|
||||
.or_else(|| value.pointer("/user_info/uid"))?,
|
||||
value
|
||||
.get("uname")
|
||||
.or_else(|| value.get("username"))
|
||||
.or_else(|| value.pointer("/sender_uinfo/base/name"))
|
||||
.or_else(|| value.pointer("/user_info/uname"))?,
|
||||
value
|
||||
@@ -890,21 +952,9 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
)
|
||||
}),
|
||||
}),
|
||||
"GUARD_BUY" => Some(ProviderEvent::Guard {
|
||||
viewer: data_viewer(data)?,
|
||||
name: data
|
||||
.get("gift_name")
|
||||
.or_else(|| data.get("giftName"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("舰长")
|
||||
.to_owned(),
|
||||
quantity: data
|
||||
.get("num")
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(1),
|
||||
price: data.get("price").and_then(value_i64).unwrap_or(0),
|
||||
}),
|
||||
"GUARD_BUY" | "USER_TOAST_MSG" | "USER_TOAST_MSG_V2" => {
|
||||
normalize_raw_guard(data, &data_viewer)
|
||||
}
|
||||
"SUPER_CHAT_MESSAGE" | "SUPER_CHAT_MESSAGE_JPN" => Some(ProviderEvent::SuperChat {
|
||||
viewer: data_viewer(data)?,
|
||||
message: data
|
||||
@@ -925,6 +975,68 @@ fn normalize_raw(raw: &Value) -> Option<ProviderEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
fn ignored_raw_command(raw: &Value) -> bool {
|
||||
let command = raw
|
||||
.get("cmd")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.split(':').next());
|
||||
let data = raw.get("data").unwrap_or(raw);
|
||||
matches!(command, Some("USER_TOAST_MSG" | "USER_TOAST_MSG_V2"))
|
||||
&& data
|
||||
.pointer("/option/source")
|
||||
.or_else(|| data.get("source"))
|
||||
.and_then(value_i64)
|
||||
== Some(2)
|
||||
}
|
||||
|
||||
fn normalize_raw_guard(
|
||||
data: &Value,
|
||||
data_viewer: &impl Fn(&Value) -> Option<PlatformViewer>,
|
||||
) -> Option<ProviderEvent> {
|
||||
let guard_level = data
|
||||
.pointer("/guard_info/guard_level")
|
||||
.or_else(|| data.get("guard_level"))
|
||||
.and_then(value_i64);
|
||||
let name = data
|
||||
.pointer("/gift_info/gift_name")
|
||||
.or_else(|| data.get("gift_name"))
|
||||
.or_else(|| data.get("giftName"))
|
||||
.or_else(|| data.get("role_name"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| guard_name_from_level(guard_level).map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "舰长".to_owned());
|
||||
let quantity = data
|
||||
.pointer("/pay_info/num")
|
||||
.or_else(|| data.get("num"))
|
||||
.and_then(value_i64)
|
||||
.map(bounded_signed_i32)
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let price = data
|
||||
.pointer("/pay_info/price")
|
||||
.or_else(|| data.get("price"))
|
||||
.and_then(value_i64)
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
Some(ProviderEvent::Guard {
|
||||
viewer: data_viewer(data)?,
|
||||
name,
|
||||
quantity,
|
||||
price,
|
||||
})
|
||||
}
|
||||
|
||||
fn guard_name_from_level(level: Option<i64>) -> Option<&'static str> {
|
||||
match level {
|
||||
Some(1) => Some("总督"),
|
||||
Some(2) => Some("提督"),
|
||||
Some(3) => Some("舰长"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_i64(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
@@ -1478,6 +1590,118 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_nested_guard_buy_with_username() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
"cmd":"GUARD_BUY",
|
||||
"data":{
|
||||
"uid":789,
|
||||
"username":"上舰观众",
|
||||
"guard_level":3,
|
||||
"num":1,
|
||||
"price":198000,
|
||||
"gift_id":10003,
|
||||
"gift_name":"舰长",
|
||||
"start_time":1_755_000_000,
|
||||
"end_time":1_755_000_000
|
||||
}
|
||||
})))
|
||||
.unwrap();
|
||||
|
||||
match event {
|
||||
ProviderEvent::Guard {
|
||||
viewer,
|
||||
name,
|
||||
quantity,
|
||||
price,
|
||||
} => {
|
||||
assert_eq!(viewer.uid, "789");
|
||||
assert_eq!(viewer.name, "上舰观众");
|
||||
assert_eq!(name, "舰长");
|
||||
assert_eq!(quantity, 1);
|
||||
assert_eq!(price, 198_000);
|
||||
}
|
||||
_ => panic!("expected guard purchase"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_current_user_toast_guard_and_ignores_mirror_source() {
|
||||
let command = |source| {
|
||||
parse_command(json!({
|
||||
"cmd":"USER_TOAST_MSG_V2",
|
||||
"data":{
|
||||
"sender_uinfo":{
|
||||
"uid":987,
|
||||
"base":{
|
||||
"name":"续舰观众",
|
||||
"face":"//i0.hdslb.com/bfs/face/guard.jpg"
|
||||
}
|
||||
},
|
||||
"guard_info":{
|
||||
"guard_level":2,
|
||||
"start_time":1_755_000_001,
|
||||
"end_time":1_755_000_001
|
||||
},
|
||||
"pay_info":{"num":2,"price":1_998_000,"unit":"月"},
|
||||
"gift_info":{"gift_id":10002},
|
||||
"option":{"source":source},
|
||||
"toast_msg":"续费大航海"
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
let event = normalize_command(command(0)).unwrap();
|
||||
match event {
|
||||
ProviderEvent::Guard {
|
||||
viewer,
|
||||
name,
|
||||
quantity,
|
||||
price,
|
||||
} => {
|
||||
assert_eq!(viewer.uid, "987");
|
||||
assert_eq!(viewer.name, "续舰观众");
|
||||
assert_eq!(
|
||||
viewer.avatar_url.as_deref(),
|
||||
Some("https://i0.hdslb.com/bfs/face/guard.jpg")
|
||||
);
|
||||
assert_eq!(name, "提督");
|
||||
assert_eq!(quantity, 2);
|
||||
assert_eq!(price, 1_998_000);
|
||||
}
|
||||
_ => panic!("expected guard purchase"),
|
||||
}
|
||||
assert!(normalize_command(command(2)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_guard_notifications_ignore_accounting_differences() {
|
||||
let guard = |name: &str, quantity, price| ProviderEvent::Guard {
|
||||
viewer: PlatformViewer {
|
||||
uid: "987".into(),
|
||||
name: "上舰观众".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
name: name.into(),
|
||||
quantity,
|
||||
price,
|
||||
};
|
||||
let first = guard("舰长", 1, 198_000);
|
||||
let duplicate = guard("舰长月卡", 12, 1_998_000);
|
||||
let different_level = guard("提督", 1, 1_998_000);
|
||||
let later = guard("舰长", 1, 198_000);
|
||||
let now = Instant::now();
|
||||
let mut deduplicator = GuardEventDeduplicator::default();
|
||||
|
||||
assert!(deduplicator.should_emit(&first, now));
|
||||
assert!(!deduplicator.should_emit(&duplicate, now + Duration::from_secs(1)));
|
||||
assert!(deduplicator.should_emit(&different_level, now + Duration::from_secs(1)));
|
||||
assert!(deduplicator.should_emit(
|
||||
&later,
|
||||
now + GUARD_DUPLICATE_WINDOW + Duration::from_millis(1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_typed_like_clicks_with_a_viewer() {
|
||||
let event = normalize_command(parse_command(json!({
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct OverlaySettings {
|
||||
pub danmaku_color: Option<String>,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
/// Relative weight of theme borders, rules, and ornamental edge artwork.
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
pub show_danmaku: bool,
|
||||
pub show_enter: bool,
|
||||
pub show_gift: bool,
|
||||
@@ -54,6 +57,8 @@ pub struct OverlaySettings {
|
||||
pub show_like: bool,
|
||||
pub show_share: bool,
|
||||
pub max_visible: u8,
|
||||
#[serde(default = "default_expand_new_danmaku")]
|
||||
pub expand_new_danmaku: bool,
|
||||
pub collapse_after_seconds: u16,
|
||||
#[serde(default = "default_unfold_duration_ms")]
|
||||
pub unfold_duration_ms: u16,
|
||||
@@ -77,6 +82,7 @@ impl Default for OverlaySettings {
|
||||
viewer_color: None,
|
||||
danmaku_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
show_danmaku: true,
|
||||
show_enter: true,
|
||||
show_gift: true,
|
||||
@@ -85,6 +91,7 @@ impl Default for OverlaySettings {
|
||||
show_like: false,
|
||||
show_share: false,
|
||||
max_visible: 5,
|
||||
expand_new_danmaku: default_expand_new_danmaku(),
|
||||
collapse_after_seconds: 12,
|
||||
unfold_duration_ms: default_unfold_duration_ms(),
|
||||
motion_intensity: 70,
|
||||
@@ -104,6 +111,7 @@ impl OverlaySettings {
|
||||
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
|
||||
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
|
||||
self.font_scale = self.font_scale.clamp(50, 300);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
|
||||
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
|
||||
self.motion_intensity = self.motion_intensity.min(100);
|
||||
@@ -128,10 +136,18 @@ fn default_font_scale() -> u16 {
|
||||
140
|
||||
}
|
||||
|
||||
fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_expand_new_danmaku() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_particle_count() -> u8 {
|
||||
8
|
||||
}
|
||||
@@ -577,7 +593,9 @@ mod tests {
|
||||
font_brightness: 1,
|
||||
viewer_color: Some(" #a1b2c3 ".into()),
|
||||
danmaku_color: Some("not-css".into()),
|
||||
decoration_line_weight: 999,
|
||||
max_visible: 99,
|
||||
expand_new_danmaku: false,
|
||||
collapse_after_seconds: 1,
|
||||
unfold_duration_ms: 9_000,
|
||||
motion_intensity: 200,
|
||||
@@ -592,7 +610,9 @@ mod tests {
|
||||
assert_eq!(settings.font_brightness, 70);
|
||||
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.decoration_line_weight, 300);
|
||||
assert_eq!(settings.max_visible, 12);
|
||||
assert!(!settings.expand_new_danmaku);
|
||||
assert_eq!(settings.collapse_after_seconds, 2);
|
||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||
assert_eq!(settings.motion_intensity, 100);
|
||||
@@ -618,6 +638,8 @@ mod tests {
|
||||
object.remove("fontBrightness");
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("decorationLineWeight");
|
||||
object.remove("expandNewDanmaku");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -629,6 +651,8 @@ mod tests {
|
||||
assert_eq!(settings.viewer_color, None);
|
||||
assert_eq!(settings.danmaku_color, None);
|
||||
assert_eq!(settings.font_scale, 140);
|
||||
assert_eq!(settings.decoration_line_weight, 160);
|
||||
assert!(settings.expand_new_danmaku);
|
||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||
assert_eq!(settings.particle_count, 8);
|
||||
assert_eq!(settings.particle_speed, 100);
|
||||
|
||||
@@ -17,11 +17,16 @@ use crate::{
|
||||
db::{ComponentRecord, Db, DbError},
|
||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||
guard_effect::{
|
||||
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
|
||||
},
|
||||
i18n,
|
||||
realtime::InMemoryComponentStore,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
};
|
||||
|
||||
const MAX_COMPONENT_INSTANCES_PER_KIND: i64 = 16;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TenantRepository {
|
||||
db: Db,
|
||||
@@ -49,17 +54,25 @@ impl TenantRepository {
|
||||
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.
|
||||
/// Ensure every active tenant has an initial instance of each built-in
|
||||
/// component. Registration normally creates these rows; this startup
|
||||
/// backfill keeps upgrades from older releases complete. Additional
|
||||
/// instances are created explicitly through the component API.
|
||||
pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> {
|
||||
for tenant in self.db.list_active_tenants().await? {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, tenant.user_id).await?;
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
|
||||
&[&tenant.user_id],
|
||||
)
|
||||
.await?;
|
||||
let existing = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
|
||||
ORDER BY created_at,id LIMIT 1",
|
||||
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||
)
|
||||
.await?;
|
||||
@@ -84,11 +97,17 @@ impl TenantRepository {
|
||||
)
|
||||
.await?;
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||
.query_opt(
|
||||
"SELECT id FROM component_instances WHERE owner_user_id=$1 AND kind=$2 \
|
||||
ORDER BY created_at,id LIMIT 1",
|
||||
&[&tenant.user_id, &SONG_REQUEST_KIND],
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
RepositoryError::Invalid(
|
||||
"failed to create the initial song request component".into(),
|
||||
)
|
||||
})?
|
||||
.get(0)
|
||||
};
|
||||
transaction
|
||||
@@ -98,38 +117,96 @@ impl TenantRepository {
|
||||
&[&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,
|
||||
],
|
||||
let has_gift_effect = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GIFT_EFFECT_KIND],
|
||||
)
|
||||
.await?;
|
||||
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?
|
||||
.get::<_, bool>(0);
|
||||
if !has_gift_effect {
|
||||
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GIFT_EFFECT_KIND,
|
||||
&GIFT_EFFECT_NAME,
|
||||
&gift_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_guard_effect = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GUARD_EFFECT_KIND],
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_guard_effect {
|
||||
let legacy_gift_settings = transaction
|
||||
.query_opt(
|
||||
"SELECT settings FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
|
||||
&[&tenant.user_id, &GIFT_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.map(|row| row.get::<_, Value>(0));
|
||||
let guard_settings = legacy_gift_settings
|
||||
.as_ref()
|
||||
.map(settings_from_legacy_gift)
|
||||
.unwrap_or_else(GuardEffectSettings::default);
|
||||
let guard_settings = serde_json::to_value(guard_settings)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_gift_menu = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GIFT_MENU_KIND],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_gift_menu {
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GIFT_MENU_KIND,
|
||||
&GIFT_MENU_NAME,
|
||||
&menu_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -162,12 +239,6 @@ impl TenantRepository {
|
||||
kind: &str,
|
||||
name: &str,
|
||||
) -> Result<ComponentInstance, RepositoryError> {
|
||||
// Every tenant receives this singleton during registration/startup.
|
||||
// Keeping creation internal prevents a second instance from racing the
|
||||
// partial unique index and turning a domain conflict into a DB error.
|
||||
if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND) {
|
||||
return Err(RepositoryError::Forbidden);
|
||||
}
|
||||
let runtime = self
|
||||
.registry
|
||||
.runtime(kind)
|
||||
@@ -190,6 +261,26 @@ impl TenantRepository {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
// Serialize instance-count checks for this account. Without the owner
|
||||
// row lock, concurrent requests could both pass the per-kind limit.
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
|
||||
&[&owner_id],
|
||||
)
|
||||
.await?;
|
||||
let instance_count = transaction
|
||||
.query_one(
|
||||
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||
&[&owner_id, &component.kind],
|
||||
)
|
||||
.await?
|
||||
.get::<_, i64>(0);
|
||||
if instance_count >= MAX_COMPONENT_INSTANCES_PER_KIND {
|
||||
return Err(RepositoryError::Invalid(format!(
|
||||
"a component kind accepts at most {MAX_COMPONENT_INSTANCES_PER_KIND} instances"
|
||||
)));
|
||||
}
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
@@ -205,6 +296,15 @@ impl TenantRepository {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if component.kind == SONG_REQUEST_KIND {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO song_request_state(owner_user_id,component_instance_id) \
|
||||
VALUES($1,$2)",
|
||||
&[&owner_id, &component.id],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
self.cache.upsert(component.clone());
|
||||
Ok(component)
|
||||
@@ -218,6 +318,12 @@ impl TenantRepository {
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, owner_id).await?;
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT id FROM users WHERE id=$1 AND status='active' FOR UPDATE",
|
||||
&[&owner_id],
|
||||
)
|
||||
.await?;
|
||||
let kind: String = transaction
|
||||
.query_opt(
|
||||
"SELECT kind FROM component_instances WHERE owner_user_id=$1 AND id=$2",
|
||||
@@ -226,10 +332,14 @@ impl TenantRepository {
|
||||
.await?
|
||||
.ok_or(RepositoryError::NotFound)?
|
||||
.get(0);
|
||||
if matches!(
|
||||
kind.as_str(),
|
||||
SONG_REQUEST_KIND | GIFT_EFFECT_KIND | GIFT_MENU_KIND
|
||||
) {
|
||||
let instance_count = transaction
|
||||
.query_one(
|
||||
"SELECT count(*) FROM component_instances WHERE owner_user_id=$1 AND kind=$2",
|
||||
&[&owner_id, &kind],
|
||||
)
|
||||
.await?
|
||||
.get::<_, i64>(0);
|
||||
if instance_count <= 1 {
|
||||
return Err(RepositoryError::Forbidden);
|
||||
}
|
||||
let changed = transaction
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct SongRequestSettings {
|
||||
pub song_title_color: Option<String>,
|
||||
#[serde(default = "default_font_scale")]
|
||||
pub font_scale: u16,
|
||||
#[serde(default = "default_decoration_line_weight")]
|
||||
pub decoration_line_weight: u16,
|
||||
#[serde(default = "default_scroll_speed")]
|
||||
pub scroll_speed_pixels_per_second: u16,
|
||||
#[serde(default = "default_edge_pause")]
|
||||
@@ -70,6 +72,7 @@ impl Default for SongRequestSettings {
|
||||
requester_color: None,
|
||||
song_title_color: None,
|
||||
font_scale: default_font_scale(),
|
||||
decoration_line_weight: default_decoration_line_weight(),
|
||||
scroll_speed_pixels_per_second: default_scroll_speed(),
|
||||
edge_pause_seconds: default_edge_pause(),
|
||||
max_queue_size: 0,
|
||||
@@ -82,6 +85,7 @@ impl Default for SongRequestSettings {
|
||||
impl SongRequestSettings {
|
||||
pub fn sanitize(mut self) -> Self {
|
||||
self.font_scale = self.font_scale.clamp(50, 250);
|
||||
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
|
||||
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||
self.requester_color = sanitize_optional_hex_color(self.requester_color);
|
||||
self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
|
||||
@@ -106,6 +110,10 @@ const fn default_font_scale() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
const fn default_decoration_line_weight() -> u16 {
|
||||
160
|
||||
}
|
||||
|
||||
const fn default_scroll_speed() -> u16 {
|
||||
28
|
||||
}
|
||||
@@ -120,34 +128,20 @@ pub enum SongCommand {
|
||||
title: String,
|
||||
normalized_title: String,
|
||||
},
|
||||
Rate(u8),
|
||||
}
|
||||
|
||||
/// Parse only explicit commands separated from their argument by whitespace.
|
||||
/// This avoids treating ordinary words such as “点歌姬” as queue mutations.
|
||||
/// Parse a request prefix followed immediately by a title or by whitespace and
|
||||
/// a title. Whitespace inside the title is normalized before deduplication.
|
||||
pub fn parse_command(text: &str) -> Option<SongCommand> {
|
||||
let canonical = collapse_whitespace(text);
|
||||
let (command, argument) = canonical.split_once(' ')?;
|
||||
match command {
|
||||
"点歌" => {
|
||||
if argument.is_empty() || argument.chars().count() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(SongCommand::Request {
|
||||
title: argument.to_owned(),
|
||||
normalized_title: argument.to_lowercase(),
|
||||
})
|
||||
}
|
||||
"打分" => match argument {
|
||||
"1" => Some(SongCommand::Rate(1)),
|
||||
"2" => Some(SongCommand::Rate(2)),
|
||||
"3" => Some(SongCommand::Rate(3)),
|
||||
"4" => Some(SongCommand::Rate(4)),
|
||||
"5" => Some(SongCommand::Rate(5)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
let argument = canonical.strip_prefix("点歌")?.trim_start();
|
||||
if argument.is_empty() || argument.chars().count() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(SongCommand::Request {
|
||||
title: argument.to_owned(),
|
||||
normalized_title: argument.to_lowercase(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collapse_whitespace(value: &str) -> String {
|
||||
@@ -172,8 +166,6 @@ pub struct SongRequestItem {
|
||||
pub requested_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -183,7 +175,6 @@ pub struct SongQueueSummary {
|
||||
pub queued_count: i64,
|
||||
pub completed_count: i64,
|
||||
pub cancelled_count: i64,
|
||||
pub rating_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -233,25 +224,20 @@ impl SongRequestService {
|
||||
};
|
||||
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
|
||||
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
|
||||
let result = match command {
|
||||
SongCommand::Request {
|
||||
title,
|
||||
normalized_title,
|
||||
} => {
|
||||
self.request_song(
|
||||
component,
|
||||
&danmaku.viewer,
|
||||
&title,
|
||||
&normalized_title,
|
||||
event.id,
|
||||
&settings,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
SongCommand::Rate(score) => {
|
||||
self.rate_current(component, &danmaku.viewer, score).await?
|
||||
}
|
||||
};
|
||||
let SongCommand::Request {
|
||||
title,
|
||||
normalized_title,
|
||||
} = command;
|
||||
let result = self
|
||||
.request_song(
|
||||
component,
|
||||
&danmaku.viewer,
|
||||
&title,
|
||||
&normalized_title,
|
||||
event.id,
|
||||
&settings,
|
||||
)
|
||||
.await?;
|
||||
if let Some(change) = result {
|
||||
self.publish_change(component, &event.room_id, change)?;
|
||||
}
|
||||
@@ -291,7 +277,7 @@ impl SongRequestService {
|
||||
};
|
||||
let sql = format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND {status_filter} \
|
||||
GROUP BY r.id ORDER BY {order} OFFSET $2 LIMIT $3",
|
||||
ORDER BY {order} OFFSET $2 LIMIT $3",
|
||||
item_select()
|
||||
);
|
||||
let rows = transaction
|
||||
@@ -334,7 +320,7 @@ impl SongRequestService {
|
||||
.query(
|
||||
&format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='queued' \
|
||||
GROUP BY r.id ORDER BY r.queue_position ASC",
|
||||
ORDER BY r.queue_position ASC",
|
||||
item_select()
|
||||
),
|
||||
&[&component.id],
|
||||
@@ -663,59 +649,6 @@ impl SongRequestService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn rate_current(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
viewer: &PlatformViewer,
|
||||
score: u8,
|
||||
) -> Result<Option<SongQueueChange>, SongRequestError> {
|
||||
let viewer_uid = bounded_identity(&viewer.uid, 64)?;
|
||||
let viewer_name = bounded_identity(&viewer.name, 80)?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
Db::set_tenant(&transaction, component.owner_id).await?;
|
||||
lock_state(&transaction, component).await?;
|
||||
let Some(row) = transaction
|
||||
.query_opt(
|
||||
"SELECT id FROM song_requests WHERE component_instance_id=$1 AND status='current' FOR UPDATE",
|
||||
&[&component.id],
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let request_id: Uuid = row.get(0);
|
||||
let score = i16::from(score);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO song_ratings \
|
||||
(id,owner_user_id,component_instance_id,song_request_id,viewer_uid,viewer_name,score) \
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) \
|
||||
ON CONFLICT(song_request_id,viewer_uid) DO UPDATE \
|
||||
SET score=EXCLUDED.score,viewer_name=EXCLUDED.viewer_name,updated_at=now()",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&component.owner_id,
|
||||
&component.id,
|
||||
&request_id,
|
||||
&viewer_uid,
|
||||
&viewer_name,
|
||||
&score,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let revision = bump_revision(&transaction, component.id).await?;
|
||||
let current = current_item(&transaction, component.id).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(Some(SongQueueChange {
|
||||
revision,
|
||||
operation: "rating-updated",
|
||||
item_id: request_id,
|
||||
item: current.clone(),
|
||||
current,
|
||||
}))
|
||||
}
|
||||
|
||||
fn publish_change(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
@@ -838,8 +771,7 @@ async fn promote_next(
|
||||
|
||||
fn item_select() -> &'static str {
|
||||
"SELECT r.id,r.song_title,r.requester_uid,r.requester_name,r.status,r.queue_position,\
|
||||
r.requested_at,r.started_at,r.finished_at,avg(v.score)::double precision,count(v.id)::BIGINT \
|
||||
FROM song_requests r LEFT JOIN song_ratings v ON v.song_request_id=r.id"
|
||||
r.requested_at,r.started_at,r.finished_at FROM song_requests r"
|
||||
}
|
||||
|
||||
fn item_from_row(row: &Row) -> SongRequestItem {
|
||||
@@ -855,8 +787,6 @@ fn item_from_row(row: &Row) -> SongRequestItem {
|
||||
requested_at: row.get(6),
|
||||
started_at: row.get(7),
|
||||
finished_at: row.get(8),
|
||||
average_score: row.get(9),
|
||||
rating_count: row.get(10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,10 +795,7 @@ async fn request_item(
|
||||
request_id: Uuid,
|
||||
) -> Result<Option<SongRequestItem>, SongRequestError> {
|
||||
Ok(transaction
|
||||
.query_opt(
|
||||
&format!("{} WHERE r.id=$1 GROUP BY r.id", item_select()),
|
||||
&[&request_id],
|
||||
)
|
||||
.query_opt(&format!("{} WHERE r.id=$1", item_select()), &[&request_id])
|
||||
.await?
|
||||
.map(|row| item_from_row(&row)))
|
||||
}
|
||||
@@ -880,7 +807,7 @@ async fn current_item(
|
||||
Ok(transaction
|
||||
.query_opt(
|
||||
&format!(
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='current' GROUP BY r.id",
|
||||
"{} WHERE r.component_instance_id=$1 AND r.status='current'",
|
||||
item_select()
|
||||
),
|
||||
&[&component_id],
|
||||
@@ -898,8 +825,7 @@ async fn queue_summary(
|
||||
"SELECT count(*) FILTER (WHERE status IN ('current','queued'))::BIGINT,\
|
||||
count(*) FILTER (WHERE status='queued')::BIGINT,\
|
||||
count(*) FILTER (WHERE status='completed')::BIGINT,\
|
||||
count(*) FILTER (WHERE status='cancelled')::BIGINT,\
|
||||
(SELECT count(*) FROM song_ratings WHERE component_instance_id=$1)::BIGINT \
|
||||
count(*) FILTER (WHERE status='cancelled')::BIGINT \
|
||||
FROM song_requests WHERE component_instance_id=$1",
|
||||
&[&component_id],
|
||||
)
|
||||
@@ -909,7 +835,6 @@ async fn queue_summary(
|
||||
queued_count: row.get(1),
|
||||
completed_count: row.get(2),
|
||||
cancelled_count: row.get(3),
|
||||
rating_count: row.get(4),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1071,7 +996,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_requests_and_scores_with_normalized_whitespace() {
|
||||
fn parses_only_requests_with_normalized_whitespace() {
|
||||
assert_eq!(
|
||||
parse_command(" 点歌 My Song "),
|
||||
Some(SongCommand::Request {
|
||||
@@ -1079,11 +1004,18 @@ mod tests {
|
||||
normalized_title: "my song".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_command("打分 5"), Some(SongCommand::Rate(5)));
|
||||
assert_eq!(
|
||||
parse_command("点歌夜曲"),
|
||||
Some(SongCommand::Request {
|
||||
title: "夜曲".into(),
|
||||
normalized_title: "夜曲".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(parse_command("打分 5"), None);
|
||||
assert_eq!(parse_command("打分 0"), None);
|
||||
assert_eq!(parse_command("点歌姬"), None);
|
||||
assert_eq!(parse_command("点歌"), None);
|
||||
assert_eq!(parse_command(&format!("点歌 {}", "歌".repeat(81))), None);
|
||||
assert_eq!(parse_command(&format!("点歌{}", "歌".repeat(81))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1098,6 +1030,7 @@ mod tests {
|
||||
font_brightness: u16::MAX,
|
||||
requester_color: Some(" #a1b2c3 ".into()),
|
||||
song_title_color: Some("transparent".into()),
|
||||
decoration_line_weight: 999,
|
||||
scroll_speed_pixels_per_second: 0,
|
||||
edge_pause_seconds: 200,
|
||||
max_queue_size: u32::MAX,
|
||||
@@ -1110,6 +1043,7 @@ mod tests {
|
||||
assert_eq!(bounded.font_brightness, 180);
|
||||
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
|
||||
assert_eq!(bounded.song_title_color, None);
|
||||
assert_eq!(bounded.decoration_line_weight, 300);
|
||||
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
|
||||
assert_eq!(bounded.edge_pause_seconds, 15);
|
||||
assert_eq!(bounded.max_queue_size, 10_000);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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.
|
||||
//! Font IDs map to fixed, same-origin WOFF2 assets in the frontend rather than
|
||||
//! accepting arbitrary CSS. This keeps settings portable between OBS hosts and
|
||||
//! prevents untrusted component settings from becoming a CSS injection boundary.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum FontFamilyId {
|
||||
#[default]
|
||||
FangSong,
|
||||
Kai,
|
||||
LiyuShoushu,
|
||||
}
|
||||
|
||||
pub const fn default_font_brightness() -> u16 {
|
||||
@@ -35,5 +36,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(sanitize_font_brightness(1), 70);
|
||||
assert_eq!(sanitize_font_brightness(u16::MAX), 180);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&FontFamilyId::LiyuShoushu).unwrap(),
|
||||
"\"liyu-shoushu\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ font_brightness = 130
|
||||
# danmaku_color = "#C6AE7A"
|
||||
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
|
||||
font_scale = 140
|
||||
# 主题装饰线、边框与上下古风边缘的相对粗细;允许范围 50-300。
|
||||
decoration_line_weight = 160
|
||||
max_visible = 5
|
||||
collapse_after_seconds = 12
|
||||
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
## 核心目标
|
||||
|
||||
- 每个账户拥有一个可切换的 Bilibili 直播间、一个 CookieCloud 来源和一条独立监听连接。
|
||||
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。
|
||||
- 组件不绑定或选择直播源;账户事件流会提供给该账户所有启用的组件实例。同一 kind 可以拥有多个实例,每个实例独立保存名称、设置、OBS
|
||||
token 和实时通道。
|
||||
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
||||
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
||||
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
||||
@@ -37,7 +38,8 @@ flowchart LR
|
||||
只标识账户级监听,不存储在组件行中。
|
||||
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
||||
6. OBS 使用实例级只读 token 订阅一个组件实例,不能读取控制台 API;同类型的其他实例拥有不同
|
||||
`component_id`,可在不同 OBS 场景中使用不同样式。
|
||||
|
||||
## 状态所有权
|
||||
|
||||
@@ -49,7 +51,7 @@ flowchart LR
|
||||
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||
| 点歌队列和评分 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
|
||||
| 点歌队列与历史 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
|
||||
| 账户语言偏好 | PostgreSQL | React/OBS runtime | TOML 目录验证、RLS 隔离 |
|
||||
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# 组件开发指南
|
||||
|
||||
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
||||
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||
组件是“消费所属账户事件流的独立功能实例”。当前内建
|
||||
`danmaku_overlay`、`song_request`、`gift_effect`、 `guard_effect` 与
|
||||
`gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||
|
||||
账户注册时会为每种内建 kind 创建一个初始实例。控制台可以为同一 kind 再创建多个命名实例;每个实例都有独立 settings、OBS
|
||||
token、`publicId` 与 `EventHub`
|
||||
channel。当前每账户每 kind 最多 16 个实例,且至少保留一个,避免误删后由启动回填产生一个意外的新地址。删除实例会级联删除它的 token 与组件专属关系数据,并立即关闭该实例的 WebSocket
|
||||
channel。
|
||||
|
||||
## 一个组件由什么组成
|
||||
|
||||
@@ -28,7 +34,8 @@
|
||||
- 数据库操作必须包含 owner/component 条件。
|
||||
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
||||
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
||||
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
||||
7. 增加数据库创建/设置 API;初始化组件专属关系数据,并确认删除实例时可以安全级联。不要把组件专属关系数据无限塞入JSON
|
||||
settings。
|
||||
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
||||
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
||||
|
||||
@@ -57,4 +64,4 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
|
||||
|
||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
||||
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md) 与
|
||||
[`gift-menu.md`](gift-menu.md)。
|
||||
[`guard-effect.md`](guard-effect.md)、[`gift-menu.md`](gift-menu.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `danmaku_overlay` 弹幕姬
|
||||
|
||||
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
|
||||
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。账户注册时会创建一个初始实例;同一账户可以继续添加多个弹幕姬实例,为横屏、竖屏或其他 OBS 场景分别保存样式和 OBS 地址。所有实例共享账户直播监听,但事件投影和 WebSocket 广播仍按实例 ID 隔离。
|
||||
|
||||
## 订阅事件
|
||||
|
||||
@@ -12,27 +12,31 @@
|
||||
|
||||
## 设置
|
||||
|
||||
| 字段 | 范围/单位 | 行为 |
|
||||
| ------------------------ | ----------- | -------------------------- |
|
||||
| `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 | 控制各事件类别订阅 |
|
||||
| 字段 | 范围/单位 | 行为 |
|
||||
| ------------------------ | ----------- | ------------------------------------------ |
|
||||
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
|
||||
| `fontFamily` | 字体 ID | `song`、`fang-song`、`kai`、`liyu-shoushu` |
|
||||
| `fontBrightness` | 70–180% | 只调整文字亮度 |
|
||||
| `viewerColor` | `#RRGGBB` | 可选的用户昵称颜色覆盖 |
|
||||
| `danmakuColor` | `#RRGGBB` | 可选的弹幕正文颜色覆盖 |
|
||||
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||
| `decorationLineWeight` | 50–300% | 边框、分隔线与古风边缘粗细 |
|
||||
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||
| `expandNewDanmaku` | boolean | 新弹幕是否先展开并在超时后收缩 |
|
||||
| `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 设备缺少首选字体时会依次回退到可用衬线字体。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
|
||||
input 只改善交互,不能取代服务端校验。字体 ID 只映射到前端预先注册并由服务同域提供的 WOFF2 字体,不接受任意 CSS,也不依赖 OBS 设备安装的字体;`song`、`fang-song`、`kai`
|
||||
与 `liyu-shoushu` 分别使用 Noto Serif SC、ZCOOL XiaoWei、LXGW
|
||||
WenKai 与漓雨手书。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
|
||||
|
||||
## 主题
|
||||
|
||||
@@ -51,13 +55,14 @@ input 只改善交互,不能取代服务端校验。字体 ID 只映射到前
|
||||
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件插入队首,以卷轴动画横向展开。
|
||||
1. 新事件追加在可视区域底部;启用 `expandNewDanmaku`
|
||||
时,普通弹幕以卷轴动画横向展开。关闭时,新弹幕保持紧凑态并从组件底边外向上推入,同步顶起旧事件;超出顶部的旧事件会被裁切。
|
||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||
5. 超过 `maxVisible` 的最旧卡才会离开队列。
|
||||
|
||||
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
|
||||
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。每条普通弹幕右端还会显示主题着色的内置透明莲花图案;渲染时预留文字空间,不会遮挡昵称、正文或表情。
|
||||
|
||||
## 礼物展示
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 全屏礼物特效组件
|
||||
|
||||
`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和
|
||||
`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅
|
||||
`live.gift.combo`,避免一次连击重复触发完整特效。
|
||||
`gift_effect`
|
||||
在账户注册时创建一个初始实例,并允许为不同 OBS 场景添加多个独立样式实例。每个实例只消费账户级直播源中的
|
||||
`live.gift`,使用自己的只读 token 作为透明 OBS 浏览器源;不订阅 `live.guard.buy` 或
|
||||
`live.gift.combo`。大航海事件由独立的 [`guard_effect`](guard-effect.md) 组件处理。
|
||||
|
||||
## 展示行为
|
||||
|
||||
@@ -11,11 +12,10 @@
|
||||
`highValueThreshold` 和 `featuredValueThreshold`
|
||||
分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。
|
||||
|
||||
舰长、提督或总督使用同一个 `live.guard.buy`
|
||||
路径,临时显示不透明星河、闪耀粒子、身份和用户昵称。多个普通礼物可并发;大航海展示选择最新事件。
|
||||
实时礼物进入浏览器内存中的有界 FIFO。画布始终只播放队首的一笔礼物,当前流星组、短笺或月下清供动画结束后才取出下一笔,因此密集投喂不会叠加特效或同时放大粒子负载。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不写入数据库,OBS 断线重连后只接收新事件。
|
||||
|
||||
`moonlit-water`(“静夜曲水”)则采用“月下清供”:价值低于 50 元(原始
|
||||
`totalPrice < 50000`)的礼物以右侧水纹短笺展示图标、用户、名称和价格;50 元及以上礼物在整张浏览器源上展开月轮、水纹、礼物图标和文字。舰长、提督、总督也使用全屏月夜祝贺。该主题的全屏动效仍不绘制不透明底色,OBS 场景始终可见。
|
||||
`moonlit-water`(“静夜曲水”)采用“月下清供”:价值低于 50 元(原始
|
||||
`totalPrice < 50000`)的礼物以右侧水纹短笺展示图标、用户、名称和价格;50 元及以上礼物在整张浏览器源上展开月轮、水纹、礼物图标和文字。该主题的全屏动效不绘制不透明底色,OBS 场景始终可见。
|
||||
|
||||
## 尺寸与性能
|
||||
|
||||
@@ -23,8 +23,7 @@ OBS 建议从 `1920×1080` 开始,但渲染器没有固定画布。`ResizeObse
|
||||
按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整:
|
||||
|
||||
- 三档数量、尺寸和速度;
|
||||
- 拖尾强度和最大并发特效数;
|
||||
- 大航海星数和全屏持续时间;
|
||||
- 拖尾强度和最大等待礼物数;
|
||||
- 中文字体与文字亮度;
|
||||
- 低性能模式(限制粒子和流星数量)。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 礼物菜单组件
|
||||
|
||||
`gift_menu`
|
||||
是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。
|
||||
在账户注册时创建一个初始实例,也可以为不同 OBS 场景添加多个实例。每个实例独立保存菜单内容、样式和只读 token;它把直播间礼物或大航海投喂映射为主播提供的内容说明,并输出透明 OBS 浏览器源。
|
||||
|
||||
## 目录与触发器
|
||||
|
||||
@@ -25,7 +25,7 @@ schema 没有提供盲盒原始 ID/名称,因此这类事件可以按实际电
|
||||
|
||||
## OBS 行为
|
||||
|
||||
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、高亮时长和动效强度均可调整。`jade-banquet`
|
||||
每行横向展示图标、触发条件与主播自定义说明。行数、行高、中文字体、文字亮度、文字比例、装饰线粗细、高亮时长和动效强度均可调整。`jade-banquet`
|
||||
仅在内容超过实际视口高度时滚动;渲染器复制三组菜单并在等价位置间无缝归一化,实现最后一行之后紧接第一行。
|
||||
|
||||
命中时,青玉主题选择距离当前滚动位置最近的匹配副本,将它平滑对齐到视口第一行并暂停自动滚动;静夜主题则以匹配项为新一页的第一行。两种主题都会播放流金渐变与星花粒子。触发用户名称作为整行前景居中显示,覆盖原礼物图标和说明,并允许长名称换行。青玉滚动器保留亚像素余量,低速设置也保持线性。低性能模式和系统减少动态效果偏好会关闭装饰粒子及分页过渡。舰长、提督和总督使用随前端打包的透明图标。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 大航海特效组件
|
||||
|
||||
`guard_effect`
|
||||
是与礼物星雨完全独立的组件类型。账户注册或升级时创建一个初始实例,同一账户可以继续添加多个实例;每个实例分别拥有 settings、只读 OBS
|
||||
token、`publicId` 和 WebSocket 广播通道。它只订阅 `live.guard.buy`,不接收普通礼物或连击事件。
|
||||
|
||||
从旧版本升级且账户尚无大航海组件时,服务会从最早的礼物特效实例复制主题、字体、星数、时长、性能模式及感谢文案作为初始设置。此后两个组件的设置互不关联。
|
||||
|
||||
## 青玉星落
|
||||
|
||||
舰长、提督或总督分别选择 `captain.webm`、`admiral.webm` 或 `general.webm`
|
||||
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户名,以及可编辑的收尾文字。标题模板可使用
|
||||
`{guard}`
|
||||
插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、用户名、“相伴前行”。用户名缺失时才回退显示 UID。
|
||||
|
||||
大航海事件进入浏览器内存中的有界 FIFO,当前约八秒的视频或月夜庆祝完整结束后才播放下一项,不会覆盖正在播放的用户。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不持久化,OBS 断线重连后只接收新事件。
|
||||
|
||||
感谢文字固定使用 Rust 静态服务同域提供的鸿雷行书简体 WOFF2,不读取 OBS 机器的系统字体。该字体随源文件提供的说明不包含开放再分发许可,公开或商业部署前需由部署者确认相应的 Web 嵌入和再分发授权。
|
||||
|
||||
## 静夜曲水
|
||||
|
||||
静夜主题使用透明月轮、水纹、身份、观众昵称与星光庆祝。控制台可以调整中文字体、文字亮度、星光数量、全屏持续时间和低性能模式。它不会绘制不透明的全场底色。
|
||||
|
||||
## OBS 与预览
|
||||
|
||||
组件适配任意浏览器源尺寸,控制台提供舰长、提督和总督三个独立预览入口。OBS 地址与礼物星雨完全不同;场景需要两种特效时,应分别添加两个浏览器源。
|
||||
@@ -1,27 +1,27 @@
|
||||
# `song_request` 点歌姬
|
||||
|
||||
每个账户自动拥有一个不可删除、不可重复创建的 `song_request`
|
||||
实例,并与该账户的固定直播源绑定。组件只订阅规范化的
|
||||
每个账户自动拥有一个初始 `song_request`
|
||||
实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、历史和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的
|
||||
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||
|
||||
## 弹幕命令
|
||||
|
||||
- `点歌 <歌名>`:合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||
- `打分 <1-5>`:对事务执行时的当前歌曲评分。同一 Bilibili UID 只有一票,重复评分会覆盖旧分数。
|
||||
- `点歌<歌名>` 或
|
||||
`点歌 <歌名>`:命令与歌名之间的空格可有可无;合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||
|
||||
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
||||
|
||||
## 设置
|
||||
|
||||
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`scrollSpeedPixelsPerSecond`
|
||||
`themeId`、`fontFamily`、`fontBrightness`、`requesterColor`、`songTitleColor`、`fontScale`、`decorationLineWeight`、`scrollSpeedPixelsPerSecond`
|
||||
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
|
||||
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
|
||||
|
||||
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名、紧凑评分和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
|
||||
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
|
||||
|
||||
## OBS 与管理
|
||||
|
||||
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
||||
OBS 顶部固定显示当前歌曲和点歌用户,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
||||
|
||||
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史与评分,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
||||
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
||||
RLS 再次限制组件归属。
|
||||
|
||||
+16
-4
@@ -43,11 +43,15 @@ log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
||||
`overlay.settings.snapshot` 兼容帧。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||
close 结束连接。
|
||||
|
||||
`componentId`/`publicId` 标识组件实例,而不是组件类型。同一账户可以创建多个相同 `componentKind`
|
||||
的实例;它们使用各自的设置、token 和 WebSocket 地址,客户端不能把同 kind 视为同一个订阅通道。
|
||||
|
||||
`language` 是组件所属账户的 locale。用户在控制台修改语言后,所有已连接组件会立即收到
|
||||
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
||||
|
||||
设置快照中的 `themeId` 是稳定的主题标识。弹幕姬与点歌姬支持 `jade-scroll` 和
|
||||
`moonlit-water`;礼物特效支持 `jade-starfall` 和 `moonlit-water`;礼物菜单支持 `jade-banquet` 和
|
||||
`moonlit-water`;礼物特效与大航海特效分别支持 `jade-starfall` 和 `moonlit-water`;礼物菜单支持
|
||||
`jade-banquet` 和
|
||||
`moonlit-water`。消费者应把未知主题降级为自身默认主题,不能因主题发布顺序不同而中断实时消息。
|
||||
|
||||
## 版本化事件信封
|
||||
@@ -102,8 +106,8 @@ close 结束连接。
|
||||
3. `song.queue.snapshot.end`:提交该快照。
|
||||
|
||||
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
|
||||
`completed`、`cancelled`、`cleared` 或
|
||||
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
|
||||
`completed`、`cancelled` 或
|
||||
`cleared`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
|
||||
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
|
||||
|
||||
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||
@@ -112,11 +116,19 @@ close 结束连接。
|
||||
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
||||
`live.gift`;连击事件用于更新同一张视觉卡片。
|
||||
|
||||
`gift_effect` 只订阅 `live.gift` 与 `live.guard.buy`。礼物档位由 sanitized settings 中的
|
||||
`gift_effect` 只订阅 `live.gift`,不会收到 `live.guard.buy`。礼物档位由 sanitized settings 中的
|
||||
`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或
|
||||
`gift.animationUrl`
|
||||
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
||||
|
||||
`guard_effect` 只订阅
|
||||
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。服务端会将 Bilibili 的
|
||||
`GUARD_BUY`、`USER_TOAST_MSG` 与当前的 `USER_TOAST_MSG_V2` 统一规范化为该事件;V2 的 `source = 2`
|
||||
镜像通知会被忽略,同一购买产生的重叠通知也会在短时间窗口内去重。
|
||||
|
||||
`gift_effect` 与 `guard_effect`
|
||||
的浏览器渲染器分别维护有界 FIFO,并且每次只播放一个 active 特效。动画完成后按到达顺序推进下一项;这个队列仅用于 OBS 视觉节流,不是可靠业务队列,也不会在重连后恢复。
|
||||
|
||||
`gift_menu` 同样只消费一次性 `live.gift` 与 `live.guard.buy`。命中配置后投影为
|
||||
`gift-menu.triggered`,payload 包含 `itemIds`、`viewer` 和
|
||||
`sourceEventId`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
||||
|
||||
+4
-1
@@ -14,6 +14,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
||||
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||
`SET`。
|
||||
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||
- 同一 kind 的多个实例仍逐行执行 owner 归属校验;实例 token 不能订阅同账户的另一个实例。
|
||||
- 路由器按事件的可信 `owner_id` 扇出,并在投影发布前再次验证 owner、账户 source 和 component ID。
|
||||
|
||||
## Secret 生命周期
|
||||
@@ -25,7 +26,7 @@ token。以下规则是实现约束,而不是可选部署建议。
|
||||
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
||||
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
||||
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
||||
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
|
||||
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件实例级轮换 |
|
||||
|
||||
`security.data_encryption_key`
|
||||
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
||||
@@ -37,6 +38,8 @@ token。以下规则是实现约束,而不是可选部署建议。
|
||||
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
|
||||
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
|
||||
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
|
||||
- 已登录账户可用当前 TOTP 或未消费恢复码开始短期 TOTP replacement
|
||||
enrollment。旧密钥在新动态码确认前保持有效;确认事务会更换密钥、轮换全部恢复码并撤销除当前会话外的其他网页登录会话。
|
||||
|
||||
## CookieCloud 与 SSRF
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 经理瓷给我的要求
|
||||
|
||||
1. 右下角的点歌界面应该从上往下滚动 ok
|
||||
2. 通过woff2直接从前端serve字体 ok
|
||||
3. 小礼物用扇子,大礼物用跳跃的鱼
|
||||
+152
-50
@@ -56,6 +56,7 @@ name = "简体中文"
|
||||
"api.error.enrollment_rate_limited" = "注册尝试过于频繁,请稍后重试。"
|
||||
"api.error.invitation_unavailable" = "邀请码无效、已过期或已使用。"
|
||||
"api.error.enrollment_unavailable" = "注册流程无效或已过期。"
|
||||
"api.error.totp_reset_unavailable" = "TOTP 重置流程无效或已过期,请重新开始。"
|
||||
"api.error.account_unavailable" = "账户不可用或用户名已被使用。"
|
||||
"api.error.song_request_not_found" = "点歌记录不存在。"
|
||||
"api.error.song_request_conflict" = "点歌队列已变化,请刷新后重试。"
|
||||
@@ -141,10 +142,11 @@ name = "简体中文"
|
||||
"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_family_description" = "字体随服务提供并由 OBS 同域加载,不依赖设备已安装字体。"
|
||||
"settings.font_family.song" = "思源宋体 · 端正"
|
||||
"settings.font_family.fang_song" = "小薇体 · 清雅"
|
||||
"settings.font_family.kai" = "霞鹜文楷 · 书卷"
|
||||
"settings.font_family.liyu_shoushu" = "漓雨手书 · 毛笔手写"
|
||||
"settings.font_brightness" = "文字亮度"
|
||||
"settings.font_brightness_description" = "只提亮文字,不改变透明背景、礼物图片或直播画面。"
|
||||
"settings.viewer_color" = "用户昵称颜色"
|
||||
@@ -152,7 +154,11 @@ name = "简体中文"
|
||||
"settings.use_theme_color" = "跟随主题"
|
||||
"settings.text_color_description" = "选择后覆盖主题颜色;恢复后会随主题自动变化。"
|
||||
"settings.font_size" = "字号"
|
||||
"settings.decoration_line_weight" = "装饰线粗细"
|
||||
"settings.decoration_line_weight_description" = "统一调整主题边框、分隔线和上下古风装饰边缘的视觉粗细。"
|
||||
"settings.max_visible" = "最大可见条数"
|
||||
"settings.new_danmaku_animation" = "新弹幕动画"
|
||||
"settings.expand_new_danmaku" = "启用展开后自动收缩"
|
||||
"settings.auto_collapse" = "自动收缩"
|
||||
"settings.unfold_duration" = "卷轴展开时长"
|
||||
"settings.motion_intensity" = "动效强度"
|
||||
@@ -167,6 +173,7 @@ name = "简体中文"
|
||||
"settings.event.like" = "点赞"
|
||||
"settings.event.share" = "分享"
|
||||
"settings.low_performance" = "低性能模式"
|
||||
"settings.queue_capacity_description" = "达到上限后忽略新的等待事件,正在播放和已排队的特效不受影响。"
|
||||
"settings.high_gift" = "高价值礼物阈值(厘)"
|
||||
"settings.featured_gift" = "特别高价值阈值(厘)"
|
||||
"settings.saving" = "正在保存…"
|
||||
@@ -248,18 +255,34 @@ name = "简体中文"
|
||||
"components.title" = "我的组件"
|
||||
"components.eyebrow" = "直播组件"
|
||||
"components.empty_title" = "还没有组件"
|
||||
"components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。"
|
||||
"components.empty_description" = "账户初始化完成后,服务会为每种内置组件创建一个初始实例。"
|
||||
"components.danmaku_mark" = "弹"
|
||||
"components.song_mark" = "歌"
|
||||
"components.gift_mark" = "礼"
|
||||
"components.guard_mark" = "舰"
|
||||
"components.gift_menu_mark" = "单"
|
||||
"components.generic_mark" = "件"
|
||||
"components.danmaku_type" = "直播弹幕姬"
|
||||
"components.song_type" = "直播点歌姬"
|
||||
"components.gift_type" = "全屏礼物星雨"
|
||||
"components.guard_type" = "全屏大航海特效"
|
||||
"components.gift_menu_type" = "直播礼物菜单"
|
||||
"components.coming_soon" = "即将支持"
|
||||
"components.future" = "更多主题 · 互动组件"
|
||||
"components.add_instance" = "添加组件实例"
|
||||
"components.add_instance_description" = "同一类型可以添加多个实例,每个实例单独保存样式和 OBS 地址。"
|
||||
"components.instance_type" = "组件类型"
|
||||
"components.instance_name" = "实例名称"
|
||||
"components.instance_name_placeholder" = "例如:竖屏场景"
|
||||
"components.create_instance" = "添加实例"
|
||||
"components.creating" = "正在添加…"
|
||||
"components.create_blocker" = "完成或清空正在填写的组件实例名称"
|
||||
"components.discard_changes_confirm" = "当前组件有尚未保存的设置。确定放弃这些修改吗?"
|
||||
"components.created" = "组件实例已添加,可以单独配置样式和 OBS 地址。"
|
||||
"components.create_failed" = "无法添加组件实例"
|
||||
"components.delete_instance" = "删除实例"
|
||||
"components.deleting" = "正在删除…"
|
||||
"components.delete_confirm" = "确定删除“{name}”吗?它的 OBS 地址、令牌和组件数据将同时失效。"
|
||||
"components.deleted" = "组件实例已删除。"
|
||||
"components.delete_failed" = "无法删除组件实例;每种类型至少需要保留一个实例。"
|
||||
"components.settings_blocker" = "保存或还原当前组件设置"
|
||||
"components.settings_load_failed" = "无法读取组件设置"
|
||||
"components.load_failed" = "控制台数据加载失败"
|
||||
@@ -269,9 +292,11 @@ name = "简体中文"
|
||||
"components.danmaku_settings" = "弹幕姬设置"
|
||||
"components.danmaku_description" = "每一项都独立保存在当前用户的组件下。"
|
||||
"components.song_settings" = "点歌姬设置"
|
||||
"components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
|
||||
"components.song_description" = "观众发送「点歌歌名」或「点歌 歌名」加入待唱队列。"
|
||||
"components.gift_settings" = "全屏礼物特效设置"
|
||||
"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。"
|
||||
"components.gift_description" = "礼物化作流星横跨透明画布;该组件只接收礼物,不接收大航海事件。"
|
||||
"components.guard_settings" = "全屏大航海特效设置"
|
||||
"components.guard_description" = "舰长、提督和总督事件独立触发全屏启航特效。"
|
||||
"components.gift_menu_settings" = "礼物菜单设置"
|
||||
"components.gift_menu_description" = "把当前直播间的礼物、大航海身份或指定电池单价映射为主播提供的直播内容。"
|
||||
"components.open_song_stats" = "打开点歌统计"
|
||||
@@ -284,6 +309,24 @@ name = "简体中文"
|
||||
"account.eyebrow" = "账户直播源"
|
||||
"account.description" = "配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。"
|
||||
"account.loading" = "正在读取账户直播源…"
|
||||
"security.title" = "账户安全"
|
||||
"security.description" = "为当前登录账户更换 TOTP 验证器;旧密钥会保持有效,直到新动态码确认成功。"
|
||||
"security.totp_reset_warning" = "需要使用当前动态验证码或一个未使用的恢复码再次验证身份。重置成功后,其他网页登录会话将被撤销。"
|
||||
"security.current_totp_code" = "当前 6 位动态验证码"
|
||||
"security.use_recovery_for_reset" = "验证器不可用?使用恢复码重置"
|
||||
"security.start_totp_reset" = "开始更换验证器"
|
||||
"security.preparing_totp" = "正在生成新 TOTP…"
|
||||
"security.totp_reset_start_failed" = "无法开始 TOTP 重置"
|
||||
"security.totp_reset_missing_id" = "服务端没有返回有效的 TOTP 重置标识"
|
||||
"security.old_totp_still_active" = "扫描并确认新二维码前,原有 TOTP 仍然有效;此时取消不会锁定账户。"
|
||||
"security.new_totp_code" = "新验证器中的 6 位动态验证码"
|
||||
"security.confirm_totp_reset" = "确认并启用新 TOTP"
|
||||
"security.cancel_totp_reset" = "取消本次重置"
|
||||
"security.totp_reset_cancelled" = "已取消;原有 TOTP 未被修改。"
|
||||
"security.totp_reset_confirm_failed" = "新动态验证码无效,或重置流程已经过期"
|
||||
"security.recovery_replaced" = "TOTP 已更换。原恢复码全部失效;以下新恢复码仅显示一次,请立即离线保存。"
|
||||
"security.totp_reset_complete" = "新 TOTP 和恢复码已启用。"
|
||||
"security.totp_reset_blocker" = "完成或取消 TOTP 重置,并保存新的一次性恢复码"
|
||||
"song.queue_changing" = "点歌队列正在频繁变化,请稍后重试"
|
||||
"song.queue_load_failed" = "点歌队列读取失败"
|
||||
"song.cancel_confirm" = "确定取消「{title}」吗?"
|
||||
@@ -302,11 +345,8 @@ name = "简体中文"
|
||||
"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})"
|
||||
@@ -319,7 +359,6 @@ name = "简体中文"
|
||||
"song.column.title" = "歌曲"
|
||||
"song.column.requester" = "点歌用户"
|
||||
"song.column.result" = "结果"
|
||||
"song.column.rating" = "评分"
|
||||
"song.column.finished" = "结束时间"
|
||||
"song.status.completed" = "已完成"
|
||||
"song.status.cancelled" = "已取消"
|
||||
@@ -371,13 +410,11 @@ name = "简体中文"
|
||||
"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.request_help" = "发送「点歌歌名」加入队列"
|
||||
"song.overlay.queue_aria" = "待唱队列"
|
||||
"song.overlay.queue_title" = "待唱列表"
|
||||
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
|
||||
@@ -388,7 +425,11 @@ name = "简体中文"
|
||||
"gift.theme.jade_starfall.name" = "青玉星落"
|
||||
"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。"
|
||||
"gift.theme.moonlit_water.name" = "静夜曲水"
|
||||
"gift.theme.moonlit_water.description" = "50 元以下礼物以水纹短笺轻声出现;50 元及以上礼物与大航海触发透明全屏月下清供。"
|
||||
"gift.theme.moonlit_water.description" = "50 元以下礼物以水纹短笺轻声出现;50 元及以上礼物触发透明全屏月下清供。"
|
||||
"guard.theme.jade_starfall.name" = "青玉星落"
|
||||
"guard.theme.jade_starfall.description" = "根据舰长、提督或总督播放对应的八秒全屏视频与行书卷轴。"
|
||||
"guard.theme.moonlit_water.name" = "静夜曲水"
|
||||
"guard.theme.moonlit_water.description" = "以透明月轮、水纹和星光庆祝大航海开通或续费。"
|
||||
"gift.settings.thresholds" = "礼物价值分档(原始价格,1000 = 1 元)"
|
||||
"gift.settings.high_threshold" = "高价值阈值"
|
||||
"gift.settings.featured_threshold" = "特别高价值阈值"
|
||||
@@ -401,24 +442,34 @@ name = "简体中文"
|
||||
"gift.settings.size" = "礼物主体大小"
|
||||
"gift.settings.speed" = "飞行速度"
|
||||
"gift.settings.trail" = "拖尾强度"
|
||||
"gift.settings.guard_stars" = "大航海星光数量"
|
||||
"gift.settings.guard_duration" = "大航海全屏时长"
|
||||
"gift.settings.concurrent" = "最大同时特效数"
|
||||
"guard.settings.stars" = "大航海星光数量"
|
||||
"guard.settings.duration" = "大航海全屏时长"
|
||||
"guard.settings.copy" = "青玉星落 · 感谢文字"
|
||||
"guard.settings.title_template" = "第一行"
|
||||
"guard.settings.title_template_description" = "使用 {guard} 插入舰长、提督或总督;默认“{guard}启航”。"
|
||||
"guard.settings.closing_text" = "第三行"
|
||||
"guard.settings.closing_text_description" = "显示在用户名下方;默认“相伴前行”。"
|
||||
"guard.settings.queue_capacity" = "最大等待大航海数"
|
||||
"gift.settings.queue_capacity" = "最大等待礼物数"
|
||||
"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" = "预览舰长特效"
|
||||
"guard.preview.title" = "全屏大航海特效预览"
|
||||
"guard.preview.description" = "预览按 16:9 缩放显示;三个身份分别使用各自的视频。"
|
||||
"guard.preview.captain_button" = "预览舰长启航"
|
||||
"guard.preview.admiral_button" = "预览提督启航"
|
||||
"guard.preview.governor_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} 的守护"
|
||||
"guard.effect_aria" = "大航海全屏庆祝特效"
|
||||
"guard.salute" = "星河为你闪耀"
|
||||
"guard.title" = "{guard}·星光献礼"
|
||||
"guard.viewer" = "感谢 {viewer} 的守护"
|
||||
"gift.moonlit.ceremony_label" = "月下清供"
|
||||
"gift_menu.theme.jade_banquet.name" = "青玉华宴"
|
||||
"gift_menu.theme.jade_banquet.description" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
||||
@@ -535,6 +586,7 @@ name = "English"
|
||||
"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.totp_reset_unavailable" = "The TOTP reset is invalid or expired. Start again."
|
||||
"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."
|
||||
@@ -620,10 +672,11 @@ name = "English"
|
||||
"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_family_description" = "Fonts are served by this service and loaded from the same origin instead of relying on fonts installed on the OBS device."
|
||||
"settings.font_family.song" = "Noto Serif SC · Formal"
|
||||
"settings.font_family.fang_song" = "ZCOOL XiaoWei · Refined"
|
||||
"settings.font_family.kai" = "LXGW WenKai · Calligraphic"
|
||||
"settings.font_family.liyu_shoushu" = "Liyu Shoushu · Brush handwriting"
|
||||
"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"
|
||||
@@ -631,7 +684,11 @@ name = "English"
|
||||
"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.decoration_line_weight" = "Decoration line weight"
|
||||
"settings.decoration_line_weight_description" = "Adjusts theme borders, dividers, and the upper and lower ornamental edges together."
|
||||
"settings.max_visible" = "Maximum visible items"
|
||||
"settings.new_danmaku_animation" = "New chat animation"
|
||||
"settings.expand_new_danmaku" = "Expand, then automatically collapse"
|
||||
"settings.auto_collapse" = "Auto-collapse"
|
||||
"settings.unfold_duration" = "Scroll-open duration"
|
||||
"settings.motion_intensity" = "Motion intensity"
|
||||
@@ -646,6 +703,7 @@ name = "English"
|
||||
"settings.event.like" = "Like"
|
||||
"settings.event.share" = "Share"
|
||||
"settings.low_performance" = "Low-performance mode"
|
||||
"settings.queue_capacity_description" = "New waiting events are ignored at the limit; the active effect and queued items are preserved."
|
||||
"settings.high_gift" = "High-value gift threshold (milli-CNY)"
|
||||
"settings.featured_gift" = "Featured gift threshold (milli-CNY)"
|
||||
"settings.saving" = "Saving…"
|
||||
@@ -727,18 +785,34 @@ name = "English"
|
||||
"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.empty_description" = "The service creates one initial instance of every built-in component after account initialization."
|
||||
"components.danmaku_mark" = "Chat"
|
||||
"components.song_mark" = "Song"
|
||||
"components.gift_mark" = "Gift"
|
||||
"components.guard_mark" = "Guard"
|
||||
"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.guard_type" = "Full-screen membership effect"
|
||||
"components.gift_menu_type" = "Live gift menu"
|
||||
"components.coming_soon" = "Coming soon"
|
||||
"components.future" = "More themes · Interactive components"
|
||||
"components.add_instance" = "Add component instance"
|
||||
"components.add_instance_description" = "Add multiple instances of one type, each with its own style and OBS address."
|
||||
"components.instance_type" = "Component type"
|
||||
"components.instance_name" = "Instance name"
|
||||
"components.instance_name_placeholder" = "For example: Portrait scene"
|
||||
"components.create_instance" = "Add instance"
|
||||
"components.creating" = "Adding…"
|
||||
"components.create_blocker" = "finish or clear the component instance name being entered"
|
||||
"components.discard_changes_confirm" = "The current component has unsaved settings. Discard those changes?"
|
||||
"components.created" = "Component instance added. Its style and OBS address can be configured independently."
|
||||
"components.create_failed" = "Could not add the component instance"
|
||||
"components.delete_instance" = "Delete instance"
|
||||
"components.deleting" = "Deleting…"
|
||||
"components.delete_confirm" = "Delete “{name}”? Its OBS address, token, and component data will stop working."
|
||||
"components.deleted" = "Component instance deleted."
|
||||
"components.delete_failed" = "Could not delete the component instance; at least one instance of each type must remain."
|
||||
"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"
|
||||
@@ -748,9 +822,11 @@ name = "English"
|
||||
"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.song_description" = "Viewers send “点歌Song name” or “点歌 Song name” to join the upcoming queue."
|
||||
"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_description" = "Gifts become meteors crossing a transparent canvas. This component receives gifts only, not membership events."
|
||||
"components.guard_settings" = "Full-screen membership effect settings"
|
||||
"components.guard_description" = "Guard, Admiral, and Governor events independently trigger a full-screen voyage effect."
|
||||
"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"
|
||||
@@ -763,6 +839,24 @@ name = "English"
|
||||
"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…"
|
||||
"security.title" = "Account security"
|
||||
"security.description" = "Replace the TOTP authenticator for the current account. The old secret remains active until the new code is confirmed."
|
||||
"security.totp_reset_warning" = "Verify again with the current TOTP or an unused recovery code. A successful reset revokes every other web login session."
|
||||
"security.current_totp_code" = "Current six-digit TOTP code"
|
||||
"security.use_recovery_for_reset" = "Authenticator unavailable? Reset with a recovery code"
|
||||
"security.start_totp_reset" = "Replace authenticator"
|
||||
"security.preparing_totp" = "Generating new TOTP…"
|
||||
"security.totp_reset_start_failed" = "Could not start the TOTP reset"
|
||||
"security.totp_reset_missing_id" = "The server did not return a valid TOTP reset identifier"
|
||||
"security.old_totp_still_active" = "Your old TOTP remains active until this QR code is confirmed. Cancelling now cannot lock the account."
|
||||
"security.new_totp_code" = "Six-digit code from the new authenticator"
|
||||
"security.confirm_totp_reset" = "Confirm and enable new TOTP"
|
||||
"security.cancel_totp_reset" = "Cancel this reset"
|
||||
"security.totp_reset_cancelled" = "Reset cancelled. The existing TOTP was not changed."
|
||||
"security.totp_reset_confirm_failed" = "The new code is invalid or the reset has expired"
|
||||
"security.recovery_replaced" = "TOTP was replaced and every old recovery code is invalid. Save these new one-time codes offline now; they are shown only once."
|
||||
"security.totp_reset_complete" = "The new TOTP and recovery codes are active."
|
||||
"security.totp_reset_blocker" = "finish or cancel TOTP reset and save the new one-time recovery codes"
|
||||
"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}”?"
|
||||
@@ -781,11 +875,8 @@ name = "English"
|
||||
"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})"
|
||||
@@ -798,7 +889,6 @@ name = "English"
|
||||
"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"
|
||||
@@ -850,13 +940,11 @@ name = "English"
|
||||
"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.request_help" = "Send “点歌Song name” or “点歌 Song name” to join the queue"
|
||||
"song.overlay.queue_aria" = "Upcoming queue"
|
||||
"song.overlay.queue_title" = "Upcoming songs"
|
||||
"song.overlay.queue_empty" = "Who will request the next song?"
|
||||
@@ -867,7 +955,11 @@ name = "English"
|
||||
"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.theme.moonlit_water.description" = "Gifts below CNY 50 arrive as a quiet waterline note; CNY 50+ gifts receive a transparent full-scene moonlit offering."
|
||||
"guard.theme.jade_starfall.name" = "Jade Starfall"
|
||||
"guard.theme.jade_starfall.description" = "Plays the corresponding eight-second full-screen video and calligraphic scroll for each membership tier."
|
||||
"guard.theme.moonlit_water.name" = "Moonlit Stillwater"
|
||||
"guard.theme.moonlit_water.description" = "Celebrates membership purchases and renewals with a transparent moon, waterlines, and starlight."
|
||||
"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"
|
||||
@@ -880,24 +972,34 @@ name = "English"
|
||||
"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"
|
||||
"guard.settings.stars" = "Membership star count"
|
||||
"guard.settings.duration" = "Membership full-screen duration"
|
||||
"guard.settings.copy" = "Jade Starfall · membership message"
|
||||
"guard.settings.title_template" = "First line"
|
||||
"guard.settings.title_template_description" = "Use {guard} to insert the Guard, Admiral, or Governor tier; the default is “{guard}启航”."
|
||||
"guard.settings.closing_text" = "Third line"
|
||||
"guard.settings.closing_text_description" = "Shown below the viewer name; the default is “相伴前行”."
|
||||
"guard.settings.queue_capacity" = "Maximum queued memberships"
|
||||
"gift.settings.queue_capacity" = "Maximum queued gifts"
|
||||
"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"
|
||||
"guard.preview.title" = "Full-screen membership effect preview"
|
||||
"guard.preview.description" = "The preview is scaled to 16:9; each membership tier uses its own video."
|
||||
"guard.preview.captain_button" = "Preview Guard voyage"
|
||||
"guard.preview.admiral_button" = "Preview Admiral voyage"
|
||||
"guard.preview.governor_button" = "Preview Governor voyage"
|
||||
"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"
|
||||
"guard.effect_aria" = "Full-screen membership celebration"
|
||||
"guard.salute" = "THE STARS SHINE FOR YOU"
|
||||
"guard.title" = "{guard} · STARLIGHT TRIBUTE"
|
||||
"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."
|
||||
|
||||
Reference in New Issue
Block a user