Compare commits
5
Commits
4eda93cf24
...
1020c75e37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1020c75e37 | ||
|
|
e5186f62bd | ||
|
|
845b0f5900 | ||
|
|
a36d511d39 | ||
|
|
acc4c117f7 |
@@ -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 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
||||
|
||||
|
||||
+16
-4
@@ -5,8 +5,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
||||
## 路由
|
||||
|
||||
| 路由 | 权限 | 作用 |
|
||||
| --------------------------------------- | --------------- | ------------------------------------ |
|
||||
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
|
||||
| --------------------------------------- | --------------- | -------------------------------- |
|
||||
| `/control/` | 登录用户 | 组件实例、测试、设置和 OBS token |
|
||||
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
|
||||
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
|
||||
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
|
||||
@@ -14,7 +14,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
||||
| `/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,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.')
|
||||
+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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -927,6 +927,13 @@ 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);
|
||||
@@ -1994,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 {
|
||||
@@ -2017,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;
|
||||
@@ -2352,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) {
|
||||
|
||||
+427
-117
@@ -17,6 +17,7 @@ import {
|
||||
normalizeComponents,
|
||||
normalizeGiftCatalog,
|
||||
normalizeGiftEffectSettings,
|
||||
normalizeGuardEffectSettings,
|
||||
normalizeGiftMenuSettings,
|
||||
normalizeInvitations,
|
||||
normalizeEnrollment,
|
||||
@@ -29,9 +30,12 @@ import {
|
||||
import { TotpQr } from './auth'
|
||||
import { Overlay } from './overlay'
|
||||
import { GiftEffectOverlay } from './giftEffect'
|
||||
import { GuardEffectOverlay } from './guardEffect'
|
||||
import { GiftMenuOverlay } from './giftMenu'
|
||||
import type { GiftEffectPreviewMode } from './giftEffect'
|
||||
import type { GuardEffectPreviewMode } from './guardEffect'
|
||||
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
|
||||
import { getGuardEffectTheme, guardEffectThemes } from './guardThemes'
|
||||
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes'
|
||||
import { PwaControls, usePwaUpdateBlocker } from './pwa'
|
||||
import { SongRequestOverlay } from './songOverlay'
|
||||
@@ -41,6 +45,7 @@ import { fontFamilies } from './typography'
|
||||
import type { FontFamilyId } from './typography'
|
||||
import {
|
||||
defaultGiftEffectSettings,
|
||||
defaultGuardEffectSettings,
|
||||
defaultGiftMenuSettings,
|
||||
defaultOverlaySettings,
|
||||
defaultSongRequestSettings,
|
||||
@@ -51,6 +56,7 @@ import type {
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
GiftEffectSettings,
|
||||
GuardEffectSettings,
|
||||
GiftCatalogItem,
|
||||
GiftMenuItem,
|
||||
GiftMenuSettings,
|
||||
@@ -201,10 +207,50 @@ function isGiftEffectKind(kind: string): boolean {
|
||||
return kind === 'gift_effect'
|
||||
}
|
||||
|
||||
function isGuardEffectKind(kind: string): boolean {
|
||||
return kind === 'guard_effect'
|
||||
}
|
||||
|
||||
function isGiftMenuKind(kind: string): boolean {
|
||||
return kind === 'gift_menu'
|
||||
}
|
||||
|
||||
const componentKinds = [
|
||||
'danmaku_overlay',
|
||||
'song_request',
|
||||
'gift_effect',
|
||||
'guard_effect',
|
||||
'gift_menu',
|
||||
] as const
|
||||
|
||||
function componentKindLabel(kind: string): string {
|
||||
return isDanmakuKind(kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(kind)
|
||||
? translate('components.song_type')
|
||||
: isGiftEffectKind(kind)
|
||||
? translate('components.gift_type')
|
||||
: isGuardEffectKind(kind)
|
||||
? translate('components.guard_type')
|
||||
: isGiftMenuKind(kind)
|
||||
? translate('components.gift_menu_type')
|
||||
: kind
|
||||
}
|
||||
|
||||
function componentKindMark(kind: string): string {
|
||||
return isDanmakuKind(kind)
|
||||
? translate('components.danmaku_mark')
|
||||
: isSongRequestKind(kind)
|
||||
? translate('components.song_mark')
|
||||
: isGiftEffectKind(kind)
|
||||
? translate('components.gift_mark')
|
||||
: isGuardEffectKind(kind)
|
||||
? translate('components.guard_mark')
|
||||
: isGiftMenuKind(kind)
|
||||
? translate('components.gift_menu_mark')
|
||||
: translate('components.generic_mark')
|
||||
}
|
||||
|
||||
type Flash = { kind: 'success' | 'error'; text: string } | undefined
|
||||
|
||||
function Panel({
|
||||
@@ -408,6 +454,7 @@ function SettingsEditor({
|
||||
type="range"
|
||||
min="2"
|
||||
max="120"
|
||||
disabled={!settings.expandNewDanmaku}
|
||||
value={settings.collapseAfterSeconds}
|
||||
onChange={event => edit('collapseAfterSeconds', +event.target.value)}
|
||||
/>
|
||||
@@ -422,6 +469,7 @@ function SettingsEditor({
|
||||
min="200"
|
||||
max="5000"
|
||||
step="100"
|
||||
disabled={!settings.expandNewDanmaku}
|
||||
value={settings.unfoldDurationMs}
|
||||
onChange={event => edit('unfoldDurationMs', +event.target.value)}
|
||||
/>
|
||||
@@ -465,6 +513,18 @@ function SettingsEditor({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="toggle-grid compact-toggle-grid">
|
||||
<legend>{translate('settings.new_danmaku_animation')}</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.expandNewDanmaku}
|
||||
onChange={event => edit('expandNewDanmaku', event.target.checked)}
|
||||
/>
|
||||
<span>{translate('settings.expand_new_danmaku')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="toggle-grid">
|
||||
<legend>{translate('settings.show_events')}</legend>
|
||||
{eventToggles.map(([key, labelKey]) => (
|
||||
@@ -852,42 +912,15 @@ function GiftEffectSettingsEditor({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
{translate('gift.settings.guard_stars')} <output>{settings.guardStarCount}</output>
|
||||
</span>
|
||||
{translate('gift.settings.queue_capacity')}
|
||||
<input
|
||||
type="range"
|
||||
min="8"
|
||||
max="96"
|
||||
value={settings.guardStarCount}
|
||||
onChange={event => edit('guardStarCount', +event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
{translate('gift.settings.guard_duration')}{' '}
|
||||
<output>{(settings.guardEffectDurationMs / 1000).toFixed(1)}s</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1000"
|
||||
max="15000"
|
||||
step="250"
|
||||
value={settings.guardEffectDurationMs}
|
||||
onChange={event => edit('guardEffectDurationMs', +event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
{translate('gift.settings.concurrent')} <output>{settings.maxConcurrentEffects}</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={settings.maxConcurrentEffects}
|
||||
onChange={event => edit('maxConcurrentEffects', +event.target.value)}
|
||||
max="1000"
|
||||
value={settings.queueCapacity}
|
||||
onChange={event => edit('queueCapacity', +event.target.value)}
|
||||
/>
|
||||
<small>{translate('settings.queue_capacity_description')}</small>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className="toggle-grid compact-toggle-grid">
|
||||
@@ -923,7 +956,7 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
|
||||
className="preview-panel"
|
||||
>
|
||||
<div className="preset-buttons gift-preview-buttons">
|
||||
{(['normal', 'high', 'featured', 'guard'] as const).map(candidate => (
|
||||
{(['normal', 'high', 'featured'] as const).map(candidate => (
|
||||
<button
|
||||
type="button"
|
||||
className={candidate === mode ? 'active' : 'secondary'}
|
||||
@@ -946,6 +979,171 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
|
||||
)
|
||||
}
|
||||
|
||||
function GuardEffectSettingsEditor({
|
||||
settings,
|
||||
onChange,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
settings: GuardEffectSettings
|
||||
onChange: (settings: GuardEffectSettings) => void
|
||||
onSave: () => Promise<void>
|
||||
saving: boolean
|
||||
}) {
|
||||
const edit = <K extends keyof GuardEffectSettings>(key: K, value: GuardEffectSettings[K]) =>
|
||||
onChange({ ...settings, [key]: value })
|
||||
const theme = getGuardEffectTheme(settings.themeId)
|
||||
return (
|
||||
<div className="settings-editor guard-effect-settings">
|
||||
<div className="field-grid theme-selector">
|
||||
<label>
|
||||
{translate('settings.theme')}
|
||||
<select
|
||||
value={settings.themeId}
|
||||
onChange={event =>
|
||||
edit('themeId', event.target.value as GuardEffectSettings['themeId'])
|
||||
}
|
||||
>
|
||||
{guardEffectThemes.map(candidate => (
|
||||
<option value={candidate.id} key={candidate.id}>
|
||||
{translate(candidate.nameKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>{translate(theme.descriptionKey)}</small>
|
||||
</label>
|
||||
</div>
|
||||
{settings.themeId === 'jade-starfall' ? (
|
||||
<fieldset className="limit-grid guard-copy-settings">
|
||||
<legend>{translate('guard.settings.copy')}</legend>
|
||||
<label>
|
||||
{translate('guard.settings.title_template')}
|
||||
<input
|
||||
required
|
||||
maxLength={80}
|
||||
value={settings.titleTemplate}
|
||||
onChange={event => edit('titleTemplate', event.target.value)}
|
||||
/>
|
||||
<small>{translate('guard.settings.title_template_description')}</small>
|
||||
</label>
|
||||
<label>
|
||||
{translate('guard.settings.closing_text')}
|
||||
<input
|
||||
required
|
||||
maxLength={80}
|
||||
value={settings.closingText}
|
||||
onChange={event => edit('closingText', event.target.value)}
|
||||
/>
|
||||
<small>{translate('guard.settings.closing_text_description')}</small>
|
||||
</label>
|
||||
</fieldset>
|
||||
) : (
|
||||
<>
|
||||
<TypographySettingsFields
|
||||
fontFamily={settings.fontFamily}
|
||||
fontBrightness={settings.fontBrightness}
|
||||
onFontFamily={value => edit('fontFamily', value)}
|
||||
onFontBrightness={value => edit('fontBrightness', value)}
|
||||
/>
|
||||
<div className="slider-grid guard-global-settings">
|
||||
<label>
|
||||
<span>
|
||||
{translate('guard.settings.stars')} <output>{settings.starCount}</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="8"
|
||||
max="96"
|
||||
value={settings.starCount}
|
||||
onChange={event => edit('starCount', +event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>
|
||||
{translate('guard.settings.duration')}{' '}
|
||||
<output>{(settings.effectDurationMs / 1000).toFixed(1)}s</output>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1000"
|
||||
max="15000"
|
||||
step="250"
|
||||
value={settings.effectDurationMs}
|
||||
onChange={event => edit('effectDurationMs', +event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className="toggle-grid compact-toggle-grid">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.lowPerformanceMode}
|
||||
onChange={event => edit('lowPerformanceMode', event.target.checked)}
|
||||
/>
|
||||
<span>{translate('settings.low_performance')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
<div className="field-grid queue-settings">
|
||||
<label>
|
||||
{translate('guard.settings.queue_capacity')}
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={settings.queueCapacity}
|
||||
onChange={event => edit('queueCapacity', +event.target.value)}
|
||||
/>
|
||||
<small>{translate('settings.queue_capacity_description')}</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions align-end">
|
||||
<button type="button" disabled={saving} onClick={() => void onSave()}>
|
||||
{saving ? translate('settings.saving') : translate('settings.save_sync')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GuardEffectPreview({ settings }: { settings: GuardEffectSettings }) {
|
||||
const [mode, setMode] = useState<GuardEffectPreviewMode>('captain')
|
||||
const [nonce, setNonce] = useState(0)
|
||||
const trigger = (next: GuardEffectPreviewMode) => {
|
||||
setMode(next)
|
||||
setNonce(current => current + 1)
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
title={translate('guard.preview.title')}
|
||||
description={translate('guard.preview.description')}
|
||||
className="preview-panel"
|
||||
>
|
||||
<div className="preset-buttons guard-preview-buttons">
|
||||
{(['captain', 'admiral', 'governor'] as const).map(candidate => (
|
||||
<button
|
||||
type="button"
|
||||
className={candidate === mode ? 'active' : 'secondary'}
|
||||
onClick={() => trigger(candidate)}
|
||||
key={candidate}
|
||||
>
|
||||
{translate(`guard.preview.${candidate}_button`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="gift-preview-viewport">
|
||||
<GuardEffectOverlay
|
||||
preview
|
||||
previewSettings={settings}
|
||||
previewMode={mode}
|
||||
previewNonce={nonce}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function GiftMenuSettingsEditor({
|
||||
componentId,
|
||||
settings,
|
||||
@@ -1880,14 +2078,12 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
|
||||
|
||||
function TestEvents({
|
||||
componentId,
|
||||
giftOnly = false,
|
||||
eventKinds,
|
||||
}: {
|
||||
componentId: string
|
||||
giftOnly?: boolean
|
||||
eventKinds: Array<'danmaku' | 'enter' | 'gift' | 'guard'>
|
||||
}) {
|
||||
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(
|
||||
giftOnly ? 'gift' : 'danmaku',
|
||||
)
|
||||
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(eventKinds[0])
|
||||
const [uid, setUid] = useState('test-viewer')
|
||||
const [name, setName] = useState(() => translate('test.default_viewer'))
|
||||
const [text, setText] = useState(() => translate('test.default_text'))
|
||||
@@ -1940,10 +2136,18 @@ function TestEvents({
|
||||
<label>
|
||||
{translate('test.event_type')}
|
||||
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
|
||||
{!giftOnly && <option value="danmaku">{translate('settings.event.danmaku')}</option>}
|
||||
{!giftOnly && <option value="enter">{translate('test.enter')}</option>}
|
||||
{eventKinds.includes('danmaku') && (
|
||||
<option value="danmaku">{translate('settings.event.danmaku')}</option>
|
||||
)}
|
||||
{eventKinds.includes('enter') && (
|
||||
<option value="enter">{translate('test.enter')}</option>
|
||||
)}
|
||||
{eventKinds.includes('gift') && (
|
||||
<option value="gift">{translate('settings.event.gift')}</option>
|
||||
)}
|
||||
{eventKinds.includes('guard') && (
|
||||
<option value="guard">{translate('test.guard')}</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -2048,11 +2252,28 @@ function ComponentList({
|
||||
components,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
creating,
|
||||
}: {
|
||||
components: ComponentSummary[]
|
||||
selectedId?: string
|
||||
onSelect: (component: ComponentSummary) => void
|
||||
onCreate: (kind: string, name: string) => Promise<boolean>
|
||||
creating: boolean
|
||||
}) {
|
||||
const [kind, setKind] = useState<(typeof componentKinds)[number]>('danmaku_overlay')
|
||||
const [name, setName] = useState('')
|
||||
usePwaUpdateBlocker(
|
||||
'component-create',
|
||||
translate('components.create_blocker'),
|
||||
creating || Boolean(name.trim()),
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (await onCreate(kind, name)) setName('')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="component-sidebar jade-panel">
|
||||
<div className="component-sidebar-heading">
|
||||
@@ -2074,49 +2295,43 @@ function ComponentList({
|
||||
key={component.id}
|
||||
>
|
||||
<span className="component-icon" aria-hidden="true">
|
||||
{isDanmakuKind(component.kind)
|
||||
? translate('components.danmaku_mark')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_mark')
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_mark')
|
||||
: isGiftMenuKind(component.kind)
|
||||
? translate('components.gift_menu_mark')
|
||||
: translate('components.generic_mark')}
|
||||
{componentKindMark(component.kind)}
|
||||
</span>
|
||||
<span>
|
||||
<b>
|
||||
{isDanmakuKind(component.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_type')
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_type')
|
||||
: isGiftMenuKind(component.kind)
|
||||
? translate('components.gift_menu_type')
|
||||
: component.name}
|
||||
</b>
|
||||
<small>
|
||||
{isDanmakuKind(component.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(component.kind)
|
||||
? translate('components.song_type')
|
||||
: isGiftEffectKind(component.kind)
|
||||
? translate('components.gift_type')
|
||||
: isGiftMenuKind(component.kind)
|
||||
? translate('components.gift_menu_type')
|
||||
: component.kind}
|
||||
</small>
|
||||
<b>{component.name}</b>
|
||||
<small>{componentKindLabel(component.kind)}</small>
|
||||
</span>
|
||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="future-components">
|
||||
<span>{translate('components.coming_soon')}</span>
|
||||
<small>{translate('components.future')}</small>
|
||||
</div>
|
||||
<form className="component-create" onSubmit={event => void submit(event)}>
|
||||
<b>{translate('components.add_instance')}</b>
|
||||
<small>{translate('components.add_instance_description')}</small>
|
||||
<select
|
||||
aria-label={translate('components.instance_type')}
|
||||
value={kind}
|
||||
onChange={event => setKind(event.target.value as typeof kind)}
|
||||
>
|
||||
{componentKinds.map(componentKind => (
|
||||
<option value={componentKind} key={componentKind}>
|
||||
{componentKindLabel(componentKind)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
maxLength={80}
|
||||
aria-label={translate('components.instance_name')}
|
||||
placeholder={translate('components.instance_name_placeholder')}
|
||||
value={name}
|
||||
onChange={event => setName(event.target.value)}
|
||||
/>
|
||||
<button disabled={creating || !name.trim()}>
|
||||
{translate(creating ? 'components.creating' : 'components.create_instance')}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -2134,6 +2349,8 @@ export function ComponentsPage({
|
||||
const [settings, setSettings] = useState<ComponentSettings>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const selectedIdRef = useRef<string | undefined>(undefined)
|
||||
const settingsRequestRef = useRef(0)
|
||||
@@ -2147,7 +2364,7 @@ export function ComponentsPage({
|
||||
usePwaUpdateBlocker(
|
||||
'component-settings',
|
||||
translate('components.settings_blocker'),
|
||||
saving || settingsDirty,
|
||||
saving || deleting || settingsDirty,
|
||||
)
|
||||
|
||||
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
|
||||
@@ -2171,6 +2388,11 @@ export function ComponentsPage({
|
||||
...defaultGiftEffectSettings,
|
||||
...normalizeGiftEffectSettings(payload),
|
||||
}
|
||||
: isGuardEffectKind(component.kind)
|
||||
? {
|
||||
...defaultGuardEffectSettings,
|
||||
...normalizeGuardEffectSettings(payload),
|
||||
}
|
||||
: isGiftMenuKind(component.kind)
|
||||
? {
|
||||
...defaultGiftMenuSettings,
|
||||
@@ -2225,6 +2447,12 @@ export function ComponentsPage({
|
||||
}, [loadComponentSettings])
|
||||
|
||||
const choose = (component: ComponentSummary) => {
|
||||
if (
|
||||
component.id !== selectedId &&
|
||||
settingsDirty &&
|
||||
!window.confirm(translate('components.discard_changes_confirm'))
|
||||
)
|
||||
return
|
||||
selectedIdRef.current = component.id
|
||||
setSelectedId(component.id)
|
||||
const url = new URL(location.href)
|
||||
@@ -2233,6 +2461,76 @@ export function ComponentsPage({
|
||||
void loadComponentSettings(component)
|
||||
}
|
||||
|
||||
const createInstance = async (kind: string, name: string): Promise<boolean> => {
|
||||
if (settingsDirty && !window.confirm(translate('components.discard_changes_confirm')))
|
||||
return false
|
||||
setCreating(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
'/api/v1/components',
|
||||
json('POST', { kind, name: name.trim() }),
|
||||
)
|
||||
const component = normalizeComponents(payload)[0]
|
||||
if (!component) throw new Error(translate('components.create_failed'))
|
||||
setComponents(current => [...current, component])
|
||||
selectedIdRef.current = component.id
|
||||
setSelectedId(component.id)
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.set('component', component.id)
|
||||
history.replaceState(null, '', url)
|
||||
await loadComponentSettings(component)
|
||||
setFlash({ kind: 'success', text: translate('components.created') })
|
||||
return true
|
||||
} catch (reason) {
|
||||
setFlash({
|
||||
kind: 'error',
|
||||
text: errorMessage(reason, translate('components.create_failed')),
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (
|
||||
!selected ||
|
||||
!window.confirm(translate('components.delete_confirm', { name: selected.name }))
|
||||
)
|
||||
return
|
||||
setDeleting(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
await api(`/api/v1/components/${encodeURIComponent(selected.id)}`, json('DELETE'))
|
||||
const remaining = components.filter(component => component.id !== selected.id)
|
||||
setComponents(remaining)
|
||||
const next = remaining.find(component => component.kind === selected.kind) ?? remaining[0]
|
||||
settingsRequestRef.current += 1
|
||||
savedSettingsRef.current = undefined
|
||||
setSettings(undefined)
|
||||
selectedIdRef.current = next?.id
|
||||
setSelectedId(next?.id)
|
||||
const url = new URL(location.href)
|
||||
if (next) {
|
||||
url.searchParams.set('component', next.id)
|
||||
history.replaceState(null, '', url)
|
||||
await loadComponentSettings(next)
|
||||
} else {
|
||||
url.searchParams.delete('component')
|
||||
history.replaceState(null, '', url)
|
||||
}
|
||||
setFlash({ kind: 'success', text: translate('components.deleted') })
|
||||
} catch (reason) {
|
||||
setFlash({
|
||||
kind: 'error',
|
||||
text: errorMessage(reason, translate('components.delete_failed')),
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!selected || !settings) return
|
||||
const componentId = selected.id
|
||||
@@ -2255,6 +2553,11 @@ export function ComponentsPage({
|
||||
...defaultGiftEffectSettings,
|
||||
...normalizeGiftEffectSettings(payload),
|
||||
}
|
||||
: isGuardEffectKind(selected.kind)
|
||||
? {
|
||||
...defaultGuardEffectSettings,
|
||||
...normalizeGuardEffectSettings(payload),
|
||||
}
|
||||
: isGiftMenuKind(selected.kind)
|
||||
? {
|
||||
...defaultGiftMenuSettings,
|
||||
@@ -2282,7 +2585,13 @@ export function ComponentsPage({
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<div className="dashboard-grid">
|
||||
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
|
||||
<ComponentList
|
||||
components={components}
|
||||
selectedId={selectedId}
|
||||
onSelect={choose}
|
||||
onCreate={createInstance}
|
||||
creating={creating}
|
||||
/>
|
||||
<div className="dashboard-content">
|
||||
{loading && (
|
||||
<div className="loading-panel jade-panel">{translate('components.loading')}</div>
|
||||
@@ -2292,29 +2601,10 @@ export function ComponentsPage({
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">
|
||||
{isDanmakuKind(selected.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(selected.kind)
|
||||
? translate('components.song_type')
|
||||
: isGiftEffectKind(selected.kind)
|
||||
? translate('components.gift_type')
|
||||
: isGiftMenuKind(selected.kind)
|
||||
? translate('components.gift_menu_type')
|
||||
: selected.kind}
|
||||
</p>
|
||||
<h1>
|
||||
{isDanmakuKind(selected.kind)
|
||||
? translate('components.danmaku_type')
|
||||
: isSongRequestKind(selected.kind)
|
||||
? translate('components.song_type')
|
||||
: isGiftEffectKind(selected.kind)
|
||||
? translate('components.gift_type')
|
||||
: isGiftMenuKind(selected.kind)
|
||||
? translate('components.gift_menu_type')
|
||||
: selected.name}
|
||||
</h1>
|
||||
<p className="eyebrow">{componentKindLabel(selected.kind)}</p>
|
||||
<h1>{selected.name}</h1>
|
||||
</div>
|
||||
<div className="page-heading-actions">
|
||||
<span
|
||||
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
|
||||
>
|
||||
@@ -2322,6 +2612,17 @@ export function ComponentsPage({
|
||||
? translate('common.disabled')
|
||||
: translate('common.enabled')}
|
||||
</span>
|
||||
{components.filter(component => component.kind === selected.kind).length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={deleting}
|
||||
onClick={() => void deleteSelected()}
|
||||
>
|
||||
{translate(deleting ? 'components.deleting' : 'components.delete_instance')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isDanmakuKind(selected.kind) && settings ? (
|
||||
<>
|
||||
@@ -2387,6 +2688,21 @@ export function ComponentsPage({
|
||||
</Panel>
|
||||
<GiftEffectPreview settings={settings as GiftEffectSettings} />
|
||||
</>
|
||||
) : isGuardEffectKind(selected.kind) && settings ? (
|
||||
<>
|
||||
<Panel
|
||||
title={translate('components.guard_settings')}
|
||||
description={translate('components.guard_description')}
|
||||
>
|
||||
<GuardEffectSettingsEditor
|
||||
settings={settings as GuardEffectSettings}
|
||||
onChange={next => setSettings(next)}
|
||||
onSave={saveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
</Panel>
|
||||
<GuardEffectPreview settings={settings as GuardEffectSettings} />
|
||||
</>
|
||||
) : isGiftMenuKind(selected.kind) && settings ? (
|
||||
<>
|
||||
<Panel
|
||||
@@ -2411,10 +2727,19 @@ export function ComponentsPage({
|
||||
<ObsAccessPanel component={selected} key={selected.id} />
|
||||
{(isDanmakuKind(selected.kind) ||
|
||||
isGiftEffectKind(selected.kind) ||
|
||||
isGuardEffectKind(selected.kind) ||
|
||||
isGiftMenuKind(selected.kind)) && (
|
||||
<TestEvents
|
||||
componentId={selected.id}
|
||||
giftOnly={isGiftEffectKind(selected.kind) || isGiftMenuKind(selected.kind)}
|
||||
eventKinds={
|
||||
isGiftEffectKind(selected.kind)
|
||||
? ['gift']
|
||||
: isGuardEffectKind(selected.kind)
|
||||
? ['guard']
|
||||
: isGiftMenuKind(selected.kind)
|
||||
? ['gift', 'guard']
|
||||
: ['danmaku', 'enter', 'gift', 'guard']
|
||||
}
|
||||
key={`test-${selected.id}`}
|
||||
/>
|
||||
)}
|
||||
@@ -2659,7 +2984,6 @@ export function SongRequestsPage({
|
||||
[translate('song.stat.active'), summary?.activeCount ?? 0],
|
||||
[translate('song.stat.completed'), summary?.completedCount ?? 0],
|
||||
[translate('song.stat.cancelled'), summary?.cancelledCount ?? 0],
|
||||
[translate('song.stat.ratings'), summary?.ratingCount ?? 0],
|
||||
].map(([label, value]) => (
|
||||
<div className="stat-card jade-panel" key={label}>
|
||||
<small>{label}</small>
|
||||
@@ -2678,14 +3002,6 @@ export function SongRequestsPage({
|
||||
{active.current.requester.name} · UID {active.current.requester.uid}
|
||||
</small>
|
||||
<h2>{active.current.title}</h2>
|
||||
<p>
|
||||
{active.current.ratingCount
|
||||
? translate('song.average_score', {
|
||||
score: active.current.averageScore?.toFixed(2) ?? '—',
|
||||
count: active.current.ratingCount,
|
||||
})
|
||||
: translate('song.no_rating')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
@@ -2762,7 +3078,6 @@ export function SongRequestsPage({
|
||||
<th>{translate('song.column.title')}</th>
|
||||
<th>{translate('song.column.requester')}</th>
|
||||
<th>{translate('song.column.result')}</th>
|
||||
<th>{translate('song.column.rating')}</th>
|
||||
<th>{translate('song.column.finished')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -2784,17 +3099,12 @@ export function SongRequestsPage({
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{item.ratingCount
|
||||
? `${item.averageScore?.toFixed(1)} / 5(${item.ratingCount})`
|
||||
: '—'}
|
||||
</td>
|
||||
<td>{formatDate(item.finishedAt ?? undefined)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{historyPage && historyPage.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<td colSpan={4}>
|
||||
<div className="empty-state">{translate('song.no_history')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
+103
-137
@@ -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)
|
||||
return (
|
||||
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
|
||||
{Array.from({ length: count }, (_, index) => {
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
const meteors = Array.from({ length: count }, (_, index) => {
|
||||
const seed = hash(`${effect.id}:${index}`)
|
||||
const startY = 7 + (seed % 78)
|
||||
const drift = ((seed >>> 8) % 37) - 18
|
||||
const delay = index * 95 + ((seed >>> 16) % 260)
|
||||
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
|
||||
return {
|
||||
index,
|
||||
startY: 7 + (seed % 78),
|
||||
drift: ((seed >>> 8) % 37) - 18,
|
||||
delay: index * 95 + ((seed >>> 16) % 260),
|
||||
duration,
|
||||
lifetime:
|
||||
(reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260),
|
||||
}
|
||||
})
|
||||
const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600)
|
||||
return (
|
||||
<div
|
||||
className={`meteor-burst tier-${tier}`}
|
||||
aria-label={gift.name || translate('common.gift')}
|
||||
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
|
||||
onComplete()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{meteors.map(({ index, startY, drift, delay, duration }) => {
|
||||
return (
|
||||
<div
|
||||
className="gift-meteor"
|
||||
@@ -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,34 +393,21 @@ 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'
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
style={{
|
||||
...giftThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
}}
|
||||
>
|
||||
<div className="meteor-sky" aria-live="polite">
|
||||
{remote.effects
|
||||
.filter(effect => effect.kind === 'gift')
|
||||
.map(effect => {
|
||||
const 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)
|
||||
if (moonlit) {
|
||||
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
|
||||
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
|
||||
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} key={effect.id} />
|
||||
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
) : (
|
||||
<MeteorBurst
|
||||
effect={effect}
|
||||
tier={tier}
|
||||
@@ -464,12 +416,26 @@ export function GiftEffectOverlay({
|
||||
viewportWidth={bounds.width}
|
||||
trailIntensity={settings.trailIntensity}
|
||||
lowPerformance={settings.lowPerformanceMode}
|
||||
onComplete={onComplete}
|
||||
key={effect.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
|
||||
data-theme={theme.id}
|
||||
data-connection={stream?.connection || 'idle'}
|
||||
data-queue-length={remote.pendingCount}
|
||||
style={{
|
||||
...giftThemeVariables(theme),
|
||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||
}}
|
||||
>
|
||||
<div className="meteor-sky" aria-live="polite">
|
||||
{activeVisual}
|
||||
</div>
|
||||
{guard && <GuardCelebration effect={guard} settings={settings} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
@@ -335,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
|
||||
@@ -390,6 +393,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
}, [items.length, language, preview, setItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.expandNewDanmaku) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
}
|
||||
const newest = items[items.length - 1]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
@@ -402,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={
|
||||
@@ -430,12 +515,12 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -39,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}`,
|
||||
@@ -49,7 +47,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
||||
status: 'queued',
|
||||
queuePosition: index + 1,
|
||||
requestedAt: now,
|
||||
ratingCount: 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -199,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 (
|
||||
@@ -358,15 +347,11 @@ 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>
|
||||
</section>
|
||||
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
|
||||
<header>
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
|
||||
+37
-11
@@ -24,6 +24,7 @@ export type OverlaySettings = {
|
||||
showLike: boolean
|
||||
showShare: boolean
|
||||
maxVisible: number
|
||||
expandNewDanmaku: boolean
|
||||
collapseAfterSeconds: number
|
||||
unfoldDurationMs: number
|
||||
motionIntensity: number
|
||||
@@ -50,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
||||
showLike: false,
|
||||
showShare: false,
|
||||
maxVisible: 5,
|
||||
expandNewDanmaku: true,
|
||||
collapseAfterSeconds: 12,
|
||||
unfoldDurationMs: 1000,
|
||||
motionIntensity: 70,
|
||||
@@ -100,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
|
||||
@@ -111,9 +113,7 @@ export type GiftEffectSettings = {
|
||||
high: MeteorTierSettings
|
||||
featured: MeteorTierSettings
|
||||
trailIntensity: number
|
||||
guardStarCount: number
|
||||
guardEffectDurationMs: number
|
||||
maxConcurrentEffects: number
|
||||
queueCapacity: number
|
||||
lowPerformanceMode: boolean
|
||||
}
|
||||
|
||||
@@ -127,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,
|
||||
}
|
||||
|
||||
@@ -195,7 +220,11 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
|
||||
}
|
||||
|
||||
export type ComponentSettings =
|
||||
OverlaySettings | SongRequestSettings | GiftEffectSettings | GiftMenuSettings
|
||||
| OverlaySettings
|
||||
| SongRequestSettings
|
||||
| GiftEffectSettings
|
||||
| GuardEffectSettings
|
||||
| GiftMenuSettings
|
||||
|
||||
export type SongRequester = { uid: string; name: string }
|
||||
|
||||
@@ -208,8 +237,6 @@ export type SongRequestItem = {
|
||||
requestedAt: string
|
||||
startedAt?: string | null
|
||||
finishedAt?: string | null
|
||||
averageScore?: number | null
|
||||
ratingCount: number
|
||||
}
|
||||
|
||||
export type SongQueueSummary = {
|
||||
@@ -217,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,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.';
|
||||
@@ -302,6 +302,10 @@ 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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ struct OverlayFileConfig {
|
||||
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>,
|
||||
@@ -345,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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(|| {
|
||||
} => {
|
||||
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!({
|
||||
|
||||
@@ -57,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,
|
||||
@@ -89,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,
|
||||
@@ -141,6 +144,10 @@ fn default_unfold_duration_ms() -> u16 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_expand_new_danmaku() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_particle_count() -> u8 {
|
||||
8
|
||||
}
|
||||
@@ -588,6 +595,7 @@ mod tests {
|
||||
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,
|
||||
@@ -604,6 +612,7 @@ mod tests {
|
||||
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);
|
||||
@@ -630,6 +639,7 @@ mod tests {
|
||||
object.remove("viewerColor");
|
||||
object.remove("danmakuColor");
|
||||
object.remove("decorationLineWeight");
|
||||
object.remove("expandNewDanmaku");
|
||||
object.remove("unfoldDurationMs");
|
||||
object.remove("particleCount");
|
||||
object.remove("particleSpeed");
|
||||
@@ -642,6 +652,7 @@ mod tests {
|
||||
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,13 +117,22 @@ impl TenantRepository {
|
||||
&[&tenant.user_id, &component_id],
|
||||
)
|
||||
.await?;
|
||||
let has_gift_effect = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GIFT_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_gift_effect {
|
||||
let gift_settings = serde_json::to_value(GiftEffectSettings::default())
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
@@ -114,13 +142,61 @@ impl TenantRepository {
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_guard_effect = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GUARD_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_guard_effect {
|
||||
let legacy_gift_settings = transaction
|
||||
.query_opt(
|
||||
"SELECT settings FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2 ORDER BY created_at,id LIMIT 1",
|
||||
&[&tenant.user_id, &GIFT_EFFECT_KIND],
|
||||
)
|
||||
.await?
|
||||
.map(|row| row.get::<_, Value>(0));
|
||||
let guard_settings = legacy_gift_settings
|
||||
.as_ref()
|
||||
.map(settings_from_legacy_gift)
|
||||
.unwrap_or_else(GuardEffectSettings::default);
|
||||
let guard_settings = serde_json::to_value(guard_settings)
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
&GUARD_EFFECT_KIND,
|
||||
&GUARD_EFFECT_NAME,
|
||||
&guard_settings,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let has_gift_menu = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||
WHERE owner_user_id=$1 AND kind=$2)",
|
||||
&[&tenant.user_id, &GIFT_MENU_KIND],
|
||||
)
|
||||
.await?
|
||||
.get::<_, bool>(0);
|
||||
if !has_gift_menu {
|
||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||
.map_err(|error| RepositoryError::Invalid(error.to_string()))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO component_instances \
|
||||
(id,owner_user_id,kind,name,settings,settings_version,enabled) \
|
||||
VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING",
|
||||
VALUES($1,$2,$3,$4,$5,1,true)",
|
||||
&[
|
||||
&Uuid::new_v4(),
|
||||
&tenant.user_id,
|
||||
@@ -130,6 +206,7 @@ impl TenantRepository {
|
||||
],
|
||||
)
|
||||
.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
|
||||
|
||||
@@ -128,16 +128,13 @@ 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 {
|
||||
"点歌" => {
|
||||
let argument = canonical.strip_prefix("点歌")?.trim_start();
|
||||
if argument.is_empty() || argument.chars().count() > 80 {
|
||||
return None;
|
||||
}
|
||||
@@ -145,17 +142,6 @@ pub fn parse_command(text: &str) -> Option<SongCommand> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_whitespace(value: &str) -> String {
|
||||
@@ -180,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)]
|
||||
@@ -191,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)]
|
||||
@@ -241,12 +224,12 @@ 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 {
|
||||
let SongCommand::Request {
|
||||
title,
|
||||
normalized_title,
|
||||
} => {
|
||||
self.request_song(
|
||||
} = command;
|
||||
let result = self
|
||||
.request_song(
|
||||
component,
|
||||
&danmaku.viewer,
|
||||
&title,
|
||||
@@ -254,12 +237,7 @@ impl SongRequestService {
|
||||
event.id,
|
||||
&settings,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
SongCommand::Rate(score) => {
|
||||
self.rate_current(component, &danmaku.viewer, score).await?
|
||||
}
|
||||
};
|
||||
.await?;
|
||||
if let Some(change) = result {
|
||||
self.publish_change(component, &event.room_id, change)?;
|
||||
}
|
||||
@@ -299,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
|
||||
@@ -342,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],
|
||||
@@ -671,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,
|
||||
@@ -846,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 {
|
||||
@@ -863,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,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)))
|
||||
}
|
||||
@@ -888,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],
|
||||
@@ -906,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],
|
||||
)
|
||||
@@ -917,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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1079,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 {
|
||||
@@ -1087,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]
|
||||
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 隔离。
|
||||
|
||||
## 订阅事件
|
||||
|
||||
@@ -13,15 +13,16 @@
|
||||
## 设置
|
||||
|
||||
| 字段 | 范围/单位 | 行为 |
|
||||
| ------------------------ | ----------- | -------------------------- |
|
||||
| ------------------------ | ----------- | ------------------------------------------ |
|
||||
| `themeId` | 主题 ID | 选择已安装的完整视觉主题 |
|
||||
| `fontFamily` | 字体 ID | `song`、`fang-song`、`kai` |
|
||||
| `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% | 卡片、流光与焦点动画强度 |
|
||||
@@ -33,7 +34,9 @@
|
||||
| `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 与漓雨手书。颜色覆盖同样只接受六位十六进制颜色;留空时使用主题色,切换主题后会自动采用新主题的配色。
|
||||
|
||||
## 主题
|
||||
|
||||
@@ -52,7 +55,8 @@ input 只改善交互,不能取代服务端校验。字体 ID 只映射到前
|
||||
|
||||
## 卡片生命周期
|
||||
|
||||
1. 新事件追加在可视区域底部,以卷轴动画横向展开;旧事件被向上顶出并裁切。
|
||||
1. 新事件追加在可视区域底部;启用 `expandNewDanmaku`
|
||||
时,普通弹幕以卷轴动画横向展开。关闭时,新弹幕保持紧凑态并从组件底边外向上推入,同步顶起旧事件;超出顶部的旧事件会被裁切。
|
||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||
|
||||
@@ -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 浏览器源。
|
||||
|
||||
## 目录与触发器
|
||||
|
||||
|
||||
@@ -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,13 +1,13 @@
|
||||
# `song_request` 点歌姬
|
||||
|
||||
每个账户自动拥有一个不可删除、不可重复创建的 `song_request`
|
||||
实例,并与该账户的固定直播源绑定。组件只订阅规范化的
|
||||
每个账户自动拥有一个初始 `song_request`
|
||||
实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、历史和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的
|
||||
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||
|
||||
## 弹幕命令
|
||||
|
||||
- `点歌 <歌名>`:合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||
- `打分 <1-5>`:对事务执行时的当前歌曲评分。同一 Bilibili UID 只有一票,重复评分会覆盖旧分数。
|
||||
- `点歌<歌名>` 或
|
||||
`点歌 <歌名>`:命令与歌名之间的空格可有可无;合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||
|
||||
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
和 `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`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
||||
|
||||
+2
-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 或写入镜像。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 经理瓷给我的要求
|
||||
|
||||
1. 右下角的点歌界面应该从上往下滚动 ok
|
||||
2. 通过woff2直接从前端serve字体 ok
|
||||
3. 小礼物用扇子,大礼物用跳跃的鱼
|
||||
+110
-50
@@ -142,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" = "用户昵称颜色"
|
||||
@@ -156,6 +157,8 @@ name = "简体中文"
|
||||
"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" = "动效强度"
|
||||
@@ -170,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" = "正在保存…"
|
||||
@@ -251,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" = "控制台数据加载失败"
|
||||
@@ -272,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" = "打开点歌统计"
|
||||
@@ -323,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})"
|
||||
@@ -340,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" = "已取消"
|
||||
@@ -392,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" = "下一首,会由谁来点呢?"
|
||||
@@ -409,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" = "特别高价值阈值"
|
||||
@@ -422,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" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
||||
@@ -642,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"
|
||||
@@ -656,6 +687,8 @@ name = "English"
|
||||
"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"
|
||||
@@ -670,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…"
|
||||
@@ -751,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"
|
||||
@@ -772,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"
|
||||
@@ -823,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})"
|
||||
@@ -840,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"
|
||||
@@ -892,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?"
|
||||
@@ -909,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"
|
||||
@@ -922,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