更新点歌和弹幕微调

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