更新点歌和弹幕微调

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
+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;
}
+272 -75
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,9 +230,11 @@ function componentKindLabel(kind: string): string {
? translate('components.song_type')
: isGiftEffectKind(kind)
? translate('components.gift_type')
: isGiftMenuKind(kind)
? translate('components.gift_menu_type')
: kind
: isGuardEffectKind(kind)
? translate('components.guard_type')
: isGiftMenuKind(kind)
? translate('components.gift_menu_type')
: kind
}
function componentKindMark(kind: string): string {
@@ -226,9 +244,11 @@ function componentKindMark(kind: string): string {
? translate('components.song_mark')
: isGiftEffectKind(kind)
? translate('components.gift_mark')
: isGiftMenuKind(kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')
: isGuardEffectKind(kind)
? translate('components.guard_mark')
: isGiftMenuKind(kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
@@ -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>}
<option value="gift">{translate('settings.event.gift')}</option>
<option value="guard">{translate('test.guard')}</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,12 +2388,17 @@ export function ComponentsPage({
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(component.kind)
: isGuardEffectKind(component.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
...defaultGuardEffectSettings,
...normalizeGuardEffectSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
: isGiftMenuKind(component.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
} catch (reason) {
@@ -2370,12 +2553,17 @@ export function ComponentsPage({
...defaultGiftEffectSettings,
...normalizeGiftEffectSettings(payload),
}
: isGiftMenuKind(selected.kind)
: isGuardEffectKind(selected.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
...defaultGuardEffectSettings,
...normalizeGuardEffectSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
: isGiftMenuKind(selected.kind)
? {
...defaultGiftMenuSettings,
...normalizeGiftMenuSettings(payload),
}
: { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
setFlash({
@@ -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%);
}
}
+108 -142
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)
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
const meteors = Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
return {
index,
startY: 7 + (seed % 78),
drift: ((seed >>> 8) % 37) - 18,
delay: index * 95 + ((seed >>> 16) % 260),
duration,
lifetime:
(reducedMotion ? Math.max(duration, 6_000) : duration) + index * 95 + ((seed >>> 16) % 260),
}
})
const lifetime = Math.max(...meteors.map(meteor => meteor.lifetime), 1_600)
return (
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const startY = 7 + (seed % 78)
const drift = ((seed >>> 8) % 37) - 18
const delay = index * 95 + ((seed >>> 16) % 260)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
<div
className={`meteor-burst tier-${tier}`}
aria-label={gift.name || translate('common.gift')}
style={{ ['--burst-duration' as string]: `${lifetime}ms` } as CSSProperties}
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.animationName === 'gift-burst-lifetime') {
onComplete()
}
}}
>
{meteors.map(({ index, startY, drift, delay, duration }) => {
return (
<div
className="gift-meteor"
@@ -254,7 +241,13 @@ function MeteorBurst({
* compact and stays out of the centre of a broadcaster's scene: a gift image,
* name and a single water-line appear briefly without adding a panel behind it.
*/
function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
function MoonlitGiftWhisper({
effect,
onComplete,
}: {
effect: VisualEffect
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
@@ -271,6 +264,13 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
} as CSSProperties
}
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-whisper-appear'
)
onComplete()
}}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
@@ -292,13 +292,31 @@ function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: GiftTier }) {
function MoonlitGiftCeremony({
effect,
tier,
onComplete,
}: {
effect: VisualEffect
tier: GiftTier
onComplete: () => void
}) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
return (
<section className={`moonlit-gift-ceremony tier-${tier}`} aria-live="polite">
<section
className={`moonlit-gift-ceremony tier-${tier}`}
aria-live="polite"
onAnimationEnd={event => {
if (
event.target === event.currentTarget &&
event.animationName === 'moonlit-offering-sweep'
)
onComplete()
}}
>
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
@@ -338,59 +356,6 @@ function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: Gif
)
}
function GuardCelebration({
effect,
settings,
}: {
effect: VisualEffect
settings: GiftEffectSettings
}) {
const count = settings.lowPerformanceMode
? Math.min(24, settings.guardStarCount)
: settings.guardStarCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('gift.guard_aria')}
style={
{ ['--guard-duration' as string]: `${settings.guardEffectDurationMs}ms` } as CSSProperties
}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('gift.guard_salute')}</span>
<strong>{translate('gift.guard_title', { guard })}</strong>
<b>{translate('gift.guard_viewer', { viewer })}</b>
</div>
</section>
)
}
export function GiftEffectOverlay({
preview = false,
previewSettings,
@@ -428,48 +393,49 @@ export function GiftEffectOverlay({
}, [])
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const guard = remote.effects.find(effect => effect.kind === 'guard')
const moonlit = theme.id === 'moonlit-water'
const effect = remote.effect
let activeVisual = null
if (effect) {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
const onComplete = () => remote.completeEffect(effect.id)
activeVisual = moonlit ? (
totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} onComplete={onComplete} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} onComplete={onComplete} key={effect.id} />
)
) : (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
onComplete={onComplete}
key={effect.id}
/>
)
}
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
data-queue-length={remote.pendingCount}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
if (moonlit) {
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} key={effect.id} />
)
}
return (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
key={effect.id}
/>
)
})}
{activeVisual}
</div>
{guard && <GuardCelebration effect={guard} settings={settings} />}
</main>
)
}
-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;
+4 -19
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,14 +347,10 @@ export function SongRequestOverlay({
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-score">
<b>{score(queue.current)}</b>
<small>{translate('song.overlay.rate_help')}</small>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
<div className="song-waveform" aria-hidden="true">
{Array.from({ length: 8 }, (_, index) => (
<i key={index} />
))}
</div>
</section>
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
+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 \
+39 -115
View File
@@ -128,34 +128,20 @@ pub enum SongCommand {
title: String,
normalized_title: String,
},
Rate(u8),
}
/// Parse only explicit commands separated from their argument by whitespace.
/// This avoids treating ordinary words such as “点歌姬” as queue mutations.
/// Parse a request prefix followed immediately by a title or by whitespace and
/// a title. Whitespace inside the title is normalized before deduplication.
pub fn parse_command(text: &str) -> Option<SongCommand> {
let canonical = collapse_whitespace(text);
let (command, argument) = canonical.split_once(' ')?;
match command {
"点歌" => {
if argument.is_empty() || argument.chars().count() > 80 {
return None;
}
Some(SongCommand::Request {
title: argument.to_owned(),
normalized_title: argument.to_lowercase(),
})
}
"打分" => match argument {
"1" => Some(SongCommand::Rate(1)),
"2" => Some(SongCommand::Rate(2)),
"3" => Some(SongCommand::Rate(3)),
"4" => Some(SongCommand::Rate(4)),
"5" => Some(SongCommand::Rate(5)),
_ => None,
},
_ => None,
let argument = canonical.strip_prefix("点歌")?.trim_start();
if argument.is_empty() || argument.chars().count() > 80 {
return None;
}
Some(SongCommand::Request {
title: argument.to_owned(),
normalized_title: argument.to_lowercase(),
})
}
fn collapse_whitespace(value: &str) -> String {
@@ -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,25 +224,20 @@ impl SongRequestService {
};
let settings: SongRequestSettings = serde_json::from_value(component.settings.clone())
.map_err(|error| SongRequestError::Invalid(error.to_string()))?;
let result = match command {
SongCommand::Request {
title,
normalized_title,
} => {
self.request_song(
component,
&danmaku.viewer,
&title,
&normalized_title,
event.id,
&settings,
)
.await?
}
SongCommand::Rate(score) => {
self.rate_current(component, &danmaku.viewer, score).await?
}
};
let SongCommand::Request {
title,
normalized_title,
} = command;
let result = self
.request_song(
component,
&danmaku.viewer,
&title,
&normalized_title,
event.id,
&settings,
)
.await?;
if let Some(change) = result {
self.publish_change(component, &event.room_id, change)?;
}
@@ -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]