Compare commits

...
5 Commits
Author SHA1 Message Date
felis 1020c75e37 fix guard effects 2026-08-28 21:28:53 -07:00
felis e5186f62bd 调整弹幕姬进入动态 2026-08-19 12:50:19 -07:00
felis 845b0f5900 更新点歌和弹幕微调 2026-08-19 12:30:30 -07:00
felis a36d511d39 update UI details for new stream 2026-08-14 12:20:46 -07:00
felis acc4c117f7 Font related improvements 2026-08-13 12:05:47 -07:00
60 changed files with 2969 additions and 963 deletions
+12 -7
View File
@@ -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
View File
@@ -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)
+30
View File
@@ -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",
+4 -1
View File
@@ -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"
},
+7
View File
@@ -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.
+33
View File
@@ -0,0 +1,33 @@
# Bundled font notice
## Liyu Shoushu
`liyu-shoushu-v0.107.woff2` is a WOFF2 conversion of `LiyuShoushu.ttf` from the official
[Liyu Shoushu v0.107 release](https://github.com/InkPantherType/LiyuShoushu/releases/tag/v0.107).
- Release tag commit: `219a0f42cb7b3f0d00be6e5bf9f8b5298a1f3060`
- Original TTF SHA-256: `16554439b7e95da1fc354ed7b2c1551e83e942c3c5afb2a2e883761c6037dc22`
- Bundled WOFF2 SHA-256: `b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3`
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
- Coverage retained: 18,828 glyphs and 18,221 Unicode mappings
- License: SIL Open Font License 1.1; see
[`licenses/liyu-shoushu-OFL.txt`](licenses/liyu-shoushu-OFL.txt)
The original font credits Yuji Kataoka and the Yuji Project Authors for the base brush glyphs, LXGW
and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's
[zi2zi-JiT](https://github.com/kaonashi-tyc/zi2zi-JiT) for generated Chinese glyphs. This notice
preserves the zi2zi-JiT attribution required by the upstream font documentation.
## HongLei XingShu Jian
`honglei-xingshu-jian.woff2` is a full-font WOFF2 conversion of the project owner's
`鸿雷行书简体.otf`; it is used only by the independent Jade Starfall membership component.
- Embedded family: `hongleixingshu` / `鸿雷行书简体`
- Original OTF SHA-256: `5081c025bc350339885297550b33d6742cd6467c7db5b340c537d0eb1e357234`
- Bundled WOFF2 SHA-256: `6b925c5825bbbc936e37fa1a8db9a81db99a6f09e59e0b2feea2fb6917726b6d`
- Conversion: FontTools 4.63.0 WOFF2 compression with Brotli 1.2.0; no glyph subsetting
- Coverage retained: 7,012 glyphs
- License: the supplied download notice does not grant an open-source or redistribution license.
Confirm that the deployment owner holds Web embedding and redistribution rights before public or
commercial deployment.
Binary file not shown.
@@ -0,0 +1,94 @@
Copyright 2026 Alexander Tseng.
Portions Copyright 2021 The Yuji Project Authors (https://github.com/Kinutafontfactory/Yuji).
Portions Copyright LXGW and LXGWZhenKai Project Authors (https://github.com/lxgw/LxgwZhenKai).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+1
View File
@@ -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
View File
@@ -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),
},
}
}
+43 -8
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useState } from 'react'
type QueueItem = { id: string }
type EffectQueueState<T extends QueueItem> = {
active?: T
pending: T[]
}
export function enqueueEffect<T extends QueueItem>(
state: EffectQueueState<T>,
effect: T,
capacity: number,
): EffectQueueState<T> {
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
return state
}
if (!state.active) return { active: effect, pending: state.pending }
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
if (state.pending.length >= boundedCapacity) return state
return { ...state, pending: [...state.pending, effect] }
}
export function completeEffect<T extends QueueItem>(
state: EffectQueueState<T>,
activeId: string,
): EffectQueueState<T> {
if (state.active?.id !== activeId) return state
const [active, ...pending] = state.pending
return { active, pending }
}
export function useEffectQueue<T extends QueueItem>() {
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
const enqueue = useCallback((effect: T, capacity: number) => {
setState(current => enqueueEffect(current, effect, capacity))
}, [])
const complete = useCallback((activeId: string) => {
setState(current => completeEffect(current, activeId))
}, [])
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
}
+15
View File
@@ -0,0 +1,15 @@
@font-face {
font-family: 'Liyu Shoushu';
font-style: normal;
font-display: block;
font-weight: 400;
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
}
@font-face {
font-family: 'HongLei XingShu';
font-style: normal;
font-display: block;
font-weight: 400;
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
}
+14 -237
View File
@@ -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
View File
@@ -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>
)
}
-16
View File
@@ -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,
}
}
+422
View File
@@ -0,0 +1,422 @@
.guard-effect-overlay {
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--guard-cyan, #a9fff2);
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
background: transparent;
pointer-events: none;
}
.jade-guard-voyage {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
background: #000;
}
.jade-guard-voyage > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
animation: jade-guard-video-fade var(--guard-duration) linear both;
}
.jade-guard-scroll {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
display: grid;
width: min(86vw, 1180px);
min-height: min(34vh, 360px);
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
place-content: center;
justify-items: center;
gap: clamp(5px, 0.9vh, 12px);
border-block: 1px solid rgba(255, 229, 153, 0.7);
color: #fff4cf;
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
text-align: center;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.96),
0 0 14px rgba(0, 0, 0, 0.92),
0 0 22px rgba(255, 220, 130, 0.48);
background: linear-gradient(
90deg,
transparent,
rgba(3, 15, 22, 0.34) 12%,
rgba(3, 15, 22, 0.62) 50%,
rgba(3, 15, 22, 0.34) 88%,
transparent
);
box-shadow:
0 -8px 24px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.2);
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
will-change: clip-path, opacity, transform;
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
}
.jade-guard-scroll::before,
.jade-guard-scroll::after {
content: '';
position: absolute;
top: -7%;
bottom: -7%;
width: clamp(5px, 0.55vw, 10px);
border: 1px solid rgba(255, 239, 190, 0.82);
border-radius: 999px;
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
}
.jade-guard-scroll::before {
left: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll::after {
right: clamp(13px, 2vw, 32px);
}
.jade-guard-scroll strong,
.jade-guard-scroll b,
.jade-guard-scroll span {
position: relative;
z-index: 1;
overflow-wrap: anywhere;
}
.jade-guard-scroll strong {
color: #fff5d2;
font-size: clamp(42px, 7vw, 126px);
font-weight: 400;
letter-spacing: 0.12em;
line-height: 1;
}
.jade-guard-scroll b {
color: #d4fff3;
font-size: clamp(24px, 3.7vw, 66px);
font-weight: 400;
letter-spacing: 0.16em;
line-height: 1.08;
}
.jade-guard-scroll span {
color: #ffe5a6;
font-size: clamp(34px, 5.4vw, 94px);
letter-spacing: 0.18em;
line-height: 1;
}
.guard-celebration {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
overflow: hidden;
place-items: center;
opacity: 0;
color: #edfffa;
background:
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
}
.guard-nebula {
position: absolute;
inset: -25%;
background: conic-gradient(
from 90deg,
transparent,
rgba(93, 237, 209, 0.18),
transparent 32%,
rgba(255, 207, 230, 0.12),
transparent 68%,
rgba(255, 230, 156, 0.13),
transparent
);
filter: blur(28px);
animation: guard-nebula-turn 9s linear infinite;
}
.guard-stars {
position: absolute;
inset: 0;
}
.guard-stars i {
position: absolute;
width: var(--star-size);
height: var(--star-size);
opacity: 0.1;
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
filter: drop-shadow(0 0 6px var(--guard-cyan));
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
}
.guard-halo {
position: absolute;
width: min(62vmin, 720px);
aspect-ratio: 1;
border: 1px solid rgba(155, 255, 235, 0.4);
border-radius: 50%;
box-shadow:
0 0 60px rgba(87, 241, 212, 0.25),
inset 0 0 70px rgba(255, 224, 166, 0.12);
animation: guard-halo-breathe 2.6s ease-in-out infinite;
}
.guard-halo i {
position: absolute;
inset: 7%;
border: 1px solid rgba(255, 227, 170, 0.38);
border-radius: 45% 55% 48% 52%;
transform: rotate(30deg);
}
.guard-halo i:nth-child(2) {
inset: 15%;
border-color: rgba(255, 195, 224, 0.32);
transform: rotate(76deg);
}
.guard-halo i:nth-child(3) {
inset: 23%;
border-color: rgba(123, 246, 224, 0.45);
transform: rotate(122deg);
}
.guard-copy {
position: relative;
z-index: 3;
display: grid;
max-width: min(82vw, 1000px);
justify-items: center;
gap: clamp(8px, 1.5vh, 20px);
text-align: center;
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
filter: brightness(var(--component-font-brightness, 1.3));
}
.guard-copy span {
color: var(--guard-gold);
font-size: clamp(13px, 1.6vw, 30px);
letter-spacing: 0.45em;
}
.guard-copy strong {
color: #f3fffc;
font-size: clamp(38px, 7vw, 132px);
font-weight: 500;
letter-spacing: 0.1em;
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
}
.guard-copy b {
color: var(--guard-rose);
font-size: clamp(16px, 2.3vw, 44px);
font-weight: 500;
letter-spacing: 0.12em;
}
.guard-low-motion .guard-nebula,
.guard-low-motion .guard-halo {
animation: none;
}
.guard-theme-moonlit-water {
color: #eadfc3;
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
}
.guard-theme-moonlit-water .guard-celebration {
color: #eadfc3;
background: transparent;
font-family: inherit;
}
.guard-theme-moonlit-water .guard-celebration::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: min(62vmin, 780px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(246, 237, 207, 0.82) 0 42%,
rgba(226, 224, 193, 0.2) 63%,
transparent 71%
);
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
transform: translate(-50%, -50%);
}
.guard-theme-moonlit-water .guard-nebula {
background:
repeating-radial-gradient(
ellipse at 50% 80%,
transparent 0 7%,
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
transparent 7.7% 12%
),
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
filter: blur(1px);
animation: moonlit-guard-water 7s ease-in-out infinite;
}
.guard-theme-moonlit-water .guard-stars i {
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
}
.guard-theme-moonlit-water .guard-halo {
width: min(77vmin, 930px);
border-color: rgba(231, 201, 130, 0.42);
box-shadow:
0 0 44px rgba(231, 201, 130, 0.18),
inset 0 0 65px rgba(182, 207, 180, 0.1);
}
.guard-theme-moonlit-water .guard-halo i {
border-color: rgba(203, 219, 184, 0.36);
}
.guard-theme-moonlit-water .guard-copy {
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
}
.guard-theme-moonlit-water .guard-copy span,
.guard-theme-moonlit-water .guard-copy b {
color: #e7c982;
}
.guard-theme-moonlit-water .guard-copy strong {
color: #eadfc3;
filter: none;
}
@keyframes guard-effect-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@keyframes jade-guard-video-fade {
0%,
100% {
opacity: 0;
}
6.25%,
93.75% {
opacity: 1;
}
}
@keyframes jade-guard-scroll-reveal {
0% {
opacity: 0;
clip-path: inset(0 50% round 10px);
transform: translate(-56%, -50%);
}
12.5%,
87.5% {
opacity: 1;
clip-path: inset(0 round 10px);
transform: translate(-50%, -50%);
}
100% {
opacity: 0;
clip-path: inset(0 round 10px);
transform: translate(-46%, -50%);
}
}
@keyframes jade-guard-copy-fade {
0%,
100% {
opacity: 0;
}
12.5%,
87.5% {
opacity: 1;
}
}
@keyframes guard-star-pulse {
0%,
100% {
opacity: 0.08;
transform: rotate(0) scale(0.45);
}
48% {
opacity: 1;
transform: rotate(50deg) scale(1.32);
}
}
@keyframes guard-nebula-turn {
to {
transform: rotate(360deg);
}
}
@keyframes guard-halo-breathe {
50% {
transform: scale(1.08) rotate(3deg);
box-shadow:
0 0 110px rgba(87, 241, 212, 0.38),
inset 0 0 90px rgba(255, 224, 166, 0.2);
}
}
@keyframes moonlit-guard-water {
50% {
opacity: 0.68;
transform: scale(1.04) translateY(-1.4%);
}
}
@keyframes moonlit-guard-reveal {
0%,
100% {
opacity: 0;
}
8%,
86% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.guard-nebula,
.guard-stars i,
.guard-halo {
animation: none;
}
.jade-guard-scroll {
clip-path: none;
transform: translate(-50%, -50%);
animation-name: jade-guard-copy-fade;
}
}
+286
View File
@@ -0,0 +1,286 @@
/** Full-viewport renderer for the independently subscribed membership component. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGuardEffectSettings } from './api'
import { useEffectQueue } from './effectQueue'
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGuardEffectSettings } from './types'
import type { GuardEffectSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type GuardPayload = {
viewer?: Viewer
guardName?: string
quantity?: number
price?: number
settings?: Partial<GuardEffectSettings>
}
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
type GuardEffect = {
id: string
payload: GuardPayload
}
type GuardLevel = 'captain' | 'admiral' | 'governor'
export type GuardEffectPreviewMode = GuardLevel
const JADE_VIDEO_DURATION_MS = 8_000
function guardLevel(guardName: string | undefined): GuardLevel {
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
return 'captain'
}
function guardVideoUrl(level: GuardLevel): string {
return `/assets/${level === 'governor' ? 'general' : level}.webm`
}
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
return {
id: `preview-${level}-${nonce}`,
type: 'live.guard.buy',
payload: {
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
guardName: translate(`gift_menu.guard.${level}`),
quantity: 1,
price: 198_000,
},
}
}
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
if (envelope.type !== 'live.guard.buy') return undefined
return {
id: envelope.id,
payload: envelope.payload ?? {},
}
}
function useGuardEffect(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GuardEffectSettings | undefined,
previewMode: GuardEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGuardEffectSettings)
const queue = useEffectQueue<GuardEffect>()
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
for (const message of pending) {
lastSequenceRef.current = message.sequence
const envelope = message.envelope as GuardEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const next = toGuardEffect(envelope)
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const currentSettings = previewSettings ?? settingsRef.current
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
}, [
language,
preview,
previewMode,
previewNonce,
previewSettings?.effectDurationMs,
previewSettings?.themeId,
])
return {
settings,
effect: queue.active,
pendingCount: queue.pendingCount,
completeEffect: queue.complete,
}
}
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function JadeGuardVoyage({
effect,
settings,
muted,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
muted: boolean
onComplete: () => void
}) {
const level = guardLevel(effect.payload.guardName)
const guard = translate(`gift_menu.guard.${level}`)
const viewer =
effect.payload.viewer?.name || effect.payload.viewer?.uid || translate('common.viewer')
const title = settings.titleTemplate.replaceAll('{guard}', guard)
return (
<section
className={`jade-guard-voyage guard-level-${level}`}
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
>
<video
autoPlay
playsInline
preload="auto"
muted={muted}
src={guardVideoUrl(level)}
key={`${effect.id}:${level}`}
aria-hidden="true"
onAnimationEnd={event => {
if (event.animationName === 'jade-guard-video-fade') onComplete()
}}
/>
<div className="jade-guard-scroll" aria-live="polite">
<strong>{title}</strong>
<b>{viewer}</b>
<span>{settings.closingText}</span>
</div>
</section>
)
}
function MoonlitGuardCelebration({
effect,
settings,
onComplete,
}: {
effect: GuardEffect
settings: GuardEffectSettings
onComplete: () => void
}) {
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('guard.effect_aria')}
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
onComplete()
}}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('guard.salute')}</span>
<strong>{translate('guard.title', { guard })}</strong>
<b>{translate('guard.viewer', { viewer })}</b>
</div>
</section>
)
}
export function GuardEffectOverlay({
preview = false,
previewSettings,
previewMode = 'captain',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GuardEffectSettings
previewMode?: GuardEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGuardEffect(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGuardEffectTheme(settings.themeId)
const effect = remote.effect
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
return (
<main
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...guardThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
{effect &&
onComplete &&
(theme.id === 'moonlit-water' ? (
<MoonlitGuardCelebration
key={effect.id}
effect={effect}
settings={settings}
onComplete={onComplete}
/>
) : (
<JadeGuardVoyage
key={effect.id}
effect={effect}
settings={settings}
muted={preview}
onComplete={onComplete}
/>
))}
</main>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { CSSProperties } from 'react'
import type { GuardEffectThemeId } from './types'
export type GuardEffectTheme = {
id: GuardEffectThemeId
nameKey: string
descriptionKey: string
className: string
palette: {
cyan: string
gold: string
rose: string
night: string
}
motion: {
reveal: string
starPulse: string
}
}
export const guardEffectThemes: readonly GuardEffectTheme[] = [
{
id: 'jade-starfall',
nameKey: 'guard.theme.jade_starfall.name',
descriptionKey: 'guard.theme.jade_starfall.description',
className: 'guard-theme-jade-starfall',
palette: {
cyan: '#a9fff2',
gold: '#ffe7a4',
rose: '#ffd0e5',
night: '#020c18',
},
motion: {
reveal: 'guard-effect-reveal',
starPulse: 'guard-star-pulse',
},
},
{
id: 'moonlit-water',
nameKey: 'guard.theme.moonlit_water.name',
descriptionKey: 'guard.theme.moonlit_water.description',
className: 'guard-theme-moonlit-water',
palette: {
cyan: '#c6d8c6',
gold: '#e7c982',
rose: '#dbc6a0',
night: 'transparent',
},
motion: {
reveal: 'moonlit-guard-reveal',
starPulse: 'guard-star-pulse',
},
},
]
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
}
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
return getGuardEffectTheme(id).id
}
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
return {
['--guard-cyan' as string]: theme.palette.cyan,
['--guard-gold' as string]: theme.palette.gold,
['--guard-rose' as string]: theme.palette.rose,
['--guard-night' as string]: theme.palette.night,
['--guard-motion-reveal' as string]: theme.motion.reveal,
['--guard-motion-star' as string]: theme.motion.starPulse,
}
}
+7
View File
@@ -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} />
+90 -5
View File
@@ -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}
/>
+3 -49
View File
@@ -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;
-15
View File
@@ -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>
+2
View File
@@ -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
View File
@@ -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 = {
+7 -5
View File
@@ -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 {
+20
View File
@@ -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',
+1
View File
@@ -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",
+14 -4
View File
@@ -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"] }
+3 -1
View File
@@ -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.';
+4
View File
@@ -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(
+18
View File
@@ -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");
+27 -1
View File
@@ -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));
}
+4
View File
@@ -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),
+27 -18
View File
@@ -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;
+247
View File
@@ -0,0 +1,247 @@
//! Full-screen membership-purchase effect component.
//!
//! This component is deliberately separate from gift effects: every instance
//! owns its settings, read-only OBS token, event subscription, and WebSocket
//! channel, and consumes only canonical guard-purchase events.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
components::{ComponentDefinition, ComponentError, EventSubscription},
domain::LiveEventKind,
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
};
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuardEffectThemeId {
#[default]
JadeStarfall,
MoonlitWater,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GuardEffectSettings {
#[serde(default)]
pub theme_id: GuardEffectThemeId,
#[serde(default)]
pub font_family: FontFamilyId,
#[serde(default = "default_font_brightness")]
pub font_brightness: u16,
pub star_count: u8,
pub effect_duration_ms: u16,
#[serde(default = "default_title_template")]
pub title_template: String,
#[serde(default = "default_closing_text")]
pub closing_text: String,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: u16,
pub low_performance_mode: bool,
}
impl Default for GuardEffectSettings {
fn default() -> Self {
Self {
theme_id: GuardEffectThemeId::default(),
font_family: FontFamilyId::default(),
font_brightness: default_font_brightness(),
star_count: 48,
effect_duration_ms: 5_200,
title_template: default_title_template(),
closing_text: default_closing_text(),
queue_capacity: default_queue_capacity(),
low_performance_mode: false,
}
}
}
impl GuardEffectSettings {
pub fn sanitize(mut self) -> Self {
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.star_count = self.star_count.clamp(8, 96);
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
self.title_template = sanitize_copy(self.title_template, &default_title_template());
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
self
}
}
const fn default_queue_capacity() -> u16 {
256
}
/// Preserve the membership-related portion of the first legacy gift-effect
/// instance when an existing account receives its initial guard component.
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable");
for (legacy, current) in [
("themeId", "themeId"),
("fontFamily", "fontFamily"),
("fontBrightness", "fontBrightness"),
("guardStarCount", "starCount"),
("guardEffectDurationMs", "effectDurationMs"),
("guardTitleTemplate", "titleTemplate"),
("guardClosingText", "closingText"),
("lowPerformanceMode", "lowPerformanceMode"),
] {
if let Some(candidate) = value.get(legacy) {
migrated[current] = candidate.clone();
}
}
serde_json::from_value::<GuardEffectSettings>(migrated)
.unwrap_or_default()
.sanitize()
}
fn default_title_template() -> String {
"{guard}启航".into()
}
fn default_closing_text() -> String {
"相伴前行".into()
}
fn sanitize_copy(value: String, fallback: &str) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
fallback.to_owned()
} else {
normalized.chars().take(80).collect()
}
}
pub struct GuardEffectDefinition;
impl GuardEffectDefinition {
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
})
}
}
impl ComponentDefinition for GuardEffectDefinition {
fn kind(&self) -> &'static str {
GUARD_EFFECT_KIND
}
fn settings_version(&self) -> u32 {
1
}
fn default_settings(&self) -> Value {
serde_json::to_value(GuardEffectSettings::default())
.expect("GuardEffectSettings is always JSON serializable")
}
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
ComponentError::InvalidSettings {
kind: GUARD_EFFECT_KIND.to_owned(),
detail: error.to_string(),
}
})
}
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
self.parse(settings.clone())?;
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_are_bounded_and_copy_is_sanitized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["fontBrightness"] = Value::from(999);
settings["starCount"] = Value::from(255);
settings["effectDurationMs"] = Value::from(200);
settings["queueCapacity"] = Value::from(9_999);
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
settings["closingText"] = Value::from(" ");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["fontBrightness"], 180);
assert_eq!(sanitized["starCount"], 96);
assert_eq!(sanitized["effectDurationMs"], 1_000);
assert_eq!(sanitized["queueCapacity"], 1_000);
assert_eq!(
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
80
);
assert_eq!(sanitized["closingText"], "相伴前行");
}
#[test]
fn old_settings_receive_copy_defaults() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings.as_object_mut().unwrap().remove("titleTemplate");
settings.as_object_mut().unwrap().remove("closingText");
settings.as_object_mut().unwrap().remove("queueCapacity");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
assert_eq!(sanitized["closingText"], "相伴前行");
assert_eq!(sanitized["queueCapacity"], 256);
}
#[test]
fn legacy_gift_settings_preserve_membership_configuration() {
let legacy = serde_json::json!({
"themeId": "moonlit-water",
"fontFamily": "kai",
"fontBrightness": 145,
"guardStarCount": 72,
"guardEffectDurationMs": 8_500,
"guardTitleTemplate": "{guard}出发",
"guardClosingText": "一路顺风",
"lowPerformanceMode": true,
"normal": { "count": 20 }
});
let migrated = settings_from_legacy_gift(&legacy);
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
assert_eq!(migrated.star_count, 72);
assert_eq!(migrated.effect_duration_ms, 8_500);
assert_eq!(migrated.title_template, "{guard}出发");
assert_eq!(migrated.closing_text, "一路顺风");
assert!(migrated.low_performance_mode);
}
#[test]
fn component_only_subscribes_to_guard_purchases() {
let definition = GuardEffectDefinition;
let subscriptions = definition
.subscriptions(&definition.default_settings())
.unwrap();
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
assert!(!subscriptions.contains(LiveEventKind::Gift));
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
}
#[test]
fn moonlit_water_theme_is_accepted_and_serialized() {
let definition = GuardEffectDefinition;
let mut settings = definition.default_settings();
settings["themeId"] = Value::from("moonlit-water");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["themeId"], "moonlit-water");
}
}
+1
View File
@@ -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;
+243 -19
View File
@@ -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!({
+11
View File
@@ -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);
+128 -18
View File
@@ -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
+24 -100
View File
@@ -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]
+8 -3
View File
@@ -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\""
);
}
}
+5 -3
View File
@@ -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 或用户数据 |
+11 -4
View File
@@ -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)。
+9 -5
View File
@@ -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. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
+8 -9
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
# 礼物菜单组件
`gift_menu`
是每个账户自动拥有且不可删除的单例组件。它把直播间礼物或大航海投喂映射为主播提供的内容说明,并以独立只读 token 输出透明 OBS 浏览器源。
在账户注册时创建一个初始实例,也可以为不同 OBS 场景添加多个实例。每个实例独立保存菜单内容、样式和只读 token;它把直播间礼物或大航海投喂映射为主播提供的内容说明,并输出透明 OBS 浏览器源。
## 目录与触发器
+26
View File
@@ -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 地址与礼物星雨完全不同;场景需要两种特效时,应分别添加两个浏览器源。
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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 或写入镜像。
+5
View File
@@ -0,0 +1,5 @@
# 经理瓷给我的要求
1. 右下角的点歌界面应该从上往下滚动 ok
2. 通过woff2直接从前端serve字体 ok
3. 小礼物用扇子,大礼物用跳跃的鱼
+110 -50
View File
@@ -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."