更新点歌和弹幕微调
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# 多用户直播组件服务
|
# 多用户直播组件服务
|
||||||
|
|
||||||
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
|
这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬、
|
||||||
`gift_effect` 全屏礼物特效与 `gift_menu`
|
`gift_effect` 全屏礼物特效、`guard_effect` 大航海特效与 `gift_menu`
|
||||||
礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
礼物菜单,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。
|
||||||
|
|
||||||
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
|
直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili)
|
||||||
@@ -21,6 +21,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr
|
|||||||
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
|
- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md)
|
||||||
- [`song_request` 点歌姬](docs/components/song-request.md)
|
- [`song_request` 点歌姬](docs/components/song-request.md)
|
||||||
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
|
- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md)
|
||||||
|
- [`guard_effect` 大航海特效](docs/components/guard-effect.md)
|
||||||
- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
|
- [`gift_menu` 礼物菜单](docs/components/gift-menu.md)
|
||||||
- [WebSocket 实时协议](docs/protocol.md)
|
- [WebSocket 实时协议](docs/protocol.md)
|
||||||
- [租户、Secret 与部署安全](docs/security.md)
|
- [租户、Secret 与部署安全](docs/security.md)
|
||||||
@@ -72,7 +73,7 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c
|
|||||||
`[database].url`
|
`[database].url`
|
||||||
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
|
指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL
|
||||||
RLS 约束,HTTP
|
RLS 约束,HTTP
|
||||||
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
|
API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的
|
||||||
`account_preferences` 表中。
|
`account_preferences` 表中。
|
||||||
|
|
||||||
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
|
`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP
|
||||||
@@ -211,12 +212,16 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通
|
|||||||
`/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe`
|
`/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 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
礼物菜单读取账户当前直播间的礼物目录与图标,可把指定礼物、舰长/提督/总督或指定礼物单价映射为自定义直播内容。OBS 以横排行无限循环展示,实际投喂命中时自动定位、暂停并播放渐变星花高亮。
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
|
|||||||
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
|
| `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 |
|
||||||
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
|
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
|
||||||
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
|
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
|
||||||
| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 |
|
| `src/giftEffect.tsx` | 礼物流星与视口自适应渲染 |
|
||||||
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
|
| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 |
|
||||||
|
| `src/guardEffect.tsx` | 独立大航海视频、感谢卷轴与月夜庆祝渲染 |
|
||||||
|
| `src/guardThemes.ts` | 可扩展大航海特效主题注册表与 CSS 变量 |
|
||||||
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
|
| `src/giftMenu.tsx` | 礼物菜单无限循环、触发定位与高亮 reducer |
|
||||||
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
|
| `src/giftMenuThemes.ts` | 可扩展礼物菜单主题注册表 |
|
||||||
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
|
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
|
||||||
@@ -60,9 +62,10 @@ metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则
|
|||||||
## 字体资源
|
## 字体资源
|
||||||
|
|
||||||
Noto Serif SC、ZCOOL XiaoWei 与 LXGW
|
Noto Serif SC、ZCOOL XiaoWei 与 LXGW
|
||||||
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书则以固定版本的 WOFF2 文件保存在
|
WenKai 由锁定的 Fontsource 依赖提供;漓雨手书与鸿雷行书简体则以固定 WOFF2 文件保存在
|
||||||
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。每套字体的 OFL-1.1 许可证均输出到
|
`public/fonts/`。浏览器从 Rust 静态服务同域加载这些字体,不依赖 OBS 设备的系统字体。前三套 Fontsource 字体与漓雨手书的 OFL-1.1 许可证输出到
|
||||||
`/fonts/licenses/`。
|
`/fonts/licenses/`;鸿雷行书的随附说明不构成开放授权,公开或商业部署前必须确认 Web 嵌入与再分发权利,具体摘要见
|
||||||
|
`public/fonts/NOTICE.md`。
|
||||||
|
|
||||||
## 格式化与构建
|
## 格式化与构建
|
||||||
|
|
||||||
@@ -83,4 +86,5 @@ YAML 和项目文档。
|
|||||||
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
|
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
|
||||||
- [点歌姬组件](../../docs/components/song-request.md)
|
- [点歌姬组件](../../docs/components/song-request.md)
|
||||||
- [全屏礼物特效](../../docs/components/gift-effect.md)
|
- [全屏礼物特效](../../docs/components/gift-effect.md)
|
||||||
|
- [大航海特效](../../docs/components/guard-effect.md)
|
||||||
- [礼物菜单组件](../../docs/components/gift-menu.md)
|
- [礼物菜单组件](../../docs/components/gift-menu.md)
|
||||||
|
|||||||
@@ -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
|
`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.
|
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.
@@ -17,3 +17,17 @@ The original font credits Yuji Kataoka and the Yuji Project Authors for the base
|
|||||||
and the LXGW ZhenKai Project Authors for punctuation and symbols, and Yuchen Tian's
|
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
|
[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.
|
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.
@@ -20,6 +20,7 @@ async function filesBelow(directory, prefix = '') {
|
|||||||
const files = await filesBelow(dist)
|
const files = await filesBelow(dist)
|
||||||
const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai']
|
const expectedFonts = ['noto-serif-sc', 'zcool-xiaowei', 'lxgw-wenkai']
|
||||||
const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2'
|
const liyuFont = 'fonts/liyu-shoushu-v0.107.woff2'
|
||||||
|
const hongleiFont = 'fonts/honglei-xingshu-jian.woff2'
|
||||||
|
|
||||||
for (const font of expectedFonts) {
|
for (const font of expectedFonts) {
|
||||||
if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) {
|
if (!files.some(file => file.startsWith(`assets/${font}-`) && file.endsWith('.woff2'))) {
|
||||||
@@ -40,6 +41,26 @@ const liyuDigest = createHash('sha256')
|
|||||||
if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') {
|
if (liyuDigest !== 'b17f8c4fc6612a539d7a46e0eb5f57fdc732cff1d17804563b00a3f0a7eb9ab3') {
|
||||||
throw new Error(`Unexpected liyu-shoushu WOFF2 digest: ${liyuDigest}`)
|
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'))
|
const legacyWoff = files.find(file => file.endsWith('.woff'))
|
||||||
if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`)
|
if (legacyWoff) throw new Error(`Unexpected legacy WOFF asset: ${legacyWoff}`)
|
||||||
@@ -50,7 +71,13 @@ const css = (
|
|||||||
)
|
)
|
||||||
).join('\n')
|
).join('\n')
|
||||||
|
|
||||||
for (const family of ['Noto Serif SC', 'ZCOOL XiaoWei', 'LXGW WenKai', 'Liyu Shoushu']) {
|
for (const family of [
|
||||||
|
'Noto Serif SC',
|
||||||
|
'ZCOOL XiaoWei',
|
||||||
|
'LXGW WenKai',
|
||||||
|
'Liyu Shoushu',
|
||||||
|
'HongLei XingShu',
|
||||||
|
]) {
|
||||||
const declarations = [
|
const declarations = [
|
||||||
`font-family:${family}`,
|
`font-family:${family}`,
|
||||||
`font-family:${JSON.stringify(family)}`,
|
`font-family:${JSON.stringify(family)}`,
|
||||||
@@ -64,4 +91,4 @@ if (!css.includes('font-display:block') || css.includes('font-display:swap')) {
|
|||||||
throw new Error('Bundled fonts must block local fallback while loading')
|
throw new Error('Bundled fonts must block local fallback while loading')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Verified same-origin WOFF2 component fonts and licenses.')
|
console.log('Verified same-origin component fonts, licenses, and membership videos.')
|
||||||
|
|||||||
+14
-6
@@ -11,6 +11,7 @@ import type {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
ComponentSummary,
|
ComponentSummary,
|
||||||
CookieCloudSource,
|
CookieCloudSource,
|
||||||
|
GuardEffectSettings,
|
||||||
GiftEffectSettings,
|
GiftEffectSettings,
|
||||||
GiftCatalogItem,
|
GiftCatalogItem,
|
||||||
GiftMenuItem,
|
GiftMenuItem,
|
||||||
@@ -27,8 +28,10 @@ import { normalizeThemeId } from './themes'
|
|||||||
import { normalizeFontFamilyId } from './typography'
|
import { normalizeFontFamilyId } from './typography'
|
||||||
import { normalizeGiftEffectThemeId } from './giftThemes'
|
import { normalizeGiftEffectThemeId } from './giftThemes'
|
||||||
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
|
import { normalizeGiftMenuThemeId } from './giftMenuThemes'
|
||||||
|
import { normalizeGuardEffectThemeId } from './guardThemes'
|
||||||
import {
|
import {
|
||||||
defaultGiftEffectSettings,
|
defaultGiftEffectSettings,
|
||||||
|
defaultGuardEffectSettings,
|
||||||
defaultGiftMenuSettings,
|
defaultGiftMenuSettings,
|
||||||
defaultOverlaySettings,
|
defaultOverlaySettings,
|
||||||
defaultSongRequestSettings,
|
defaultSongRequestSettings,
|
||||||
@@ -261,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 {
|
function normalizeGiftMenuItem(value: unknown): GiftMenuItem | undefined {
|
||||||
const item = object(value)
|
const item = object(value)
|
||||||
const trigger = object(item.trigger)
|
const trigger = object(item.trigger)
|
||||||
@@ -366,11 +380,6 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde
|
|||||||
typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined,
|
typeof item.startedAt === 'string' || item.startedAt === null ? item.startedAt : undefined,
|
||||||
finishedAt:
|
finishedAt:
|
||||||
typeof item.finishedAt === 'string' || item.finishedAt === null ? item.finishedAt : undefined,
|
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +399,6 @@ export function normalizeSongRequestPage(value: unknown): SongRequestPage {
|
|||||||
queuedCount: Number(summary.queuedCount ?? 0),
|
queuedCount: Number(summary.queuedCount ?? 0),
|
||||||
completedCount: Number(summary.completedCount ?? 0),
|
completedCount: Number(summary.completedCount ?? 0),
|
||||||
cancelledCount: Number(summary.cancelledCount ?? 0),
|
cancelledCount: Number(summary.cancelledCount ?? 0),
|
||||||
ratingCount: Number(summary.ratingCount ?? 0),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
.song-stat-grid {
|
.song-stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+256
-59
@@ -17,6 +17,7 @@ import {
|
|||||||
normalizeComponents,
|
normalizeComponents,
|
||||||
normalizeGiftCatalog,
|
normalizeGiftCatalog,
|
||||||
normalizeGiftEffectSettings,
|
normalizeGiftEffectSettings,
|
||||||
|
normalizeGuardEffectSettings,
|
||||||
normalizeGiftMenuSettings,
|
normalizeGiftMenuSettings,
|
||||||
normalizeInvitations,
|
normalizeInvitations,
|
||||||
normalizeEnrollment,
|
normalizeEnrollment,
|
||||||
@@ -29,9 +30,12 @@ import {
|
|||||||
import { TotpQr } from './auth'
|
import { TotpQr } from './auth'
|
||||||
import { Overlay } from './overlay'
|
import { Overlay } from './overlay'
|
||||||
import { GiftEffectOverlay } from './giftEffect'
|
import { GiftEffectOverlay } from './giftEffect'
|
||||||
|
import { GuardEffectOverlay } from './guardEffect'
|
||||||
import { GiftMenuOverlay } from './giftMenu'
|
import { GiftMenuOverlay } from './giftMenu'
|
||||||
import type { GiftEffectPreviewMode } from './giftEffect'
|
import type { GiftEffectPreviewMode } from './giftEffect'
|
||||||
|
import type { GuardEffectPreviewMode } from './guardEffect'
|
||||||
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
|
import { getGiftEffectTheme, giftEffectThemes } from './giftThemes'
|
||||||
|
import { getGuardEffectTheme, guardEffectThemes } from './guardThemes'
|
||||||
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes'
|
import { getGiftMenuTheme, giftMenuGuardIconUrl, giftMenuThemes } from './giftMenuThemes'
|
||||||
import { PwaControls, usePwaUpdateBlocker } from './pwa'
|
import { PwaControls, usePwaUpdateBlocker } from './pwa'
|
||||||
import { SongRequestOverlay } from './songOverlay'
|
import { SongRequestOverlay } from './songOverlay'
|
||||||
@@ -41,6 +45,7 @@ import { fontFamilies } from './typography'
|
|||||||
import type { FontFamilyId } from './typography'
|
import type { FontFamilyId } from './typography'
|
||||||
import {
|
import {
|
||||||
defaultGiftEffectSettings,
|
defaultGiftEffectSettings,
|
||||||
|
defaultGuardEffectSettings,
|
||||||
defaultGiftMenuSettings,
|
defaultGiftMenuSettings,
|
||||||
defaultOverlaySettings,
|
defaultOverlaySettings,
|
||||||
defaultSongRequestSettings,
|
defaultSongRequestSettings,
|
||||||
@@ -51,6 +56,7 @@ import type {
|
|||||||
ComponentSummary,
|
ComponentSummary,
|
||||||
CookieCloudSource,
|
CookieCloudSource,
|
||||||
GiftEffectSettings,
|
GiftEffectSettings,
|
||||||
|
GuardEffectSettings,
|
||||||
GiftCatalogItem,
|
GiftCatalogItem,
|
||||||
GiftMenuItem,
|
GiftMenuItem,
|
||||||
GiftMenuSettings,
|
GiftMenuSettings,
|
||||||
@@ -201,11 +207,21 @@ function isGiftEffectKind(kind: string): boolean {
|
|||||||
return kind === 'gift_effect'
|
return kind === 'gift_effect'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGuardEffectKind(kind: string): boolean {
|
||||||
|
return kind === 'guard_effect'
|
||||||
|
}
|
||||||
|
|
||||||
function isGiftMenuKind(kind: string): boolean {
|
function isGiftMenuKind(kind: string): boolean {
|
||||||
return kind === 'gift_menu'
|
return kind === 'gift_menu'
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentKinds = ['danmaku_overlay', 'song_request', 'gift_effect', 'gift_menu'] as const
|
const componentKinds = [
|
||||||
|
'danmaku_overlay',
|
||||||
|
'song_request',
|
||||||
|
'gift_effect',
|
||||||
|
'guard_effect',
|
||||||
|
'gift_menu',
|
||||||
|
] as const
|
||||||
|
|
||||||
function componentKindLabel(kind: string): string {
|
function componentKindLabel(kind: string): string {
|
||||||
return isDanmakuKind(kind)
|
return isDanmakuKind(kind)
|
||||||
@@ -214,6 +230,8 @@ function componentKindLabel(kind: string): string {
|
|||||||
? translate('components.song_type')
|
? translate('components.song_type')
|
||||||
: isGiftEffectKind(kind)
|
: isGiftEffectKind(kind)
|
||||||
? translate('components.gift_type')
|
? translate('components.gift_type')
|
||||||
|
: isGuardEffectKind(kind)
|
||||||
|
? translate('components.guard_type')
|
||||||
: isGiftMenuKind(kind)
|
: isGiftMenuKind(kind)
|
||||||
? translate('components.gift_menu_type')
|
? translate('components.gift_menu_type')
|
||||||
: kind
|
: kind
|
||||||
@@ -226,6 +244,8 @@ function componentKindMark(kind: string): string {
|
|||||||
? translate('components.song_mark')
|
? translate('components.song_mark')
|
||||||
: isGiftEffectKind(kind)
|
: isGiftEffectKind(kind)
|
||||||
? translate('components.gift_mark')
|
? translate('components.gift_mark')
|
||||||
|
: isGuardEffectKind(kind)
|
||||||
|
? translate('components.guard_mark')
|
||||||
: isGiftMenuKind(kind)
|
: isGiftMenuKind(kind)
|
||||||
? translate('components.gift_menu_mark')
|
? translate('components.gift_menu_mark')
|
||||||
: translate('components.generic_mark')
|
: translate('components.generic_mark')
|
||||||
@@ -434,6 +454,7 @@ function SettingsEditor({
|
|||||||
type="range"
|
type="range"
|
||||||
min="2"
|
min="2"
|
||||||
max="120"
|
max="120"
|
||||||
|
disabled={!settings.expandNewDanmaku}
|
||||||
value={settings.collapseAfterSeconds}
|
value={settings.collapseAfterSeconds}
|
||||||
onChange={event => edit('collapseAfterSeconds', +event.target.value)}
|
onChange={event => edit('collapseAfterSeconds', +event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -448,6 +469,7 @@ function SettingsEditor({
|
|||||||
min="200"
|
min="200"
|
||||||
max="5000"
|
max="5000"
|
||||||
step="100"
|
step="100"
|
||||||
|
disabled={!settings.expandNewDanmaku}
|
||||||
value={settings.unfoldDurationMs}
|
value={settings.unfoldDurationMs}
|
||||||
onChange={event => edit('unfoldDurationMs', +event.target.value)}
|
onChange={event => edit('unfoldDurationMs', +event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -491,6 +513,18 @@ function SettingsEditor({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<fieldset className="toggle-grid">
|
||||||
<legend>{translate('settings.show_events')}</legend>
|
<legend>{translate('settings.show_events')}</legend>
|
||||||
{eventToggles.map(([key, labelKey]) => (
|
{eventToggles.map(([key, labelKey]) => (
|
||||||
@@ -878,42 +912,15 @@ function GiftEffectSettingsEditor({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>
|
{translate('gift.settings.queue_capacity')}
|
||||||
{translate('gift.settings.guard_stars')} <output>{settings.guardStarCount}</output>
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="number"
|
||||||
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"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="12"
|
max="1000"
|
||||||
value={settings.maxConcurrentEffects}
|
value={settings.queueCapacity}
|
||||||
onChange={event => edit('maxConcurrentEffects', +event.target.value)}
|
onChange={event => edit('queueCapacity', +event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<small>{translate('settings.queue_capacity_description')}</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<fieldset className="toggle-grid compact-toggle-grid">
|
<fieldset className="toggle-grid compact-toggle-grid">
|
||||||
@@ -949,7 +956,7 @@ function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) {
|
|||||||
className="preview-panel"
|
className="preview-panel"
|
||||||
>
|
>
|
||||||
<div className="preset-buttons gift-preview-buttons">
|
<div className="preset-buttons gift-preview-buttons">
|
||||||
{(['normal', 'high', 'featured', 'guard'] as const).map(candidate => (
|
{(['normal', 'high', 'featured'] as const).map(candidate => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={candidate === mode ? 'active' : 'secondary'}
|
className={candidate === mode ? 'active' : 'secondary'}
|
||||||
@@ -972,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({
|
function GiftMenuSettingsEditor({
|
||||||
componentId,
|
componentId,
|
||||||
settings,
|
settings,
|
||||||
@@ -1906,14 +2078,12 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) {
|
|||||||
|
|
||||||
function TestEvents({
|
function TestEvents({
|
||||||
componentId,
|
componentId,
|
||||||
giftOnly = false,
|
eventKinds,
|
||||||
}: {
|
}: {
|
||||||
componentId: string
|
componentId: string
|
||||||
giftOnly?: boolean
|
eventKinds: Array<'danmaku' | 'enter' | 'gift' | 'guard'>
|
||||||
}) {
|
}) {
|
||||||
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(
|
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>(eventKinds[0])
|
||||||
giftOnly ? 'gift' : 'danmaku',
|
|
||||||
)
|
|
||||||
const [uid, setUid] = useState('test-viewer')
|
const [uid, setUid] = useState('test-viewer')
|
||||||
const [name, setName] = useState(() => translate('test.default_viewer'))
|
const [name, setName] = useState(() => translate('test.default_viewer'))
|
||||||
const [text, setText] = useState(() => translate('test.default_text'))
|
const [text, setText] = useState(() => translate('test.default_text'))
|
||||||
@@ -1966,10 +2136,18 @@ function TestEvents({
|
|||||||
<label>
|
<label>
|
||||||
{translate('test.event_type')}
|
{translate('test.event_type')}
|
||||||
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
|
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
|
||||||
{!giftOnly && <option value="danmaku">{translate('settings.event.danmaku')}</option>}
|
{eventKinds.includes('danmaku') && (
|
||||||
{!giftOnly && <option value="enter">{translate('test.enter')}</option>}
|
<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>
|
<option value="gift">{translate('settings.event.gift')}</option>
|
||||||
|
)}
|
||||||
|
{eventKinds.includes('guard') && (
|
||||||
<option value="guard">{translate('test.guard')}</option>
|
<option value="guard">{translate('test.guard')}</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -2210,6 +2388,11 @@ export function ComponentsPage({
|
|||||||
...defaultGiftEffectSettings,
|
...defaultGiftEffectSettings,
|
||||||
...normalizeGiftEffectSettings(payload),
|
...normalizeGiftEffectSettings(payload),
|
||||||
}
|
}
|
||||||
|
: isGuardEffectKind(component.kind)
|
||||||
|
? {
|
||||||
|
...defaultGuardEffectSettings,
|
||||||
|
...normalizeGuardEffectSettings(payload),
|
||||||
|
}
|
||||||
: isGiftMenuKind(component.kind)
|
: isGiftMenuKind(component.kind)
|
||||||
? {
|
? {
|
||||||
...defaultGiftMenuSettings,
|
...defaultGiftMenuSettings,
|
||||||
@@ -2370,6 +2553,11 @@ export function ComponentsPage({
|
|||||||
...defaultGiftEffectSettings,
|
...defaultGiftEffectSettings,
|
||||||
...normalizeGiftEffectSettings(payload),
|
...normalizeGiftEffectSettings(payload),
|
||||||
}
|
}
|
||||||
|
: isGuardEffectKind(selected.kind)
|
||||||
|
? {
|
||||||
|
...defaultGuardEffectSettings,
|
||||||
|
...normalizeGuardEffectSettings(payload),
|
||||||
|
}
|
||||||
: isGiftMenuKind(selected.kind)
|
: isGiftMenuKind(selected.kind)
|
||||||
? {
|
? {
|
||||||
...defaultGiftMenuSettings,
|
...defaultGiftMenuSettings,
|
||||||
@@ -2500,6 +2688,21 @@ export function ComponentsPage({
|
|||||||
</Panel>
|
</Panel>
|
||||||
<GiftEffectPreview settings={settings as GiftEffectSettings} />
|
<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 ? (
|
) : isGiftMenuKind(selected.kind) && settings ? (
|
||||||
<>
|
<>
|
||||||
<Panel
|
<Panel
|
||||||
@@ -2524,10 +2727,19 @@ export function ComponentsPage({
|
|||||||
<ObsAccessPanel component={selected} key={selected.id} />
|
<ObsAccessPanel component={selected} key={selected.id} />
|
||||||
{(isDanmakuKind(selected.kind) ||
|
{(isDanmakuKind(selected.kind) ||
|
||||||
isGiftEffectKind(selected.kind) ||
|
isGiftEffectKind(selected.kind) ||
|
||||||
|
isGuardEffectKind(selected.kind) ||
|
||||||
isGiftMenuKind(selected.kind)) && (
|
isGiftMenuKind(selected.kind)) && (
|
||||||
<TestEvents
|
<TestEvents
|
||||||
componentId={selected.id}
|
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}`}
|
key={`test-${selected.id}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2772,7 +2984,6 @@ export function SongRequestsPage({
|
|||||||
[translate('song.stat.active'), summary?.activeCount ?? 0],
|
[translate('song.stat.active'), summary?.activeCount ?? 0],
|
||||||
[translate('song.stat.completed'), summary?.completedCount ?? 0],
|
[translate('song.stat.completed'), summary?.completedCount ?? 0],
|
||||||
[translate('song.stat.cancelled'), summary?.cancelledCount ?? 0],
|
[translate('song.stat.cancelled'), summary?.cancelledCount ?? 0],
|
||||||
[translate('song.stat.ratings'), summary?.ratingCount ?? 0],
|
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div className="stat-card jade-panel" key={label}>
|
<div className="stat-card jade-panel" key={label}>
|
||||||
<small>{label}</small>
|
<small>{label}</small>
|
||||||
@@ -2791,14 +3002,6 @@ export function SongRequestsPage({
|
|||||||
{active.current.requester.name} · UID {active.current.requester.uid}
|
{active.current.requester.name} · UID {active.current.requester.uid}
|
||||||
</small>
|
</small>
|
||||||
<h2>{active.current.title}</h2>
|
<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>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
@@ -2875,7 +3078,6 @@ export function SongRequestsPage({
|
|||||||
<th>{translate('song.column.title')}</th>
|
<th>{translate('song.column.title')}</th>
|
||||||
<th>{translate('song.column.requester')}</th>
|
<th>{translate('song.column.requester')}</th>
|
||||||
<th>{translate('song.column.result')}</th>
|
<th>{translate('song.column.result')}</th>
|
||||||
<th>{translate('song.column.rating')}</th>
|
|
||||||
<th>{translate('song.column.finished')}</th>
|
<th>{translate('song.column.finished')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -2897,17 +3099,12 @@ export function SongRequestsPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{item.ratingCount
|
|
||||||
? `${item.averageScore?.toFixed(1)} / 5(${item.ratingCount})`
|
|
||||||
: '—'}
|
|
||||||
</td>
|
|
||||||
<td>{formatDate(item.finishedAt ?? undefined)}</td>
|
<td>{formatDate(item.finishedAt ?? undefined)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{historyPage && historyPage.items.length === 0 && (
|
{historyPage && historyPage.items.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>
|
<td colSpan={4}>
|
||||||
<div className="empty-state">{translate('song.no_history')}</div>
|
<div className="empty-state">{translate('song.no_history')}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
type QueueItem = { id: string }
|
||||||
|
|
||||||
|
type EffectQueueState<T extends QueueItem> = {
|
||||||
|
active?: T
|
||||||
|
pending: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueEffect<T extends QueueItem>(
|
||||||
|
state: EffectQueueState<T>,
|
||||||
|
effect: T,
|
||||||
|
capacity: number,
|
||||||
|
): EffectQueueState<T> {
|
||||||
|
if (state.active?.id === effect.id || state.pending.some(item => item.id === effect.id)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
if (!state.active) return { active: effect, pending: state.pending }
|
||||||
|
const boundedCapacity = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : 1
|
||||||
|
if (state.pending.length >= boundedCapacity) return state
|
||||||
|
return { ...state, pending: [...state.pending, effect] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeEffect<T extends QueueItem>(
|
||||||
|
state: EffectQueueState<T>,
|
||||||
|
activeId: string,
|
||||||
|
): EffectQueueState<T> {
|
||||||
|
if (state.active?.id !== activeId) return state
|
||||||
|
const [active, ...pending] = state.pending
|
||||||
|
return { active, pending }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEffectQueue<T extends QueueItem>() {
|
||||||
|
const [state, setState] = useState<EffectQueueState<T>>({ pending: [] })
|
||||||
|
const enqueue = useCallback((effect: T, capacity: number) => {
|
||||||
|
setState(current => enqueueEffect(current, effect, capacity))
|
||||||
|
}, [])
|
||||||
|
const complete = useCallback((activeId: string) => {
|
||||||
|
setState(current => completeEffect(current, activeId))
|
||||||
|
}, [])
|
||||||
|
return { active: state.active, pendingCount: state.pending.length, enqueue, complete }
|
||||||
|
}
|
||||||
@@ -5,3 +5,11 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
|
src: url('/fonts/liyu-shoushu-v0.107.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'HongLei XingShu';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('/fonts/honglei-xingshu-jian.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|||||||
+14
-237
@@ -18,6 +18,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meteor-burst {
|
||||||
|
animation: gift-burst-lifetime var(--burst-duration) linear both;
|
||||||
|
}
|
||||||
|
|
||||||
.gift-meteor {
|
.gift-meteor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--meteor-y);
|
top: var(--meteor-y);
|
||||||
@@ -156,122 +160,7 @@
|
|||||||
animation-delay: -360ms;
|
animation-delay: -360ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guard-celebration {
|
.gift-low-motion .meteor-spark {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
display: grid;
|
|
||||||
overflow: hidden;
|
|
||||||
place-items: center;
|
|
||||||
opacity: 0;
|
|
||||||
color: #edfffa;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
|
|
||||||
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
|
|
||||||
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--gift-night);
|
|
||||||
animation: var(--gift-motion-guard, gift-guard-reveal) var(--guard-duration) ease-in-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-nebula {
|
|
||||||
position: absolute;
|
|
||||||
inset: -25%;
|
|
||||||
background: conic-gradient(
|
|
||||||
from 90deg,
|
|
||||||
transparent,
|
|
||||||
rgba(93, 237, 209, 0.18),
|
|
||||||
transparent 32%,
|
|
||||||
rgba(255, 207, 230, 0.12),
|
|
||||||
transparent 68%,
|
|
||||||
rgba(255, 230, 156, 0.13),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
filter: blur(28px);
|
|
||||||
animation: gift-nebula-turn 9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-stars {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-stars i {
|
|
||||||
position: absolute;
|
|
||||||
width: var(--star-size);
|
|
||||||
height: var(--star-size);
|
|
||||||
opacity: 0.1;
|
|
||||||
background: linear-gradient(135deg, #fff8ca, var(--gift-cyan) 58%, var(--gift-rose));
|
|
||||||
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
|
||||||
filter: drop-shadow(0 0 6px var(--gift-cyan));
|
|
||||||
animation: var(--gift-motion-star, gift-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-halo {
|
|
||||||
position: absolute;
|
|
||||||
width: min(62vmin, 720px);
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border: 1px solid rgba(155, 255, 235, 0.4);
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow:
|
|
||||||
0 0 60px rgba(87, 241, 212, 0.25),
|
|
||||||
inset 0 0 70px rgba(255, 224, 166, 0.12);
|
|
||||||
animation: gift-halo-breathe 2.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-halo i {
|
|
||||||
position: absolute;
|
|
||||||
inset: 7%;
|
|
||||||
border: 1px solid rgba(255, 227, 170, 0.38);
|
|
||||||
border-radius: 45% 55% 48% 52%;
|
|
||||||
transform: rotate(30deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-halo i:nth-child(2) {
|
|
||||||
inset: 15%;
|
|
||||||
border-color: rgba(255, 195, 224, 0.32);
|
|
||||||
transform: rotate(76deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-halo i:nth-child(3) {
|
|
||||||
inset: 23%;
|
|
||||||
border-color: rgba(123, 246, 224, 0.45);
|
|
||||||
transform: rotate(122deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-copy {
|
|
||||||
position: relative;
|
|
||||||
z-index: 3;
|
|
||||||
display: grid;
|
|
||||||
max-width: min(82vw, 1000px);
|
|
||||||
justify-items: center;
|
|
||||||
gap: clamp(8px, 1.5vh, 20px);
|
|
||||||
text-align: center;
|
|
||||||
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-copy span {
|
|
||||||
color: var(--gift-gold);
|
|
||||||
font-size: clamp(13px, 1.6vw, 30px);
|
|
||||||
letter-spacing: 0.45em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-copy strong {
|
|
||||||
color: #f3fffc;
|
|
||||||
font-size: clamp(38px, 7vw, 132px);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
|
|
||||||
}
|
|
||||||
|
|
||||||
.guard-copy b {
|
|
||||||
color: var(--gift-rose);
|
|
||||||
font-size: clamp(16px, 2.3vw, 44px);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gift-low-motion .meteor-spark,
|
|
||||||
.gift-low-motion .guard-nebula,
|
|
||||||
.gift-low-motion .guard-halo {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +182,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gift-burst-lifetime {
|
||||||
|
from,
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes gift-meteor-sparkle {
|
@keyframes gift-meteor-sparkle {
|
||||||
from {
|
from {
|
||||||
opacity: 0.25;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.gift-meteor {
|
.gift-meteor {
|
||||||
animation-duration: max(var(--meteor-duration), 6s);
|
animation-duration: max(var(--meteor-duration), 6s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meteor-spark,
|
.meteor-spark {
|
||||||
.guard-nebula,
|
|
||||||
.guard-stars i,
|
|
||||||
.guard-halo {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,8 +224,7 @@
|
|||||||
|
|
||||||
.moonlit-whisper-copy,
|
.moonlit-whisper-copy,
|
||||||
.moonlit-gift-whisper > small,
|
.moonlit-gift-whisper > small,
|
||||||
.moonlit-ceremony-copy,
|
.moonlit-ceremony-copy {
|
||||||
.guard-copy {
|
|
||||||
filter: brightness(var(--component-font-brightness, 1.3));
|
filter: brightness(var(--component-font-brightness, 1.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,76 +471,6 @@
|
|||||||
animation: moonlit-image-halo 2.8s ease-in-out infinite;
|
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-moon,
|
||||||
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
|
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-water i,
|
||||||
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
|
.gift-theme-moonlit-water.gift-low-motion .moonlit-ceremony-stars,
|
||||||
@@ -784,10 +568,3 @@
|
|||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes moonlit-guard-water {
|
|
||||||
50% {
|
|
||||||
opacity: 0.68;
|
|
||||||
transform: scale(1.04) translateY(-1.4%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+103
-137
@@ -1,7 +1,8 @@
|
|||||||
/** Full-viewport gift meteor and guard celebration renderer. */
|
/** Full-viewport gift renderer. */
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { normalizeGiftEffectSettings } from './api'
|
import { normalizeGiftEffectSettings } from './api'
|
||||||
|
import { useEffectQueue } from './effectQueue'
|
||||||
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
|
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
|
||||||
import { translate, useI18n } from './i18n'
|
import { translate, useI18n } from './i18n'
|
||||||
import type { ComponentStream } from './stream'
|
import type { ComponentStream } from './stream'
|
||||||
@@ -21,22 +22,17 @@ type EffectPayload = {
|
|||||||
viewer?: Viewer
|
viewer?: Viewer
|
||||||
gift?: Gift
|
gift?: Gift
|
||||||
quantity?: number
|
quantity?: number
|
||||||
guardName?: string
|
|
||||||
price?: number
|
|
||||||
settings?: Partial<GiftEffectSettings>
|
settings?: Partial<GiftEffectSettings>
|
||||||
}
|
}
|
||||||
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
|
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
|
||||||
type VisualEffect = {
|
type VisualEffect = {
|
||||||
id: string
|
id: string
|
||||||
kind: 'gift' | 'guard'
|
|
||||||
payload: EffectPayload
|
payload: EffectPayload
|
||||||
receivedAt: number
|
|
||||||
expiresAt: number
|
|
||||||
/** Preview cards force a tier so custom thresholds do not change the selected demo. */
|
/** Preview cards force a tier so custom thresholds do not change the selected demo. */
|
||||||
previewTier?: GiftTier
|
previewTier?: GiftTier
|
||||||
}
|
}
|
||||||
type GiftTier = 'normal' | 'high' | 'featured'
|
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+. */
|
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
|
||||||
const MOONLIT_CEREMONY_THRESHOLD = 50_000
|
const MOONLIT_CEREMONY_THRESHOLD = 50_000
|
||||||
@@ -59,14 +55,7 @@ function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
|
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
|
||||||
const viewer = { uid: 'preview', name: translate('gift.preview.viewer') }
|
const viewer = { uid: '10001', name: translate('gift.preview.viewer') }
|
||||||
if (mode === 'guard') {
|
|
||||||
return {
|
|
||||||
id: `preview-guard-${nonce}`,
|
|
||||||
type: 'live.guard.buy',
|
|
||||||
payload: { viewer, guardName: translate('gift.preview.guard'), quantity: 1, price: 198_000 },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
|
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
|
||||||
return {
|
return {
|
||||||
id: `preview-${mode}-${nonce}`,
|
id: `preview-${mode}-${nonce}`,
|
||||||
@@ -86,23 +75,12 @@ function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnve
|
|||||||
|
|
||||||
function toVisualEffect(
|
function toVisualEffect(
|
||||||
envelope: EffectEnvelope,
|
envelope: EffectEnvelope,
|
||||||
guardDuration: number,
|
|
||||||
previewTier?: GiftTier,
|
previewTier?: GiftTier,
|
||||||
): VisualEffect | undefined {
|
): VisualEffect | undefined {
|
||||||
const kind =
|
if (envelope.type !== 'live.gift') return undefined
|
||||||
envelope.type === 'live.gift'
|
|
||||||
? 'gift'
|
|
||||||
: envelope.type === 'live.guard.buy'
|
|
||||||
? 'guard'
|
|
||||||
: undefined
|
|
||||||
if (!kind) return undefined
|
|
||||||
const now = Date.now()
|
|
||||||
return {
|
return {
|
||||||
id: envelope.id,
|
id: envelope.id,
|
||||||
kind,
|
|
||||||
payload: envelope.payload ?? {},
|
payload: envelope.payload ?? {},
|
||||||
receivedAt: now,
|
|
||||||
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
|
|
||||||
previewTier,
|
previewTier,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +94,7 @@ function useGiftEffects(
|
|||||||
language: string,
|
language: string,
|
||||||
) {
|
) {
|
||||||
const [settings, setSettings] = useState(defaultGiftEffectSettings)
|
const [settings, setSettings] = useState(defaultGiftEffectSettings)
|
||||||
const [effects, setEffects] = useState<VisualEffect[]>([])
|
const queue = useEffectQueue<VisualEffect>()
|
||||||
const settingsRef = useRef(settings)
|
const settingsRef = useRef(settings)
|
||||||
const lastSequenceRef = useRef(0)
|
const lastSequenceRef = useRef(0)
|
||||||
|
|
||||||
@@ -139,36 +117,24 @@ function useGiftEffects(
|
|||||||
setSettings(next)
|
setSettings(next)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
|
const effect = toVisualEffect(envelope)
|
||||||
if (!effect) continue
|
if (!effect) continue
|
||||||
setEffects(current =>
|
queue.enqueue(effect, settingsRef.current.queueCapacity)
|
||||||
[effect, ...current.filter(item => item.id !== effect.id)].slice(
|
|
||||||
0,
|
|
||||||
settingsRef.current.maxConcurrentEffects,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, [preview, stream, stream?.messages])
|
}, [preview, stream, stream?.messages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preview) return
|
if (!preview) return
|
||||||
const effect = toVisualEffect(
|
const effect = toVisualEffect(previewEnvelope(previewMode, previewNonce), previewMode)
|
||||||
previewEnvelope(previewMode, previewNonce),
|
if (effect) queue.enqueue(effect, (previewSettings ?? settingsRef.current).queueCapacity)
|
||||||
previewSettings?.guardEffectDurationMs ?? settingsRef.current.guardEffectDurationMs,
|
}, [language, preview, previewMode, previewNonce])
|
||||||
previewMode === 'guard' ? undefined : previewMode,
|
|
||||||
)
|
|
||||||
if (effect) setEffects(current => [effect, ...current].slice(0, 4))
|
|
||||||
}, [language, preview, previewMode, previewNonce, previewSettings?.guardEffectDurationMs])
|
|
||||||
|
|
||||||
useEffect(() => {
|
return {
|
||||||
const timer = window.setInterval(() => {
|
settings,
|
||||||
const now = Date.now()
|
effect: queue.active,
|
||||||
setEffects(current => current.filter(effect => effect.expiresAt > now))
|
pendingCount: queue.pendingCount,
|
||||||
}, 1_000)
|
completeEffect: queue.complete,
|
||||||
return () => window.clearInterval(timer)
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { settings, effects }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function GiftImage({ gift }: { gift: Gift }) {
|
function GiftImage({ gift }: { gift: Gift }) {
|
||||||
@@ -200,6 +166,7 @@ function MeteorBurst({
|
|||||||
viewportWidth,
|
viewportWidth,
|
||||||
trailIntensity,
|
trailIntensity,
|
||||||
lowPerformance,
|
lowPerformance,
|
||||||
|
onComplete,
|
||||||
}: {
|
}: {
|
||||||
effect: VisualEffect
|
effect: VisualEffect
|
||||||
tier: GiftTier
|
tier: GiftTier
|
||||||
@@ -208,19 +175,39 @@ function MeteorBurst({
|
|||||||
viewportWidth: number
|
viewportWidth: number
|
||||||
trailIntensity: number
|
trailIntensity: number
|
||||||
lowPerformance: boolean
|
lowPerformance: boolean
|
||||||
|
onComplete: () => void
|
||||||
}) {
|
}) {
|
||||||
const gift = effect.payload.gift ?? {}
|
const gift = effect.payload.gift ?? {}
|
||||||
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
|
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
|
||||||
const size = Math.max(20, tierSettings.size * scale)
|
const size = Math.max(20, tierSettings.size * scale)
|
||||||
const speed = Math.max(80, tierSettings.speed * scale)
|
const speed = Math.max(80, tierSettings.speed * scale)
|
||||||
return (
|
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||||
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
|
const meteors = Array.from({ length: count }, (_, index) => {
|
||||||
{Array.from({ length: count }, (_, index) => {
|
|
||||||
const seed = hash(`${effect.id}:${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)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="gift-meteor"
|
className="gift-meteor"
|
||||||
@@ -254,7 +241,13 @@ function MeteorBurst({
|
|||||||
* compact and stays out of the centre of a broadcaster's scene: a gift image,
|
* 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.
|
* 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 gift = effect.payload.gift ?? {}
|
||||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||||
const quantity = effect.payload.quantity || 1
|
const quantity = effect.payload.quantity || 1
|
||||||
@@ -271,6 +264,13 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
|
|||||||
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
|
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
|
onAnimationEnd={event => {
|
||||||
|
if (
|
||||||
|
event.target === event.currentTarget &&
|
||||||
|
event.animationName === 'moonlit-whisper-appear'
|
||||||
|
)
|
||||||
|
onComplete()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<i className="moonlit-whisper-ripple" aria-hidden="true" />
|
<i className="moonlit-whisper-ripple" aria-hidden="true" />
|
||||||
<span className="moonlit-whisper-image">
|
<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
|
* 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.
|
* 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 gift = effect.payload.gift ?? {}
|
||||||
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||||
const quantity = effect.payload.quantity || 1
|
const quantity = effect.payload.quantity || 1
|
||||||
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
|
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
|
||||||
return (
|
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-moon" aria-hidden="true" />
|
||||||
<div className="moonlit-ceremony-water" aria-hidden="true">
|
<div className="moonlit-ceremony-water" aria-hidden="true">
|
||||||
<i />
|
<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({
|
export function GiftEffectOverlay({
|
||||||
preview = false,
|
preview = false,
|
||||||
previewSettings,
|
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 scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
|
||||||
const guard = remote.effects.find(effect => effect.kind === 'guard')
|
|
||||||
const moonlit = theme.id === 'moonlit-water'
|
const moonlit = theme.id === 'moonlit-water'
|
||||||
return (
|
const effect = remote.effect
|
||||||
<main
|
let activeVisual = null
|
||||||
ref={root}
|
if (effect) {
|
||||||
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
|
|
||||||
data-theme={theme.id}
|
|
||||||
data-connection={stream?.connection || 'idle'}
|
|
||||||
style={{
|
|
||||||
...giftThemeVariables(theme),
|
|
||||||
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="meteor-sky" aria-live="polite">
|
|
||||||
{remote.effects
|
|
||||||
.filter(effect => effect.kind === 'gift')
|
|
||||||
.map(effect => {
|
|
||||||
const tier = tierFor(effect, settings)
|
const tier = tierFor(effect, settings)
|
||||||
const gift = effect.payload.gift
|
const gift = effect.payload.gift
|
||||||
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
|
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
|
||||||
if (moonlit) {
|
const onComplete = () => remote.completeEffect(effect.id)
|
||||||
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
|
activeVisual = moonlit ? (
|
||||||
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
|
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
|
<MeteorBurst
|
||||||
effect={effect}
|
effect={effect}
|
||||||
tier={tier}
|
tier={tier}
|
||||||
@@ -464,12 +416,26 @@ export function GiftEffectOverlay({
|
|||||||
viewportWidth={bounds.width}
|
viewportWidth={bounds.width}
|
||||||
trailIntensity={settings.trailIntensity}
|
trailIntensity={settings.trailIntensity}
|
||||||
lowPerformance={settings.lowPerformanceMode}
|
lowPerformance={settings.lowPerformanceMode}
|
||||||
|
onComplete={onComplete}
|
||||||
key={effect.id}
|
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>
|
</div>
|
||||||
{guard && <GuardCelebration effect={guard} settings={settings} />}
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ export type GiftEffectTheme = {
|
|||||||
jade: string
|
jade: string
|
||||||
cyan: string
|
cyan: string
|
||||||
gold: string
|
gold: string
|
||||||
rose: string
|
|
||||||
night: string
|
|
||||||
}
|
}
|
||||||
motion: {
|
motion: {
|
||||||
meteor: string
|
meteor: string
|
||||||
guardReveal: string
|
|
||||||
starPulse: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +26,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
|||||||
jade: '#72f3d8',
|
jade: '#72f3d8',
|
||||||
cyan: '#a9fff2',
|
cyan: '#a9fff2',
|
||||||
gold: '#ffe7a4',
|
gold: '#ffe7a4',
|
||||||
rose: '#ffd0e5',
|
|
||||||
night: '#020c18',
|
|
||||||
},
|
},
|
||||||
motion: {
|
motion: {
|
||||||
meteor: 'gift-meteor-flight',
|
meteor: 'gift-meteor-flight',
|
||||||
guardReveal: 'gift-guard-reveal',
|
|
||||||
starPulse: 'gift-star-pulse',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,13 +40,9 @@ export const giftEffectThemes: readonly GiftEffectTheme[] = [
|
|||||||
jade: '#8daea2',
|
jade: '#8daea2',
|
||||||
cyan: '#c6d8c6',
|
cyan: '#c6d8c6',
|
||||||
gold: '#e7c982',
|
gold: '#e7c982',
|
||||||
rose: '#dbc6a0',
|
|
||||||
night: 'transparent',
|
|
||||||
},
|
},
|
||||||
motion: {
|
motion: {
|
||||||
meteor: 'moonlit-offering-sweep',
|
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-jade' as string]: theme.palette.jade,
|
||||||
['--gift-cyan' as string]: theme.palette.cyan,
|
['--gift-cyan' as string]: theme.palette.cyan,
|
||||||
['--gift-gold' as string]: theme.palette.gold,
|
['--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-meteor' as string]: theme.motion.meteor,
|
||||||
['--gift-motion-guard' as string]: theme.motion.guardReveal,
|
|
||||||
['--gift-motion-star' as string]: theme.motion.starPulse,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
.guard-effect-overlay {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--guard-cyan, #a9fff2);
|
||||||
|
font-family: var(--component-font-family, 'Noto Serif SC', 'Songti SC', 'STSong', serif);
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-voyage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-voyage > video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
animation: jade-guard-video-fade var(--guard-duration) linear both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
width: min(86vw, 1180px);
|
||||||
|
min-height: min(34vh, 360px);
|
||||||
|
padding: clamp(20px, 3.2vh, 42px) clamp(54px, 8vw, 130px);
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: clamp(5px, 0.9vh, 12px);
|
||||||
|
border-block: 1px solid rgba(255, 229, 153, 0.7);
|
||||||
|
color: #fff4cf;
|
||||||
|
font-family: 'HongLei XingShu', 'Liyu Shoushu', cursive;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.96),
|
||||||
|
0 0 14px rgba(0, 0, 0, 0.92),
|
||||||
|
0 0 22px rgba(255, 220, 130, 0.48);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(3, 15, 22, 0.34) 12%,
|
||||||
|
rgba(3, 15, 22, 0.62) 50%,
|
||||||
|
rgba(3, 15, 22, 0.34) 88%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
clip-path: inset(0 50% round 10px);
|
||||||
|
transform: translate(-56%, -50%);
|
||||||
|
will-change: clip-path, opacity, transform;
|
||||||
|
animation: jade-guard-scroll-reveal 4s cubic-bezier(0.22, 0.78, 0.22, 1) 4s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll::before,
|
||||||
|
.jade-guard-scroll::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -7%;
|
||||||
|
bottom: -7%;
|
||||||
|
width: clamp(5px, 0.55vw, 10px);
|
||||||
|
border: 1px solid rgba(255, 239, 190, 0.82);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #806329, #fff0b3 48%, #947132);
|
||||||
|
box-shadow: 0 0 14px rgba(255, 224, 145, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll::before {
|
||||||
|
left: clamp(13px, 2vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll::after {
|
||||||
|
right: clamp(13px, 2vw, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll strong,
|
||||||
|
.jade-guard-scroll b,
|
||||||
|
.jade-guard-scroll span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll strong {
|
||||||
|
color: #fff5d2;
|
||||||
|
font-size: clamp(42px, 7vw, 126px);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll b {
|
||||||
|
color: #d4fff3;
|
||||||
|
font-size: clamp(24px, 3.7vw, 66px);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll span {
|
||||||
|
color: #ffe5a6;
|
||||||
|
font-size: clamp(34px, 5.4vw, 94px);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-celebration {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
color: #edfffa;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%),
|
||||||
|
radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%),
|
||||||
|
radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--guard-night);
|
||||||
|
animation: var(--guard-motion-reveal, guard-effect-reveal) var(--guard-duration) ease-in-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-nebula {
|
||||||
|
position: absolute;
|
||||||
|
inset: -25%;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(93, 237, 209, 0.18),
|
||||||
|
transparent 32%,
|
||||||
|
rgba(255, 207, 230, 0.12),
|
||||||
|
transparent 68%,
|
||||||
|
rgba(255, 230, 156, 0.13),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
filter: blur(28px);
|
||||||
|
animation: guard-nebula-turn 9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-stars {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-stars i {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--star-size);
|
||||||
|
height: var(--star-size);
|
||||||
|
opacity: 0.1;
|
||||||
|
background: linear-gradient(135deg, #fff8ca, var(--guard-cyan) 58%, var(--guard-rose));
|
||||||
|
clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%);
|
||||||
|
filter: drop-shadow(0 0 6px var(--guard-cyan));
|
||||||
|
animation: var(--guard-motion-star, guard-star-pulse) 2.8s ease-in-out var(--star-delay) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-halo {
|
||||||
|
position: absolute;
|
||||||
|
width: min(62vmin, 720px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid rgba(155, 255, 235, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px rgba(87, 241, 212, 0.25),
|
||||||
|
inset 0 0 70px rgba(255, 224, 166, 0.12);
|
||||||
|
animation: guard-halo-breathe 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-halo i {
|
||||||
|
position: absolute;
|
||||||
|
inset: 7%;
|
||||||
|
border: 1px solid rgba(255, 227, 170, 0.38);
|
||||||
|
border-radius: 45% 55% 48% 52%;
|
||||||
|
transform: rotate(30deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-halo i:nth-child(2) {
|
||||||
|
inset: 15%;
|
||||||
|
border-color: rgba(255, 195, 224, 0.32);
|
||||||
|
transform: rotate(76deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-halo i:nth-child(3) {
|
||||||
|
inset: 23%;
|
||||||
|
border-color: rgba(123, 246, 224, 0.45);
|
||||||
|
transform: rotate(122deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
max-width: min(82vw, 1000px);
|
||||||
|
justify-items: center;
|
||||||
|
gap: clamp(8px, 1.5vh, 20px);
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 18px rgba(108, 255, 226, 0.72);
|
||||||
|
filter: brightness(var(--component-font-brightness, 1.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-copy span {
|
||||||
|
color: var(--guard-gold);
|
||||||
|
font-size: clamp(13px, 1.6vw, 30px);
|
||||||
|
letter-spacing: 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-copy strong {
|
||||||
|
color: #f3fffc;
|
||||||
|
font-size: clamp(38px, 7vw, 132px);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54));
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-copy b {
|
||||||
|
color: var(--guard-rose);
|
||||||
|
font-size: clamp(16px, 2.3vw, 44px);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-low-motion .guard-nebula,
|
||||||
|
.guard-low-motion .guard-halo {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water {
|
||||||
|
color: #eadfc3;
|
||||||
|
font-family: var(--component-font-family, FangSong, STFangsong, 'Noto Serif SC', serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-celebration {
|
||||||
|
color: #eadfc3;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-celebration::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: min(62vmin, 780px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(246, 237, 207, 0.82) 0 42%,
|
||||||
|
rgba(226, 224, 193, 0.2) 63%,
|
||||||
|
transparent 71%
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 80px rgba(231, 201, 130, 0.24);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-nebula {
|
||||||
|
background:
|
||||||
|
repeating-radial-gradient(
|
||||||
|
ellipse at 50% 80%,
|
||||||
|
transparent 0 7%,
|
||||||
|
rgba(220, 225, 192, 0.2) 7.2% 7.45%,
|
||||||
|
transparent 7.7% 12%
|
||||||
|
),
|
||||||
|
linear-gradient(90deg, transparent, rgba(231, 201, 130, 0.12), transparent);
|
||||||
|
filter: blur(1px);
|
||||||
|
animation: moonlit-guard-water 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-stars i {
|
||||||
|
background: linear-gradient(135deg, #eadfc3, #e7c982 62%, #adc3ad);
|
||||||
|
filter: drop-shadow(0 0 5px rgba(231, 201, 130, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-halo {
|
||||||
|
width: min(77vmin, 930px);
|
||||||
|
border-color: rgba(231, 201, 130, 0.42);
|
||||||
|
box-shadow:
|
||||||
|
0 0 44px rgba(231, 201, 130, 0.18),
|
||||||
|
inset 0 0 65px rgba(182, 207, 180, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-halo i {
|
||||||
|
border-color: rgba(203, 219, 184, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-copy {
|
||||||
|
text-shadow: 0 2px 13px rgba(8, 35, 38, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-copy span,
|
||||||
|
.guard-theme-moonlit-water .guard-copy b {
|
||||||
|
color: #e7c982;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-theme-moonlit-water .guard-copy strong {
|
||||||
|
color: #eadfc3;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes guard-effect-reveal {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
8%,
|
||||||
|
86% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jade-guard-video-fade {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
6.25%,
|
||||||
|
93.75% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jade-guard-scroll-reveal {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
clip-path: inset(0 50% round 10px);
|
||||||
|
transform: translate(-56%, -50%);
|
||||||
|
}
|
||||||
|
12.5%,
|
||||||
|
87.5% {
|
||||||
|
opacity: 1;
|
||||||
|
clip-path: inset(0 round 10px);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
clip-path: inset(0 round 10px);
|
||||||
|
transform: translate(-46%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jade-guard-copy-fade {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12.5%,
|
||||||
|
87.5% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes guard-star-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.08;
|
||||||
|
transform: rotate(0) scale(0.45);
|
||||||
|
}
|
||||||
|
48% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(50deg) scale(1.32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes guard-nebula-turn {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes guard-halo-breathe {
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08) rotate(3deg);
|
||||||
|
box-shadow:
|
||||||
|
0 0 110px rgba(87, 241, 212, 0.38),
|
||||||
|
inset 0 0 90px rgba(255, 224, 166, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moonlit-guard-water {
|
||||||
|
50% {
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: scale(1.04) translateY(-1.4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moonlit-guard-reveal {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
8%,
|
||||||
|
86% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.guard-nebula,
|
||||||
|
.guard-stars i,
|
||||||
|
.guard-halo {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jade-guard-scroll {
|
||||||
|
clip-path: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation-name: jade-guard-copy-fade;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/** Full-viewport renderer for the independently subscribed membership component. */
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { normalizeGuardEffectSettings } from './api'
|
||||||
|
import { useEffectQueue } from './effectQueue'
|
||||||
|
import { getGuardEffectTheme, guardThemeVariables } from './guardThemes'
|
||||||
|
import { translate, useI18n } from './i18n'
|
||||||
|
import type { ComponentStream } from './stream'
|
||||||
|
import { defaultGuardEffectSettings } from './types'
|
||||||
|
import type { GuardEffectSettings } from './types'
|
||||||
|
import { typographyVariables } from './typography'
|
||||||
|
|
||||||
|
type Viewer = { uid?: string; name?: string }
|
||||||
|
type GuardPayload = {
|
||||||
|
viewer?: Viewer
|
||||||
|
guardName?: string
|
||||||
|
quantity?: number
|
||||||
|
price?: number
|
||||||
|
settings?: Partial<GuardEffectSettings>
|
||||||
|
}
|
||||||
|
type GuardEnvelope = { id: string; type: string; payload?: GuardPayload }
|
||||||
|
type GuardEffect = {
|
||||||
|
id: string
|
||||||
|
payload: GuardPayload
|
||||||
|
}
|
||||||
|
type GuardLevel = 'captain' | 'admiral' | 'governor'
|
||||||
|
export type GuardEffectPreviewMode = GuardLevel
|
||||||
|
|
||||||
|
const JADE_VIDEO_DURATION_MS = 8_000
|
||||||
|
|
||||||
|
function guardLevel(guardName: string | undefined): GuardLevel {
|
||||||
|
const normalized = guardName?.trim().toLocaleLowerCase() ?? ''
|
||||||
|
if (normalized.includes('总督') || normalized.includes('governor')) return 'governor'
|
||||||
|
if (normalized.includes('提督') || normalized.includes('admiral')) return 'admiral'
|
||||||
|
return 'captain'
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardVideoUrl(level: GuardLevel): string {
|
||||||
|
return `/assets/${level === 'governor' ? 'general' : level}.webm`
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewEnvelope(level: GuardLevel, nonce: number): GuardEnvelope {
|
||||||
|
return {
|
||||||
|
id: `preview-${level}-${nonce}`,
|
||||||
|
type: 'live.guard.buy',
|
||||||
|
payload: {
|
||||||
|
viewer: { uid: '10001', name: translate('gift.preview.viewer') },
|
||||||
|
guardName: translate(`gift_menu.guard.${level}`),
|
||||||
|
quantity: 1,
|
||||||
|
price: 198_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGuardEffect(envelope: GuardEnvelope): GuardEffect | undefined {
|
||||||
|
if (envelope.type !== 'live.guard.buy') return undefined
|
||||||
|
return {
|
||||||
|
id: envelope.id,
|
||||||
|
payload: envelope.payload ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGuardEffect(
|
||||||
|
preview: boolean,
|
||||||
|
stream: ComponentStream | undefined,
|
||||||
|
previewSettings: GuardEffectSettings | undefined,
|
||||||
|
previewMode: GuardEffectPreviewMode,
|
||||||
|
previewNonce: number,
|
||||||
|
language: string,
|
||||||
|
) {
|
||||||
|
const [settings, setSettings] = useState(defaultGuardEffectSettings)
|
||||||
|
const queue = useEffectQueue<GuardEffect>()
|
||||||
|
const settingsRef = useRef(settings)
|
||||||
|
const lastSequenceRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRef.current = settings
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preview || !stream) return
|
||||||
|
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
|
||||||
|
for (const message of pending) {
|
||||||
|
lastSequenceRef.current = message.sequence
|
||||||
|
const envelope = message.envelope as GuardEnvelope
|
||||||
|
if (
|
||||||
|
envelope.type === 'component.settings.snapshot' ||
|
||||||
|
envelope.type === 'component.settings.updated'
|
||||||
|
) {
|
||||||
|
const next = normalizeGuardEffectSettings(envelope.payload?.settings)
|
||||||
|
settingsRef.current = next
|
||||||
|
setSettings(next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const next = toGuardEffect(envelope)
|
||||||
|
if (next) queue.enqueue(next, settingsRef.current.queueCapacity)
|
||||||
|
}
|
||||||
|
}, [preview, stream, stream?.messages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview) return
|
||||||
|
const currentSettings = previewSettings ?? settingsRef.current
|
||||||
|
const effect = toGuardEffect(previewEnvelope(previewMode, previewNonce))
|
||||||
|
if (effect) queue.enqueue(effect, currentSettings.queueCapacity)
|
||||||
|
}, [
|
||||||
|
language,
|
||||||
|
preview,
|
||||||
|
previewMode,
|
||||||
|
previewNonce,
|
||||||
|
previewSettings?.effectDurationMs,
|
||||||
|
previewSettings?.themeId,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
effect: queue.active,
|
||||||
|
pendingCount: queue.pendingCount,
|
||||||
|
completeEffect: queue.complete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value: string): number {
|
||||||
|
let result = 2166136261
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
result ^= value.charCodeAt(index)
|
||||||
|
result = Math.imul(result, 16777619)
|
||||||
|
}
|
||||||
|
return result >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function JadeGuardVoyage({
|
||||||
|
effect,
|
||||||
|
settings,
|
||||||
|
muted,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
effect: GuardEffect
|
||||||
|
settings: GuardEffectSettings
|
||||||
|
muted: boolean
|
||||||
|
onComplete: () => void
|
||||||
|
}) {
|
||||||
|
const level = guardLevel(effect.payload.guardName)
|
||||||
|
const guard = translate(`gift_menu.guard.${level}`)
|
||||||
|
const viewer =
|
||||||
|
effect.payload.viewer?.uid || effect.payload.viewer?.name || translate('common.viewer')
|
||||||
|
const title = settings.titleTemplate.replaceAll('{guard}', guard)
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`jade-guard-voyage guard-level-${level}`}
|
||||||
|
aria-label={translate('guard.effect_aria')}
|
||||||
|
style={{ ['--guard-duration' as string]: `${JADE_VIDEO_DURATION_MS}ms` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
muted={muted}
|
||||||
|
src={guardVideoUrl(level)}
|
||||||
|
key={`${effect.id}:${level}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
onAnimationEnd={event => {
|
||||||
|
if (event.animationName === 'jade-guard-video-fade') onComplete()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="jade-guard-scroll" aria-live="polite">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<b>{viewer}</b>
|
||||||
|
<span>{settings.closingText}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonlitGuardCelebration({
|
||||||
|
effect,
|
||||||
|
settings,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
effect: GuardEffect
|
||||||
|
settings: GuardEffectSettings
|
||||||
|
onComplete: () => void
|
||||||
|
}) {
|
||||||
|
const count = settings.lowPerformanceMode ? Math.min(24, settings.starCount) : settings.starCount
|
||||||
|
const viewer = effect.payload.viewer?.name || translate('common.viewer')
|
||||||
|
const guard = effect.payload.guardName || translate('common.guard')
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="guard-celebration"
|
||||||
|
aria-label={translate('guard.effect_aria')}
|
||||||
|
style={{ ['--guard-duration' as string]: `${settings.effectDurationMs}ms` } as CSSProperties}
|
||||||
|
onAnimationEnd={event => {
|
||||||
|
if (event.target === event.currentTarget && event.animationName === 'moonlit-guard-reveal')
|
||||||
|
onComplete()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="guard-nebula" />
|
||||||
|
<div className="guard-stars" aria-hidden="true">
|
||||||
|
{Array.from({ length: count }, (_, index) => {
|
||||||
|
const seed = hash(`${effect.id}:guard:${index}`)
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
key={index}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
left: `${seed % 100}%`,
|
||||||
|
top: `${(seed >>> 8) % 100}%`,
|
||||||
|
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
|
||||||
|
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="guard-halo" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
<div className="guard-copy">
|
||||||
|
<span>{translate('guard.salute')}</span>
|
||||||
|
<strong>{translate('guard.title', { guard })}</strong>
|
||||||
|
<b>{translate('guard.viewer', { viewer })}</b>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GuardEffectOverlay({
|
||||||
|
preview = false,
|
||||||
|
previewSettings,
|
||||||
|
previewMode = 'captain',
|
||||||
|
previewNonce = 0,
|
||||||
|
stream,
|
||||||
|
}: {
|
||||||
|
preview?: boolean
|
||||||
|
previewSettings?: GuardEffectSettings
|
||||||
|
previewMode?: GuardEffectPreviewMode
|
||||||
|
previewNonce?: number
|
||||||
|
stream?: ComponentStream
|
||||||
|
}) {
|
||||||
|
const { language } = useI18n()
|
||||||
|
const remote = useGuardEffect(
|
||||||
|
preview,
|
||||||
|
stream,
|
||||||
|
previewSettings,
|
||||||
|
previewMode,
|
||||||
|
previewNonce,
|
||||||
|
language,
|
||||||
|
)
|
||||||
|
const settings = previewSettings ?? remote.settings
|
||||||
|
const theme = getGuardEffectTheme(settings.themeId)
|
||||||
|
const effect = remote.effect
|
||||||
|
const onComplete = effect ? () => remote.completeEffect(effect.id) : undefined
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={`guard-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'guard-low-motion' : ''}`}
|
||||||
|
data-theme={theme.id}
|
||||||
|
data-connection={stream?.connection || 'idle'}
|
||||||
|
data-queue-length={remote.pendingCount}
|
||||||
|
style={{
|
||||||
|
...guardThemeVariables(theme),
|
||||||
|
...typographyVariables(settings.fontFamily, settings.fontBrightness),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{effect &&
|
||||||
|
onComplete &&
|
||||||
|
(theme.id === 'moonlit-water' ? (
|
||||||
|
<MoonlitGuardCelebration
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
settings={settings}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<JadeGuardVoyage
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
settings={settings}
|
||||||
|
muted={preview}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import type { GuardEffectThemeId } from './types'
|
||||||
|
|
||||||
|
export type GuardEffectTheme = {
|
||||||
|
id: GuardEffectThemeId
|
||||||
|
nameKey: string
|
||||||
|
descriptionKey: string
|
||||||
|
className: string
|
||||||
|
palette: {
|
||||||
|
cyan: string
|
||||||
|
gold: string
|
||||||
|
rose: string
|
||||||
|
night: string
|
||||||
|
}
|
||||||
|
motion: {
|
||||||
|
reveal: string
|
||||||
|
starPulse: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const guardEffectThemes: readonly GuardEffectTheme[] = [
|
||||||
|
{
|
||||||
|
id: 'jade-starfall',
|
||||||
|
nameKey: 'guard.theme.jade_starfall.name',
|
||||||
|
descriptionKey: 'guard.theme.jade_starfall.description',
|
||||||
|
className: 'guard-theme-jade-starfall',
|
||||||
|
palette: {
|
||||||
|
cyan: '#a9fff2',
|
||||||
|
gold: '#ffe7a4',
|
||||||
|
rose: '#ffd0e5',
|
||||||
|
night: '#020c18',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
reveal: 'guard-effect-reveal',
|
||||||
|
starPulse: 'guard-star-pulse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moonlit-water',
|
||||||
|
nameKey: 'guard.theme.moonlit_water.name',
|
||||||
|
descriptionKey: 'guard.theme.moonlit_water.description',
|
||||||
|
className: 'guard-theme-moonlit-water',
|
||||||
|
palette: {
|
||||||
|
cyan: '#c6d8c6',
|
||||||
|
gold: '#e7c982',
|
||||||
|
rose: '#dbc6a0',
|
||||||
|
night: 'transparent',
|
||||||
|
},
|
||||||
|
motion: {
|
||||||
|
reveal: 'moonlit-guard-reveal',
|
||||||
|
starPulse: 'guard-star-pulse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getGuardEffectTheme(id: unknown): GuardEffectTheme {
|
||||||
|
return guardEffectThemes.find(theme => theme.id === id) ?? guardEffectThemes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeGuardEffectThemeId(id: unknown): GuardEffectThemeId {
|
||||||
|
return getGuardEffectTheme(id).id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guardThemeVariables(theme: GuardEffectTheme): CSSProperties {
|
||||||
|
return {
|
||||||
|
['--guard-cyan' as string]: theme.palette.cyan,
|
||||||
|
['--guard-gold' as string]: theme.palette.gold,
|
||||||
|
['--guard-rose' as string]: theme.palette.rose,
|
||||||
|
['--guard-night' as string]: theme.palette.night,
|
||||||
|
['--guard-motion-reveal' as string]: theme.motion.reveal,
|
||||||
|
['--guard-motion-star' as string]: theme.motion.starPulse,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
SongRequestsPage,
|
SongRequestsPage,
|
||||||
} from './control'
|
} from './control'
|
||||||
import { GiftEffectOverlay } from './giftEffect'
|
import { GiftEffectOverlay } from './giftEffect'
|
||||||
|
import { GuardEffectOverlay } from './guardEffect'
|
||||||
import { GiftMenuOverlay } from './giftMenu'
|
import { GiftMenuOverlay } from './giftMenu'
|
||||||
import { Overlay, tokenFromFragment } from './overlay'
|
import { Overlay, tokenFromFragment } from './overlay'
|
||||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||||
@@ -32,6 +33,7 @@ import './fonts.css'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
import './control.css'
|
import './control.css'
|
||||||
import './giftEffect.css'
|
import './giftEffect.css'
|
||||||
|
import './guardEffect.css'
|
||||||
import './giftMenu.css'
|
import './giftMenu.css'
|
||||||
import './song.css'
|
import './song.css'
|
||||||
import './themeEdges.css'
|
import './themeEdges.css'
|
||||||
@@ -48,6 +50,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken
|
|||||||
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
|
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
|
||||||
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
|
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
|
||||||
if (stream.componentKind === 'gift_effect') return <GiftEffectOverlay 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 === 'gift_menu') return <GiftMenuOverlay stream={stream} />
|
||||||
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
|
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
|
||||||
return <Overlay stream={stream} />
|
return <Overlay stream={stream} />
|
||||||
|
|||||||
@@ -390,6 +390,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
|||||||
}, [items.length, language, preview, setItems])
|
}, [items.length, language, preview, setItems])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!settings.expandNewDanmaku) {
|
||||||
|
setExpandedKey(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
const newest = items[items.length - 1]
|
const newest = items[items.length - 1]
|
||||||
if (!newest) {
|
if (!newest) {
|
||||||
setExpandedKey(undefined)
|
setExpandedKey(undefined)
|
||||||
@@ -402,7 +406,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
|||||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||||
)
|
)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [items, settings.collapseAfterSeconds, shape])
|
}, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -435,7 +439,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
|
|||||||
<Card
|
<Card
|
||||||
item={item}
|
item={item}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
expanded={item.key === expandedKey}
|
expanded={settings.expandNewDanmaku && item.key === expandedKey}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ body,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-current-copy,
|
.song-current-copy {
|
||||||
.song-score {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -83,15 +82,13 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.song-current-copy,
|
.song-current-copy,
|
||||||
.song-score,
|
|
||||||
.song-queue > header,
|
.song-queue > header,
|
||||||
.song-row,
|
.song-row,
|
||||||
.song-queue-empty {
|
.song-queue-empty {
|
||||||
filter: brightness(var(--component-font-brightness, 1.3));
|
filter: brightness(var(--component-font-brightness, 1.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-current-copy small,
|
.song-current-copy small {
|
||||||
.song-score small {
|
|
||||||
color: var(--component-song-requester-color, var(--theme-compact-user));
|
color: var(--component-song-requester-color, var(--theme-compact-user));
|
||||||
font-size: 0.68em;
|
font-size: 0.68em;
|
||||||
}
|
}
|
||||||
@@ -104,16 +101,6 @@ body,
|
|||||||
text-shadow: 0 0 20px rgba(133, 255, 232, 0.2);
|
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 {
|
.song-queue {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -359,13 +346,6 @@ body,
|
|||||||
grid-template-columns: auto minmax(0, 1fr);
|
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 {
|
.song-overlay.song-short .song-current {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding-block: 4px;
|
padding-block: 4px;
|
||||||
@@ -458,14 +438,12 @@ body,
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-moonlit-water .song-current-copy,
|
.theme-moonlit-water .song-current-copy {
|
||||||
.theme-moonlit-water .song-score {
|
|
||||||
gap: 0.18em;
|
gap: 0.18em;
|
||||||
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
|
text-shadow: 0 1px 6px rgba(10, 34, 38, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-moonlit-water .song-current-copy small,
|
.theme-moonlit-water .song-current-copy small,
|
||||||
.theme-moonlit-water .song-score small,
|
|
||||||
.theme-moonlit-water .song-requester,
|
.theme-moonlit-water .song-requester,
|
||||||
.theme-moonlit-water .song-queue > header {
|
.theme-moonlit-water .song-queue > header {
|
||||||
color: var(--component-song-requester-color, #a9b79b);
|
color: var(--component-song-requester-color, #a9b79b);
|
||||||
@@ -480,27 +458,10 @@ body,
|
|||||||
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
|
text-shadow: 0 1px 7px rgba(10, 34, 38, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-moonlit-water .song-score b,
|
|
||||||
.theme-moonlit-water .song-index {
|
.theme-moonlit-water .song-index {
|
||||||
color: #c9b06f;
|
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 {
|
.theme-moonlit-water .song-waveform {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
@@ -634,13 +595,6 @@ body,
|
|||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
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 {
|
.song-overlay.theme-moonlit-water.song-short {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding-block: 6px;
|
padding-block: 6px;
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
|||||||
queuePosition: 0,
|
queuePosition: 0,
|
||||||
requestedAt: now,
|
requestedAt: now,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
averageScore: 4.8,
|
|
||||||
ratingCount: 26,
|
|
||||||
},
|
},
|
||||||
queued: titles.map((title, index) => ({
|
queued: titles.map((title, index) => ({
|
||||||
id: `preview-${index}`,
|
id: `preview-${index}`,
|
||||||
@@ -49,7 +47,6 @@ function previewData(): { current: SongRequestItem; queued: SongRequestItem[] }
|
|||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuePosition: index + 1,
|
queuePosition: index + 1,
|
||||||
requestedAt: now,
|
requestedAt: now,
|
||||||
ratingCount: 0,
|
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,14 +196,6 @@ function useSongQueue(preview: boolean, language: string, stream?: ComponentStre
|
|||||||
return { settings, queue }
|
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. */
|
/** Theme-owned stars and florets used inside the compact current-song card. */
|
||||||
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
|
function SongParticles({ theme, count = 8 }: { theme: OverlayThemeDefinition; count?: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -358,15 +347,11 @@ export function SongRequestOverlay({
|
|||||||
<strong>{translate('song.overlay.request_help')}</strong>
|
<strong>{translate('song.overlay.request_help')}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="song-score">
|
|
||||||
<b>{score(queue.current)}</b>
|
|
||||||
<small>{translate('song.overlay.rate_help')}</small>
|
|
||||||
<div className="song-waveform" aria-hidden="true">
|
<div className="song-waveform" aria-hidden="true">
|
||||||
{Array.from({ length: 8 }, (_, index) => (
|
{Array.from({ length: 8 }, (_, index) => (
|
||||||
<i key={index} />
|
<i key={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
|
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
+37
-11
@@ -24,6 +24,7 @@ export type OverlaySettings = {
|
|||||||
showLike: boolean
|
showLike: boolean
|
||||||
showShare: boolean
|
showShare: boolean
|
||||||
maxVisible: number
|
maxVisible: number
|
||||||
|
expandNewDanmaku: boolean
|
||||||
collapseAfterSeconds: number
|
collapseAfterSeconds: number
|
||||||
unfoldDurationMs: number
|
unfoldDurationMs: number
|
||||||
motionIntensity: number
|
motionIntensity: number
|
||||||
@@ -50,6 +51,7 @@ export const defaultOverlaySettings: OverlaySettings = {
|
|||||||
showLike: false,
|
showLike: false,
|
||||||
showShare: false,
|
showShare: false,
|
||||||
maxVisible: 5,
|
maxVisible: 5,
|
||||||
|
expandNewDanmaku: true,
|
||||||
collapseAfterSeconds: 12,
|
collapseAfterSeconds: 12,
|
||||||
unfoldDurationMs: 1000,
|
unfoldDurationMs: 1000,
|
||||||
motionIntensity: 70,
|
motionIntensity: 70,
|
||||||
@@ -100,7 +102,7 @@ export type MeteorTierSettings = {
|
|||||||
speed: number
|
speed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full-viewport visual settings for gift meteors and guard celebrations. */
|
/** Full-viewport visual settings for gifts. */
|
||||||
export type GiftEffectSettings = {
|
export type GiftEffectSettings = {
|
||||||
themeId: GiftEffectThemeId
|
themeId: GiftEffectThemeId
|
||||||
fontFamily: FontFamilyId
|
fontFamily: FontFamilyId
|
||||||
@@ -111,9 +113,7 @@ export type GiftEffectSettings = {
|
|||||||
high: MeteorTierSettings
|
high: MeteorTierSettings
|
||||||
featured: MeteorTierSettings
|
featured: MeteorTierSettings
|
||||||
trailIntensity: number
|
trailIntensity: number
|
||||||
guardStarCount: number
|
queueCapacity: number
|
||||||
guardEffectDurationMs: number
|
|
||||||
maxConcurrentEffects: number
|
|
||||||
lowPerformanceMode: boolean
|
lowPerformanceMode: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +127,34 @@ export const defaultGiftEffectSettings: GiftEffectSettings = {
|
|||||||
high: { count: 6, size: 126, speed: 720 },
|
high: { count: 6, size: 126, speed: 720 },
|
||||||
featured: { count: 10, size: 168, speed: 880 },
|
featured: { count: 10, size: 168, speed: 880 },
|
||||||
trailIntensity: 78,
|
trailIntensity: 78,
|
||||||
guardStarCount: 48,
|
queueCapacity: 256,
|
||||||
guardEffectDurationMs: 5_200,
|
lowPerformanceMode: false,
|
||||||
maxConcurrentEffects: 8,
|
}
|
||||||
|
|
||||||
|
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,
|
lowPerformanceMode: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +220,11 @@ export const defaultGiftMenuSettings: GiftMenuSettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ComponentSettings =
|
export type ComponentSettings =
|
||||||
OverlaySettings | SongRequestSettings | GiftEffectSettings | GiftMenuSettings
|
| OverlaySettings
|
||||||
|
| SongRequestSettings
|
||||||
|
| GiftEffectSettings
|
||||||
|
| GuardEffectSettings
|
||||||
|
| GiftMenuSettings
|
||||||
|
|
||||||
export type SongRequester = { uid: string; name: string }
|
export type SongRequester = { uid: string; name: string }
|
||||||
|
|
||||||
@@ -208,8 +237,6 @@ export type SongRequestItem = {
|
|||||||
requestedAt: string
|
requestedAt: string
|
||||||
startedAt?: string | null
|
startedAt?: string | null
|
||||||
finishedAt?: string | null
|
finishedAt?: string | null
|
||||||
averageScore?: number | null
|
|
||||||
ratingCount: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SongQueueSummary = {
|
export type SongQueueSummary = {
|
||||||
@@ -217,7 +244,6 @@ export type SongQueueSummary = {
|
|||||||
queuedCount: number
|
queuedCount: number
|
||||||
completedCount: number
|
completedCount: number
|
||||||
cancelledCount: number
|
cancelledCount: number
|
||||||
ratingCount: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SongRequestPage = {
|
export type SongRequestPage = {
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ crate 导出。
|
|||||||
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
||||||
| `realtime` | account event routing 与 component-scoped fanout |
|
| `realtime` | account event routing 与 component-scoped fanout |
|
||||||
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||||
| `song_request` | 点歌命令、事务队列、评分、快照和管理服务 |
|
| `song_request` | 点歌命令、事务队列、快照和管理服务 |
|
||||||
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 |
|
||||||
|
| `guard_effect` | 独立大航海主题、感谢文案与 `live.guard.buy` 事件订阅 |
|
||||||
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
| `gift_menu` | 礼物菜单设置、触发匹配与 OBS 高亮投影 |
|
||||||
|
|
||||||
## 重要不变量
|
## 重要不变量
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use crate::{
|
|||||||
db::{Db, DbError},
|
db::{Db, DbError},
|
||||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||||
|
guard_effect::{GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings},
|
||||||
i18n,
|
i18n,
|
||||||
overlay::OverlaySettings,
|
overlay::OverlaySettings,
|
||||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||||
@@ -669,6 +670,23 @@ impl AuthService {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.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_component_id = Uuid::new_v4();
|
||||||
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
let menu_settings = serde_json::to_value(GiftMenuSettings::default())
|
||||||
.expect("GiftMenuSettings is always JSON serializable");
|
.expect("GiftMenuSettings is always JSON serializable");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use crate::{
|
|||||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||||
gift_effect::GiftEffectDefinition,
|
gift_effect::GiftEffectDefinition,
|
||||||
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
gift_menu::{GiftMenuDefinition, GiftMenuProjection},
|
||||||
|
guard_effect::GuardEffectDefinition,
|
||||||
overlay::OverlaySettings,
|
overlay::OverlaySettings,
|
||||||
song_request::{SongRequestDefinition, SongRequestProjection},
|
song_request::{SongRequestDefinition, SongRequestProjection},
|
||||||
};
|
};
|
||||||
@@ -389,6 +390,12 @@ impl ComponentRegistry {
|
|||||||
Arc::new(PassthroughProjection),
|
Arc::new(PassthroughProjection),
|
||||||
)
|
)
|
||||||
.expect("built-in component kinds are unique");
|
.expect("built-in component kinds are unique");
|
||||||
|
registry
|
||||||
|
.register(
|
||||||
|
Arc::new(GuardEffectDefinition),
|
||||||
|
Arc::new(PassthroughProjection),
|
||||||
|
)
|
||||||
|
.expect("built-in component kinds are unique");
|
||||||
registry
|
registry
|
||||||
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
.register(Arc::new(GiftMenuDefinition), Arc::new(GiftMenuProjection))
|
||||||
.expect("built-in component kinds are unique");
|
.expect("built-in component kinds are unique");
|
||||||
@@ -560,7 +567,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() {
|
fn builtin_gift_effect_only_subscribes_to_gifts() {
|
||||||
let registry = ComponentRegistry::default();
|
let registry = ComponentRegistry::default();
|
||||||
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
|
assert!(registry.kinds().contains(&"gift_effect".to_owned()));
|
||||||
let runtime = registry.runtime("gift_effect").unwrap();
|
let runtime = registry.runtime("gift_effect").unwrap();
|
||||||
@@ -574,7 +581,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
let subscriptions = runtime.subscriptions(&instance).unwrap();
|
||||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
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::GuardPurchase));
|
||||||
|
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ struct OverlayFileConfig {
|
|||||||
font_scale: Option<u16>,
|
font_scale: Option<u16>,
|
||||||
decoration_line_weight: Option<u16>,
|
decoration_line_weight: Option<u16>,
|
||||||
max_visible: Option<u8>,
|
max_visible: Option<u8>,
|
||||||
|
expand_new_danmaku: Option<bool>,
|
||||||
collapse_after_seconds: Option<u16>,
|
collapse_after_seconds: Option<u16>,
|
||||||
unfold_duration_ms: Option<u16>,
|
unfold_duration_ms: Option<u16>,
|
||||||
motion_intensity: Option<u8>,
|
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_like: file.events.like.unwrap_or(default.show_like),
|
||||||
show_share: file.events.share.unwrap_or(default.show_share),
|
show_share: file.events.share.unwrap_or(default.show_share),
|
||||||
max_visible: file.max_visible.unwrap_or(default.max_visible),
|
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: file
|
||||||
.collapse_after_seconds
|
.collapse_after_seconds
|
||||||
.unwrap_or(default.collapse_after_seconds),
|
.unwrap_or(default.collapse_after_seconds),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Full-screen gift and membership effect component.
|
//! Full-screen gift effect component.
|
||||||
//!
|
//!
|
||||||
//! The component is deliberately passive: it subscribes to canonical gift and
|
//! The component is deliberately passive: it subscribes only to canonical gift
|
||||||
//! guard-purchase events and projects them to its own authenticated OBS stream.
|
//! events and projects them to its own authenticated OBS stream.
|
||||||
//! All visual differentiation is settings-driven in the browser, so receiving
|
//! All visual differentiation is settings-driven in the browser, so receiving
|
||||||
//! an effect never creates database writes or depends on an OBS connection.
|
//! an effect never creates database writes or depends on an OBS connection.
|
||||||
|
|
||||||
@@ -57,9 +57,8 @@ pub struct GiftEffectSettings {
|
|||||||
pub high: MeteorTierSettings,
|
pub high: MeteorTierSettings,
|
||||||
pub featured: MeteorTierSettings,
|
pub featured: MeteorTierSettings,
|
||||||
pub trail_intensity: u8,
|
pub trail_intensity: u8,
|
||||||
pub guard_star_count: u8,
|
#[serde(default = "default_queue_capacity")]
|
||||||
pub guard_effect_duration_ms: u16,
|
pub queue_capacity: u16,
|
||||||
pub max_concurrent_effects: u8,
|
|
||||||
pub low_performance_mode: bool,
|
pub low_performance_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +86,7 @@ impl Default for GiftEffectSettings {
|
|||||||
speed: 880,
|
speed: 880,
|
||||||
},
|
},
|
||||||
trail_intensity: 78,
|
trail_intensity: 78,
|
||||||
guard_star_count: 48,
|
queue_capacity: default_queue_capacity(),
|
||||||
guard_effect_duration_ms: 5_200,
|
|
||||||
max_concurrent_effects: 8,
|
|
||||||
low_performance_mode: false,
|
low_performance_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,13 +102,15 @@ impl GiftEffectSettings {
|
|||||||
self.high = self.high.sanitize();
|
self.high = self.high.sanitize();
|
||||||
self.featured = self.featured.sanitize();
|
self.featured = self.featured.sanitize();
|
||||||
self.trail_intensity = self.trail_intensity.min(100);
|
self.trail_intensity = self.trail_intensity.min(100);
|
||||||
self.guard_star_count = self.guard_star_count.clamp(8, 96);
|
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||||
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
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn default_queue_capacity() -> u16 {
|
||||||
|
256
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GiftEffectDefinition;
|
pub struct GiftEffectDefinition;
|
||||||
|
|
||||||
impl GiftEffectDefinition {
|
impl GiftEffectDefinition {
|
||||||
@@ -148,10 +147,7 @@ impl ComponentDefinition for GiftEffectDefinition {
|
|||||||
|
|
||||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||||
self.parse(settings.clone())?;
|
self.parse(settings.clone())?;
|
||||||
Ok(EventSubscription::new([
|
Ok(EventSubscription::new([LiveEventKind::Gift]))
|
||||||
LiveEventKind::Gift,
|
|
||||||
LiveEventKind::GuardPurchase,
|
|
||||||
]))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,25 +164,38 @@ mod tests {
|
|||||||
settings["featured"]["size"] = Value::from(9_999);
|
settings["featured"]["size"] = Value::from(9_999);
|
||||||
settings["highValueThreshold"] = Value::from(50_000);
|
settings["highValueThreshold"] = Value::from(50_000);
|
||||||
settings["featuredValueThreshold"] = Value::from(10_000);
|
settings["featuredValueThreshold"] = Value::from(10_000);
|
||||||
|
settings["queueCapacity"] = Value::from(9_999);
|
||||||
let sanitized = definition.validate_settings(settings).unwrap();
|
let sanitized = definition.validate_settings(settings).unwrap();
|
||||||
assert_eq!(sanitized["normal"]["count"], 1);
|
assert_eq!(sanitized["normal"]["count"], 1);
|
||||||
assert_eq!(sanitized["fontBrightness"], 180);
|
assert_eq!(sanitized["fontBrightness"], 180);
|
||||||
assert_eq!(sanitized["featured"]["size"], 480);
|
assert_eq!(sanitized["featured"]["size"], 480);
|
||||||
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
assert_eq!(sanitized["featuredValueThreshold"], 50_000);
|
||||||
|
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn component_only_subscribes_to_durable_gifts_and_guards() {
|
fn component_only_subscribes_to_durable_gifts() {
|
||||||
let definition = GiftEffectDefinition;
|
let definition = GiftEffectDefinition;
|
||||||
let subscriptions = definition
|
let subscriptions = definition
|
||||||
.subscriptions(&definition.default_settings())
|
.subscriptions(&definition.default_settings())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(subscriptions.contains(LiveEventKind::Gift));
|
assert!(subscriptions.contains(LiveEventKind::Gift));
|
||||||
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
assert!(!subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||||
assert!(!subscriptions.contains(LiveEventKind::Danmaku));
|
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]
|
#[test]
|
||||||
fn moonlit_water_theme_is_accepted_and_serialized() {
|
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||||
let definition = GiftEffectDefinition;
|
let definition = GiftEffectDefinition;
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! Full-screen membership-purchase effect component.
|
||||||
|
//!
|
||||||
|
//! This component is deliberately separate from gift effects: every instance
|
||||||
|
//! owns its settings, read-only OBS token, event subscription, and WebSocket
|
||||||
|
//! channel, and consumes only canonical guard-purchase events.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::{ComponentDefinition, ComponentError, EventSubscription},
|
||||||
|
domain::LiveEventKind,
|
||||||
|
typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const GUARD_EFFECT_KIND: &str = "guard_effect";
|
||||||
|
pub const GUARD_EFFECT_NAME: &str = "大航海特效";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum GuardEffectThemeId {
|
||||||
|
#[default]
|
||||||
|
JadeStarfall,
|
||||||
|
MoonlitWater,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GuardEffectSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme_id: GuardEffectThemeId,
|
||||||
|
#[serde(default)]
|
||||||
|
pub font_family: FontFamilyId,
|
||||||
|
#[serde(default = "default_font_brightness")]
|
||||||
|
pub font_brightness: u16,
|
||||||
|
pub star_count: u8,
|
||||||
|
pub effect_duration_ms: u16,
|
||||||
|
#[serde(default = "default_title_template")]
|
||||||
|
pub title_template: String,
|
||||||
|
#[serde(default = "default_closing_text")]
|
||||||
|
pub closing_text: String,
|
||||||
|
#[serde(default = "default_queue_capacity")]
|
||||||
|
pub queue_capacity: u16,
|
||||||
|
pub low_performance_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GuardEffectSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme_id: GuardEffectThemeId::default(),
|
||||||
|
font_family: FontFamilyId::default(),
|
||||||
|
font_brightness: default_font_brightness(),
|
||||||
|
star_count: 48,
|
||||||
|
effect_duration_ms: 5_200,
|
||||||
|
title_template: default_title_template(),
|
||||||
|
closing_text: default_closing_text(),
|
||||||
|
queue_capacity: default_queue_capacity(),
|
||||||
|
low_performance_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuardEffectSettings {
|
||||||
|
pub fn sanitize(mut self) -> Self {
|
||||||
|
self.font_brightness = sanitize_font_brightness(self.font_brightness);
|
||||||
|
self.star_count = self.star_count.clamp(8, 96);
|
||||||
|
self.effect_duration_ms = self.effect_duration_ms.clamp(1_000, 15_000);
|
||||||
|
self.title_template = sanitize_copy(self.title_template, &default_title_template());
|
||||||
|
self.closing_text = sanitize_copy(self.closing_text, &default_closing_text());
|
||||||
|
self.queue_capacity = self.queue_capacity.clamp(1, 1_000);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_queue_capacity() -> u16 {
|
||||||
|
256
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preserve the membership-related portion of the first legacy gift-effect
|
||||||
|
/// instance when an existing account receives its initial guard component.
|
||||||
|
pub fn settings_from_legacy_gift(value: &Value) -> GuardEffectSettings {
|
||||||
|
let mut migrated = serde_json::to_value(GuardEffectSettings::default())
|
||||||
|
.expect("GuardEffectSettings is always JSON serializable");
|
||||||
|
for (legacy, current) in [
|
||||||
|
("themeId", "themeId"),
|
||||||
|
("fontFamily", "fontFamily"),
|
||||||
|
("fontBrightness", "fontBrightness"),
|
||||||
|
("guardStarCount", "starCount"),
|
||||||
|
("guardEffectDurationMs", "effectDurationMs"),
|
||||||
|
("guardTitleTemplate", "titleTemplate"),
|
||||||
|
("guardClosingText", "closingText"),
|
||||||
|
("lowPerformanceMode", "lowPerformanceMode"),
|
||||||
|
] {
|
||||||
|
if let Some(candidate) = value.get(legacy) {
|
||||||
|
migrated[current] = candidate.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::from_value::<GuardEffectSettings>(migrated)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.sanitize()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_title_template() -> String {
|
||||||
|
"{guard}启航".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_closing_text() -> String {
|
||||||
|
"相伴前行".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_copy(value: String, fallback: &str) -> String {
|
||||||
|
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
fallback.to_owned()
|
||||||
|
} else {
|
||||||
|
normalized.chars().take(80).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GuardEffectDefinition;
|
||||||
|
|
||||||
|
impl GuardEffectDefinition {
|
||||||
|
fn parse(&self, settings: Value) -> Result<GuardEffectSettings, ComponentError> {
|
||||||
|
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||||
|
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||||
|
detail: error.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComponentDefinition for GuardEffectDefinition {
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
GUARD_EFFECT_KIND
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_version(&self) -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_settings(&self) -> Value {
|
||||||
|
serde_json::to_value(GuardEffectSettings::default())
|
||||||
|
.expect("GuardEffectSettings is always JSON serializable")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||||
|
serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| {
|
||||||
|
ComponentError::InvalidSettings {
|
||||||
|
kind: GUARD_EFFECT_KIND.to_owned(),
|
||||||
|
detail: error.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||||
|
self.parse(settings.clone())?;
|
||||||
|
Ok(EventSubscription::new([LiveEventKind::GuardPurchase]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_are_bounded_and_copy_is_sanitized() {
|
||||||
|
let definition = GuardEffectDefinition;
|
||||||
|
let mut settings = definition.default_settings();
|
||||||
|
settings["fontBrightness"] = Value::from(999);
|
||||||
|
settings["starCount"] = Value::from(255);
|
||||||
|
settings["effectDurationMs"] = Value::from(200);
|
||||||
|
settings["queueCapacity"] = Value::from(9_999);
|
||||||
|
settings["titleTemplate"] = Value::from(format!(" {{guard}}{} ", "启航".repeat(50)));
|
||||||
|
settings["closingText"] = Value::from(" ");
|
||||||
|
|
||||||
|
let sanitized = definition.validate_settings(settings).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sanitized["fontBrightness"], 180);
|
||||||
|
assert_eq!(sanitized["starCount"], 96);
|
||||||
|
assert_eq!(sanitized["effectDurationMs"], 1_000);
|
||||||
|
assert_eq!(sanitized["queueCapacity"], 1_000);
|
||||||
|
assert_eq!(
|
||||||
|
sanitized["titleTemplate"].as_str().unwrap().chars().count(),
|
||||||
|
80
|
||||||
|
);
|
||||||
|
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_settings_receive_copy_defaults() {
|
||||||
|
let definition = GuardEffectDefinition;
|
||||||
|
let mut settings = definition.default_settings();
|
||||||
|
settings.as_object_mut().unwrap().remove("titleTemplate");
|
||||||
|
settings.as_object_mut().unwrap().remove("closingText");
|
||||||
|
settings.as_object_mut().unwrap().remove("queueCapacity");
|
||||||
|
|
||||||
|
let sanitized = definition.validate_settings(settings).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sanitized["titleTemplate"], "{guard}启航");
|
||||||
|
assert_eq!(sanitized["closingText"], "相伴前行");
|
||||||
|
assert_eq!(sanitized["queueCapacity"], 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_gift_settings_preserve_membership_configuration() {
|
||||||
|
let legacy = serde_json::json!({
|
||||||
|
"themeId": "moonlit-water",
|
||||||
|
"fontFamily": "kai",
|
||||||
|
"fontBrightness": 145,
|
||||||
|
"guardStarCount": 72,
|
||||||
|
"guardEffectDurationMs": 8_500,
|
||||||
|
"guardTitleTemplate": "{guard}出发",
|
||||||
|
"guardClosingText": "一路顺风",
|
||||||
|
"lowPerformanceMode": true,
|
||||||
|
"normal": { "count": 20 }
|
||||||
|
});
|
||||||
|
|
||||||
|
let migrated = settings_from_legacy_gift(&legacy);
|
||||||
|
|
||||||
|
assert_eq!(migrated.theme_id, GuardEffectThemeId::MoonlitWater);
|
||||||
|
assert_eq!(migrated.star_count, 72);
|
||||||
|
assert_eq!(migrated.effect_duration_ms, 8_500);
|
||||||
|
assert_eq!(migrated.title_template, "{guard}出发");
|
||||||
|
assert_eq!(migrated.closing_text, "一路顺风");
|
||||||
|
assert!(migrated.low_performance_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_only_subscribes_to_guard_purchases() {
|
||||||
|
let definition = GuardEffectDefinition;
|
||||||
|
let subscriptions = definition
|
||||||
|
.subscriptions(&definition.default_settings())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(subscriptions.contains(LiveEventKind::GuardPurchase));
|
||||||
|
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||||
|
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moonlit_water_theme_is_accepted_and_serialized() {
|
||||||
|
let definition = GuardEffectDefinition;
|
||||||
|
let mut settings = definition.default_settings();
|
||||||
|
settings["themeId"] = Value::from("moonlit-water");
|
||||||
|
let sanitized = definition.validate_settings(settings).unwrap();
|
||||||
|
assert_eq!(sanitized["themeId"], "moonlit-water");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ pub mod db;
|
|||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod gift_effect;
|
pub mod gift_effect;
|
||||||
pub mod gift_menu;
|
pub mod gift_menu;
|
||||||
|
pub mod guard_effect;
|
||||||
pub mod http_api;
|
pub mod http_api;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod live;
|
pub mod live;
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ pub struct OverlaySettings {
|
|||||||
pub show_like: bool,
|
pub show_like: bool,
|
||||||
pub show_share: bool,
|
pub show_share: bool,
|
||||||
pub max_visible: u8,
|
pub max_visible: u8,
|
||||||
|
#[serde(default = "default_expand_new_danmaku")]
|
||||||
|
pub expand_new_danmaku: bool,
|
||||||
pub collapse_after_seconds: u16,
|
pub collapse_after_seconds: u16,
|
||||||
#[serde(default = "default_unfold_duration_ms")]
|
#[serde(default = "default_unfold_duration_ms")]
|
||||||
pub unfold_duration_ms: u16,
|
pub unfold_duration_ms: u16,
|
||||||
@@ -89,6 +91,7 @@ impl Default for OverlaySettings {
|
|||||||
show_like: false,
|
show_like: false,
|
||||||
show_share: false,
|
show_share: false,
|
||||||
max_visible: 5,
|
max_visible: 5,
|
||||||
|
expand_new_danmaku: default_expand_new_danmaku(),
|
||||||
collapse_after_seconds: 12,
|
collapse_after_seconds: 12,
|
||||||
unfold_duration_ms: default_unfold_duration_ms(),
|
unfold_duration_ms: default_unfold_duration_ms(),
|
||||||
motion_intensity: 70,
|
motion_intensity: 70,
|
||||||
@@ -141,6 +144,10 @@ fn default_unfold_duration_ms() -> u16 {
|
|||||||
1_000
|
1_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_expand_new_danmaku() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn default_particle_count() -> u8 {
|
fn default_particle_count() -> u8 {
|
||||||
8
|
8
|
||||||
}
|
}
|
||||||
@@ -588,6 +595,7 @@ mod tests {
|
|||||||
danmaku_color: Some("not-css".into()),
|
danmaku_color: Some("not-css".into()),
|
||||||
decoration_line_weight: 999,
|
decoration_line_weight: 999,
|
||||||
max_visible: 99,
|
max_visible: 99,
|
||||||
|
expand_new_danmaku: false,
|
||||||
collapse_after_seconds: 1,
|
collapse_after_seconds: 1,
|
||||||
unfold_duration_ms: 9_000,
|
unfold_duration_ms: 9_000,
|
||||||
motion_intensity: 200,
|
motion_intensity: 200,
|
||||||
@@ -604,6 +612,7 @@ mod tests {
|
|||||||
assert_eq!(settings.danmaku_color, None);
|
assert_eq!(settings.danmaku_color, None);
|
||||||
assert_eq!(settings.decoration_line_weight, 300);
|
assert_eq!(settings.decoration_line_weight, 300);
|
||||||
assert_eq!(settings.max_visible, 12);
|
assert_eq!(settings.max_visible, 12);
|
||||||
|
assert!(!settings.expand_new_danmaku);
|
||||||
assert_eq!(settings.collapse_after_seconds, 2);
|
assert_eq!(settings.collapse_after_seconds, 2);
|
||||||
assert_eq!(settings.unfold_duration_ms, 5_000);
|
assert_eq!(settings.unfold_duration_ms, 5_000);
|
||||||
assert_eq!(settings.motion_intensity, 100);
|
assert_eq!(settings.motion_intensity, 100);
|
||||||
@@ -630,6 +639,7 @@ mod tests {
|
|||||||
object.remove("viewerColor");
|
object.remove("viewerColor");
|
||||||
object.remove("danmakuColor");
|
object.remove("danmakuColor");
|
||||||
object.remove("decorationLineWeight");
|
object.remove("decorationLineWeight");
|
||||||
|
object.remove("expandNewDanmaku");
|
||||||
object.remove("unfoldDurationMs");
|
object.remove("unfoldDurationMs");
|
||||||
object.remove("particleCount");
|
object.remove("particleCount");
|
||||||
object.remove("particleSpeed");
|
object.remove("particleSpeed");
|
||||||
@@ -642,6 +652,7 @@ mod tests {
|
|||||||
assert_eq!(settings.danmaku_color, None);
|
assert_eq!(settings.danmaku_color, None);
|
||||||
assert_eq!(settings.font_scale, 140);
|
assert_eq!(settings.font_scale, 140);
|
||||||
assert_eq!(settings.decoration_line_weight, 160);
|
assert_eq!(settings.decoration_line_weight, 160);
|
||||||
|
assert!(settings.expand_new_danmaku);
|
||||||
assert_eq!(settings.unfold_duration_ms, 1_000);
|
assert_eq!(settings.unfold_duration_ms, 1_000);
|
||||||
assert_eq!(settings.particle_count, 8);
|
assert_eq!(settings.particle_count, 8);
|
||||||
assert_eq!(settings.particle_speed, 100);
|
assert_eq!(settings.particle_speed, 100);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ use crate::{
|
|||||||
db::{ComponentRecord, Db, DbError},
|
db::{ComponentRecord, Db, DbError},
|
||||||
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings},
|
||||||
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
gift_menu::{GIFT_MENU_KIND, GIFT_MENU_NAME, GiftMenuSettings},
|
||||||
|
guard_effect::{
|
||||||
|
GUARD_EFFECT_KIND, GUARD_EFFECT_NAME, GuardEffectSettings, settings_from_legacy_gift,
|
||||||
|
},
|
||||||
i18n,
|
i18n,
|
||||||
realtime::InMemoryComponentStore,
|
realtime::InMemoryComponentStore,
|
||||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||||
@@ -140,6 +143,44 @@ impl TenantRepository {
|
|||||||
)
|
)
|
||||||
.await?;
|
.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
|
let has_gift_menu = transaction
|
||||||
.query_one(
|
.query_one(
|
||||||
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
"SELECT EXISTS(SELECT 1 FROM component_instances \
|
||||||
|
|||||||
@@ -128,16 +128,13 @@ pub enum SongCommand {
|
|||||||
title: String,
|
title: String,
|
||||||
normalized_title: String,
|
normalized_title: String,
|
||||||
},
|
},
|
||||||
Rate(u8),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse only explicit commands separated from their argument by whitespace.
|
/// Parse a request prefix followed immediately by a title or by whitespace and
|
||||||
/// This avoids treating ordinary words such as “点歌姬” as queue mutations.
|
/// a title. Whitespace inside the title is normalized before deduplication.
|
||||||
pub fn parse_command(text: &str) -> Option<SongCommand> {
|
pub fn parse_command(text: &str) -> Option<SongCommand> {
|
||||||
let canonical = collapse_whitespace(text);
|
let canonical = collapse_whitespace(text);
|
||||||
let (command, argument) = canonical.split_once(' ')?;
|
let argument = canonical.strip_prefix("点歌")?.trim_start();
|
||||||
match command {
|
|
||||||
"点歌" => {
|
|
||||||
if argument.is_empty() || argument.chars().count() > 80 {
|
if argument.is_empty() || argument.chars().count() > 80 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -145,17 +142,6 @@ pub fn parse_command(text: &str) -> Option<SongCommand> {
|
|||||||
title: argument.to_owned(),
|
title: argument.to_owned(),
|
||||||
normalized_title: argument.to_lowercase(),
|
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 {
|
fn collapse_whitespace(value: &str) -> String {
|
||||||
@@ -180,8 +166,6 @@ pub struct SongRequestItem {
|
|||||||
pub requested_at: DateTime<Utc>,
|
pub requested_at: DateTime<Utc>,
|
||||||
pub started_at: Option<DateTime<Utc>>,
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
pub finished_at: Option<DateTime<Utc>>,
|
pub finished_at: Option<DateTime<Utc>>,
|
||||||
pub average_score: Option<f64>,
|
|
||||||
pub rating_count: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
@@ -191,7 +175,6 @@ pub struct SongQueueSummary {
|
|||||||
pub queued_count: i64,
|
pub queued_count: i64,
|
||||||
pub completed_count: i64,
|
pub completed_count: i64,
|
||||||
pub cancelled_count: i64,
|
pub cancelled_count: i64,
|
||||||
pub rating_count: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -241,12 +224,12 @@ impl SongRequestService {
|
|||||||
};
|
};
|
||||||
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
|
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
|
||||||
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
|
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
|
||||||
let result = match command {
|
let SongCommand::Request {
|
||||||
SongCommand::Request {
|
|
||||||
title,
|
title,
|
||||||
normalized_title,
|
normalized_title,
|
||||||
} => {
|
} = command;
|
||||||
self.request_song(
|
let result = self
|
||||||
|
.request_song(
|
||||||
component,
|
component,
|
||||||
&danmaku.viewer,
|
&danmaku.viewer,
|
||||||
&title,
|
&title,
|
||||||
@@ -254,12 +237,7 @@ impl SongRequestService {
|
|||||||
event.id,
|
event.id,
|
||||||
&settings,
|
&settings,
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
}
|
|
||||||
SongCommand::Rate(score) => {
|
|
||||||
self.rate_current(component, &danmaku.viewer, score).await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(change) = result {
|
if let Some(change) = result {
|
||||||
self.publish_change(component, &event.room_id, change)?;
|
self.publish_change(component, &event.room_id, change)?;
|
||||||
}
|
}
|
||||||
@@ -299,7 +277,7 @@ impl SongRequestService {
|
|||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"{} WHERE r.component_instance_id=$1 AND {status_filter} \
|
"{} 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()
|
item_select()
|
||||||
);
|
);
|
||||||
let rows = transaction
|
let rows = transaction
|
||||||
@@ -342,7 +320,7 @@ impl SongRequestService {
|
|||||||
.query(
|
.query(
|
||||||
&format!(
|
&format!(
|
||||||
"{} WHERE r.component_instance_id=$1 AND r.status='queued' \
|
"{} 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()
|
item_select()
|
||||||
),
|
),
|
||||||
&[&component.id],
|
&[&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(
|
fn publish_change(
|
||||||
&self,
|
&self,
|
||||||
component: &ComponentInstance,
|
component: &ComponentInstance,
|
||||||
@@ -846,8 +771,7 @@ async fn promote_next(
|
|||||||
|
|
||||||
fn item_select() -> &'static str {
|
fn item_select() -> &'static str {
|
||||||
"SELECT r.id,r.song_title,r.requester_uid,r.requester_name,r.status,r.queue_position,\
|
"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 \
|
r.requested_at,r.started_at,r.finished_at FROM song_requests r"
|
||||||
FROM song_requests r LEFT JOIN song_ratings v ON v.song_request_id=r.id"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_from_row(row: &Row) -> SongRequestItem {
|
fn item_from_row(row: &Row) -> SongRequestItem {
|
||||||
@@ -863,8 +787,6 @@ fn item_from_row(row: &Row) -> SongRequestItem {
|
|||||||
requested_at: row.get(6),
|
requested_at: row.get(6),
|
||||||
started_at: row.get(7),
|
started_at: row.get(7),
|
||||||
finished_at: row.get(8),
|
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,
|
request_id: Uuid,
|
||||||
) -> Result<Option<SongRequestItem>, SongRequestError> {
|
) -> Result<Option<SongRequestItem>, SongRequestError> {
|
||||||
Ok(transaction
|
Ok(transaction
|
||||||
.query_opt(
|
.query_opt(&format!("{} WHERE r.id=$1", item_select()), &[&request_id])
|
||||||
&format!("{} WHERE r.id=$1 GROUP BY r.id", item_select()),
|
|
||||||
&[&request_id],
|
|
||||||
)
|
|
||||||
.await?
|
.await?
|
||||||
.map(|row| item_from_row(&row)))
|
.map(|row| item_from_row(&row)))
|
||||||
}
|
}
|
||||||
@@ -888,7 +807,7 @@ async fn current_item(
|
|||||||
Ok(transaction
|
Ok(transaction
|
||||||
.query_opt(
|
.query_opt(
|
||||||
&format!(
|
&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()
|
item_select()
|
||||||
),
|
),
|
||||||
&[&component_id],
|
&[&component_id],
|
||||||
@@ -906,8 +825,7 @@ async fn queue_summary(
|
|||||||
"SELECT count(*) FILTER (WHERE status IN ('current','queued'))::BIGINT,\
|
"SELECT count(*) FILTER (WHERE status IN ('current','queued'))::BIGINT,\
|
||||||
count(*) FILTER (WHERE status='queued')::BIGINT,\
|
count(*) FILTER (WHERE status='queued')::BIGINT,\
|
||||||
count(*) FILTER (WHERE status='completed')::BIGINT,\
|
count(*) FILTER (WHERE status='completed')::BIGINT,\
|
||||||
count(*) FILTER (WHERE status='cancelled')::BIGINT,\
|
count(*) FILTER (WHERE status='cancelled')::BIGINT \
|
||||||
(SELECT count(*) FROM song_ratings WHERE component_instance_id=$1)::BIGINT \
|
|
||||||
FROM song_requests WHERE component_instance_id=$1",
|
FROM song_requests WHERE component_instance_id=$1",
|
||||||
&[&component_id],
|
&[&component_id],
|
||||||
)
|
)
|
||||||
@@ -917,7 +835,6 @@ async fn queue_summary(
|
|||||||
queued_count: row.get(1),
|
queued_count: row.get(1),
|
||||||
completed_count: row.get(2),
|
completed_count: row.get(2),
|
||||||
cancelled_count: row.get(3),
|
cancelled_count: row.get(3),
|
||||||
rating_count: row.get(4),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,7 +996,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_requests_and_scores_with_normalized_whitespace() {
|
fn parses_only_requests_with_normalized_whitespace() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_command(" 点歌 My Song "),
|
parse_command(" 点歌 My Song "),
|
||||||
Some(SongCommand::Request {
|
Some(SongCommand::Request {
|
||||||
@@ -1087,11 +1004,18 @@ mod tests {
|
|||||||
normalized_title: "my song".into(),
|
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("打分 0"), None);
|
||||||
assert_eq!(parse_command("点歌姬"), 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);
|
||||||
|
assert_eq!(parse_command(&format!("点歌{}", "歌".repeat(81))), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ flowchart LR
|
|||||||
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||||
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||||
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||||
| 点歌队列和评分 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
|
| 点歌队列与历史 | PostgreSQL | OBS revision snapshot | handler 事务写入、RLS 隔离 |
|
||||||
| 账户语言偏好 | PostgreSQL | React/OBS runtime | TOML 目录验证、RLS 隔离 |
|
| 账户语言偏好 | PostgreSQL | React/OBS runtime | TOML 目录验证、RLS 隔离 |
|
||||||
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 组件开发指南
|
# 组件开发指南
|
||||||
|
|
||||||
组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、`song_request`、 `gift_effect`
|
组件是“消费所属账户事件流的独立功能实例”。当前内建
|
||||||
与 `gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
`danmaku_overlay`、`song_request`、`gift_effect`、 `guard_effect` 与
|
||||||
|
`gift_menu`,未来礼物墙或统计组件也应使用同一套契约。
|
||||||
|
|
||||||
账户注册时会为每种内建 kind 创建一个初始实例。控制台可以为同一 kind 再创建多个命名实例;每个实例都有独立 settings、OBS
|
账户注册时会为每种内建 kind 创建一个初始实例。控制台可以为同一 kind 再创建多个命名实例;每个实例都有独立 settings、OBS
|
||||||
token、`publicId` 与 `EventHub`
|
token、`publicId` 与 `EventHub`
|
||||||
@@ -63,4 +64,4 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写
|
|||||||
|
|
||||||
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与
|
||||||
[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.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)。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||||
| `decorationLineWeight` | 50–300% | 边框、分隔线与古风边缘粗细 |
|
| `decorationLineWeight` | 50–300% | 边框、分隔线与古风边缘粗细 |
|
||||||
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||||
|
| `expandNewDanmaku` | boolean | 新弹幕是否先展开并在超时后收缩 |
|
||||||
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||||
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
||||||
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
|
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
|
||||||
@@ -54,7 +55,8 @@ WenKai 与漓雨手书。颜色覆盖同样只接受六位十六进制颜色;
|
|||||||
|
|
||||||
## 卡片生命周期
|
## 卡片生命周期
|
||||||
|
|
||||||
1. 新事件追加在可视区域底部,以卷轴动画横向展开;旧事件被向上顶出并裁切。
|
1. 新事件追加在可视区域底部;启用 `expandNewDanmaku`
|
||||||
|
时,普通弹幕以卷轴动画横向展开,否则直接使用紧凑态。旧事件被向上顶出并裁切。
|
||||||
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||||
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||||
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# 全屏礼物特效组件
|
# 全屏礼物特效组件
|
||||||
|
|
||||||
`gift_effect`
|
`gift_effect`
|
||||||
在账户注册时创建一个初始实例,并允许为不同 OBS 场景添加多个独立样式实例。每个实例消费账户级直播源中的
|
在账户注册时创建一个初始实例,并允许为不同 OBS 场景添加多个独立样式实例。每个实例只消费账户级直播源中的
|
||||||
`live.gift` 和 `live.guard.buy`,使用自己的只读 token 作为透明 OBS 浏览器源;不订阅
|
`live.gift`,使用自己的只读 token 作为透明 OBS 浏览器源;不订阅 `live.guard.buy` 或
|
||||||
`live.gift.combo`,避免一次连击重复触发完整特效。
|
`live.gift.combo`。大航海事件由独立的 [`guard_effect`](guard-effect.md) 组件处理。
|
||||||
|
|
||||||
## 展示行为
|
## 展示行为
|
||||||
|
|
||||||
@@ -12,11 +12,10 @@
|
|||||||
`highValueThreshold` 和 `featuredValueThreshold`
|
`highValueThreshold` 和 `featuredValueThreshold`
|
||||||
分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。
|
分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。
|
||||||
|
|
||||||
舰长、提督或总督使用同一个 `live.guard.buy`
|
实时礼物进入浏览器内存中的有界 FIFO。画布始终只播放队首的一笔礼物,当前流星组、短笺或月下清供动画结束后才取出下一笔,因此密集投喂不会叠加特效或同时放大粒子负载。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不写入数据库,OBS 断线重连后只接收新事件。
|
||||||
路径,临时显示不透明星河、闪耀粒子、身份和用户昵称。多个普通礼物可并发;大航海展示选择最新事件。
|
|
||||||
|
|
||||||
`moonlit-water`(“静夜曲水”)则采用“月下清供”:价值低于 50 元(原始
|
`moonlit-water`(“静夜曲水”)采用“月下清供”:价值低于 50 元(原始
|
||||||
`totalPrice < 50000`)的礼物以右侧水纹短笺展示图标、用户、名称和价格;50 元及以上礼物在整张浏览器源上展开月轮、水纹、礼物图标和文字。舰长、提督、总督也使用全屏月夜祝贺。该主题的全屏动效仍不绘制不透明底色,OBS 场景始终可见。
|
`totalPrice < 50000`)的礼物以右侧水纹短笺展示图标、用户、名称和价格;50 元及以上礼物在整张浏览器源上展开月轮、水纹、礼物图标和文字。该主题的全屏动效不绘制不透明底色,OBS 场景始终可见。
|
||||||
|
|
||||||
## 尺寸与性能
|
## 尺寸与性能
|
||||||
|
|
||||||
@@ -24,8 +23,7 @@ OBS 建议从 `1920×1080` 开始,但渲染器没有固定画布。`ResizeObse
|
|||||||
按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整:
|
按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整:
|
||||||
|
|
||||||
- 三档数量、尺寸和速度;
|
- 三档数量、尺寸和速度;
|
||||||
- 拖尾强度和最大并发特效数;
|
- 拖尾强度和最大等待礼物数;
|
||||||
- 大航海星数和全屏持续时间;
|
|
||||||
- 中文字体与文字亮度;
|
- 中文字体与文字亮度;
|
||||||
- 低性能模式(限制粒子和流星数量)。
|
- 低性能模式(限制粒子和流星数量)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 大航海特效组件
|
||||||
|
|
||||||
|
`guard_effect`
|
||||||
|
是与礼物星雨完全独立的组件类型。账户注册或升级时创建一个初始实例,同一账户可以继续添加多个实例;每个实例分别拥有 settings、只读 OBS
|
||||||
|
token、`publicId` 和 WebSocket 广播通道。它只订阅 `live.guard.buy`,不接收普通礼物或连击事件。
|
||||||
|
|
||||||
|
从旧版本升级且账户尚无大航海组件时,服务会从最早的礼物特效实例复制主题、字体、星数、时长、性能模式及感谢文案作为初始设置。此后两个组件的设置互不关联。
|
||||||
|
|
||||||
|
## 青玉星落
|
||||||
|
|
||||||
|
舰长、提督或总督分别选择 `captain.webm`、`admiral.webm` 或 `general.webm`
|
||||||
|
全屏播放。视频约 8 秒,首尾各淡入/淡出 0.5 秒;第 4 秒开始在中央横向展开行书卷轴,依次显示可编辑的等级标题、开通或续费用户 UID,以及可编辑的收尾文字。标题模板可使用
|
||||||
|
`{guard}` 插入规范化的“舰长/提督/总督”,默认三行为“{guard}启航”、UID、“相伴前行”。
|
||||||
|
|
||||||
|
大航海事件进入浏览器内存中的有界 FIFO,当前约八秒的视频或月夜庆祝完整结束后才播放下一项,不会覆盖正在播放的用户。队列按事件到达顺序排列并去重,容量可在控制台设置;达到上限时保留正在播放及已等待的事件,忽略新到达事件。该视觉队列不持久化,OBS 断线重连后只接收新事件。
|
||||||
|
|
||||||
|
感谢文字固定使用 Rust 静态服务同域提供的鸿雷行书简体 WOFF2,不读取 OBS 机器的系统字体。该字体随源文件提供的说明不包含开放再分发许可,公开或商业部署前需由部署者确认相应的 Web 嵌入和再分发授权。
|
||||||
|
|
||||||
|
## 静夜曲水
|
||||||
|
|
||||||
|
静夜主题使用透明月轮、水纹、身份、观众昵称与星光庆祝。控制台可以调整中文字体、文字亮度、星光数量、全屏持续时间和低性能模式。它不会绘制不透明的全场底色。
|
||||||
|
|
||||||
|
## OBS 与预览
|
||||||
|
|
||||||
|
组件适配任意浏览器源尺寸,控制台提供舰长、提督和总督三个独立预览入口。OBS 地址与礼物星雨完全不同;场景需要两种特效时,应分别添加两个浏览器源。
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
# `song_request` 点歌姬
|
# `song_request` 点歌姬
|
||||||
|
|
||||||
每个账户自动拥有一个初始 `song_request`
|
每个账户自动拥有一个初始 `song_request`
|
||||||
实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、评分和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的
|
实例,也可以再创建同类型的命名实例。每个实例拥有独立设置、OBS 地址、队列、历史和 revision,但共享账户的固定直播监听;删除实例会同时删除该实例的队列历史。组件只订阅规范化的
|
||||||
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
`live.danmaku`;业务状态由 PostgreSQL 保存,不依赖 OBS 是否在线。
|
||||||
|
|
||||||
## 弹幕命令
|
## 弹幕命令
|
||||||
|
|
||||||
- `点歌 <歌名>`:合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
- `点歌<歌名>` 或
|
||||||
- `打分 <1-5>`:对事务执行时的当前歌曲评分。同一 Bilibili UID 只有一票,重复评分会覆盖旧分数。
|
`点歌 <歌名>`:命令与歌名之间的空格可有可无;合并首尾和连续空白,接受 1–80 字自由文本。当前或待唱队列中已有大小写不敏感的同名歌曲时忽略。
|
||||||
|
|
||||||
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
首首点歌立即成为当前歌曲。完成或取消当前歌曲时,队首自动接替;置顶待唱项只把它移动为下一首,不打断当前歌曲。完成或取消后的歌名可以再次点播。
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
|
和 `edgePauseSeconds` 控制 OBS 外观与往返滚动。`maxQueueSize`、`maxRequestsPerViewer` 与
|
||||||
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
|
`requestCooldownSeconds` 是可选防刷限制;值 `0` 表示不限制。
|
||||||
|
|
||||||
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名、紧凑评分和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
|
`moonlit-water`(“静夜曲水”)与弹幕姬共享主题 ID:当前歌曲使用圆形音符、演唱状态、大号歌名和弱化音频柱,待唱区按“点歌用户、歌名”两行显示。它不绘制卡片或面板背景,只用米金文字、青色微光、共享的上下古风细边和一条弱分隔线建立层级。用户和歌名颜色均可覆盖主题默认值。
|
||||||
|
|
||||||
## OBS 与管理
|
## OBS 与管理
|
||||||
|
|
||||||
OBS 顶部固定显示当前歌曲、点歌用户、平均分和评分人数,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
OBS 顶部固定显示当前歌曲和点歌用户,下面保存全部待唱横条。列表超过实际浏览器源高度时从顶部滚到底部,再返回顶部;布局不假设固定分辨率。
|
||||||
|
|
||||||
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史与评分,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
控制台的“打开点歌统计”进入会话保护页面。它每两秒刷新完整活动队列和近期历史;页面隐藏时停止轮询。管理者可以置顶或取消待唱项,也可以完成、取消当前歌曲,或在确认后一次性将全部活动歌曲标记为已取消。清空操作保留历史,并以一个 revision 原子广播。所有 REST 查询都从会话取得 owner,并由 PostgreSQL
|
||||||
RLS 再次限制组件归属。
|
RLS 再次限制组件归属。
|
||||||
|
|||||||
+11
-4
@@ -50,7 +50,8 @@ close 结束连接。
|
|||||||
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
`component.language.updated`,payload 为 `{ "language": "en-US" }`;新连接以认证帧为准。
|
||||||
|
|
||||||
设置快照中的 `themeId` 是稳定的主题标识。弹幕姬与点歌姬支持 `jade-scroll` 和
|
设置快照中的 `themeId` 是稳定的主题标识。弹幕姬与点歌姬支持 `jade-scroll` 和
|
||||||
`moonlit-water`;礼物特效支持 `jade-starfall` 和 `moonlit-water`;礼物菜单支持 `jade-banquet` 和
|
`moonlit-water`;礼物特效与大航海特效分别支持 `jade-starfall` 和 `moonlit-water`;礼物菜单支持
|
||||||
|
`jade-banquet` 和
|
||||||
`moonlit-water`。消费者应把未知主题降级为自身默认主题,不能因主题发布顺序不同而中断实时消息。
|
`moonlit-water`。消费者应把未知主题降级为自身默认主题,不能因主题发布顺序不同而中断实时消息。
|
||||||
|
|
||||||
## 版本化事件信封
|
## 版本化事件信封
|
||||||
@@ -105,8 +106,8 @@ close 结束连接。
|
|||||||
3. `song.queue.snapshot.end`:提交该快照。
|
3. `song.queue.snapshot.end`:提交该快照。
|
||||||
|
|
||||||
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
|
后续 `song.queue.changed` 携带连续 revision,`operation` 为 `added`、`promoted`、
|
||||||
`completed`、`cancelled`、`cleared` 或
|
`completed`、`cancelled` 或
|
||||||
`rating-updated`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
|
`cleared`。客户端发现 revision 缺口必须重连取得新快照,不能猜测缺失队列状态。 `cleared`
|
||||||
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
|
表示当前歌曲与全部待唱项已在同一事务中取消,客户端必须立即清空活动投影。
|
||||||
|
|
||||||
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||||
@@ -115,11 +116,17 @@ close 结束连接。
|
|||||||
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
||||||
`live.gift`;连击事件用于更新同一张视觉卡片。
|
`live.gift`;连击事件用于更新同一张视觉卡片。
|
||||||
|
|
||||||
`gift_effect` 只订阅 `live.gift` 与 `live.guard.buy`。礼物档位由 sanitized settings 中的
|
`gift_effect` 只订阅 `live.gift`,不会收到 `live.guard.buy`。礼物档位由 sanitized settings 中的
|
||||||
`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或
|
`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或
|
||||||
`gift.animationUrl`
|
`gift.animationUrl`
|
||||||
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。
|
||||||
|
|
||||||
|
`guard_effect` 只订阅
|
||||||
|
`live.guard.buy`。舰长、提督和总督事件进入该组件独立的广播通道;它不接收普通礼物或礼物连击,也不维护状态快照。
|
||||||
|
|
||||||
|
`gift_effect` 与 `guard_effect`
|
||||||
|
的浏览器渲染器分别维护有界 FIFO,并且每次只播放一个 active 特效。动画完成后按到达顺序推进下一项;这个队列仅用于 OBS 视觉节流,不是可靠业务队列,也不会在重连后恢复。
|
||||||
|
|
||||||
`gift_menu` 同样只消费一次性 `live.gift` 与 `live.guard.buy`。命中配置后投影为
|
`gift_menu` 同样只消费一次性 `live.gift` 与 `live.guard.buy`。命中配置后投影为
|
||||||
`gift-menu.triggered`,payload 包含 `itemIds`、`viewer` 和
|
`gift-menu.triggered`,payload 包含 `itemIds`、`viewer` 和
|
||||||
`sourceEventId`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
`sourceEventId`。一个特定礼物和一个同价电池规则可以同时命中多个菜单项;客户端应全部高亮,并滚动到第一个匹配项。未命中的投喂不会进入该组件通道。
|
||||||
|
|||||||
+66
-36
@@ -157,6 +157,8 @@ name = "简体中文"
|
|||||||
"settings.decoration_line_weight" = "装饰线粗细"
|
"settings.decoration_line_weight" = "装饰线粗细"
|
||||||
"settings.decoration_line_weight_description" = "统一调整主题边框、分隔线和上下古风装饰边缘的视觉粗细。"
|
"settings.decoration_line_weight_description" = "统一调整主题边框、分隔线和上下古风装饰边缘的视觉粗细。"
|
||||||
"settings.max_visible" = "最大可见条数"
|
"settings.max_visible" = "最大可见条数"
|
||||||
|
"settings.new_danmaku_animation" = "新弹幕动画"
|
||||||
|
"settings.expand_new_danmaku" = "启用展开后自动收缩"
|
||||||
"settings.auto_collapse" = "自动收缩"
|
"settings.auto_collapse" = "自动收缩"
|
||||||
"settings.unfold_duration" = "卷轴展开时长"
|
"settings.unfold_duration" = "卷轴展开时长"
|
||||||
"settings.motion_intensity" = "动效强度"
|
"settings.motion_intensity" = "动效强度"
|
||||||
@@ -171,6 +173,7 @@ name = "简体中文"
|
|||||||
"settings.event.like" = "点赞"
|
"settings.event.like" = "点赞"
|
||||||
"settings.event.share" = "分享"
|
"settings.event.share" = "分享"
|
||||||
"settings.low_performance" = "低性能模式"
|
"settings.low_performance" = "低性能模式"
|
||||||
|
"settings.queue_capacity_description" = "达到上限后忽略新的等待事件,正在播放和已排队的特效不受影响。"
|
||||||
"settings.high_gift" = "高价值礼物阈值(厘)"
|
"settings.high_gift" = "高价值礼物阈值(厘)"
|
||||||
"settings.featured_gift" = "特别高价值阈值(厘)"
|
"settings.featured_gift" = "特别高价值阈值(厘)"
|
||||||
"settings.saving" = "正在保存…"
|
"settings.saving" = "正在保存…"
|
||||||
@@ -256,11 +259,13 @@ name = "简体中文"
|
|||||||
"components.danmaku_mark" = "弹"
|
"components.danmaku_mark" = "弹"
|
||||||
"components.song_mark" = "歌"
|
"components.song_mark" = "歌"
|
||||||
"components.gift_mark" = "礼"
|
"components.gift_mark" = "礼"
|
||||||
|
"components.guard_mark" = "舰"
|
||||||
"components.gift_menu_mark" = "单"
|
"components.gift_menu_mark" = "单"
|
||||||
"components.generic_mark" = "件"
|
"components.generic_mark" = "件"
|
||||||
"components.danmaku_type" = "直播弹幕姬"
|
"components.danmaku_type" = "直播弹幕姬"
|
||||||
"components.song_type" = "直播点歌姬"
|
"components.song_type" = "直播点歌姬"
|
||||||
"components.gift_type" = "全屏礼物星雨"
|
"components.gift_type" = "全屏礼物星雨"
|
||||||
|
"components.guard_type" = "全屏大航海特效"
|
||||||
"components.gift_menu_type" = "直播礼物菜单"
|
"components.gift_menu_type" = "直播礼物菜单"
|
||||||
"components.add_instance" = "添加组件实例"
|
"components.add_instance" = "添加组件实例"
|
||||||
"components.add_instance_description" = "同一类型可以添加多个实例,每个实例单独保存样式和 OBS 地址。"
|
"components.add_instance_description" = "同一类型可以添加多个实例,每个实例单独保存样式和 OBS 地址。"
|
||||||
@@ -287,9 +292,11 @@ name = "简体中文"
|
|||||||
"components.danmaku_settings" = "弹幕姬设置"
|
"components.danmaku_settings" = "弹幕姬设置"
|
||||||
"components.danmaku_description" = "每一项都独立保存在当前用户的组件下。"
|
"components.danmaku_description" = "每一项都独立保存在当前用户的组件下。"
|
||||||
"components.song_settings" = "点歌姬设置"
|
"components.song_settings" = "点歌姬设置"
|
||||||
"components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。"
|
"components.song_description" = "观众发送「点歌歌名」或「点歌 歌名」加入待唱队列。"
|
||||||
"components.gift_settings" = "全屏礼物特效设置"
|
"components.gift_settings" = "全屏礼物特效设置"
|
||||||
"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。"
|
"components.gift_description" = "礼物化作流星横跨透明画布;该组件只接收礼物,不接收大航海事件。"
|
||||||
|
"components.guard_settings" = "全屏大航海特效设置"
|
||||||
|
"components.guard_description" = "舰长、提督和总督事件独立触发全屏启航特效。"
|
||||||
"components.gift_menu_settings" = "礼物菜单设置"
|
"components.gift_menu_settings" = "礼物菜单设置"
|
||||||
"components.gift_menu_description" = "把当前直播间的礼物、大航海身份或指定电池单价映射为主播提供的直播内容。"
|
"components.gift_menu_description" = "把当前直播间的礼物、大航海身份或指定电池单价映射为主播提供的直播内容。"
|
||||||
"components.open_song_stats" = "打开点歌统计"
|
"components.open_song_stats" = "打开点歌统计"
|
||||||
@@ -338,11 +345,8 @@ name = "简体中文"
|
|||||||
"song.stat.active" = "活动点歌"
|
"song.stat.active" = "活动点歌"
|
||||||
"song.stat.completed" = "已经完成"
|
"song.stat.completed" = "已经完成"
|
||||||
"song.stat.cancelled" = "已经取消"
|
"song.stat.cancelled" = "已经取消"
|
||||||
"song.stat.ratings" = "累计评分"
|
|
||||||
"song.current" = "当前歌曲"
|
"song.current" = "当前歌曲"
|
||||||
"song.current_description" = "完成或取消当前歌曲后,队首会自动接替。"
|
"song.current_description" = "完成或取消当前歌曲后,队首会自动接替。"
|
||||||
"song.average_score" = "平均 {score} 分 · {count} 人评分"
|
|
||||||
"song.no_rating" = "尚无评分"
|
|
||||||
"song.complete_current" = "完成当前歌曲"
|
"song.complete_current" = "完成当前歌曲"
|
||||||
"song.no_current" = "当前没有正在演唱的歌曲。"
|
"song.no_current" = "当前没有正在演唱的歌曲。"
|
||||||
"song.queue_title" = "待唱队列({count})"
|
"song.queue_title" = "待唱队列({count})"
|
||||||
@@ -355,7 +359,6 @@ name = "简体中文"
|
|||||||
"song.column.title" = "歌曲"
|
"song.column.title" = "歌曲"
|
||||||
"song.column.requester" = "点歌用户"
|
"song.column.requester" = "点歌用户"
|
||||||
"song.column.result" = "结果"
|
"song.column.result" = "结果"
|
||||||
"song.column.rating" = "评分"
|
|
||||||
"song.column.finished" = "结束时间"
|
"song.column.finished" = "结束时间"
|
||||||
"song.status.completed" = "已完成"
|
"song.status.completed" = "已完成"
|
||||||
"song.status.cancelled" = "已取消"
|
"song.status.cancelled" = "已取消"
|
||||||
@@ -407,13 +410,11 @@ name = "简体中文"
|
|||||||
"song.overlay.preview_viewer" = "青玉观众"
|
"song.overlay.preview_viewer" = "青玉观众"
|
||||||
"song.overlay.preview_titles" = "晚风告白|月下花笺|云海来信|落星成诗"
|
"song.overlay.preview_titles" = "晚风告白|月下花笺|云海来信|落星成诗"
|
||||||
"song.overlay.preview_viewers" = "星光旅人|花间客|小瓷片|月桂"
|
"song.overlay.preview_viewers" = "星光旅人|花间客|小瓷片|月桂"
|
||||||
"song.overlay.score" = "{score} ★ · {count} 人"
|
|
||||||
"song.overlay.current_aria" = "当前歌曲"
|
"song.overlay.current_aria" = "当前歌曲"
|
||||||
"song.overlay.sing_mark" = "唱"
|
"song.overlay.sing_mark" = "唱"
|
||||||
"song.overlay.now_singing" = "正在演唱 · {viewer} 点歌"
|
"song.overlay.now_singing" = "正在演唱 · {viewer} 点歌"
|
||||||
"song.overlay.waiting" = "等待弹幕点歌"
|
"song.overlay.waiting" = "等待弹幕点歌"
|
||||||
"song.overlay.request_help" = "发送「点歌 歌名」加入队列"
|
"song.overlay.request_help" = "发送「点歌歌名」加入队列"
|
||||||
"song.overlay.rate_help" = "发送「打分 1-5」"
|
|
||||||
"song.overlay.queue_aria" = "待唱队列"
|
"song.overlay.queue_aria" = "待唱队列"
|
||||||
"song.overlay.queue_title" = "待唱列表"
|
"song.overlay.queue_title" = "待唱列表"
|
||||||
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
|
"song.overlay.queue_empty" = "下一首,会由谁来点呢?"
|
||||||
@@ -424,7 +425,11 @@ name = "简体中文"
|
|||||||
"gift.theme.jade_starfall.name" = "青玉星落"
|
"gift.theme.jade_starfall.name" = "青玉星落"
|
||||||
"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。"
|
"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。"
|
||||||
"gift.theme.moonlit_water.name" = "静夜曲水"
|
"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.thresholds" = "礼物价值分档(原始价格,1000 = 1 元)"
|
||||||
"gift.settings.high_threshold" = "高价值阈值"
|
"gift.settings.high_threshold" = "高价值阈值"
|
||||||
"gift.settings.featured_threshold" = "特别高价值阈值"
|
"gift.settings.featured_threshold" = "特别高价值阈值"
|
||||||
@@ -437,24 +442,34 @@ name = "简体中文"
|
|||||||
"gift.settings.size" = "礼物主体大小"
|
"gift.settings.size" = "礼物主体大小"
|
||||||
"gift.settings.speed" = "飞行速度"
|
"gift.settings.speed" = "飞行速度"
|
||||||
"gift.settings.trail" = "拖尾强度"
|
"gift.settings.trail" = "拖尾强度"
|
||||||
"gift.settings.guard_stars" = "大航海星光数量"
|
"guard.settings.stars" = "大航海星光数量"
|
||||||
"gift.settings.guard_duration" = "大航海全屏时长"
|
"guard.settings.duration" = "大航海全屏时长"
|
||||||
"gift.settings.concurrent" = "最大同时特效数"
|
"guard.settings.copy" = "青玉星落 · 感谢文字"
|
||||||
|
"guard.settings.title_template" = "第一行"
|
||||||
|
"guard.settings.title_template_description" = "使用 {guard} 插入舰长、提督或总督;默认“{guard}启航”。"
|
||||||
|
"guard.settings.closing_text" = "第三行"
|
||||||
|
"guard.settings.closing_text_description" = "显示在用户 UID 下方;默认“相伴前行”。"
|
||||||
|
"guard.settings.queue_capacity" = "最大等待大航海数"
|
||||||
|
"gift.settings.queue_capacity" = "最大等待礼物数"
|
||||||
"gift.preview.title" = "全屏礼物特效预览"
|
"gift.preview.title" = "全屏礼物特效预览"
|
||||||
"gift.preview.description" = "预览按 16:9 缩放显示;OBS 浏览器源默认可用 1920×1080,也可使用任意分辨率。"
|
"gift.preview.description" = "预览按 16:9 缩放显示;OBS 浏览器源默认可用 1920×1080,也可使用任意分辨率。"
|
||||||
"gift.preview.normal_button" = "预览普通礼物"
|
"gift.preview.normal_button" = "预览普通礼物"
|
||||||
"gift.preview.high_button" = "预览高价礼物"
|
"gift.preview.high_button" = "预览高价礼物"
|
||||||
"gift.preview.featured_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.viewer" = "星光观众"
|
||||||
"gift.preview.normal" = "小花花"
|
"gift.preview.normal" = "小花花"
|
||||||
"gift.preview.high" = "青玉献礼"
|
"gift.preview.high" = "青玉献礼"
|
||||||
"gift.preview.featured" = "星河之梦"
|
"gift.preview.featured" = "星河之梦"
|
||||||
"gift.preview.guard" = "舰长"
|
"gift.preview.guard" = "舰长"
|
||||||
"gift.guard_aria" = "大航海全屏庆祝特效"
|
"guard.effect_aria" = "大航海全屏庆祝特效"
|
||||||
"gift.guard_salute" = "星河为你闪耀"
|
"guard.salute" = "星河为你闪耀"
|
||||||
"gift.guard_title" = "{guard}·星光献礼"
|
"guard.title" = "{guard}·星光献礼"
|
||||||
"gift.guard_viewer" = "感谢 {viewer} 的守护"
|
"guard.viewer" = "感谢 {viewer} 的守护"
|
||||||
"gift.moonlit.ceremony_label" = "月下清供"
|
"gift.moonlit.ceremony_label" = "月下清供"
|
||||||
"gift_menu.theme.jade_banquet.name" = "青玉华宴"
|
"gift_menu.theme.jade_banquet.name" = "青玉华宴"
|
||||||
"gift_menu.theme.jade_banquet.description" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
"gift_menu.theme.jade_banquet.description" = "青玉玻璃、流金花枝和星光高亮组成的古风礼物菜单。"
|
||||||
@@ -672,6 +687,8 @@ name = "English"
|
|||||||
"settings.decoration_line_weight" = "Decoration line weight"
|
"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.decoration_line_weight_description" = "Adjusts theme borders, dividers, and the upper and lower ornamental edges together."
|
||||||
"settings.max_visible" = "Maximum visible items"
|
"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.auto_collapse" = "Auto-collapse"
|
||||||
"settings.unfold_duration" = "Scroll-open duration"
|
"settings.unfold_duration" = "Scroll-open duration"
|
||||||
"settings.motion_intensity" = "Motion intensity"
|
"settings.motion_intensity" = "Motion intensity"
|
||||||
@@ -686,6 +703,7 @@ name = "English"
|
|||||||
"settings.event.like" = "Like"
|
"settings.event.like" = "Like"
|
||||||
"settings.event.share" = "Share"
|
"settings.event.share" = "Share"
|
||||||
"settings.low_performance" = "Low-performance mode"
|
"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.high_gift" = "High-value gift threshold (milli-CNY)"
|
||||||
"settings.featured_gift" = "Featured gift threshold (milli-CNY)"
|
"settings.featured_gift" = "Featured gift threshold (milli-CNY)"
|
||||||
"settings.saving" = "Saving…"
|
"settings.saving" = "Saving…"
|
||||||
@@ -771,11 +789,13 @@ name = "English"
|
|||||||
"components.danmaku_mark" = "Chat"
|
"components.danmaku_mark" = "Chat"
|
||||||
"components.song_mark" = "Song"
|
"components.song_mark" = "Song"
|
||||||
"components.gift_mark" = "Gift"
|
"components.gift_mark" = "Gift"
|
||||||
|
"components.guard_mark" = "Guard"
|
||||||
"components.gift_menu_mark" = "Menu"
|
"components.gift_menu_mark" = "Menu"
|
||||||
"components.generic_mark" = "App"
|
"components.generic_mark" = "App"
|
||||||
"components.danmaku_type" = "Live chat overlay"
|
"components.danmaku_type" = "Live chat overlay"
|
||||||
"components.song_type" = "Song request overlay"
|
"components.song_type" = "Song request overlay"
|
||||||
"components.gift_type" = "Full-screen gift starfall"
|
"components.gift_type" = "Full-screen gift starfall"
|
||||||
|
"components.guard_type" = "Full-screen membership effect"
|
||||||
"components.gift_menu_type" = "Live gift menu"
|
"components.gift_menu_type" = "Live gift menu"
|
||||||
"components.add_instance" = "Add component instance"
|
"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.add_instance_description" = "Add multiple instances of one type, each with its own style and OBS address."
|
||||||
@@ -802,9 +822,11 @@ name = "English"
|
|||||||
"components.danmaku_settings" = "Chat overlay settings"
|
"components.danmaku_settings" = "Chat overlay settings"
|
||||||
"components.danmaku_description" = "Every setting is stored independently on this user's component."
|
"components.danmaku_description" = "Every setting is stored independently on this user's component."
|
||||||
"components.song_settings" = "Song request settings"
|
"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_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_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.gift_menu_description" = "Map gifts available in the current room, membership tiers, or a gift unit price to content the streamer will perform."
|
||||||
"components.open_song_stats" = "Open song statistics"
|
"components.open_song_stats" = "Open song statistics"
|
||||||
@@ -853,11 +875,8 @@ name = "English"
|
|||||||
"song.stat.active" = "Active requests"
|
"song.stat.active" = "Active requests"
|
||||||
"song.stat.completed" = "Completed"
|
"song.stat.completed" = "Completed"
|
||||||
"song.stat.cancelled" = "Cancelled"
|
"song.stat.cancelled" = "Cancelled"
|
||||||
"song.stat.ratings" = "Ratings"
|
|
||||||
"song.current" = "Current song"
|
"song.current" = "Current song"
|
||||||
"song.current_description" = "Completing or cancelling the current song automatically advances the queue."
|
"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.complete_current" = "Complete current song"
|
||||||
"song.no_current" = "No song is currently being performed."
|
"song.no_current" = "No song is currently being performed."
|
||||||
"song.queue_title" = "Upcoming queue ({count})"
|
"song.queue_title" = "Upcoming queue ({count})"
|
||||||
@@ -870,7 +889,6 @@ name = "English"
|
|||||||
"song.column.title" = "Song"
|
"song.column.title" = "Song"
|
||||||
"song.column.requester" = "Requested by"
|
"song.column.requester" = "Requested by"
|
||||||
"song.column.result" = "Result"
|
"song.column.result" = "Result"
|
||||||
"song.column.rating" = "Rating"
|
|
||||||
"song.column.finished" = "Finished"
|
"song.column.finished" = "Finished"
|
||||||
"song.status.completed" = "Completed"
|
"song.status.completed" = "Completed"
|
||||||
"song.status.cancelled" = "Cancelled"
|
"song.status.cancelled" = "Cancelled"
|
||||||
@@ -922,13 +940,11 @@ name = "English"
|
|||||||
"song.overlay.preview_viewer" = "Jade Viewer"
|
"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_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.preview_viewers" = "Starlight Traveler|Garden Guest|Little Porcelain|Laurel"
|
||||||
"song.overlay.score" = "{score} ★ · {count} viewers"
|
|
||||||
"song.overlay.current_aria" = "Current song"
|
"song.overlay.current_aria" = "Current song"
|
||||||
"song.overlay.sing_mark" = "Sing"
|
"song.overlay.sing_mark" = "Sing"
|
||||||
"song.overlay.now_singing" = "Now singing · requested by {viewer}"
|
"song.overlay.now_singing" = "Now singing · requested by {viewer}"
|
||||||
"song.overlay.waiting" = "Waiting for a chat request"
|
"song.overlay.waiting" = "Waiting for a chat request"
|
||||||
"song.overlay.request_help" = "Send “点歌 Song name” to join the queue"
|
"song.overlay.request_help" = "Send “点歌Song name” or “点歌 Song name” to join the queue"
|
||||||
"song.overlay.rate_help" = "Send “打分 1-5”"
|
|
||||||
"song.overlay.queue_aria" = "Upcoming queue"
|
"song.overlay.queue_aria" = "Upcoming queue"
|
||||||
"song.overlay.queue_title" = "Upcoming songs"
|
"song.overlay.queue_title" = "Upcoming songs"
|
||||||
"song.overlay.queue_empty" = "Who will request the next song?"
|
"song.overlay.queue_empty" = "Who will request the next song?"
|
||||||
@@ -939,7 +955,11 @@ name = "English"
|
|||||||
"gift.theme.jade_starfall.name" = "Jade Starfall"
|
"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.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.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.thresholds" = "Gift value tiers (raw price, 1000 = CNY 1)"
|
||||||
"gift.settings.high_threshold" = "High-value threshold"
|
"gift.settings.high_threshold" = "High-value threshold"
|
||||||
"gift.settings.featured_threshold" = "Featured-value threshold"
|
"gift.settings.featured_threshold" = "Featured-value threshold"
|
||||||
@@ -952,24 +972,34 @@ name = "English"
|
|||||||
"gift.settings.size" = "Gift core size"
|
"gift.settings.size" = "Gift core size"
|
||||||
"gift.settings.speed" = "Flight speed"
|
"gift.settings.speed" = "Flight speed"
|
||||||
"gift.settings.trail" = "Trail intensity"
|
"gift.settings.trail" = "Trail intensity"
|
||||||
"gift.settings.guard_stars" = "Membership star count"
|
"guard.settings.stars" = "Membership star count"
|
||||||
"gift.settings.guard_duration" = "Membership full-screen duration"
|
"guard.settings.duration" = "Membership full-screen duration"
|
||||||
"gift.settings.concurrent" = "Maximum concurrent effects"
|
"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 UID; 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.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.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.normal_button" = "Preview normal gift"
|
||||||
"gift.preview.high_button" = "Preview high-value gift"
|
"gift.preview.high_button" = "Preview high-value gift"
|
||||||
"gift.preview.featured_button" = "Preview featured 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.viewer" = "Starlight viewer"
|
||||||
"gift.preview.normal" = "Little flower"
|
"gift.preview.normal" = "Little flower"
|
||||||
"gift.preview.high" = "Jade offering"
|
"gift.preview.high" = "Jade offering"
|
||||||
"gift.preview.featured" = "Dream of the Stars"
|
"gift.preview.featured" = "Dream of the Stars"
|
||||||
"gift.preview.guard" = "Guard"
|
"gift.preview.guard" = "Guard"
|
||||||
"gift.guard_aria" = "Full-screen membership celebration"
|
"guard.effect_aria" = "Full-screen membership celebration"
|
||||||
"gift.guard_salute" = "THE STARS SHINE FOR YOU"
|
"guard.salute" = "THE STARS SHINE FOR YOU"
|
||||||
"gift.guard_title" = "{guard} · STARLIGHT TRIBUTE"
|
"guard.title" = "{guard} · STARLIGHT TRIBUTE"
|
||||||
"gift.guard_viewer" = "Thank you, {viewer}, for your support"
|
"guard.viewer" = "Thank you, {viewer}, for your support"
|
||||||
"gift.moonlit.ceremony_label" = "MOONLIT OFFERING"
|
"gift.moonlit.ceremony_label" = "MOONLIT OFFERING"
|
||||||
"gift_menu.theme.jade_banquet.name" = "Jade Banquet"
|
"gift_menu.theme.jade_banquet.name" = "Jade Banquet"
|
||||||
"gift_menu.theme.jade_banquet.description" = "An ornate gift menu of jade glass, gilded blossoms, and starlight highlights."
|
"gift_menu.theme.jade_banquet.description" = "An ornate gift menu of jade glass, gilded blossoms, and starlight highlights."
|
||||||
|
|||||||
Reference in New Issue
Block a user